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
14 changes: 7 additions & 7 deletions crates/mq-integration/tests/pair_mitm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ fn mitm_survives_tunnel_reconnect() {
// SP4 spec §7.3 route step 3: no `h2` in ALPN → opaque.
#[test]
fn opaque_no_h2_alpn() {
let log = Logged::start("opaque(NoH2)");
let log = Logged::start("opaque(no_h2)");
let o = origin(Proto::H2Tls, Handler::Status(200));
let p = proxy(o.addr, p256());
let b = browser(&p, o.addr, Path::new(ORIGIN_CA));
Expand All @@ -485,7 +485,7 @@ fn opaque_no_h2_alpn() {
// SP4 spec §7.3 route step 4: an ignored host → opaque; h2 then runs with the origin.
#[test]
fn opaque_ignored_host() {
let log = Logged::start("opaque(Ignored)");
let log = Logged::start("opaque(ignored)");
let o = origin(Proto::H2Tls, Handler::Status(200));
let p = proxy(
o.addr,
Expand All @@ -502,7 +502,7 @@ fn opaque_ignored_host() {
// SP4 spec §7.3 "Peek": bytes that are not TLS → opaque, relayed as they are.
#[test]
fn opaque_non_tls_bytes() {
let log = Logged::start("opaque(NotTls)");
let log = Logged::start("opaque(not_tls)");
let o = origin(Proto::H1Plain, Handler::Status(200));
let p = proxy(o.addr, p256());
let mut s = TcpStream::connect(p.mitm_addr()).unwrap();
Expand All @@ -519,7 +519,7 @@ fn opaque_non_tls_bytes() {
// SP4 spec §7.3 route step 5: an SNI outside a NameConstraints CA's scope → opaque.
#[test]
fn opaque_out_of_scope_constrained_ca() {
let log = Logged::start("opaque(OutOfCaScope)");
let log = Logged::start("opaque(ca_scope)");
let o = origin(Proto::H2Tls, Handler::Status(200));
let mitm = mitm_cfg("ca-dns-constraint", &[], MitmTuning::default());
let p = proxy(o.addr, mitm);
Expand All @@ -533,7 +533,7 @@ fn opaque_out_of_scope_constrained_ca() {
// afterwards reaches the origin.
#[test]
fn opaque_peek_timeout() {
let log = Logged::start("opaque(Timeout)");
let log = Logged::start("opaque(timeout)");
let o = origin(Proto::H2Tls, Handler::Status(200));
let tuning = MitmTuning {
peek: Duration::from_millis(200),
Expand Down Expand Up @@ -566,7 +566,7 @@ fn echo_to_eof() -> (SocketAddr, JoinHandle<Vec<u8>>) {
// the relay carries the bytes and the EOF, and the answer still comes back.
#[test]
fn opaque_peek_eof_half_close() {
let log = Logged::start("opaque(Eof)");
let log = Logged::start("opaque(eof)");
let (addr, origin) = echo_to_eof();
let p = proxy(addr, p256());
let mut s = TcpStream::connect(p.mitm_addr()).unwrap();
Expand All @@ -586,7 +586,7 @@ fn opaque_peek_eof_half_close() {
// SP4 spec §7.3 "Accept" step 2: at `max_conns` (lowered to 1) a new flow goes opaque.
#[test]
fn opaque_at_capacity() {
let log = Logged::start("opaque(AtCapacity)");
let log = Logged::start("opaque(capacity)");
let o = origin(Proto::H2Tls, Handler::Status(200));
let tuning = MitmTuning {
max_conns: 1,
Expand Down
2 changes: 1 addition & 1 deletion crates/mq-linux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The single home for Linux syscalls that need `unsafe` or raw fds (spec §2.2).

## GSO/GRO: hand-written `libc`, not `quinn-udp` (Task 5.1)
## GSO/GRO: hand-written `libc`, not `quinn-udp`

I evaluated `quinn-udp` 0.5.16 (`UdpSocketState` over a `std::net::UdpSocket`)
in a throwaway spike on Linux 7.0 loopback. Functionally it works: a GSO send
Expand Down
2 changes: 2 additions & 0 deletions crates/mq-proxy/src/client/mitm/leaf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ struct Entry {
}

impl LeafStore {
/// # Panics
/// If generating the shard's P-256 leaf key fails.
pub fn new(ca: Arc<Ca>, clock: fn() -> SystemTime) -> Self {
Self {
ca,
Expand Down
25 changes: 5 additions & 20 deletions crates/mq-proxy/src/client/mitm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,29 +47,14 @@ pub struct Handoff {
struct Stats {
mitm: u64,
/// By `Why as usize`.
opaque: [u64; 11],
opaque: [u64; Why::ALL.len()],
tls_fail: u64,
h2_fail: u64,
dead: u64,
reqs: u64,
rejects: u64,
}

/// The `opaque_*` keys of the `mq.mitm` line, in `Why` order.
const WHY_KEYS: [&str; 11] = [
"not_tls",
"no_sni",
"bad_sni",
"no_h2",
"ignored",
"ca_scope",
"tls_incompat",
"timeout",
"too_large",
"eof",
"capacity",
];

/// SP4 spec §2.2: the front's timers, by id → their conn.
#[derive(Default)]
struct Timers(HashMap<TimerId, TcpId>);
Expand Down Expand Up @@ -127,7 +112,7 @@ impl Mitm {
h2,
stats: Stats::default(),
timers: Timers::default(),
pump_cap: crate::server::origin::PUMP_CAP,
pump_cap: crate::tls_pipe::PUMP_CAP,
}
}

Expand Down Expand Up @@ -309,8 +294,8 @@ impl Mitm {
}
let leaf = self.leaf.stats();
let mut line = format!("mq.mitm conns={live} streams={streams} mitm={}", s.mitm);
for (k, n) in WHY_KEYS.iter().zip(s.opaque) {
line += &format!(" opaque_{k}={n}");
for (w, n) in Why::ALL.iter().zip(s.opaque) {
line += &format!(" opaque_{}={n}", w.key());
}
line += &format!(
" tls_fail={} h2_fail={} dead={} leaf_hit={} leaf_miss={} reqs={} rejects={}",
Expand Down Expand Up @@ -352,7 +337,7 @@ impl Mitm {

fn count_opaque(&mut self, why: Why, sni: Option<&str>) {
self.stats.opaque[why as usize] += 1;
log::debug!("mq_mitm: {} → opaque({why:?})", sni.unwrap_or("-"));
log::debug!("mq_mitm: {} → opaque({})", sni.unwrap_or("-"), why.key());
}
}

Expand Down
41 changes: 41 additions & 0 deletions crates/mq-proxy/src/client/mitm/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,40 @@ pub enum Why {
AtCapacity,
}

impl Why {
/// Every variant, in `mq.mitm` line order (`opaque` counters index by `as usize`).
pub const ALL: [Why; 11] = [
Why::NotTls,
Why::NoSni,
Why::BadSni,
Why::NoH2,
Why::Ignored,
Why::OutOfCaScope,
Why::TlsIncompatible,
Why::Timeout,
Why::TooLarge,
Why::Eof,
Why::AtCapacity,
];

/// The `opaque_<key>` name in the `mq.mitm` line.
pub fn key(self) -> &'static str {
match self {
Why::NotTls => "not_tls",
Why::NoSni => "no_sni",
Why::BadSni => "bad_sni",
Why::NoH2 => "no_h2",
Why::Ignored => "ignored",
Why::OutOfCaScope => "ca_scope",
Why::TlsIncompatible => "tls_incompat",
Why::Timeout => "timeout",
Why::TooLarge => "too_large",
Why::Eof => "eof",
Why::AtCapacity => "capacity",
}
}
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Route {
Mitm(Sni),
Expand Down Expand Up @@ -189,6 +223,13 @@ mod tests {
Sni::canonical(s.as_bytes()).unwrap()
}

#[test]
fn why_all_is_in_discriminant_order() {
for (i, w) in Why::ALL.iter().enumerate() {
assert_eq!(*w as usize, i, "{w:?}");
}
}

fn dom(s: &str) -> DnsSubtree {
DnsSubtree::Domain(sni(s))
}
Expand Down
2 changes: 1 addition & 1 deletion crates/mq-proxy/src/client/mitm/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ impl MStream {

/// One §7.6 step: the upload, then the download, which always runs after
/// a `SendOut::Done` (a send error leaves the core `Failed` with no
/// readiness to follow, Task 5.1 ruling). `room`: the TLS output is below
/// readiness to follow). `room`: the TLS output is below
/// its caps. `true` when anything moved.
pub(super) fn step(
&mut self,
Expand Down
2 changes: 1 addition & 1 deletion crates/mq-proxy/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 mp0rta and mqproxy contributors
//! spec §6.2, §6.3, §6.5: client and server settings; the CLI maps onto these (Task 9.1).
//! spec §6.2, §6.3, §6.5: client and server settings; the CLI maps onto these.

use crate::client::mitm::MitmTuning;
use crate::client::mitm::ca::Ca;
Expand Down
5 changes: 1 addition & 4 deletions crates/mq-proxy/src/server/origin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@ pub const UPLOAD_CAP: usize = 256 * 1024;
pub const IDLE_MAX: Duration = Duration::from_secs(118);
/// spec §7.7: the idle sweep interval.
pub const SWEEP: Duration = Duration::from_secs(10);
/// spec §7.3 step 4: pump iterations per callback.
pub const PUMP_CAP: usize = 16;
pub use crate::tls_pipe::PUMP_CAP;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum Scheme {
Http,
Expand Down Expand Up @@ -261,8 +260,6 @@ type ConnKey = (Scheme, String, u16);
/// Reusable conns by key, several per key (§7.7).
type Pool = HashMap<ConnKey, Vec<OriginConnId>>;

// The §7.1 state below is filled by Tasks 5.2–5.6c.

/// Where a request's record lives (§7.1 lookups).
#[derive(Copy, Clone, Debug)]
enum Where {
Expand Down
2 changes: 1 addition & 1 deletion crates/mq-proxy/src/tls_pipe/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ impl ShardExec {
}
}

/// Tasks spawned and not yet finished (test-support accessor, Task 5.1b).
/// Tasks spawned and not yet finished (test-support accessor).
#[cfg(any(test, feature = "test-support"))]
pub fn len(&self) -> usize {
self.tasks.len() + self.spawned.borrow().len()
Expand Down
3 changes: 3 additions & 0 deletions crates/mq-proxy/src/tls_pipe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ mod exec;
mod pipe;
mod tls_io;

/// Pump iterations per callback (SP3 spec §7.3 step 4; SP4 R2 for the MITM front).
pub const PUMP_CAP: usize = 16;

pub use exec::{Dirty, ShardExec};
#[cfg(any(test, feature = "test-support"))]
pub use pipe::pipe_pair;
Expand Down
2 changes: 1 addition & 1 deletion crates/mq-proxy/tests/client_mitm_stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@ fn reject_before_head_xmq_error_response() {
assert_eq!(t.streams(), 0);
}

/// Task 5.1 ruling: a send error leaves the core `Failed` with no readiness
/// A send error leaves the core `Failed` with no readiness
/// to follow, so the front pulls the download side after `SendOut::Done`.
#[test]
fn send_error_surfaces_without_an_h3_event() {
Expand Down
5 changes: 1 addition & 4 deletions crates/mq-proxy/tests/server_origin_connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,7 @@ fn plain_http_connect_ms_is_dial_duration() {
);
let conn = oh.with_host(|h, _| h.origin().conn_of(h3)).unwrap();
assert_eq!(oh.with_host(|h, _| h.origin().connect_ms(conn)), Some(250));
assert!(
oh.events().is_empty(),
"the request is not sent here (Task 5.4)"
);
assert!(oh.events().is_empty(), "the request is not sent here");
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion crates/mq-transport/src/conn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ pub(crate) fn recv_datagram(
})
}

/// spec §4.2 `drive`, §4.3, §4.7; order per plan Task 4.6.
/// spec §4.2 `drive`, §4.3, §4.7.
pub(crate) fn drive(t: &mut Transport, now: Time) {
let inner = &mut *t.inner;
inner.deadline = None; // spec §4.3: cleared before the engine runs
Expand Down
2 changes: 1 addition & 1 deletion crates/mq-transport/src/ffi/trampolines.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 mp0rta and mqproxy contributors
//! Transport and ALPN callbacks (spec §4.4, §4.7, §4.8; plan Task 4.6 table).
//! Transport and ALPN callbacks (spec §4.4, §4.7, §4.8).
//!
//! Every trampoline runs inside `guard` (a panic aborts) and reaches `Inner` only through
//! `clock::current()`. The bookkeeping lives in plain `fn on_*(inner: &mut Inner, ..)`
Expand Down
2 changes: 1 addition & 1 deletion crates/mq-transport/tests/fabric_basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ fn handshake_completes() {
assert!(server.call(T0, move |t, _| t.conn_stats(srv_conn)).is_ok());
}

/// Smoke test of the stream methods and trampolines (the full suite is Task 4.7).
/// Smoke test of the stream methods and trampolines.
#[test]
fn stream_round_trip_and_reset_release_slots() {
let (client, server, conn, srv_conn) = connected();
Expand Down
4 changes: 1 addition & 3 deletions crates/mqproxy/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,9 +356,7 @@ fn server(a: ServerArgs, f: FileConfig) -> Result<Resolved, String> {
);
}
let startup_lines = match gateway {
Some(_) => vec![format!(
"mq_origin: hyper {HYPER_VERSION} + rustls (HTTP3=no)"
)],
Some(_) => vec![format!("mq_origin: hyper {HYPER_VERSION} + rustls")],
None => Vec::new(),
};
let cc = cc(a.cc.or(f.cc).as_deref())?;
Expand Down
4 changes: 2 additions & 2 deletions crates/mqproxy/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ fn server_gateway_on_by_default() {
assert!(cli::wants_h3(&r));
assert_eq!(
r.startup_lines,
vec!["mq_origin: hyper 1.10 + rustls (HTTP3=no)".to_string()]
vec!["mq_origin: hyper 1.10 + rustls".to_string()]
);
// The gateway flags reach `GatewayConfig`.
let r = parse(
Expand Down Expand Up @@ -1045,7 +1045,7 @@ fn spawn(args: &[String]) -> Proc {
#[test]
fn gateway_on_and_udp_lines_logged() {
let mut p = spawn(&server_args(&format!("127.0.0.1:{}", free_udp()), &[]));
p.wait_line("[INFO] mq_origin: hyper 1.10 + rustls (HTTP3=no)");
p.wait_line("[INFO] mq_origin: hyper 1.10 + rustls");
p.wait_line("gateway=on, udp=on, udp-idle=60s)");
assert_eq!(p.term(), 0, "{:#?}", p.lines);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/mqproxy/tests/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ fn startup_lines_golden() {
assert_eq!(
lines_until(&mut p, "listening on"),
vec![
"[INFO] mq_origin: hyper 1.10 + rustls (HTTP3=no)".to_string(),
"[INFO] mq_origin: hyper 1.10 + rustls".to_string(),
format!(
"[INFO] mqproxy server listening on {listen} \
(cc=bbr, sched=minrtt, gateway=on, udp=on, udp-idle=60s)"
Expand Down
2 changes: 1 addition & 1 deletion website/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Config keys map to the CLI flags in the [Options Reference](/reference/options):
| `[Auth]` | `Key` (token) | `Key` (token) |
| `[Multipath]` | `CC`, `Scheduler` | `CC`, `Scheduler`, `Path` (repeatable) |
| `[Ingress]` | — | `Socks5`, `HttpConnect`, `Gateway`, `TProxy`, `Mode`, `Fwmark`, `Table`, `Dport`, `SetupRedirect`, `SkipUid` |
| `[Gateway]` | `Enabled`, `Masquerade`, `OriginCA`, `CacheMaxBytes` | — |
| `[Gateway]` | `Enabled`, `Masquerade`, `OriginCA` | — |
| `[Mitm]` | — | `Enabled`, `CACert`, `CAKey`, `IgnoreHosts` (repeatable) |
| `[UDP]` | `Enabled`, `IdleTimeout` | — |
| `[Metrics]` | `Interval`, `PerRequest` | `Interval` |
Expand Down
11 changes: 6 additions & 5 deletions website/guide/http-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ Application (curl / SDK)
│ POST /_mqproxy/fetch (X-Mq-Auth / X-Mq-Target / X-Mq-Method + raw body)
▼
mqproxy-client ──────── HTTP/3 over MPQUIC (ALPN h3) ────────► mqproxy-server
1 HTTP request = 1 H3 request stream = 1 MPQUIC stream │ libcurl (h2→h1,
spread across paths │ TLS verify ON)
1 HTTP request = 1 H3 request stream = 1 MPQUIC stream │ hyper + rustls
spread across paths │ (h2 or HTTP/1.1,
│ TLS verify ON)
▼
Origin server
```
Expand All @@ -17,10 +18,10 @@ Instead of tunneling opaque bytes, the gateway **executes delegated HTTP request

Per-request `X-Mq-*` controls let a caller opt into gateway features without changing the API:

- `X-Mq-Origin-Protocol` pins the upstream HTTP version (`h1`/`h2`/`h3`).
- `X-Mq-Origin-Protocol`: `h1` forces HTTP/1.1; `h2`, `h3` or no header lets the server negotiate h2 or HTTP/1.1 via ALPN (there is no HTTP/3 origin client). Any other value → `400` with `x-mq-error: bad-origin-protocol`.
- `X-Mq-Accept-Encoding` requests download compression.
- `X-Mq-Forward-Cookie` forwards the `Cookie` header upstream (otherwise withheld).
- `X-Mq-Cache` opts the response into the in-memory origin cache (`--cache-max-bytes`).
- `X-Mq-Cache` is still validated (a malformed value → `400` with `x-mq-error: bad-cache-ttl`), but a valid value has no effect: the origin response cache was removed.

The server also pools and reuses origin connections across requests.

Expand Down Expand Up @@ -49,4 +50,4 @@ curl -X POST http://127.0.0.1:8080/_mqproxy/fetch \

`--path` works the same way here: the gateway's MPQUIC connection aggregates each request across all bound paths.

See the [Options Reference](/reference/options#http-gateway-mode) for server-side flags (`--no-gateway`, `--origin-ca`, `--request-metrics`, `--cache-max-bytes`, `--masquerade`).
See the [Options Reference](/reference/options#http-gateway-mode) for server-side flags (`--no-gateway`, `--origin-ca`, `--request-metrics`, `--masquerade`).
2 changes: 1 addition & 1 deletion website/guide/tls-mitm.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ With `--metrics-interval`, the client also prints a `mq.mitm` line on every tick
mq.mitm conns=<live> streams=<open> mitm=<n> opaque_not_tls=<n> opaque_no_sni=<n> opaque_bad_sni=<n> opaque_no_h2=<n> opaque_ignored=<n> opaque_ca_scope=<n> opaque_tls_incompat=<n> opaque_timeout=<n> opaque_too_large=<n> opaque_eof=<n> opaque_capacity=<n> tls_fail=<n> h2_fail=<n> dead=<n> leaf_hit=<n> leaf_miss=<n> reqs=<n> rejects=<n>
```

`conns` and `streams` are the MITM connections and h2 streams open right now; everything else is cumulative. `mitm` counts connections that were terminated, and each `opaque_*` counter counts connections relayed opaquely for one of the reasons listed [above](#what-is-relayed-opaquely). `tls_fail`, `h2_fail` and `dead` count connections that ended on a TLS error, an h2 error, or a dead peer. `leaf_hit`/`leaf_miss` count forged-certificate cache hits and misses, and `reqs` counts every h2 request received, and `rejects` counts those refused before a tunnel request was opened: an `x-mq-error` or 421 answer from the request mapping, an unavailable or refused tunnel, an `RST_STREAM` for a malformed request, or a `REFUSED_STREAM` past the per-connection stream limit. At debug log level each routing decision is logged as `mq_mitm: <sni|-> → mitm|opaque(<why>)`. Header and body values are never logged.
`conns` and `streams` are the MITM connections and h2 streams open right now; everything else is cumulative. `mitm` counts connections that were terminated, and each `opaque_*` counter counts connections relayed opaquely for one of the reasons listed [above](#what-is-relayed-opaquely). `tls_fail`, `h2_fail` and `dead` count connections that ended on a TLS error, an h2 error, or a dead peer. `leaf_hit`/`leaf_miss` count forged-certificate cache hits and misses, and `reqs` counts every h2 request received, and `rejects` counts those refused before a tunnel request was opened: an `x-mq-error` or 421 answer from the request mapping, an unavailable or refused tunnel, an `RST_STREAM` for a malformed request, or a `REFUSED_STREAM` past the per-connection stream limit. At debug log level each routing decision is logged as `mq_mitm: <sni|-> → mitm|opaque(<why>)`, where `<why>` is the `opaque_*` counter name without the prefix (e.g. `opaque(no_h2)`). Header and body values are never logged.

## Security posture

Expand Down
2 changes: 1 addition & 1 deletion website/guide/udp-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ UDP relay is exposed on the **same `--socks5` listener** — any SOCKS5 client t
--socks5 127.0.0.1:1080
```

`curl` does not speak SOCKS5 UDP ASSOCIATE, so use a UDP-capable SOCKS5 client. The repo ships `udpsocks`, a minimal test client (built by the test suite), which is handy for a quick check:
`curl` does not speak SOCKS5 UDP ASSOCIATE, so use a UDP-capable SOCKS5 client. The repo ships `udpsocks`, a minimal test client (build it with `cargo build --release --locked -p mqproxy --examples`), which is handy for a quick check:

```bash
# Relay a UDP packet to a target through the client's SOCKS5 listener
Expand Down
Loading
Loading