Chrome 150 is the bundle's own `--impersonate chrome150` target and the
record the newest desktop block now points at; Chrome 124 is Chrome 123's
hello with X25519Kyber768Draft00 in front of the others (JA4 answers with
123's key, and the group list is what the row exists for); Safari 26.0.1 is
26.0 with the session ticket gone and zstd dropped from `accept-encoding`.
TlsShape gains a QuicShape: DCID/SCID lengths, the first packet number and
the size the Initial is padded to. Those are the four numbers a censor
reads off an Initial before any of it is decrypted, so the QUIC column now
sends the impersonated client's header instead of this build's eight-byte
one.
Test 6 gains `ciphers:A,B,...`. The suite list is the one field outside the
extension block, and a swap of two suites moves neither the block nor its
offset, so the variant answers whether the *contents* of that list are
compared rather than its position or length. The help text gains the new
value in all four locales.
docs/habr-tspu-fingerprint-blocking.md is the report the shape tables were
rebuilt from: the one-field mutation runs, the dual-tong control that keeps
a sleeping censor from reading as a pass, and the model of the rule the
measurements support.
Three things the column could not say, and one it said wrongly.
**A timeout has one badge.** The QUIC Initial that never got an answer wore
`DROP` while every other window that ran out wears `TIMEOUT`, so the same
finding read as two different ones across the columns; a reader comparing
HTTP/TLS/QUIC had to know that. The badge is now `TIMEOUT` and the details
column carries no line for it — the badge names the column and the cause — while
`--json` keeps `quic_drop`, so no consumer's key moves. The legend, the README
table and the QUIC stand's own notes follow the badge.
**The details cell names the legs that share a reason.** It used to print one
line per failing protocol, repeating a shared reason once per name; the core now
groups the failures by their detail and hands the renderer the names that share
it (`HTTP,TLS13:→ m.megafonpro.ru`), and a row whose only reason is that one
carries no names at all, because the badges already say which columns are not
OK. The certificate mark is the exception that made the rule explicit: it draws
no badge, so its line is always named (`TLS:`/`TLS12:`/`TLS13:`).
**Answers we cannot read are named in our words.** rustls spells one sentence —
`peer is incompatible: …` — for twenty-one different variants, and the whole
family read as `NO TLS1.3`, which said the opposite of what happened: measured
on `aliexpress.ru`, whose TLS 1.2 column read `no_tls13` while the answer was a
ServerHello for a version the leg never offered. The variant that names a
version carries none in its message — it covers a peer picking a version above
our offer and one picking below it — so the classifier leaves the direction to
the pin (`peer_version_mismatch`): the TLS-1.2 leg, which offered 1.2 alone, can
only have been answered with 1.3, and reads `TLS SPOOF`/`response_spoofing_wrong_version`,
while the TLS-1.3 leg meeting the same message met a host without TLS 1.3 and
keeps `NO TLS1.3`. Everything else in the family is a capability mismatch
(`TLS ERR`, `peer_incompatible`), and hyper's parse failures — `invalid HTTP
version parsed` in a censorship report — now say `not_http_response`, «Ответ не
разбирается: это не HTTP», with the status where it was.
**The certificate the answer came with is marked.** The probes hand rustls an
insecure profile on purpose: a certificate a censor's middlebox presents is
evidence, and the answer behind it — the registry blockpage, the redirect target
— is the finding, so a verifying handshake would abort before either could be
read. `net::tls::verify_peer_chain` therefore judges the chain the handshake
already collected, against the same bundled Mozilla roots and the same
classifier a verifying leg uses, and the row carries the verdict as a mark:
`TlsCheck.cert`, a named line in the details cell that outlives the branches
reducing a cell to a timing, and `cert_detail` in `--json` (additive, so
`SCHEMA_VERSION` stands). No column's status moves: a site that answered `OK`
with a foreign certificate still reads `OK`, and the mark says whose certificate
it was shown.
Measured live: `aliexpress.ru` and `mvideo.ru` under `--fingerprint chrome146`
and `firefox147` read `tls_spoof`/`response_spoofing_wrong_version` on TLS 1.2
(where the same hosts read `no_tls13` before), `unknown`/`tls_decrypt_error` or
an RST on TLS 1.3, and `not_http_response` on HTTP; the base `rustls` hello the
same box meets with silence. `self-signed.badssl.com`, `expired.badssl.com` and
`wrong.host.badssl.com` mark `no_root_certificates`, `cert_expired` and
`hostname_mismatch` in the cell, and the two GGC node rows mark
`hostname_mismatch` — `*.googlevideo.com` does not cover
`r1.<cluster>.googlevideo.com`, which the README now says beside the GGC
paragraph so a mark on a synthetic row is not read as interception.
Tests: the version-naming answer resolved against both pins and passed through
untouched, the other variants, hyper's parse failure named and a message the
table does not know still quoted, the certificate line named on a row whose only
line it is and alongside a failure that keeps its own name, the mark outliving
the timing branches and absent on a chain the roots accept, and
`verify_peer_chain` rejecting the fixture chains that reach no bundled root.
The list's rule was "both third-party testers say no HTTP/3", and the pair
disagreed on these three: `intodns.ai` read an *answering QUIC handshake* as
support (`quic=ok`) while `http3check.net` requires an advertised alternative
service and said no. The handshake reading is wrong — measured here: the two
Cloudflare-fronted hosts answer `alpn=h2` only in their HTTPS record and `x.com`
has no record at all, and none of them sends `Alt-Svc: h3`, so no browser is ever
told to use HTTP/3 there. Four other checkers and a browser agree, so the intodns
column is gone from `online_h3.py` and the rule is now what a browser is told: no
`Alt-Svc` naming h3 and no `alpn=h3`, with `http3check.net` corroborating.
The three move from the whitelist into `quic_unsupported.txt` — they were added
to `whitelist_sni.txt` by the previous commit and are removed here: test 4's list
is about which SNIs to probe, not about HTTP/3. The docs that stated the old rule
(`quic_unsupported.txt`, `README.md`, `scripts/quic/README.md`) state the new one,
including the two lists of the 34-row sweep, which are 17/17 now.
Verified: the reworked script prints `no` for all three hosts, and a rebuilt test 2
draws the dash for `hub.docker.com` with the summary counting `1/1 QUIC` beside
`www.google.com`.
`gateway.discord.gg`, `hub.docker.com` and `x.com`, each in its sorted place in
`whitelist_sni.txt` (test 4's list, 191 entries now).
None of the three goes into `quic_unsupported.txt`, though: the file's rule is
the two third-party testers agreeing on "no HTTP/3", and for these hosts
intodns's own QUIC probe answers (`quic=ok`) while http3check.net reports no
HTTP/3 — a disagreement, which the rule keeps probed. Our own path ends in a TLS
`handshake_failure` (296) for all three, and that is the finding the QUIC column
exists to show. Recorded in the tester script's header, next to the same note for
the first two.
What the day's captures settled, written where the next pass can find it. The
unopenable first reply is the local desync's fake, not an endpoint behaviour; the
fake leaves with the flow's own 5-tuple and one DCID, so the edge holds a single
half-open connection and its timeout is the gate. The gate is 2.5–3.5 s, not the
4.7 s the probe's ladder lands on: the probe's fourth send is the first after it,
curl's third (3.5 s), and Chrome closes itself at 4.004 s of silence, half a
second before its own next retransmit — so a browser cannot show the gate, only
fail on it (`ERR_QUIC_PROTOCOL_ERROR`, the netlog is quoted in the section).
The levers that would remove the gate are written down with what is measured and
what is not (badsum, a TTL, a host list built by measurement, an unpadded fake),
and the tester section points at the list the detector now ships. `replay.py`
stops crashing on the long headers that are not Initials.
The provider was the one reason the tree vendored a second fork: rustls's
`ring` provider is C and assembly, and Rule 1 forbade that. The measurement in
`docs/PROFILES_AND_PROVIDER.md` settled it — the rule is gone, the provider is
`ring`:
* on the router the C provider is faster than the pure-Rust stack on the
crypto-heavy test (19.8 s against 32.6 s) and no larger (the mipsel artifact
is 5 140 576 B against 5 181 324 B);
* the base-form ClientHello moves — 19 of 105 endpoints in test 1 take a
different path — which is what that document records, and what the
fingerprint profiles exist to control;
* `ring` does build for the router targets: the MIPS images carry a GCC
toolchain (the unwinder alias step already called it), and the generic C path
is what the missing MIPS assembly falls back to. Measured with `cross`.
What went with it: `vendor/rustls-rustcrypto/` and `net/x25519.rs` (`ring`
rejects a low-order X25519 peer key itself), the `policy` job's `cargo tree`
ban list and the 6 MiB size step in `check.yml`, and the two `advisories`
ignores in `deny.toml` — `rsa` and `paste` left the graph with the provider.
Rule 1 is retired rather than renumbered: the numbers are quoted from code
comments and CI, so Rule 2 keeps its name and only the ceiling inside it is
gone; size and RSS stay measured, in `docs/OPTIMIZATIONS.md`.
The release matrix names its profile per row now: `release-router` (`-Oz`, fat
LTO, one codegen unit, `package.ring` at O3) for armv7/mipsel/mips, `release`
(O3, thin LTO, 16 cgu) for the rest. The desktop artifact grows to 6 955 520 B
and the relink to 53 s; `release-local` stays the edit-to-run loop (1.58 s,
6 157 312 B, and smaller than `release`).
Verified: `cargo test --workspace --locked` (280 + 88, 0 failed), `cargo
clippy --workspace --all-targets -- -D warnings`, a `cross` build of the
mipsel row under `release-router`, and a live `--tests 2` run of that binary
(35 domains, tls12/tls13 ok on all but one network timeout).
`validate.py` is deleted. It held a hardcoded list of expected detector verdicts
captured *before* the repeated-flight fix, so it printed about fourteen
disagreements that no longer existed — `crosscheck.py` answers the same question
against the live detector.
`browser_check.py` could return a wrong verdict, and did: `report()` decided with
`any("Handshake" in line for line in server_frames)`, where `server_frames` was
every QUIC packet not from `192.168.*` — Chrome's own background QUIC to Google,
and every other host it opened in the same capture. It reported that a browser
completed HTTP/3 with `www.whatsapp.com`, which was read as a probe defect for a
while. Scoped to the host's own connection — its SNI, via `tp_dump.py`'s
reassembly, imported rather than parsed a second time — the same capture says the
browser used `31.13.72.52`, the probe's own address, and got nothing: the verdict
was right all along. The README now records that row that way, in place of the
wrong conclusion it carried.
`replay.py` used a 4 s window where the probe and the other scripts use 8; its
subject is the retransmission schedule, so it mirrors the probe's numbers and says
so. `tools/quinn-stand` declared `rustls` without `default-features = false`, which
put `aws-lc-sys` — cmake, C, ASM — in its lock beside ring; the lock is 65 lines
shorter.
`hosts.txt` gains 35 hosts — three Cloudflare names, five Google ones, Meta,
Microsoft, Apple, Amazon, Netflix, Twitch, Spotify, Reddit, DuckDuckGo, Stack
Overflow, BBC, CNN, Slack, Zoom, Dropbox, Shopify, the New York Times, four
Yandex/RU names and the three Telegram hosts — and the README carries what the
re-run measured: 60 of 69 agreeing with aioquic at the detector's own
addresses, and 27 of the original 34 where the earlier run had 13, so the
repeated flight is what moved it.
It also records the defect that is not in the hello. `www.whatsapp.com` drops
here for the detector, for aioquic and for Chrome's own first attempt, all at
`31.13.72.52` — the address the system resolver returns — while a browser
reaches `109.194.137.78`, `whatsapp-cdn-shv-01-arn2.fbcdn.net`, and completes
HTTP/3 there. The detector's own hello is answered at that edge in 0.01 s, so
the hello is not the difference and DoH is not the fix: `dns.google`'s answer,
`157.240.205.60`, is silent too. Two things `browser_check.py` needs before the
next run trusts it are written down next to the measurements.
`net/quic.rs` builds the hello for QUIC: the header protection, the `CRYPTO`
stream and the RFC 9001 §5.2 keys; `probe/quic.rs` is the column, and
`probe/quic_quinn.rs` runs the same column through `quinn` (feature
`quinn-probe`, off by default — not a shipping candidate, as `Cargo.toml` says).
The shapes needed two things for it: `Variant::NoGrease`, because a browser
greases over TCP and not over QUIC (measured on a live Chrome's QUIC hello),
and `sends_extension`/`extension_body`, so an edit starts from the shape's own
bytes and a shape that does not send an extension does not get one. The
vendored hook gains `ClientHelloProfile::quic`: a QUIC hello's
`legacy_session_id` is empty (RFC 9001 §8.4), which the patched hook could not
reach from the connection's protocol alone.
`scripts/quic/` is the stand that validates the column against aioquic;
`tools/quinn-stand/` is the out-of-workspace second one, and `capture-run.ps1`
captures the column's traffic for a look at the wire.
The file mode in the index is 100644 — a Windows checkout cannot carry the
executable bit, and `git update-index --chmod=+x` only fixes the copy that ran
it. The step says `bash scripts/vendor-advisories.sh` so the runner does not
have to trust the mode, and the index carries 100755 as well for a human who
runs it directly.
`cargo deny check advisories` reads `Cargo.lock`, where a crate that arrives
through `[patch.crates-io] { path = … }` has no `source` — and the check skips
those. The previous commit is the proof: the local database was current
(cloned the same day) and contained RUSTSEC-2026-0285, the lock carried
`rustls 0.23.43`, and the job reported `advisories ok: 0 errors` while the
advisory applied. Every vendored crate sits behind that blind spot, so the gate
was weaker than `docs/CI.md` read.
`scripts/vendor-advisories.sh` closes it: it reads the name and version out of
each `vendor/*/Cargo.toml`, writes a throwaway manifest under `target/` that
depends on those crates at exactly those versions — the published names of the
sources the tree carries — resolves a lock for it, and runs the same check with
its own minimal `deny.toml` (the repository's `ignore` entries name advisories
this tree does not contain, and cargo-deny warns about every ignore it never
meets). `rustls-rustcrypto` is skipped there on purpose and says so: it is the one
vendored crate that is a *workspace member*, so the step above already reads its
real dependency graph, and it is also the one whose patch changes its
dependencies — asking about the published 0.0.2-alpha would report the vulnerable
`rustls-webpki 0.102.8` that our copy drops.
Proven both ways: on the current tree the script prints the three crates and
`advisories ok`; with `rustls = "=0.23.43"` the same check fails with
`error[vulnerability]: TLS 1.3 handshake messages incorrectly accepted across
encryption level boundaries`, `ID: RUSTSEC-2026-0285`.
The step runs in the `policy` job after `cargo deny`; `docs/CI.md` gains it in
the job row and in the list of what actually fails the job, and the job's stale
"four patched forks in `vendor/`" is three now that `h2` is gone.
The domains test takes its per-phase timeouts from config.yml
(connect_timeout, read_timeout, 8 s each by default), and a full 34-domain
pass over 27 strategies twice is hours of that. -T SEC writes a minimal
config.yml into the run directory with both set, which the detector reads
from its working directory: measured 14.8 s against 35 s for the same
strategy and ten domains.
The init script's restart decides liveness with pgrep, so it can report
'already running' for a daemon that was started with the previous strategy's
config and leave it in place. The bench now stops, waits for the process
count to reach zero, and only then starts — both between strategies and in
the final restore, which is what puts the original config back in force.
Verified on the Keenetic: after a run the config on the router is
byte-identical to the original and exactly one nfqws2 process is running.
Three defects the first full run exposed:
- the sed that drops a list filter ate the closing quote when the list was
the last token on a line (MODE_LIST="--hostlist=..."), leaving a config
with an unterminated string that nfqws2 refuses to source. The quote is
now excluded from the match, and the patched config is checked with
sh -n before it is installed;
- the backup was overwritten on every start, so a second run could back up
an already-patched config and have nothing to restore. It is written once
and kept until a restore consumes it;
- BusyBox sort ignores the joined form -k1,1nr, so the table was not
ordered by score. It is now -k1,1 -nr.
The script also records its own pid, so a run can be stopped by pid rather
than by a wrapper that leaves it alive.
A router-side script for the question the list-mode notice raises: which
strategy actually works here. For each strategy it drops the positive list
filters (--hostlist*, --ipset=) so the desync applies to all traffic while
the exclusions stay, substitutes the real provider interface for the
author's placeholder, restarts the service, waits for the queue, runs the
detector's domains test as JSON, and ranks the strategies by how many
checks passed. The original config is restored on any exit, including
Ctrl+C.
Verified on a Keenetic against the 27 strategies of rndnaame/nfqws-menu:
interface detection reads the field after 'dev' (the default route line
puts 'scope' fifth), the summary parses the indented JSON, and the table
sorts by the score, not by the tab-separated field sort BusyBox cannot do.