Skip to content

Releases

1.0.5 — the forwarded path keeps the characters it was sent with

Choose a tag to compare

@github-actions github-actions released this 23 Oct 05:27

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.rs unit test extended to the pchar round-trip; the %25 and #/? guards are asserted
    in both directions so this cannot silently regress.
  • Unit tests 504/504; full local regression TOTAL 97 scripts, nonzero=0 with SKIP empty
    (the roll went from 96 to 97 because uri_encode is now part of it); the packaged artefacts are
    checked by tools/release_verify.py driving 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

Choose a tag to compare

@github-actions github-actions released this 22 Oct 15:15

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.

v1.0.3

Choose a tag to compare

@github-actions github-actions released this 22 Oct 05:24

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_shape was pointing at certs_random/fallback.pem, a directory another suite generates
    and .gitignore excludes: fine on any machine that had run that suite, dead on a clean checkout.
    Two constraints are recorded in the module — openssl req -x509 stamps CA:TRUE by 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 /ping could raise
    Connection refused on 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_time is 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_e2e is 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 follow sni_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:

  1. Which certificate answers an unmatched SNI. If a multi-vhost listener relied on the
    first-written block being the implicit default, declare default_server on the block whose
    certificate should be presented in that case — which is also what nginx requires.
  2. Governor demotions that the cold tier cannot accept now stay in memory and show up as
    spill_skipped rather than as keys that vanish. If a zone's max_disk is 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_matrix 76/76, tls_session 45/45,
    tls_protocols 13/13, tls_legacy 12/12, mtls_upstream 11/11, https_backend 12/12,
    protocol_matrix 37/37, quic_business 58/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: governor 32/32, disk_cache 53/53, cache_advanced 32/32,
    proxy_cache 21/21, proxy_disk 39/39, `still...
Read more

v1.0.2

Choose a tag to compare

@github-actions github-actions released this 21 Oct 17:34

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 -t error, not a warning: $status,
    $upstream_*, $sent_http_*, $request_body, $server_name, and anything made by set. 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 automatic Host: 127.0.0.1:port could 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 -t as "missing right-hand side", so a whole vhost
    could not start.
  • proxy_pass with 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/hot used 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 (/apix with location /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 or error_page treat 502 as "upstream trouble" must add 504.
    Along with it, what counts toward max_fails now follows the same split nginx makes — connect /
    timeout / invalid-response attempts always count (even for a POST that is correctly not replayed),
    http_XXX statuses 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 the proxy_pass replacement 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 from conf/tutorial_limit.conf /
    conf/tutorial_stream.conf run for real, and tools/tutorial_limit_check.py re-checks the chapter.
  • docs/OPERATIONS.md — §5.2 (limit dimensions, empty-key semantics, the spoofable-key and
    limit_req_status warnings), §5.3 (expires, incl. the safe_status set), new §5.4
    ...
Read more

v1.0.1

Choose a tag to compare

@github-actions github-actions released this 21 Oct 08:51

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_zone while 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-Status stays HIT — nginx's vocabulary has no "disk hit" and clients and front proxies parse that value — and the tier is reported by X-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=on never 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 with proxy_cache_learning enabled it reads the same TinyLFU sketch as memory admission — so one-shot scan traffic can neither enter memory nor fill a disk. Responses above max_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.
  • invalidate also forgets the cold copy, otherwise a purge would resurrect on the next read.
  • cache_governor_tier deep now spills proxy_cache victims into the tier when one exists, so reclaiming memory no longer has to mean throwing the cache away.
  • -t prints the per-zone tier (root, cap, levels, gate, blob cap) the way the split and schedule self-descriptions do, and names min_free_size= / use_temp_path= as unimplemented rather than ignoring them in silence. A disk=on that 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 (490 onrouter-server + 13 onrouter-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-entry keys_zone (disk=on spill_after=0, disk=on spill_after=3, no disk= control): evicted-fresh entries really land in the directory as ORDK records, a cold hit returns HIT + X-Cache-Tier: disk with 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 a corrupt count that moves, and after a stop/start warm_recovered equals the index size reported before shutdown.
  • No regression in the cache suites it could have broken: proxy_cache 13/13, cache_advanced 32/32, cache_header_gap 25/25, disk_cache 53/53, learning_cache 34/34, still_online 25/25, governor 32/32 — 398 assertions across 13 suites in total, all green.
  • tools/set_if_e2e.py is now 34/34 and tools/proxy_cache_e2e.py 21/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.conf verifies end to end at 38/38, including its new /cold endpoint, so the tutorial snippet is the config that actually ran.
  • -t over all 90 shipped configs: unchanged (only the two known environment-bound cases, tls_legacy.conf which needs the native-tls rebuild and stream_ssl_preread_e2e.conf which 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, corrupt 0,
    never_read 0, pending_jobs 0). 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, the spill_after / max_value / max_size knobs, why a cold hit keeps the nginx status word, and the metric family.
  • docs/TUTORIAL.md §5 — a runnable 8-entry cold zone that shows the spill, the X-Cache-Tier: disk hit and the promotion in three curls; §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_update moves 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 no Vary dimension 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

Choose a tag to compare

@github-actions github-actions released this 21 Oct 05:30

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 passive max_fails/fail_timeout removal. 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_available now reports what the scheduler may actually pick
    (manual down, passive removal, open circuit), while onrouter_upstream_server_healthy stays
    purely the active check verdict — with no health_check configured it never leaves 1, which is
    exactly why the alerting guidance moved to available. 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 (469 onrouter-server + 13 onrouter-config);
    cargo clippy --workspace --all-targets → no errors.
  • Five real-traffic E2E suites → 145/145: canary_e2e 22/22, wasm_e2e 23/23,
    resilience_e2e 17/17 (passive removal, lazy rejoin driven by a real request, circuit
    open → half-open → recovered, server_available), affinity_e2e 7/7, proxy_split_e2e 76/76.
  • conf/tutorial_full.conf end 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.py on the extracted package → 26/26, including the front_proxy
    recognise-but-do-not-trust split and the provider-name grep over the packaged binary
    (controls front_proxy 49 / auto_trust 10).
  • GitHub Actions CI on 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 use server_available,
    the -t contract, hot reload, upgrade/rollback (binary hot upgrade via USR2 is still not
    implemented — roll or blue-green), and the pre-release checklist.
  • docs/TUTORIAL.md — the build-it-up half: conf/tutorial_hello.conf to
    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

Choose a tag to compare

@github-actions github-actions released this 20 Oct 15:23

Full Changelog: v0.6.0...v0.7.0

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 20 Oct 15:23

Full Changelog: v0.5.0...v0.6.0

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 20 Oct 04:55

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 --workspace 387/387 (374 onrouter-server + 13 onrouter-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_proxy 54/54,
    front_proxy_examples 24/24 (boots the shipped vendor example and proves the
    recognise-without-trust split at runtime), disk_cache 53/53, vip_learning 51/51,
    queue_learning 42/42, metrics 38/38, security_matrix 36/36, learning_cache 34/34,
    still_online 25/25, set_if 26/26, api_compat 57/57, rce_matrix 27/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

Choose a tag to compare

@github-actions github-actions released this 19 Oct 02:38

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-h3check binaries; crates onrouter-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 honour proxy_read_timeout as an idle timeout.
  • client_max_body_size defaults to 1m; streamed chunked bodies are length-capped and forwarded
    without copying the whole body; client_body_timeout applies to streamed uploads.
  • Upstream response bodies are read under proxy_read_timeout with the connection guard alive for
    the whole transfer, so passive health checks and metrics count a Content-Length response on its
    last frame.

Proxy / cache / TLS

  • proxy_send_timeout, proxy_next_upstream tries+deadline budgets, balancer_by_lua peer and
    timeout overrides, per-status cache counters, proxy_cache lock/stale/bypass with LRU eviction.
  • Session ticket + ssl_session_cache semantics, ssl_certificate_by_lua, hot cert reload on SIGHUP,
    mTLS to upstreams, ssl_protocols selection with optional --features native-tls for TLS 1.0/1.1.
  • L4 stream {} proxying with ssl_preread SNI 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
    with ngx.semaphore, and shared.DICT accounting with make_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

  • /metrics gained 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_basic with 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%23frag reached the backend as
    /ws#frag), a routed-vs-forwarded desync — the raw undecoded request target is forwarded now.
  • S5 proxy_request_buffering off skipped client_body_timeout, so a stalled uploader pinned a
    connection and an upstream socket forever — a per-frame Sleep guard rearms on every data frame.
  • S6 any sub_filter made 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 end in content_by_lua_block held a VM thread and, because ONRouter is one
    process, unrelated endpoints stopped answering — bounded by the preemption quantum above.
  • S8 run_phases signals errors with true, but every caller tested if !run_phases(...), so a
    missing/broken access_by_lua_file read as "check passed" and the protected body was served with
    200; a streaming content_by_lua_file that 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 --workspace 255/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.txt writes the exit-code roll-up under logs/regression/);
    highlights: rce_matrix 27/27, security_matrix 20/20, api_compat 57/57, metrics 38/38,
    observability 26/26, lua_flush_socket 23/23, thread 24/24, subrequest 18/18, static_stream 19/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.md section 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