Repository navigation
v1.0.4
1.0.3 closed a certificate-selection bug at the TLS edge. 1.0.4 is about the other half of the
handshake — what a connection can be built on at all, and what you see when it cannot — plus a
capability the incident proved we were missing.
Inbound TLS can now negotiate a post-quantum key exchange (--features pq)
A Cloudflare zone in front of a production edge started answering 525 SSL handshake failed for
one hostname while every other hostname on the same IP, same port, same listener returned 200, and
the origin log was completely empty. Capturing the actual ClientHello settles it:
sni=working.zone groups=[x25519, secp256r1, secp384r1, secp521r1, 0x11ec, 0x6399, 0xfe32] -> 200
sni=broken.zone groups=[0x11ec, 0x6399, 0xfe32] -> 525
The broken zone's origin fetch offers only post-quantum codepoints — 0x11ec is
X25519MLKEM768, plus two experimental ones — and no classic curve at all. rustls' ring provider
implements exactly {X25519, secp256r1, secp384r1}, so the intersection is empty and the only
correct answer is a clean refusal (peer is incompatible: NoKxGroupsInCommon → handshake_failure
alert, never a half-open connection).
cargo build --release --features pq appends rustls' aws_lc_rs::kx_group::X25519MLKEM768 to that
set. Deliberately narrow: ring still owns cipher suites, signing and the RNG, and the new group
is ordered last, so any client that offers X25519 keeps negotiating exactly what it negotiated
before. onrouter -V now declares which surface a binary has:
ONRouter/1.0.4 (Rust, OpenResty-compatible)
crypto: rustls/ring [pq=off]
ONRouter/1.0.4 (Rust, OpenResty-compatible)
crypto: rustls/ring, X25519MLKEM768(aws-lc) [pq=on]
It is off by default and that is a real cost-benefit, not a hedge: pq pulls aws-lc-sys (C
code, cmake) into the build. The default release artifacts stay pure Rust and still refuse
PQ-only clients. A pq Linux binary is built by the Post-quantum build CI job and kept as a
workflow artifact, not attached to this Release — a source build with --features pq is the
supported way to get one.
P-521 is not on offer in any build. No rustls provider — ring or aws-lc — implements a
secp521r1 key exchange, so a client offering only that is refused, permanently and by design, and
the docs say so instead of leaving it to be rediscovered.
Handshake failures are now visible where they happen
The 525 above was hard to find for a reason that is now fixed: an inbound TLS handshake failure was
logged at debug, and it never reaches the access log because it happens before HTTP. On a
production threshold that is indistinguishable from "the CDN lied". Failures are now warn, name
the peer, and carry the SNI on listeners that run ssl_certificate_by_lua*:
2026/10/22 14:48:52 [warn] TLS 握手失败 127.0.0.1:48968: peer is incompatible: NoKxGroupsInCommon
onrouter_tls_handshake_failures_total 2
That is the level nginx uses for the same event, and it is what turned a one-hour hunt into a
one-line read.
tools/tls_hello_tap.py — the instrument that answered it
The production box has neither tcpdump nor strace, and installing a packet sniffer on a live edge
is its own risk. The tap mirrors inbound :443 segments with an AF_PACKET raw socket,
reassembles by sequence number and decodes supported_groups from the (plaintext) ClientHello. It
touches nothing: no port, no route, no service.
$request_uri on HTTP/2 is the path it claims to be
Adding a one-line / → main-site redirect surfaced a compatibility bug that no status-code test
could see: over h2/h3, hyper rebuilds :scheme and :authority into the Uri, so $request_uri
stringified to https://yxp.hk/ instead of /. A config as ordinary as
return 302 https://yxpil.com$request_uri;
emitted Location: https://yxpil.comhttps://yxp.hk/ for every browser (h2) while measuring fine
with curl --http1.1. On framing protocols $request_uri is now :path plus the raw query, as
RFC 9113 defines it; HTTP/1.1 keeps the request-line target verbatim, including absolute-form. The
access log records the same value, so h2/h3 lines no longer read "GET https://host/path HTTP/2.0".
tools/http2_e2e.py grew five assertions over real h2c and TLS-h2 streams (three on the Location,
two on the logged request line), so the failure mode cannot come back silently.
A pq build no longer takes HTTP/3 down with it
The full roll caught what the TLS-only suites could not: on --features pq, every QUIC listener
panicked inside its worker at startup. Enabling both ring and aws-lc-rs makes rustls'
process-wide provider auto-detection fail; TCP was safe because it already built its config from an
explicit provider, but build_quic_server_config used the auto-detecting
ServerConfig::builder_with_protocol_versions. quic_business_e2e simply waited forever for an h3
handshake. The QUIC config now uses the same explicit provider as TCP — which also lets h3 negotiate
X25519MLKEM768 — and a unit test builds the h3 config under both feature sets.
Verification
cargo test --workspace— 504/504; clippy clean;cargo fmt --checkclean on every file this
release touches.- Full regression on the release binary:
python run_regression.py reg_104_gate.txt→
TOTAL 96 scripts, nonzero=0(SKIPstill empty; the count moves from 95 because
tools/tls_groups_e2e.pyjoined the roll). - The pq build re-rolled all 96 scripts after the
$request_uri, access-log and QUIC-provider
fixes, with the same result:TOTAL 96 scripts, nonzero=0. This is the roll that caught the
QUIC panic; the TLS-only suites never touch it. - New
tools/tls_groups_e2e.py— 16/16 on both builds against an OpenSSL 3.5 client. It reads
onrouter -Vto decide whether a single-groupX25519MLKEM768hello must negotiate TLS 1.3 or
must be refused, requires P-521 to be refused as an alert rather than a timeout under either build,
checks that mixed group lists (P-384:P-521,P-521:X25519) fall back to the supported group
instead of rejecting the whole ClientHello, and tiesonrouter_tls_handshake_failures_totalplus
the warn-level log line to the actual refusal count. Mutation check: putting the log level back to
debugturns exactly the three log assertions red (13/16) and leaves every behavioural assertion
green.
Scope note, stated rather than glossed: on the CI runner the ML-KEM case is skipped, because
Ubuntu 24.04's OpenSSL 3.0 cannot put that group in a ClientHello — the runner therefore proves
the pq build compiles, links and still passes the other 15 assertions (15/15), while the actual
post-quantum negotiation is measured against an OpenSSL 3.5 client. tools/http2_e2e.py— 14/14: three assertions fetch areturn 302 https://dest.local$request_uriover real h2c and TLS-h2 streams (plus an h1.1 control) and require
the exact Location; two more require the access log to carry"GET /redir?x=1 HTTP/2.0"and reject
any absolute URI in the log. Against the pre-fix binary on the production edge the same config
producedLocation: https://yxpil.comhttps://yxp.hk/over both h2 and Cloudflare, and the correct
value undercurl --http1.1— that asymmetry is what proved the bug was in the gateway, not the
config. The location value was then re-measured on the deployed pq binary through a temporary
loopback-only Lua probe (/__uri_probe?x=1came back with exactly that path).tools/quic_business_e2e.py— 58/58 on the pq build after the provider fix; the suite is the
one that found it, by hanging on the h3 handshake rather than reporting a mismatch.- Linux CI on
mainfor every commit in this batch: unit + clippy, the three E2E steps (16
suites), and the new Post-quantum build job, which compiles--features pqinside a
debian:bookwormcontainer, asserts-Vreports[pq=on], prints the highest GLIBC symbol the
artifact requires, and re-runs the TLS group contract against that binary. The container is not
cosmetic: the first pq build ran on the runner's Ubuntu 24.04 and produced a binary demanding
GLIBC_2.38, which will not start on the Debian 12 machine it was meant for — while the default
build needs only 2.34. - Gate on the bytes a user actually downloads:
SHA256SUMS.txtmatches
onrouter-v1.0.4-x86_64-pc-windows-msvc.zip(verified after download,OK), and
python tools/release_verify.py <that zip>→ 26/26 against the unpacked binary:-Vand the
Serverheader both report 1.0.4, both packaged example configs pass-t, aCF-Rayprobe is
recognised ascloudflare-edgewithout ever being trusted as an address, a missing probe is not
guessed into Cloudflare, and vendor header names score 0 hits with the control patterns counted
(front_proxy=49,auto_trust=10,x-real-ip=1,x-forwarded-for=2) so the 0 is not a grep typo. - Nine assets: eight platform archives plus
SHA256SUMS.txt. Every archive is the default build
([pq=off]); the[pq=on]Linux binary stays a CI workflow artifact, attached to the
Post-quantum build job rather than to this Release.