Skip to content

v1.0.4

Choose a tag to compare

@github-actions github-actions released this 22 Oct 15:15
· 5 commits to main since this release

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 --check clean 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 (SKIP still empty; the count moves from 95 because
    tools/tls_groups_e2e.py joined 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 -V to decide whether a single-group X25519MLKEM768 hello 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 ties onrouter_tls_handshake_failures_total plus
    the warn-level log line to the actual refusal count. Mutation check: putting the log level back to
    debug turns 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 a return 302 https://dest.local$request_uri over 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
    produced Location: https://yxpil.comhttps://yxp.hk/ over both h2 and Cloudflare, and the correct
    value under curl --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=1 came 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 main for every commit in this batch: unit + clippy, the three E2E steps (16
    suites
    ), and the new Post-quantum build job, which compiles --features pq inside a
    debian:bookworm container, asserts -V reports [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.txt matches
    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: -V and the
    Server header both report 1.0.4, both packaged example configs pass -t, a CF-Ray probe is
    recognised as cloudflare-edge without 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.