Skip to content

v1.0.2

Choose a tag to compare

@github-actions github-actions released this 21 Oct 17:34
· 30 commits to main since this release

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
    (h1/h2/h3 on one port, quic ≠ http2, request-body framing, what is shared across transports),
    §7.1 (max_fails accounting, 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, no conf/), while the
    "unzip and run" full directory — conf/, lua/, public/, freshly generated certs/, docs/ —
    is what build-release.ps1 / build-release.sh produce 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_lock defaults to off here and on in nginx. herd_e2e quantifies 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_revalidate applies 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-:authority vhosting on multi-domain ports, so it reports
    false reds. The reason lives in run_regression.py's SKIP set with a TODO; certificate and
    cross-site behaviour on that shape is covered by https_matrix_e2e 71/71 instead.
  • No h2/h3 upstreams (proxy ALPN is pinned to http/1.1), no WebSocket over h2/h3
    (RFC 8441/9220), and proxy_buffering has 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 plus expires, limit_key, herd,
    course_selection, game_udp, flash_sale, quic_business and production_shape;
    random_scenarios_e2e excluded with the reason above).
  • Suite counts that moved this round: security_matrix 49/49 (was 36 — S13's duplicate-Host case
    and its control probe added), expires 42/42, flash_sale 36/36, game_udp 31/31,
    quic_business 41/41, proxy_cache 21/21, set_if 34/34, least_time 43/43,
    tutorial_limit_check 17/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.py 26/26 against the unpacked target/dist/archives/onrouter-v1.0.2-*.zip
    — the artifact a user downloads, not the build tree's binary: its -V and Server: header say 1.0.2,
    the shipped configs -t clean, 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, corrupt 0,
    never_read 0, pending_jobs 0). The soak harness's own cleanup bug that made corrupt report
    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