Repository navigation
Releases
1.0.5 — the forwarded path keeps the characters it was sent with
1.0.4 opened TLS to post-quantum key exchange and made refused handshakes visible. 1.0.5 is one
forwarding fix, found in real traffic the day after that release: every /@username/ page 404'd
through the gateway while the same request direct to the origin returned 200.
What was happening
The gateway matches location on the decoded path (so /a%2fb cannot smuggle a segment, so
dot-segments collapse before they hit the filesystem), and it re-encodes that decoded path before
forwarding it. The re-encoder was ngx.escape_uri's set — unreserved characters only. Applied to a
path segment it escapes far more than the syntax requires:
client -> gateway -> origin
/@yinglink/ /%40yinglink/ 404
/a+b /a%2Bb wrong resource
/a:b /a%3Ab wrong resource
@ is a legal pchar in RFC 3986. nginx forwards what the client sent; we were rewriting it. The
origin (and the WAF in front of it) keys user pages on the literal @, so homepages, avatars and
banner images all 404'd — and no amount of cache clearing or config juggling could show it, because
proxy_pass ...$request_uri goes through the same encoder.
The fix
Forwarding now encodes over pchar: unreserved (A-Za-z0-9 - . _ ~), sub-delims
(! $ & ' ( ) * + , ; =) and : @ pass through untouched, and exactly the bytes that must not
go raw are escaped — %, #, ?, space, control bytes and non-ASCII. % staying in the escaped
set is deliberate: the input is already decoded, so a literal % the client sent has to reach the
origin as %25, otherwise the origin decodes a second, different set of escapes (%2F into /,
%23 into #) and the two sides end up serving different resources — the same argument that makes
the upgrade path forward the raw target.
Both forward seams share the one helper now (the proxied request URI for h1/h2/h3 and the WebSocket
upgrade fallback request line), and so do the directory-listing hrefs, which were producing
/%40name/ links for files whose names start with @.
ngx.escape_uri / ngx.unescape_uri for Lua scripts keep their strict OpenResty semantics — that
set is for serializing query arguments, not for path segments.
Evidence
tools/uri_encode_e2e.py(new, and now in CI's functional-contract step) starts an origin that
reports the raw request line it received and asserts on 14 shapes: 7/14 before the change,
14/14 after. The seven that were red are exactly the@ : + , ; = & $family.proxy.rsunit test extended to the pchar round-trip; the%25and#/?guards are asserted
in both directions so this cannot silently regress.- Unit tests 504/504; full local regression TOTAL 97 scripts, nonzero=0 with
SKIPempty
(the roll went from 96 to 97 becauseuri_encodeis now part of it); the packaged artefacts are
checked bytools/release_verify.pydriving the unpacked archive, not the build tree's binary.
Upgrading
Drop-in: replace the binary, nothing in the configuration changes. There is no new directive, no
new metric, and no behaviour change for any path that does not contain pchar characters the client
sent literally — paths with spaces, non-ASCII names, %-escapes and #/? go out exactly as they
did in 1.0.4.
onrouter -V
# ONRouter/1.0.5 (Rust, OpenResty-compatible)
# crypto: rustls/ring [pq=off]The eight platform archives below contain only onrouter and orr-gencert; hashes are in
SHA256SUMS.txt (sha256sum -c SHA256SUMS.txt). The "run it out of the box" bundle (conf, examples,
docs, generated demo certificate) is built on the release machine — see docs/OPERATIONS.md §2.1.
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.
v1.0.3
ONRouter 1.0.2 shipped a lot of capability. 1.0.3 is small and pointed: one correctness bug at the
TLS edge, six real security advisories closed, one silent data-loss path in the cache governor, and
the test/CI infrastructure that had been hiding all three from us.
The fallback certificate follows default_server, not config order
On a TLS port carrying several server blocks, a ClientHello whose SNI matches none of them
(a typo, a scanner, a client connecting by IP, a leftover pinned cert) presented the certificate of
whichever block happened to be written first — nginx presents the address's default_server. The
vhost half was already right (routing did use default_server), so this is invisible from HTTP: the
only symptom is a client asking for a domain you don't serve getting someone else's certificate, and
a config reorder silently changing which certificate answers. ssl_certificate_by_lua* had the same
skew (tls::select_script also takes the first entry as its fallback), so both are fixed together.
Why no existing suite caught it: https_matrix's multi-domain port 18443 writes default_server
first, where "follows config order" and "follows the default server" are literally the same thing.
Port 18449 now writes it last — the only arrangement that separates the two implementations —
and it is the assertion that goes red without the fix.
The framed transports are covered too, and that needed new plumbing. orr-h3check could report
status and body digests but not which certificate the server presented; it now prints
PEER_SHA256, and a new ORRH3_AUTHORITY lets SNI and :authority be supplied separately (same on
the h2 side via Node's servername). Port 18156 in tools/quic_business_e2e.py hangs three
certificates on one TCP+UDP port number, default_server deliberately last, and asserts per case:
| over h2 and over h3, separately | expected |
|---|---|
SNI=a, authority=a / SNI=b, authority=b |
a's cert + a's vhost, b's cert + b's vhost |
SNI=a, authority=b |
a's certificate, b's vhost — the two accounts stay independent |
SNI=<matches nothing>, authority=b |
the default_server certificate (written last), b's vhost |
SNI=<nothing>, authority=<nothing> |
that certificate and that vhost — the full fallback |
| same case compared across transports | identical PEER_SHA256 and identical bytes |
A mutation check makes the value concrete: with the fix temporarily reverted, the h3 cases do not
fail as a fingerprint mismatch — the handshake is refused outright,
invalid peer certificate: certificate is only valid for DnsName("a.qbiz.test"). That is exactly what
a browser shows a real visitor. router::tests::fallback_cert_follows_default_server_not_config_order
pins both the declared and the undeclared default_server shapes.
Six TLS-stack vulnerabilities closed (cargo audit)
None of them were in the WASM stack; all six were patch-level inside the already-declared semver
range, so the fix was three cargo update --precise bumps — no Cargo.toml change, no API churn.
| Crate | Was → Now | Advisory | Why it matters at a TLS edge |
|---|---|---|---|
rustls |
0.23.45 → 0.23.46 | RUSTSEC-2026-0346 | Debug on client session values leaks TLS secrets (we are a client to https upstreams) |
rustls |
〃 | RUSTSEC-2026-0347 | buffered application data sent to the outer server after an ECH rejection |
rustls |
〃 | RUSTSEC-2026-0348 | a Brotli-compressed certificate can allocate ~1 GiB before authentication — attacker-shaped input, no credentials needed |
rustls |
〃 | RUSTSEC-2026-0349 | an unauthenticated ServerHello can downgrade later connections to classical key exchange |
rustls-webpki |
0.103.15 → 0.103.16 | RUSTSEC-2026-0382 | verification may accept distrusted intermediates (this is what we verify upstream certificates with) |
webpki-root-certs |
1.0.9 → 1.0.10 | RUSTSEC-2026-0383 | bundled roots trusted distrusted emSign intermediates |
Re-running cargo audit reports zero vulnerabilities. Two warnings remain — paste
(RUSTSEC-2024-0436) and rustls-pemfile (RUSTSEC-2025-0134) — both unmaintained, not vulnerable,
accepted knowingly: rustls-pemfile only reads PEM files an operator named in config, never peer
input. cargo audit is now part of the release checklist so the next batch arrives as a five-minute
check rather than a question.
The cold tier no longer eats what the governor told it to demote
lua_shared_dict … disk= plus cache_governor deep demotes entries to disk during idle windows.
The demotion removed the value from memory and then ignored DiskStore::spill()'s refusal — disk
full, pending byte/job caps reached, writer channel closed — while still returning a "demoted" record,
so onrouter_governor_demoted_total counted entries that were actually destroyed. Under cold-tier
pressure, "demote" silently meant "delete"; capacity eviction is allowed to do that (cache semantics),
a demotion of application state is not. It now restores the entry, records the refusal
(spill_skipped), and stops the pass so the next one retries
(shared_dict::tests::governor_demote_pass_keeps_values_the_cold_tier_refuses, a cold tier sized so
small that it cannot accept a single record).
WASM: why the Cranelift advisories do not reach this binary
The Wasmtime/Cranelift batch (e.g. GHSA-jhxm-h53p-jm7w, a miscompiled guest heap access on aarch64
escaping the sandbox; RUSTSEC-2026-0094 Winch typing table.grow wrongly; RUSTSEC-2026-0022 a
panic when an async Func::call future is dropped early) targets code we do not ship: Cargo.lock
contains zero cranelift* entries and no wasmtime, and the plugin host is wasmi — a bytecode
interpreter with no JIT/AOT, so there is no codegen step that could miscompile guest memory. Of the
four crates we actually use for WASM (wasmi, wat, wast, wasmparser) RustSec records no
advisory at all, and the parser stack is current. Stated plainly rather than smoothed over: upstream
wasmi is at 2.0.0 and we are on 0.31.2. Nothing known-exploitable, but the cost of being
behind is responsiveness — the day a wasmi advisory lands, taking it is an API migration (fuel
accounting, TrapCode paths, resource-limiter naming), not a version bump. That migration is its own
change with its own evidence pass; it is not something to slip into a release. Full analysis in
docs/SECURITY_AUDIT.md.
Test and CI infrastructure — every item here was "green on this host, red in CI"
- Each TLS suite signs its own fixture (
tools/selfsigned.py, one directory per suite).
production_shapewas pointing atcerts_random/fallback.pem, a directory another suite generates
and.gitignoreexcludes: fine on any machine that had run that suite, dead on a clean checkout.
Two constraints are recorded in the module —openssl req -x509stampsCA:TRUEby default and
rustls refuses such a certificate as an end-entity (CaUsedAsEndEntity), which is exactly what
happens when a client uses the self-signed cert as its own trust anchor (the h3 probe does); and the
SAN must contain the name being connected to, because QUIC verifies it. - Readiness probes survive a gateway that is still binding. The first
/pingcould raise
Connection refusedon Linux, and one unguarded probe aborted a whole suite. - Suites that write into
logs/create it (logs/is gitignored; CI only had it because an
earlier suite happened to make it first). tokens_refill_over_timeis no longer scheduling-sensitive. At 1000 r/s it required two
back-to-back checks to land inside one millisecond — true here, false on a loaded runner. The
judgement now sits two orders of magnitude away from the jitter (2 r/s).random_scenarios_e2eis back in the roll. It had been held out because its TLS oracle
conflated certificate by SNI with vhost by Host and its probe never sent the SNI it claimed
(HTTPSConnection("127.0.0.1")used the IP), so certificate assertions failed in blocks and looked
like gateway bugs. The oracle now computes certificates from SNI alone (exact > wildcard > the
address's default server), L4 expectations followsni_match's first-match-in-order, and 20 random
topologies pass 4122/4122.- CI widened to 15 suites in three steps, adding
https_matrix(the only suite that can
discriminate the fallback-certificate question), the business-shape suites, and a step that builds
the h3 probe and runs the cross-transport suite.
Upgrading to 1.0.3
No configuration change is required and no directive was added or removed. Two behaviour differences
worth knowing:
- Which certificate answers an unmatched SNI. If a multi-vhost listener relied on the
first-written block being the implicit default, declaredefault_serveron the block whose
certificate should be presented in that case — which is also what nginx requires. - Governor demotions that the cold tier cannot accept now stay in memory and show up as
spill_skippedrather than as keys that vanish. If a zone'smax_diskis too small for its
working set, that becomes visible instead of destructive.
Verification
cargo test --workspace— 502/502 (1.0.2 shipped 500; this release adds the fallback-cert
ordering test and the refused-spill test). Clippy: no errors.- TLS suite family on the rebuilt 1.0.3 binary:
https_matrix76/76,tls_session45/45,
tls_protocols13/13,tls_legacy12/12,mtls_upstream11/11,https_backend12/12,
protocol_matrix37/37,quic_business58/58 (was 41 — the 17 added assertions are the
three-certificate port). random_scenarios_e2e --iters 20— 4122/4122 across 20 generated topologies.- Governor and its cold-tier neighbours:
governor32/32,disk_cache53/53,cache_advanced32/32,
proxy_cache21/21,proxy_disk39/39, `still...
v1.0.2
ONRouter 1.0.2 — three capabilities operators asked for (limit keys that match how a business
identifies a caller, expires, a four-layer UDP front door), and six silent-failure fixes found by
testing the shapes real traffic comes in — school enrollment, flash sale, game UDP, and the same
site served over HTTP/1.1-TLS, HTTP/2-TLS and HTTP/3. The most serious of those fixes is in the last
section: request bodies over h2 and h3 were being dropped.
Limit keys: who shares a token bucket
limit_req_zone / limit_conn_zone now key on any variable that has a value at decision time —
$http_x_api_key, $cookie_JSESSIONID, $arg_token, $uri, $host, $remote_port, $args, … and
concatenations such as $cookie_sid$remote_addr. Behind a campus, corporate or carrier NAT,
$remote_addr counts a whole building as one client; the dimension is now the operator's call, and
$binary_remote_addr actually expands (it used to stay a literal, so a zone keyed on it saw one
constant bucket).
Two rules that decide whether the feature does anything:
- empty key ⇒ this zone does not limit this request (nginx's rule). A request without the header
has no value on that axis, so "limit by caller" must be paired with an ACL or
if ($http_x_api_key = "") { return 401; }— otherwise headerless traffic is simply unlimited. - a key that cannot have a value at decision time is a
-terror, not a warning:$status,
$upstream_*,$sent_http_*,$request_body,$server_name, and anything made byset. Every
one of those would have merged the whole zone into one anonymous bucket — a rate limiter that
silently counts the wrong thing is a policy inversion.
limit_conn became legal in location, where it replaces the inherited server rules rather than
merging with them (merging would silently relax a limit someone wrote on purpose).
Documented boundaries, not surprises: zone=<name>:<size>'s size is display-only — capacity is
32 shards × 16384 keys with LRU eviction; a client-asserted key is bypassable by rotating it
(vip_learning with such a key gets a -t warning for the same reason); limit_req_status
defaults to 429 where nginx defaults to 503.
Proven by tools/limit_key_e2e.py 35/35 with every request from one IP, so "different key ⇒
different bucket" is directly observable, and by tools/tutorial_limit_check.py 17/17 which re-runs
the exact numbers printed in the tutorial chapter.
expires
All nginx value forms (off / epoch / max / @hh:mm:ss / +|-<time> / bare text as the
Cache-Control value) at server and location, with the location replacing the server rule
wholesale (off = explicitly none, not unset). It lands on the static, proxied, cache-hit,
return and Lua paths including already-flushed streaming responses, whose headers freeze at the
first flush. The cacheable status set is taken code-for-code from nginx's
ngx_http_headers_filter_module.c safe_status branch — 200/201/204/206/301/302/303/304/307/308 —
which is a 1.0.2 fix: ONRouter skipped 204 where nginx does not. A malformed time (expires 1x;)
fails -t instead of shipping a site with no expiry headers at all. tools/expires_e2e.py 42/42.
Four-layer UDP
stream { server { listen <addr> udp; proxy_pass …; proxy_responses <n>; proxy_timeout <t>; } }:
one session per client 4-tuple riding a socket connected to its backend, so cross-client reply
delivery is impossible at the kernel level rather than by a lookup table we could get wrong;
proxy_responses 0 is the heartbeat shape — forwarded without creating a session, a task or an fd;
sessions hold a worker_connections permit and are reaped at proxy_timeout; when the budget runs
out the gateway drops and counts instead of grinding to EMFILE. udp_max_sessions <n>; is an
ONRouter ceiling nginx has no counterpart for, and it exists because UDP 4-tuples are spoofable:
measured with 600 simultaneous clients against a 256-permit process, the uncapped listener starves
the HTTP layer too (even /metrics becomes unreachable) while the capped one holds 32 sessions,
counts 568 rejections and keeps serving /ping. tools/game_udp_e2e.py 31/31. Rate is not a
claim here: the same suite measured ≈45k pps idle and ≈4k pps inside a full regression, so the
assertions are about accounting (received = forwarded + dropped, by reason) and about nothing
reaching a backend the gateway did not forward.
The fix that mattered: h2 and h3 request bodies
The gateway asked "is there a body?" of Content-Length / Transfer-Encoding. Framed protocols
carry neither — bytes arrive in DATA frames and the stream's end flag is the end of the body. So
a 64 KB POST over h2 or h3 reached the upstream as a POST with no body and no Content-Length, while
HTTP/1.1 was fine: ngx.req.get_body_data() reported nil on h2/h3 and 4098 on h1 for the same
request. Nothing errored; the backend just received an empty write. Every API mutation on the
site's main TLS transport was silently losing its payload.
Framed versions now always drain the stream to end (an empty stream costs one poll), and the length
reaches the HTTP/1.1 upstream as Content-Length. tools/quic_business_e2e.py 41/41 pins it — and
the rest of what "same site, three transports" has to mean:
| checked over h1.1-TLS / h2-TLS / h3 | result |
|---|---|
| static index + 20 assets, byte-for-byte | same SHA on all three |
expires, gzip round-trip through a real decoder, Range 206 slice, HEAD with no body, conditional 304 |
identical |
proxy_cache hot key: 30 requests over h2 then 30 over h3 |
origin hit exactly once, X-Cache: HIT on both |
| stampede: 20 concurrent h3 streams on a brand-new key | ≤2 origin fetches |
cookie-keyed limit_req: burst spent on h2 is still spent on h3 |
one shared bucket; a fresh cookie restores it; no cookie ⇒ unlimited on h3 too |
| POST 64 KB | length and SHA at the origin match, duplicate key answered 409 across transports |
| 2 MB response, SSE with 8 frames 200 ms apart | byte-exact; frames arrive spaced, not coalesced |
| 50 streams on one h3 connection / 20 abandoned streams | all 200; connection still usable |
| upstream timeout / unreachable upstream | 504 on both, and identical codes across transports |
Alt-Svc: h3 |
advertised from the TCP side |
One config trap surfaced while writing it, now documented in OPERATIONS §5.4: listen 443 ssl quic
does not enable h2 on the TCP side (nginx is the same — http2 is a separate parameter), so an
h2-only client gets no application protocol; -t and the startup banner now print
TLS+HTTP/3(同端口 UDP) so the endpoint's shape is visible without probing it.
Other silent failures fixed
- A duplicated
Host:header is a 400 (RFC 9112) instead of silently using the first — which meant a
client library's automaticHost: 127.0.0.1:portcould route a request to the listener's
default vhost: a cross-site routing error that looked completely normal. if ($http_x_api_key = "")— the plainest way to say "this header is absent" — lost its empty
right-hand side to paren stripping and failed-tas "missing right-hand side", so a whole vhost
could not start.proxy_passwith a URI part now does the pure string replacement nginx documents ("the part of a
normalized request URI matching the location is replaced by a URI specified in the directive").
When the request path equals the location prefix exactly, nothing is appended:
location /api/hot+proxy_pass …/api/hotused to forward/api/hot/, which split the resource
in the backend's route table and forked the cache key. Nginx's non-normalising corners are pinned
too (/apixwithlocation /api→/v1x;location /api+…/v1/→/v1//x).- An upstream that times out returns 504, not 502, matching
ngx_http_upstream_next()
(FT_TIMEOUT/FT_HTTP_504→GATEWAY_TIME_OUT, the rest →BAD_GATEWAY). Behaviour change to
plan for: sites whose alerts orerror_pagetreat 502 as "upstream trouble" must add 504.
Along with it, what counts towardmax_failsnow follows the same split nginx makes — connect /
timeout / invalid-response attempts always count (even for a POST that is correctly not replayed),
http_XXXstatuses count only when the gateway actually switched nodes and never for 403/404 — so
a node that only ever answers 503 stops being unkillable.
Business-shape suites added this round: course_selection_e2e (2400 students at the moment
enrollment opens: no oversell, and the origin's enroll count equals the gateway's admitted count
exactly), flash_sale_e2e 36/36 (1200 concurrent on one hot SKU cost the origin exactly one fetch —
the suite requires the origin's own counter and the gateway's X-Cache: MISS count to be the same
number, which is how the trailing-slash bug was caught; 400 concurrent over 50 seats, no oversell; a
slow node's four orders came back 504 with exactly those four idempotency keys on that node and
nowhere else), herd_e2e 18/18, production_shape_e2e 529/529.
Documentation
docs/TUTORIAL.md— two new chapters: §12 限流与配额 (starts from "who shares a bucket", with the
measured 1×200+3×429 vs 4×200 queue comparison at 1/512/1009/1506 ms and the shared-egress-IP
experiment) and §13 四层转发 (TCP tunnels, SNI steering, the three UDP shapes, the capped-vs-uncapped
measurement); §3.1 gains theproxy_passreplacement rules; §11 lists the suites by what they prove
(functional contract vs business shape) and the regression runner's--only; §14/§15 renumbered.
Every number in the two new chapters comes fromconf/tutorial_limit.conf/
conf/tutorial_stream.confrun for real, andtools/tutorial_limit_check.pyre-checks the chapter.docs/OPERATIONS.md— §5.2 (limit dimensions, empty-key semantics, the spoofable-key and
limit_req_statuswarnings), §5.3 (expires, incl. thesafe_statusset), new §5.4
...
v1.0.1
ONRouter 1.0.1 — three config-and-cache semantics gaps closed, each with the evidence bar set on 1.0.0: a disk cold tier for proxy_cache, conditional revalidation on expiry, and a REWRITE phase at server level.
What's new
proxy_cache on disk: proxy_cache_path <dir> … disk=on
Until now proxy_cache_path accepted a path and used only keys_zone, so a restart dropped every cached response and levels / max_size / inactive were decoration. 1.0.1 gives the response cache the same cold tier that lua_shared_dict disk= already has — the same store code, the same accounting, a separate metric family:
proxy_cache_path logs/cache_disk keys_zone=big:64m disk=on max_size=2g max_value=1m;- Whatever LRU pushes out of
keys_zonewhile still fresh is written asynchronously: one writer thread per zone, temp+rename, self-describing entries with CRC. The request path never waits on the disk. - The next request for that key is served from the tier and promoted back into memory, so the second hit is memory-only.
X-Cache-StatusstaysHIT— nginx's vocabulary has no "disk hit" and clients and front proxies parse that value — and the tier is reported byX-Cache-Tier: disk. - A restart rebuilds the index by scanning the directory.
onrouter_proxy_cache_disk_operations_total{zone=…,op="warm_recovered"}is the count it recovered. - Opt-in. A zone without
disk=onnever creates its directory; behaviour is byte-identical to 1.0.0. - Only fresh entries, only entries worth keeping.
spill_after(default 1) is the gate, and withproxy_cache_learningenabled it reads the same TinyLFU sketch as memory admission — so one-shot scan traffic can neither enter memory nor fill a disk. Responses abovemax_value(default 4 MiB) stay in memory. - Corruption never becomes a response. An entry failing magic / version / CRC / length is dropped and counted in
corrupt, and the request goes to the origin. The payload format carries its own version and refuses a lying length field instead of trusting it into an allocation. invalidatealso forgets the cold copy, otherwise a purge would resurrect on the next read.cache_governor_tier deepnow spillsproxy_cachevictims into the tier when one exists, so reclaiming memory no longer has to mean throwing the cache away.-tprints the per-zone tier (root, cap,levels, gate, blob cap) the way the split and schedule self-descriptions do, and namesmin_free_size=/use_temp_path=as unimplemented rather than ignoring them in silence. Adisk=onthat cannot open its directory fails the config load instead of quietly downgrading; hot reload drains the old store's queue before opening the new one, so a reload never leaves two stores on one directory.
This is a cold tier, not nginx's full write-through disk cache: an object that never left memory is not on disk. Size keys_zone for the hot set, don't lean on max_size.
proxy_cache_revalidate on; — ask, don't resend
An expired entry used to mean "fetch the whole object again". Now the gateway takes the cached entry's own ETag / Last-Modified to the origin; a 304 refreshes the expiry, replays the stored body and reports X-Cache-Status: REVALIDATED. What is saved is the second transfer of an object nobody changed — not the round trip, so this is not a substitute for staying at the origin (that would be proxy_cache_background_update, still open, see below).
Two properties are deliberate: the client's own If-* headers are replaced on this path, because "the copy in our cache is unchanged" is not "the copy in your browser is unchanged"; and headers absent from the 304 are kept, never dropped, so a content-type cannot vanish from a revalidated entry.
REWRITE at server level
return / rewrite / set / if belong to nginx's REWRITE phase, which runs before location matching. ONRouter executed that phase only inside a location, so a server-level one produced a -t warning and was skipped — which made the plainest HTTPS-redirect vhost in the world,
server {
listen 80;
server_name www.example.com;
return 301 https://$host$request_uri;
}a config that passes -t and then answers 404. It now returns 301. Server-level set/rewrite also change which location matches, as they do in nginx.
One asymmetry to know: server-level return runs before access control (nginx-consistent — a redirect-only vhost has no location to run it after), while a location-level return still runs after allow/deny/auth_basic/access_by_*. That difference is ONRouter's deliberate choice, documented in the capability matrix; the two levels are not interchangeable.
Also fixed on the way: return never expanded variables in its body or redirect URL, so return 301 https://$host$request_uri sent the literal $host. Variables are now interpolated, and unrecognised names stay literal.
Metrics
onrouter_proxy_cache_disk_{entries,bytes,value_bytes,capacity_bytes,pending_jobs,pending_bytes} and onrouter_proxy_cache_disk_operations_total{zone,op} with op in spill / spill_skipped / cold_hit / promote / evict / expire / corrupt / cold_error / never_read / warm_recovered. The family is absent when no zone declares disk=on. Shared-dictionary tiers keep their own onrouter_dict_disk_* family so response-cache bytes are never read as dictionary bytes.
Verification
cargo test --workspace→ 503/503 (490onrouter-server+ 13onrouter-config); clippy with no new findings.- New
conf/proxy_disk.conf+tools/proxy_disk_e2e.py→ 39/39 on real traffic with three zones of the same 8-entrykeys_zone(disk=on spill_after=0,disk=on spill_after=3, nodisk=control): evicted-fresh entries really land in the directory asORDKrecords, a cold hit returnsHIT+X-Cache-Tier: diskwith the backend counter unchanged — the only evidence that matters — promotion then removes the tier marker, the control zone's directory is never created, the gated zone writes nothing for one-shot traffic, flipping a byte in every cold file yields correct bodies with 12 refetches and acorruptcount that moves, and after a stop/startwarm_recoveredequals the index size reported before shutdown. - No regression in the cache suites it could have broken:
proxy_cache13/13,cache_advanced32/32,cache_header_gap25/25,disk_cache53/53,learning_cache34/34,still_online25/25,governor32/32 — 398 assertions across 13 suites in total, all green. tools/set_if_e2e.pyis now 34/34 andtools/proxy_cache_e2e.py21/21; the six CI feature suites
total 184 assertions (canary 22, wasm 23, resilience 17, affinity 7, proxy_split 76, proxy_disk 39).conf/tutorial_full.confverifies end to end at 38/38, including its new/coldendpoint, so the tutorial snippet is the config that actually ran.-tover all 90 shipped configs: unchanged (only the two known environment-bound cases,tls_legacy.confwhich needs thenative-tlsrebuild andstream_ssl_preread_e2e.confwhich is stream-only by design).- Full roll on the final binary:
python run_regression.py reg_101_roll.txt→TOTAL 86 scripts, nonzero=0. - 21-minute soak: PASS — 2,231,814 requests, 0 failures, 1778.1 req/s, private memory
18.58 → 19.29 MiB (+0.71 MiB, +0.021 MiB/min against a 1 MiB/min gate), peak 21.07 MiB; the cold
tier churned the whole time (81,723 spills / 80,986 cold hits / 80,986 promotions,corrupt0,
never_read0,pending_jobs0). A cold-tier-only run agrees: +1.07 MiB over 2,175,742 requests.
Documentation
docs/OPERATIONS.md§9.1 — what the tier does and does not promise, thespill_after/max_value/max_sizeknobs, why a cold hit keeps the nginx status word, and the metric family.docs/TUTORIAL.md§5 — a runnable 8-entrycoldzone that shows the spill, theX-Cache-Tier: diskhit and the promotion in threecurls; §11.2 adds the new suite.docs/CAPABILITY_MATRIX.md— the "proxy_cache is memory-only" gap line, the server-level-REWRITE row in the ignored-directives table and the disk-tier roadmap item are all replaced by implemented rows with evidence;proxy_cache_background_updatemoves to the roadmap with the reason it is not shipped (a background refetch carries its own content-negotiation headers into an entry other clients read, and the cache key has noVarydimension yet — that is a variant-keying task, not a threading task).README.md— 1.0.1 in the feature map and in the cache section, replacing the old "the path is never written" sentence.
中文摘要
1.0.1 只做一件事,但按 1.0 的证据标准做完:proxy_cache_path <目录> … disk=on 让响应缓存拿到与 lua_shared_dict disk= 同一套磁盘冷层。被 keys_zone 挤出去且还没过期的响应异步落盘(每区一个写线程、temp+rename、条目自描述带 CRC),下次同 key 请求从磁盘读回并提升进内存,重启靠扫目录重建索引。是开关而非默认:没写 disk=on 的区连目录都不创建,行为与 1.0.0 逐字节一致。冷命中的 X-Cache-Status 仍是 HIT(不改 nginx 词表),层级由 X-Cache-Tier: disk 说明,账目在 onrouter_proxy_cache_disk_*。spill_after 决定谁值得写盘,开着缓存学习时与内存准入用同一把频率草图,一次性扫库流量既进不了内存也写不满磁盘;坏条目一律按未命中回源并计入 corrupt,绝不拼成响应发出去;invalidate 会连磁盘副本一起摘。深睡在配了冷层的区改为下沉而不是丢弃。min_free_size= / use_temp_path= 在 -t 里明说未实现。注意它是冷层不是 nginx 的全量写盘:没被挤出过内存的对象不在磁盘上。
Full Changelog: v1.0.0...v1.0.1
v1.0.0
ONRouter 1.0.0 — the first release whose deliverable is "run it in production", not just "speak the config": CPU affinity, outage resilience, canary splitting, a WASM plugin host, CI, a 21-minute soak and two handbooks.
What's new
CPU affinity — worker_threads + worker_cpu_affinity
worker_threads N; sizes the runtime, and worker_cpu_affinity auto|off|<hex masks>; pins each
worker to a core or a core set: Linux sched_setaffinity, Windows SetThreadAffinityMask, and on
macOS — where no hard thread affinity exists — the gateway says so instead of pretending. Masks
rotate when there are more cores than workers, and -t prints the per-thread plan before the
process ever accepts a connection, so the binding is reviewable in code review rather than
guessed from top.
Resilience — circuit_breaker and lazy_rejoin
circuit_breaker errors=… window=… open=…;adds the closed → open → half-open machine on top of
the existing passivemax_fails/fail_timeoutremoval. Failures count consecutively inside the
window: one success in between resets the tally.lazy_rejoin <interval>;makes an ordinary user request the recovery probe. A dead backend is
carried by the remaining servers, the next real request doubles as the trial, and a success puts
the node back — no separate health-check traffic to budget for.- Metrics:
onrouter_upstream_server_availablenow reports what the scheduler may actually pick
(manualdown, passive removal, open circuit), whileonrouter_upstream_server_healthystays
purely the active check verdict — with nohealth_checkconfigured it never leaves 1, which is
exactly why the alerting guidance moved toavailable. Also new:
onrouter_upstream_lazy_probes_total,onrouter_upstream_circuit_state,
onrouter_upstream_circuit_trips_total.
Canary / grey release
canary $var "<sticky key>" { cookie …; header …; arg …; ua ~* …; os …; percent …; default …; }
selects a whole declared upstream per request — first match wins, and the sticky key keeps one user
on one version instead of bouncing between two. $proxy_split / $proxy_split_served plus
proxy_split_failover on|off|http://target turn a cross-version failover into a visible ledger line
rather than a silent 200, and the response cache is keyed by the branch that served it.
WASM gateway plugins
wasm_plugin <name> <file.wat|file.wasm> [fuel=…] [memory=…]; plugged into access_by_wasm,
header_filter_by_wasm and body_filter_by_wasm. A small stable host ABI exposes request/response
headers, status and body; fuel and a memory cap bound a bad module's blast radius, and traps fail
closed — a broken plugin cannot quietly pass traffic through unmodified.
Verification
cargo test --workspace→ 482/482 (469onrouter-server+ 13onrouter-config);
cargo clippy --workspace --all-targets→ no errors.- Five real-traffic E2E suites → 145/145:
canary_e2e22/22,wasm_e2e23/23,
resilience_e2e17/17 (passive removal, lazy rejoin driven by a real request, circuit
open → half-open → recovered,server_available),affinity_e2e7/7,proxy_split_e2e76/76. conf/tutorial_full.confend to end → 33/33 (static, canary conditions/percent/stickiness,
proxy path rewrite, cache MISS→HIT, WASM, Lua shared dict, circuit 502 + half-open recovery,
lazy rejoin).- 21-minute soak: PASS — 2,591,305 requests, 0 failures, 20.92 min, 2064.7 req/s, backend
hits 1,963,093; private memory 20.11 → 21.97 MiB (+1.86 MiB, slope +0.067 MiB/min against a
1 MiB/min gate), peak 22.15 MiB. An earlier run of the same configuration added +2.19 MiB over
2,787,193 requests, so the growth tracks warm-up, not elapsed requests. tools/release_verify.pyon the extracted package → 26/26, including the front_proxy
recognise-but-do-not-trust split and the provider-name grep over the packaged binary
(controlsfront_proxy49 /auto_trust10).- GitHub Actions
CIon this commit: unit + clippy + the five suites, success.
Downloads / assets
Per-platform archives built by release.yml (onrouter + orr-gencert, plus SHA256SUMS.txt).
Note the honest gap: those workflow assets still carry only the two executables. The runnable
directory — conf/*.conf, the wasm/wasm_static/soak_static trees, lua/, public/, both
docs, and a certs/ pair generated at package time so a fresh extract passes
onrouter -t -c conf/onrouter.conf — is what build-release.sh / build-release.ps1 produce in
the tree; wiring release.yml to that same staging is the follow-up for the next tag.
Documentation
docs/OPERATIONS.md— the operator half: every 1.0 directive with its default and the failure
it guards against, ports/TLS/cert placement, affinity and topology reading, resilience and canary
wiring, cache/Lua/WASM limits, metric families with the alerts that useserver_available,
the-tcontract, hot reload, upgrade/rollback (binary hot upgrade viaUSR2is still not
implemented — roll or blue-green), and the pre-release checklist.docs/TUTORIAL.md— the build-it-up half:conf/tutorial_hello.confto
conf/tutorial_full.conf, feature by feature, with the commands and the metrics that prove each
step actually took.
中文摘要
1.0.0 把“能跑配置”升级成“能上线”:worker 线程绑定 CPU 核心(Linux/Windows 生效,macOS 明确说明不支持,
-t 会打印每线程的绑定计划);circuit_breaker 熔断叠加原有被动摘除,lazy_rejoin 让真实用户请求充当
恢复探针,宕机后端由其他服务器分担、恢复后自动加回;canary 按 cookie / header / 参数 / UA / 系统 /
百分比把整条 upstream 版本发给用户,粘性键保证同一人不来回跳版本,跨版本 failover 在账本里可见;
wasm_plugin 在 access / header_filter / body_filter 三个相位接管网关,带 fuel 与内存上限,出错一律 fail-closed。
新增 onrouter_upstream_server_available 表达“此刻可被调度”,与只反映主动健康检查的 server_healthy 分工。
验证:单元 482/482,五组真实流量 E2E 145/145,教程配置端到端 33/33,发布包 release_verify 26/26,
21 分钟压测 PASS(2,591,305 请求、0 失败、2064.7 req/s,私有内存 +1.86 MiB、斜率 +0.067 MiB/min)。
运维手册与使用教程分别在 docs/OPERATIONS.md、docs/TUTORIAL.md。
Full Changelog: v0.7.0...v1.0.0
v0.7.0
v0.6.0
v0.5.0
ONRouter v0.5.0 — the release where the CDN/front-proxy layer landed with no provider
knowledge inside the binary, and where a feature's cost is measured against its own control
row instead of asserted. Everything new is default-off, memory-bounded, and proven with real
traffic against a spawned gateway.
Front proxy, configured rather than compiled in
front_proxy <name> { … } blocks declare a probe header, an ordered real-client header chain,
role headers for scheme/country/host, and the ranges that peer is that fleet. The engine never
names a provider: grep -a -i -o over the release binary finds 0 hits for Cloudflare,
Fastly, CloudFront, Akamai or any of their header names, while the controls
front_proxy/auto_trust hit 48/10 times and the only baked-in header names are nginx's own
x-real-ip (1) and x-forwarded-for (2). A provider renaming a header or rotating its ranges is
a config edit, not a code path.
Recognition never grants trust. Every header-derived value requires the direct TCP peer to sit
inside profile.trust ∪ set_real_ip_from, and promoting a profile's own ranges into a rewriting
authority root takes the explicit front_proxy_auto_trust on, which -t and startup both name
the vhost for. egress mode then moves $remote_addr (so limit_req, geo, ACLs, VIP
reputation and the access log all key on the true client), restores the visitor-side $scheme,
and emits RFC 9209 Cache-Status: onrouter; detail=… for verified peers only. Four "declared but
inert" shapes are -t errors, not warnings.
conf/front_proxy_examples.conf is the operator-facing half: copy-paste profiles for a
Cloudflare-shaped edge (22 published ranges, refreshed from the provider's own list, with the
commands to re-fetch), a CloudFront-shaped one (0 ranges shipped on purpose — pull the
origin-facing prefix list at deploy time) and a plain-XFF self-built edge, plus a detect server
that boots as-is and an egress upgrade template.
Learning trio — one ledger, all off by default
proxy_cache_learning (TinyLFU admission + eviction), limit_req_learning (per-key burst shape
feeding queue/delay) and vip_learning (reputation → queue priority + cache residency) share one
accounting ledger under learning_memory_budget. Attack surface was designed in: a forged
realip report cannot buy VIP weight.
Surviving an upstream outage
still_online.rs replays a last-known-good response when the upstream is down, behind a
credential gate that cannot be switched off — no Set-Cookie/Authorization-bearing response is ever
replayed to a different principal — with a single-flight background retry once health returns.
Cache / disk tier / config semantics
Cold tier under ngx.shared.DICT (disk_cache.rs) persists asynchronously and rebuilds its index
by scanning the directory, so recovery does not depend on a sidecar manifest; the Lua API is
unchanged. proxy_ignore_headers, empty-and-multi-value proxy_no_cache/proxy_cache_bypass,
add_header with an undefined variable (omitted, not printed literally), $upstream_cache_status,
and the REWRITE-phase set + if subset (=, !=, ~, ~*, !~, !~* wrapping
rewrite/set/return, config order preserved; nested if, file tests and if without a block
are parse-time errors).
Performance: the diagnostics stopped paying for themselves
All four log macros expanded to with_log(level, format!(…)), and format! is an argument — it
ran unconditionally and threw the String away under the default warn threshold, so a
per-request diagnostic was a real cost. They now test log::enabled(level) first. Measured with
bench_front_proxy.py (one binary, five identically-shaped vhosts + a floor gateway, branch
self-proof per row, 5 passes taking the median), read as each row against its own off row
because the floor row's own round spread reached 36%: the egress penalty at c=1 went from
−0.4%…−9.2% to −0.4%…−4.8% (cache-hit row −9.2% → −0.4%), c=32 stayed inside jitter, and the
off/detect/notrust control rows did not move. detect costs nothing measurable — it returns
after labelling, before address parsing. What remains on the egress path is per-request
String/Vec cloning, which is the next target.
Security
docs/SECURITY_AUDIT.md records S9-S12, including the front-proxy trust boundary and the fact
that a stale or too-wide trust range is an operator-owned snapshot the engine cannot validate.
rce_matrix 27/27: a .lua file or the config has code-execution power, and a script that
concatenates ngx.var.arg_* into load() / os.execute() is an application-level RCE the
gateway will not contain.
Verification
cargo test --workspace387/387 (374onrouter-server+ 13onrouter-config).- 74 Python E2E scripts (
tools/*_e2e.py+ 8 root scenario suites), all exit 0 with real traffic
against a spawned gateway and fake backends:python run_regression.py→
TOTAL 74 scripts, nonzero=0(2026-10-20). Highlights:front_proxy54/54,
front_proxy_examples24/24 (boots the shipped vendor example and proves the
recognise-without-trust split at runtime),disk_cache53/53,vip_learning51/51,
queue_learning42/42,metrics38/38,security_matrix36/36,learning_cache34/34,
still_online25/25,set_if26/26,api_compat57/57,rce_matrix27/27. - Binary string audit for vendor names/headers, re-run against this 0.5.0 build.
Assets
Eight cross-platform archives (Linux x86_64/aarch64/armv7/riscv64 as .tar.gz, macOS x86_64/aarch64
and Windows x86_64/aarch64 as .zip), each containing onrouter + orr-gencert, plus
SHA256SUMS.txt whose digests verify against the release's flat asset names — checked against
GitHub's own server-side digest for every asset: 8/8 identical.
The CI-built Windows x86_64 asset was extracted and driven with real traffic, not just
inspected: --version and the response Server: header both report ONRouter/0.5.0,
-t conf/front_proxy_examples.conf exits 0, a static file is served (200, 2694 bytes), a full
Cloudflare-header request through that binary is labelled X-Fp: cloudflare-edge while
$remote_addr stays 127.0.0.1 (recognise ≠ trust holds in the shipped artefact), and the
vendor-string audit over the packaged binary is again 0 hits with front_proxy at 48 as control.
Full Changelog: v0.4.0...v0.5.0
v0.4.0
ONRouter v0.4.0 — the project is renamed from LearnON, this carries the nginx/OpenResty parity
campaign that followed v0.3.0, and it is the first release with a documented security audit
(docs/SECURITY_AUDIT.md, findings S1-S8) where every finding was reproduced with real traffic
before it was fixed.
Naming
onrouter,orr-gencert,orr-h3checkbinaries; cratesonrouter-config/onrouter-server;
conf/onrouter.conf;ONROUTER_*env prefix;Server: ONRouter/0.4.0;onrouter_*metric names.
Request path
- Upgrade (WebSocket) tunnels now pass the ACCESS-phase controls (
allow/deny,auth_basic,
limit_req/limit_conn) and honourproxy_read_timeoutas an idle timeout. client_max_body_sizedefaults to1m; streamed chunked bodies are length-capped and forwarded
without copying the whole body;client_body_timeoutapplies to streamed uploads.- Upstream response bodies are read under
proxy_read_timeoutwith the connection guard alive for
the whole transfer, so passive health checks and metrics count aContent-Lengthresponse on its
last frame.
Proxy / cache / TLS
proxy_send_timeout,proxy_next_upstreamtries+deadline budgets,balancer_by_luapeer and
timeout overrides, per-status cache counters,proxy_cachelock/stale/bypass with LRU eviction.- Session ticket +
ssl_session_cachesemantics,ssl_certificate_by_lua, hot cert reload on SIGHUP,
mTLS to upstreams,ssl_protocolsselection with optional--features native-tlsfor TLS 1.0/1.1. - L4
stream {}proxying withssl_prereadSNI routing, connection accounting and graceful drain.
Lua surface
receiveuntil, timer coroutines,ngx.sleep/cosocket/ngx.req.socket/ngx.flush,ngx.var
read+write,ngx.re.*,ngx.balancer,init_worker_by_lua+lua_package_path, bundled
resty.cookie,resty.limit.{req,count,conn},resty.http/resty.redis, concurrent subrequests
withngx.semaphore, andshared.DICTaccounting withmake_room.- Cooperative preemption: every coroutine resume arms a Lua count hook
(lua_slice_instructions, default 1 000 000 instructions ~ 10 ms), so a runaway loop can no longer
blackout the single process.
Observability
/metricsgained per-upstream node connections/health/success/failure, cache status, TLS handshake
failures, limit rejections and dropped access-log lines; the combined log carries$request_time
plus$upstream_addr/$upstream_status/$upstream_header_time.
Security audit (S1-S8)
Each row was reproduced against the pre-fix binary first, and each has a probe that fails there and
passes here (tools/security_matrix_e2e.py 20/20, tools/rce_matrix_e2e.py 27/27):
- S1
auth_basicwith no user file served the location without any credential — now 401 +error_log. - S2 an htpasswd line with an empty hash authenticated on an empty password — such entries are dropped.
- S3 the htpasswd cache keyed on mtime only, so rewrites that preserved mtime kept revoked users
logged in — now keyed on(mtime, len)and bounded to 1 s. - S4 the Upgrade request line was built from the decoded path (
/ws%23fragreached the backend as
/ws#frag), a routed-vs-forwarded desync — the raw undecoded request target is forwarded now. - S5
proxy_request_buffering offskippedclient_body_timeout, so a stalled uploader pinned a
connection and an upstream socket forever — a per-frameSleepguard rearms on every data frame. - S6 any
sub_filtermade the static handler read the whole file into memory (32 MiB x concurrency,
switched on by one directive) — only the requested window is read, and above 8 MiB it streams
unfiltered with a logged reason. - S7
while true do endincontent_by_lua_blockheld a VM thread and, because ONRouter is one
process, unrelated endpoints stopped answering — bounded by the preemption quantum above. - S8
run_phasessignals errors withtrue, but every caller testedif !run_phases(...), so a
missing/brokenaccess_by_lua_fileread as "check passed" and the protected body was served with
200; a streamingcontent_by_lua_filethat failed to load produced an empty 200. Callers
now terminate on error (nginx parity: an unreadable phase file is a 500, never a pass).
Remote-execution surface
There is no path from client input to code execution: config _by_lua_* bodies and
_by_lua_file paths are config-time literals (no request-variable interpolation, so ?f= cannot
redirect a script path), every lua.load() in the gateway compiles a constant library source, the
debug library is unavailable so mlua's named registry (where cross-request handles live) is
unreachable, there is no process spawn or request-driven file write in the shipped path, no
X-Accel-Redirect, proxy_pass targets cannot be steered by $arg_/request headers (verified
against a canary listener), encoded CR/LF is answered with 400, and internal locations stay
unreachable externally across nine encodings.
Trust boundary, stated plainly: Lua scripts are not sandboxed — os, io, load, loadfile,
dofile, require and package are available inside a script, exactly as in OpenResty. Anyone who
can write a .lua file or the config has code-execution power; and a script that concatenates
ngx.var.arg_* into load() / os.execute() is an application-level RCE the gateway will not
contain. Treat config and scripts as trusted code.
Verification
cargo test --workspace255/255 (242 onrouter-server + 13 onrouter-config).- 65 Python E2E suites, all exiting 0 with real traffic against a spawned gateway and fake backends
(python run_regression.py summaryE.txtwrites the exit-code roll-up underlogs/regression/);
highlights:rce_matrix27/27,security_matrix20/20,api_compat57/57,metrics38/38,
observability26/26,lua_flush_socket23/23,thread24/24,subrequest18/18,static_stream19/19. - Windows A/B vs OpenResty (
bench_ab.py --sweep 1,4 --requests 12000, 16 logical cores, keep-alive
GET without TLS): static 1.26x / 2.22x, proxy 2.96x at c=4, Lua 0.71-0.89x of LuaJIT. Read
docs/OPENRESTY_GAP_ANALYSIS.mdsection 3 for the caveats — no Linux baseline has been recorded yet.
Assets
Eight cross-platform archives (Linux x86_64/aarch64/armv7/riscv64 as .tar.gz, macOS x86_64/aarch64
and Windows x86_64/aarch64 as .zip), each containing onrouter + orr-gencert, plus
SHA256SUMS.txt. The Windows x86_64 asset was verified by extracting it and driving real requests:
--version reports ONRouter/0.4.0, the response carries Server: ONRouter/0.4.0, and the S8
fail-closed behaviour is present in the packaged binary.
Full Changelog: v0.3.0...v0.4.0