Repository navigation
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
(h1/h2/h3 on one port,quic≠http2, request-body framing, what is shared across transports),
§7.1 (max_failsaccounting, 504 vs 502), §7.5 (UDP), §12.1 (the UDP and shared-dict metric
families, previously undocumented), §13.1 (the 1.0.2 soak record and the honest 1.0.1 roll line),
§16 (release checklist now names the three CI steps and the held-out suite with its reason).docs/OPERATIONS.md§2.1 was wrong about the artifacts and is corrected: GitHub Release assets are
two binaries per platform in a flat archive (no wrapper directory, noconf/), while the
"unzip and run" full directory —conf/,lua/,public/, freshly generatedcerts/,docs/—
is whatbuild-release.ps1/build-release.shproduce locally. Both extraction recipes are now
written out.docs/CAPABILITY_MATRIX.md/README.md— new rows with evidence for all of the above, including
the parts that are deliberate divergences (see below), and stale counts replaced by the ones
this roll produced.
Known boundaries, stated rather than discovered in production
proxy_cache_lockdefaults to off here and on in nginx.herd_e2equantifies both
settings; flipping a shipped default is an operator decision, so the number is published and the
default stays.- Disk cold-tier records store no validators, so
proxy_cache_revalidateapplies to memory-resident
entries; an expired cold object is refetched whole. - Zone capacity in
zone=<name>:<size>is display-only; capacity is the 32×16384 shard limit. set-created variables and response-phase variables are refused as limit keys, not accepted and
then silently emptied.- One suite,
tools/random_scenarios_e2e.py, is held out of the regression roll: its TLS oracle
conflates per-SNI certificates with per-:authorityvhosting on multi-domain ports, so it reports
false reds. The reason lives inrun_regression.py'sSKIPset with a TODO; certificate and
cross-site behaviour on that shape is covered byhttps_matrix_e2e71/71 instead. - No h2/h3 upstreams (proxy ALPN is pinned to
http/1.1), no WebSocket over h2/h3
(RFC 8441/9220), andproxy_bufferinghas no counterpart — proxied bodies are buffered and
forwarded as they arrive, so an SSE endpoint relies on the upstream flushing, not on a directive.
Verification
cargo test --workspace→ 500/500 on the bin target (clippy: no errors; warnings are
pre-existing style noise and not gating in CI).- Full regression on the final binary:
python run_regression.py reg_102_ship.txt→
TOTAL 94 scripts, nonzero=0 (the 1.0.1 roll's 86 plusexpires,limit_key,herd,
course_selection,game_udp,flash_sale,quic_businessandproduction_shape;
random_scenarios_e2eexcluded with the reason above). - Suite counts that moved this round:
security_matrix49/49 (was 36 — S13's duplicate-Hostcase
and its control probe added),expires42/42,flash_sale36/36,game_udp31/31,
quic_business41/41,proxy_cache21/21,set_if34/34,least_time43/43,
tutorial_limit_check17/17. - CI widened from six suites to fourteen across three steps, so the h2/h3 body bug cannot come back
unnoticed on Linux either. tools/release_verify.py26/26 against the unpackedtarget/dist/archives/onrouter-v1.0.2-*.zip
— the artifact a user downloads, not the build tree's binary: its-VandServer:header say 1.0.2,
the shipped configs-tclean, and the vendor-name grep has a live control count so a wrong pattern
cannot pass by finding nothing.- 21-minute soak: PASS — 2,275,361 requests, 0 failures, 1812.8 req/s, private memory
20.47 → 20.96 MiB (+0.49 MiB, +0.055 MiB/min against a 1 MiB/min gate), peak 22.33 MiB; cold tier
churned throughout (83,363 spills / 82,632 cold hits / 82,632 promotions,corrupt0,
never_read0,pending_jobs0). The soak harness's own cleanup bug that madecorruptreport
256 stale files is fixed, so that account reads clean from now on.
中文摘要
1.0.2 做的是"限流按谁计数、响应什么时候算新鲜、UDP 这道门"三件事,外加六处静默失效的修复——
它们都是照真实业务形状打出来的:学校选课、大促闪购、游戏 UDP,以及同一个站点分别走
h1.1-TLS / h2-TLS / HTTP/3 的跨传输对撞。最严重的一条在最后:h2 与 h3 的请求体在被转给后端的
时候被丢掉了。网关过去用 Content-Length / Transfer-Encoding 判断"有没有体",而分帧协议两个都
不带——一个 64 KB 的 POST 交到后端时是没有 body、也没有 Content-Length 的空 POST,h1 一切正常,
谁都不报错,业务只表现为"写操作偶发丢内容"。现在分帧协议一律读到流结束,长度以 Content-Length
带给 HTTP/1.1 后端,并由 tools/quic_business_e2e.py 41 项把长度与摘要在源站侧逐项对齐。
其余修复同样按 nginx 源码逐条比对:上游超时回 504(不再是 502,这会改变告警与
error_page 的命中面,升级前请加上 504)、max_fails 的计数口径、proxy_pass 带 URI 时的纯字符串
替换(不再多补一个斜杠)、if ($x = "") 这种"头不存在"的写法不再被判定成语法错误、重复 Host
头按 RFC 9112 回 400、expires 的可缓存状态码集合把 204 补回来(nginx 的 safe_status 里有它)。
限流的键扩到"判定时刻有值的任何请求期变量",空键不限流、取不到值的键直接 -t 否决;limit_conn
允许写在 location 且整组替换server 级规则。UDP 四层转发按 4 元组建会话、后端 socket 用
connect,串话在内核层面就不成立;udp_max_sessions 是 ONRouter 加的护栏,因为四元组可伪造,
实测不设上限时 UDP 洪水会把七层一起饿死。文档新增两章(限流与配额、四层转发)和一节
§5.4(三种传输在同一端口上要怎么写),里面的每个数字都由可跑的配置文件复现;发布包的说明也改对了
——GitHub Release 上每个平台只有两个可执行文件,"开箱可跑"的完整目录是 build-release.* 在本地打的。
Full Changelog: v1.0.1...v1.0.2