docs: add AGENTS.md, CONTRIBUTING.md and docs/CI.md; English for the engineering docs

AGENTS.md is the repository guidelines the build files, deny.toml and the
workflows already cite by section number; it stops being gitignored so those
references resolve for anyone who clones. CONTRIBUTING.md states what CI
actually checks and what is not merged; docs/CI.md is the reference for the
workflow files.

The four engineering documents are rewritten in English (README stays Russian:
it is the user manual). Two claims were wrong and are corrected on the way:
docs/i18n-fa.tsv still pointed the translator at crates/dpi-core/src/i18n/
(the module moved to dpi-detector in P5), and OPTIMIZATIONS.md claimed UPX is
not used while the release matrix ships three -upx variants that install.sh
selects when the target has under 200 MB free.
This commit is contained in:
Kirill Minovsky 2026-09-24 12:22:46 +03:00
parent 181634bc39
commit 9863cfa2a7
8 changed files with 824 additions and 505 deletions

1
.gitignore vendored
View file

@ -5,7 +5,6 @@
/dpi_detector_crash.log
.idea/
.vscode/
AGENTS.md
/vendor/*/target/
/vendor/*/Cargo.lock
/dist/

162
AGENTS.md Normal file
View file

@ -0,0 +1,162 @@
# Repository Guidelines
## Project Overview
`dpi-detector`: Rust engine classifying network censorship (TCP/TLS/DNS). Targets:
desktop to 16–32 MB embedded routers. Rewrite of a Python prototype — hence the
Python-shaped `config.yml` (UPPER_SNAKE keys, Russian comments). This tree = source of
truth for behaviour.
Pre-1.0: CLI, `--json` shape, module layout unstable — change whenever it makes the code
smaller, clearer or cheaper. Such a change MUST land as ONE commit with code, tests,
`README.md`.
Terms overloaded here — read from context:
- `test`: diagnostic 0–6 (`--tests 2`), NOT unit test (`cargo test`). "test 2 is broken" = the diagnostic.
- `profile`: censorship region (`--profile ru|ir|cn|global`), build profile (`--profile release-local`), TLS fingerprint (`--fingerprint chrome146`, 30 total).
## Architecture & Data Flow
Acyclic: `classify` (stage tracking, `DpiStatus`, `Detail`) → `net` (TCP/TLS,
fingerprints) → `dns` (UDP/DoH/DoT/SOCKS) → `probe` (tests 0–6); `config`/`profile`
beside. `net/` NEVER reaches `dns/`; `dns/` NEVER reaches `probe/`. Binary owns the
interface — CLI, TUI, `--json`, i18n — NEVER implements probing.
Flow: `main.rs` (clap, `AppConfig`, `Messages`, panic hook) → `runner::run_test_suite`
(concurrency gate `Arc<Semaphore>` via `probe::permit`) → `dpi_core::probe::*` per test →
`views::render_*` table or `json::Results` → `json::Report` to stdout/`--output`.
Runtime: `#[tokio::main(flavor = "current_thread")]`; blocking work ⇒ `spawn_blocking`.
Locks: `parking_lot`; `tokio::sync::Mutex` ONLY across `.await`. Errors: `thiserror` in
core, no `anyhow`, no `unwrap()` outside tests. Logs: `tracing` (`WARN`; `-v` ⇒ `DEBUG`).
## Key Directories
- `crates/dpi-core/src/classify/` — `types.rs` (`DpiStatus`, 33 variants, `ConnectionStage`), `detail.rs` (`Detail`, `AlertKind`, `StackKind`), `classifier.rs` (error ⇒ verdict), `stream.rs` (`DpiProbeStream`).
- `crates/dpi-core/src/net/` — `tcp.rs`, `tls.rs` (`TlsProfile`, cert verifier), `fingerprint/` (30 profiles: `shapes/`, `identity.rs`, `h2.rs`, `variant.rs`), `ja3.rs`/`ja4.rs`, `pq_kx.rs`, `cert_compression.rs`, `hpke.rs`, `follow_up.rs`, `bind.rs`, `sysinfo/`, `netinfo.rs`.
- `crates/dpi-core/src/dns/` — `wire.rs` (RFC 1035), `udp.rs`, `doh.rs`, `dot.rs`, `socks.rs`, `resolve.rs`.
- `crates/dpi-core/src/probe/` — one module per test: `dns_avail.rs` (1), `domains.rs` (2), `tcp16.rs` (3), `whitelist.rs` (4), `telegram.rs` (5), `burst.rs` (6); plus `cymru.rs`, `connector.rs`, `http.rs`.
- `crates/dpi-core/src/config.rs` — `AppConfig` (`config.yml` schema), `ConfigWarning`, embedded data lists.
- `crates/dpi-detector/src/i18n/` — ONLY location for user-facing strings.
- `crates/dpi-detector/src/tui/`, `views/` — terminal backend, width math, one renderer per test.
- `vendor/` — patched upstream crates; each carries `README-PATCH.md` + `PATCH.diff`.
- `docs/` — English. `CI.md` (CI, release, supply chain), `ADDING_A_PROFILE.md` (profile procedure), `REFACTORING.md` (invariants + phases), `OPTIMIZATIONS.md` (size log), `i18n-fa.tsv|csv` (dev-only worksheet, Russian header). `README.md` is the only Russian document: it is the user manual.
- `tools/`, `scripts/` — `fingerprint/` (profile harness), `diag/dns-ca-report.ps1`, `bench-strategies.sh` (router benchmark).
## Development Commands
Toolchain from `rust-toolchain.toml` (rustup, cargo, CI read it).
| Goal | Command |
| --- | --- |
| Build | `cargo build --workspace --locked` |
| Test | `cargo test --workspace --locked` |
| One test | `cargo test -p dpi-core test_parse_dns_response_a` |
| Live-network test | `cargo test -p dpi-core --features live-network test_doh_connect_h2` (the only one that dials `dns.google`) |
| Lint | `cargo clippy --workspace --all-targets --locked -- -D warnings` |
| Doc links | `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --locked --no-deps` |
| Supply chain | `cargo deny --locked check bans licenses sources advisories` (cargo-deny 0.20.2) |
| Run | `cargo run -p dpi-detector -- --tests 2 --lang en` |
| Example harness | `cargo run --release --example tls_fingerprint -- diff a.hex b.hex` |
```powershell
cargo build --profile release-local --target x86_64-pc-windows-msvc -p dpi-detector # iterate, ~1.5 s
$env:RUSTFLAGS = '-C target-feature=+crt-static'
cargo build --release --target x86_64-pc-windows-msvc # artifact only, ~55 s
```
- Iterate with `release-local`; `--release` ONLY for an artifact. Measurements + flag-set/cache trap: `[profile.release-local]` comment in `Cargo.toml`.
- `cargo test`/`clippy` = third unit set, no shared cache. Dev `opt-level = 0` ⇒ **false timeouts**; timing runs MUST use `release-local`.
- NEVER run a formatter — house style ≠ rustfmt default. Format by hand; NEVER commit whitespace-only reformat of untouched files.
- NEVER commit, NEVER post to GitHub, unless asked: show target + exact text, wait for confirmation. "Fix the PR feedback" = draft, not post.
## Code Conventions & Common Patterns
### Systems rules (non-negotiable)
**Rule 1 — Pure Rust, zero C/C++.** Router targets (`mipsel-unknown-linux-musl`) lack a
working C cross-compiler; `ring` has no MIPS target. NEVER add a crate building
C/C++/ASM/CMake/NASM/Perl (`openssl`, `boringssl`, `aws-lc-rs`, `rquest`, `wreq`,
`curl-sys`, `zstd-sys`). Crypto: `rustls` + pure-Rust `rustls-rustcrypto`. Enforced by the
`policy` CI job (`cargo tree --target all -e normal,build -i <crate>`); `deny.toml`'s
`bans` cannot express it.
**Rule 2 — Low-memory ceilings.** 3–6 MB binary + RSS. Stream/slice, NEVER buffer whole
streams; no `tmpfs` temp files without an explicit CLI path; decoders capped —
`net/cert_compression.rs` refuses a zstd window > 1 MiB before `ruzstd` allocates.
**Rule 3 — Classification fidelity.** Every TCP/TLS probe wraps in `DpiProbeStream`,
stages `TcpConnecting → TcpConnected → TlsClientHelloSent → TlsHandshakeDone →
HttpPayload`. Reset/EOF at `TlsClientHelloSent && bytes_recv == 0` MUST be
`DpiStatus::TlsRst`, never a generic connection error; connect timeout MUST be
`SynDropped`. Middlebox accuracy = the product.
**Rule 4 — Signals stay Latin.** Badges (`DpiStatus::display_label()`) + RFC protocol
tokens (`TCP`, `TLS`, `DoH`, `SNI`, `ClientHello`) NEVER translated — RTL keeps numbers
and IPs in reading order, one log line greps alike for all users. Headers, banners,
prompts, `--legend` prose *are* translated.
**Rule 5 — `--json` language-independent.** `status` = snake_case
`DpiStatus::as_str()` (`ok`, `syn_dropped`, `tls_rst`, `no_ca_bundle`); `detail` =
`Detail::code()` (`tls_drop_handshake`, `http_403`). serde MUST agree with `as_str()` — a
test pins that. `--lang` NEVER reaches machine output.
**Rule 6 — User-facing strings in `i18n`.** No display text at a call site: add a
`Messages` field (`i18n/messages.rs`), fill all four languages — struct literals fail to
compile otherwise. `i18n/details.rs` = exhaustive `match` over `Detail`. Farsi = Latin
"Finglish". Allowed literals: badges, protocol tokens, JSON keys, file names, OS
registry/env keys, `Detail` codes, raw error text appended after a localized label.
### Patterns worth copying
- TUI repaints ONLY via `render::frame_repaint`/`render::frame_home`; padding and erase rules, and why they exist, in `frame_repaint`'s doc comment. NEVER hand-pad a row, NEVER home to `(0, 0)`, NEVER clear the screen first.
- Totality tables synced by tests: `DpiStatus::ALL`, `TlsFingerprint::ALL` ↔ `SHAPES`, `TELEGRAM_DCS`, `KNOWN_KEYS`. A profile without a `TlsShape` record panics in `spec()`.
- Shipped defaults live in the root data files (`config.yml`, `domains.txt`, `burst-domains.txt`, `tcp16.json`, `whitelist_sni.txt`), embedded via `include_str!`; precedence and key handling documented in `config.rs`.
- stdout = machine channel: `--json` writes a parseable document, nothing else may. Human output via `print_out`/`println_out`/`Emitter::emit` (`--json`-aware); diagnostics via `tracing` (stderr); TUI owns the screen. A stray `println!` breaks a consumer's `jq` and a frame at once.
- Sanitize server-controlled bytes (TLS alert text, `Detail::Other`, HTTP status lines, cert fields) through `tui` helpers on EVERY render path, error branches included.
- Search before writing a helper: two implementations = a bug even when both work. Central: `clean_domain`, `netinfo` facade, `probe::permit`, `render::frame_repaint`/`frame_home`, `i18n::{fmt_speed, fmt_size, detail_text}`, `tui::widgets` width math. Missing capability ⇒ extend the central helper, NEVER fork locally.
- Fixtures carry no device data: RFC 5737 (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`), RFC 3849 (`2001:db8::/32`), RFC 7042 (`00-00-5E-00-53-xx`). Captured `/proc`/conntrack/route output ONLY after replacing every address and MAC — repo is public, history cannot be taken back.
- Edit sources with `edit`/`write`, NEVER a script: a Python one-liner hides the diff and silently rewrites line endings. Python fine for reading, filtering, hashing, driving a router over SSH.
## Important Files
- `crates/dpi-detector/src/main.rs` — entry: CLI parse, config, i18n, panic hook, menu, per-run loop.
- `crates/dpi-detector/src/runner.rs` — `run_test_suite`: orchestration, list loaders, `Report` serialization.
- `crates/dpi-detector/src/json.rs` — `--json` payload, `SCHEMA_VERSION`.
- `crates/dpi-detector/src/args.rs` — `CliArgs` + runtime-built clap `Command` (localized help).
- `crates/dpi-core/src/classify/classifier.rs` — error ⇒ verdict decisions.
- `config.yml` (root, 430 lines) — shipped default runtime config, embedded via `include_str!`.
- `domains.txt`, `burst-domains.txt`, `tcp16.json`, `whitelist_sni.txt` — per-test target lists, all embedded; test 6 keeps its own on purpose.
- `README.md` — user manual (**Russian**), authoritative CLI reference. MUST update in the same commit as a CLI/`--json`/`Detail` change.
- `Cargo.toml`, `rust-toolchain.toml`, `deny.toml`, `Cross.toml`, `.cargo/config.toml` — build, toolchain, supply-chain, cross-compile policy.
- `.github/workflows/`, `.omp/rules/`, `.omp/agents/` — what CI runs (explained in `docs/CI.md`); repo-local agent rules and profiles.
## Runtime/Tooling Preferences
- Rust only, via cargo. Exactly one feature exists — `dpi-core`'s `live-network`, which gates the one test that needs egress (Testing & QA); everything else is a curated selection in `[workspace.dependencies]` — e.g. `brotli-decompressor`/`ruzstd` for decode-only RFC 8879/8878 support instead of rustls's compressor-linking features.
- `vendor/` = patched upstream, not ours: read the crate's `README-PATCH.md` before touching it; a change goes back to the fork, NEVER into the vendored copy. `rustls-rustcrypto` = the one vendored crate that is a workspace member; root `Cargo.toml` explains why, and which three are not.
- Auxiliary tooling lives outside the workspace, writes ONLY under `target/`, NEVER invoked by cargo: Python 3 (`tools/fingerprint/fingerprint.py`), Go (`tools/fingerprint/utls`, uTLS v1.8.2), `tools/diag/*.ps1`, `scripts/*.sh`.
- CI, release, toolchain rows, supply chain: `docs/CI.md` — read it when changing a dependency, an installer or the build matrix. Nothing there needs running by hand before a push.
## Testing & QA
Inline unit tests ONLY in `#[cfg(test)] mod tests` beside the code — one test module per
source module; no `tests/`, no `benches/`, no snapshot or mocking framework, no test
runner. Plain `#[test]` plus a few `#[tokio::test]`; names are full sentences
(`test_reset_classified_from_os_code_not_text`).
- Dev-dependencies ONLY in `dpi-core` (`tokio`/`test-util`, `brotli` for the `cert_compression` round trip). Keep it that way; no mocking crate.
- Bar: 100% pass, `cargo clippy --workspace --all-targets -- -D warnings` = 0 warnings — Linux AND Windows (the Linux runner cannot see `cfg(windows)` code; `check.yml` has the detail).
- Fixtures: root data files via `include_str!` (several tests re-parse the shipped `config.yml`, so it cannot drift); DER certificates in `crates/dpi-core/src/net/testdata/` with `generate.py` as the regenerator; inline literals for synthetic wire bytes, `/proc`/nfqws2 text and update JSON.
- Network: EXACTLY one test touches the real internet (`dns/doh.rs::test_doh_connect_h2` → `dns.google`), and it sits behind `dpi-core`'s non-default `live-network` feature so that the plain `cargo test --workspace --locked` — the CI command — needs no egress at all; run it deliberately with `cargo test -p dpi-core --features live-network test_doh_connect_h2`. Five async tests use `127.0.0.1:0`; `net/tls.rs` handshakes run in-process with fixture certs. No `#[ignore]`d tests — platform differences via `cfg`.
- MUST name the failure mode before adding a test: cannot say what a consumer observes on regression ⇒ not a test yet; a regression test MUST trigger the real prior failure path. NEVER assert on source text — reading shipped *data* (`include_str!("config.yml")`) is fine, grepping a `.rs` file for a call or comment is not. Tests MUST be full-suite safe: MUST NOT leave env vars, cwd or console mode changed.
- CI adds installer smoke tests, a Windows runner, the pure-Rust policy and an artifact round trip; `release.yml` gates the tag before the 11-row matrix — job by job in `docs/CI.md`. Nothing needs running by hand before a push: a *new dependency*, an installer edit or a broken doc link is what fails it.
- Fingerprint profiles verified by the out-of-workspace harness, not `cargo test`:
```
python tools/fingerprint/fingerprint.py all [profile]
python tools/fingerprint/fingerprint.py echo-diff chrome131
```
Re-check a profile whenever its data changes; a difference the harness reports that `tools/fingerprint/README.md` does not name as deliberate = a bug. `docs/ADDING_A_PROFILE.md` lists the five artifact locations a new profile touches.

63
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,63 @@
# Contributing
The rules below are not bureaucracy — they are what CI and review actually check.
## What gets accepted
- **Small changes** — a bug fix, a typo, documentation, a narrow improvement: open a pull request directly.
- **Large changes** — a new subsystem, an architectural shift, a new dependency, edits spanning several modules: discuss first (an issue or the [chat](https://t.me/DPI_detector)), then write code. An issue is not a substitute for that discussion, and a discussion does not guarantee the pull request will be merged.
**Do not open an issue for work you are about to submit** — link the existing one from your pull request instead. A new issue makes sense for reporting a problem, or for work you are not going to do yourself.
**One pull request, one logical change.** Incidental cleanup, drive-by refactoring and anything outside the agreed scope belongs in its own change.
## AI agents: a tool, not an author
Using agents is fine. Handing an agent a vague goal and submitting whatever comes out is not.
Before opening a pull request you MUST:
- constrain the agent to the agreed scope and reject everything else;
- read every changed hunk and understand the resulting behaviour;
- run the checks and **verify the changed behaviour yourself**;
- open the pull request after that review, rather than letting an agent publish it on its own.
You are responsible for the code, regardless of who or what generated it.
## What is checked before a pull request
```bash
cargo build --workspace --locked
cargo test --workspace --locked
cargo clippy --workspace --all-targets --locked -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --locked --no-deps
```
- **100% of tests pass, 0 clippy warnings** — on Linux *and* Windows. Part of the code lives behind `cfg(windows)` and does not compile on the Linux runner at all, so breakage there is visible only on Windows.
- **A test MUST name the failure mode.** If you cannot say what a consumer observes when it regresses, it is not a test yet. A regression test MUST reproduce the prior failure. Tests that assert on source text, copy a literal into a struct, or merely assert that output is non-empty do not qualify.
- **Verify by hand.** A test run does not prove the behaviour works: for a bug, reproduce it and show the reproduction no longer fires; for a new capability, launch the binary and use it; for an output change, show what you actually saw. State the exact scenario and its result in the pull request description.
- **One honest sentence in your own words** about what changed and why. A generated summary or a pasted agent transcript does not replace it.
## What will not be merged
Six system rules — in full in `AGENTS.md`, in brief here:
1. **No C/C++ in the dependency graph.** Target devices are `mipsel-unknown-linux-musl` routers with no working C cross-compiler, and `ring` has no MIPS target. `openssl`, `aws-lc-rs`, `curl-sys`, `zstd-sys` and the like are rejected.
2. **No unbounded allocations, no temporary files.** The budget is 3–6 MB for the binary and for RSS.
3. **No error classification outside the connection stages.** Every TCP/TLS probe wraps its connection in `DpiProbeStream`: a reset or EOF after ClientHello is `TlsRst`, a connect timeout is `SynDropped`.
4. **No translated badges or protocol tokens.** `OK`, `TLS RST`, `SNI` stay Latin in every language.
5. **No language in machine output.** `--json` does not depend on `--lang`.
6. **No interface strings outside `i18n`.** New text means a `Messages` field filled in all four languages; otherwise it does not compile.
## Documentation
Update documentation in the same commit as the code:
- `README.md` — when CLI flags, the `--json` shape or the detail codes change;
- `AGENTS.md` and `docs/` — when conventions, the build or CI change.
Build, release matrix and CI are described in `docs/CI.md`; adding a fingerprint profile is covered by `docs/ADDING_A_PROFILE.md`.
## Licence
By submitting a change you agree to license it under MIT, the licence of this project. No CLA or DCO signature is required.

View file

@ -1,277 +1,284 @@
# Добавление профиля отпечатка
# Adding a fingerprint profile
Как в репозиторий попадает новая форма: новая версия браузера, новый клиент, новая
платформа. Откуда берутся числа, чем они проверяются и что должно упасть само, если
форма перестанет соответствовать источнику.
How a new shape gets into the repository: a new browser version, a new client, a new
platform. Where the numbers come from, what checks them, and what must fail on its own
if a shape stops matching its source.
Профиль — это **данные**, а не код. Одна запись в таблице, и всё остальное —
билдер, парсеры, `--legend`, JSON, конфиг, тест 6 — читает её.
A profile is **data**, not code. One record in a table, and everything else — the
builder, the parsers, `--legend`, JSON, the config, test 6 — reads it.
---
## 1. Что такое профиль
## 1. What a profile is
| Элемент | Где | Что описывает |
| Element | Where | What it describes |
|---|---|---|
| `TlsShape` + `SHAPES` | `crates/dpi-core/src/net/fingerprint/shapes/` (`mod.rs` — структура и таблица, по файлу на семейство клиента) | форма: имена, `source`, шифры, группы, схемы подписи, порядок расширений, `raw_exts`, `suppress`, `drop13`/`drop12`, ALPN, `padding_to`, сжатие сертификата, доли ключа, GREASE/ECH/перемешивание |
| `HttpIdentity` | `.../fingerprint/identity.rs` | `User-Agent`, набор, порядок и **регистр** имён заголовков |
| `H2Fingerprint` | `.../fingerprint/h2.rs` | преамбула: SETTINGS с порядком, окно соединения, порядок псевдозаголовков, приоритет |
| `TlsFingerprint` | `.../fingerprint/mod.rs` | вариант, `code` (JSON и конфиг), `token` (бейдж, латиница), `label` («CHROME 133»), `parse`, `ALL`, `DEFAULT_SET` |
| Пины и структурные тесты | `.../fingerprint/tests.rs` | JA3/JA4 против источника и гейты §4 |
| Строка стенда | `tools/fingerprint/fingerprint.py` (`PROFILES`) | код, обёртка бандла и файл-захват форка — или `utls:HelloX` на месте обёртки, если бандл этого клиента не оборачивает |
| `TlsShape` + `SHAPES` | `crates/dpi-core/src/net/fingerprint/shapes/` (`mod.rs` — the struct and the table, one file per client family) | the shape: names, `source`, ciphers, groups, signature schemes, extension order, `raw_exts`, `suppress`, `drop13`/`drop12`, ALPN, `padding_to`, certificate compression, key shares, GREASE/ECH/shuffling |
| `HttpIdentity` | `.../fingerprint/identity.rs` | `User-Agent`, the set, the order and the **case** of header names |
| `H2Fingerprint` | `.../fingerprint/h2.rs` | the preface: SETTINGS with order, connection window, pseudo-header order, priority |
| `TlsFingerprint` | `.../fingerprint/mod.rs` | the variant, `code` (JSON and config), `token` (badge, Latin), `label` ("CHROME 133"), `parse`, `ALL`, `DEFAULT_SET` |
| Pins and structural tests | `.../fingerprint/tests.rs` | JA3/JA4 against the source and the §4 gates |
| The harness row | `tools/fingerprint/fingerprint.py` (`PROFILES`) | the code, the bundle wrapper and the fork's capture file — or `utls:HelloX` in place of the wrapper when the bundle does not wrap this client |
Правила, на которых это держится:
The rules this rests on:
* **Вариант без записи — ошибка.** `spec()` паникует, `fingerprint_table_is_total`
сверяет `ALL` и `SHAPES` в обе стороны.
* **`ALL` — единственный рукописный список.** Новый профиль добавляется туда, а не
в `match`-ветки по всему коду.
* **Имя = версия клиента.** `chrome146`, `firefox147`, `tor145`; версии нет только
у `rustls` — это контрольная форма, а не клиент. Имена обёрток (`curl_*`) и имена
без версии отвергаются: имя, разрешающееся не в ту версию, чем называется, молча
измеряет другого клиента.
* **Один профиль на форму.** Из нескольких версий, чьи hello совпадают (проверяется
через `tls.peet.ws` по каждой обёртке), остаётся самая новая: у Chrome 133–146
совпадают JA4, peetprint, akamai-отпечаток h2, кадры преамбулы и имена заголовков
— отличается только версия в `User-Agent` и `sec-ch-ua`. Запись, отличающаяся от
существующей только UA, — это не профиль, а вторая строка с тем же ключом.
**Единственное исключение — `chrome133`.** Он добавлен намеренно и повторяет
hello `chrome146`: две записи, у которых совпадает всё, кроме `sec-ch-ua` и
`User-Agent`, дают прогону способ спросить, читает ли цензор идентичность.
Одинаковый вердикт говорит, что не читает; разошедшийся — что читает, и это
было бы первое различие между формами, которое видно не в hello. Второй такой
записи быть не должно: исключение ценно ровно потому, что оно одно.
* **i18n не трогается.** Токены латиницей (Rule 4), список профилей печатает
`--legend` из таблицы, ключ JA4 — из того же билдера, что и проба
(`hello_ja4_variants`), поэтому добавить язык или профиль не значит править
строки.
* **A variant without a record is an error.** `spec()` panics, and
`fingerprint_table_is_total` checks `ALL` and `SHAPES` against each other in both
directions.
* **`ALL` is the only hand-written list.** A new profile is added there, not into
`match` arms all over the code.
* **The name = the client version.** `chrome146`, `firefox147`, `tor145`; only
`rustls` has no version — it is the baseline shape, not a client. Wrapper names
(`curl_*`) and names without a version are rejected: a name that resolves to a
version other than the one it claims silently measures a different client.
* **One profile per shape.** Of several versions whose hellos coincide (checked
through `tls.peet.ws` for each wrapper), the newest is kept: Chrome 133–146 share
JA4, peetprint, the akamai h2 fingerprint, the preface frames and the header names
— only the version in `User-Agent` and `sec-ch-ua` differs. A record that differs
from an existing one only in UA is not a profile but a second row with the same
key. **The single exception is `chrome133`.** It was added deliberately and repeats
the hello of `chrome146`: two records that agree on everything except `sec-ch-ua`
and `User-Agent` give the run a way to ask whether the censor reads identity. An
identical verdict says it does not; a diverging one says it does, and that would be
the first difference between shapes that shows up outside the hello. There must be
no second such record: the exception is valuable precisely because it is the only
one.
* **i18n is not touched.** The tokens are Latin (Rule 4), the profile list is printed
by `--legend` from the table, and the JA4 key comes from the same builder as the
probe (`hello_ja4_variants`), so adding a language or a profile does not mean
editing strings.
## 2. Откуда брать числа
## 2. Where to get the numbers
| Источник | Что даёт | Чем является |
| Source | What it gives | What it is |
|---|---|---|
| Бандл `curl-impersonate` (обёртка) | флаги обёртки: преамбула h2, набор заголовков, TLS-списки, `--ech`, `--tls-permute-extensions`, `--tls-key-shares-limit` | **источник истины**: обёртка и есть клиент |
| Его же захваты (`tests/signatures/*.yaml` на теге бандла) | шифры, набор и порядок расширений, группы, доли, JA3/JA4 | пруф (чужая запись клиента) |
| uTLS (пин `v1.8.2`, `tools/fingerprint/utls`) | эталонные спеки hello: списки, порядок, кривые, схемы, ALPN, тела расширений (ALPS, сжатие сертификата, `psk_key_exchange_modes`, `record_size_limit`, delegated credentials, заготовка channel ID) | **источник спеки, не пруф**: у uTLS нет того, что шлёт обёртка, — и **источник истины вместо обёртки**, когда обёртки нет вовсе |
| Та же спека, снятая нашим дампером (`go run . dump <HelloSpec> -o capture.hex`; `HelloGolang` библиотека собирает через `crypto/tls`, ему нужен `-handshake`) | байты hello: тела расширений, порядок, доли ключа, ALPN, объявленные длины | **пруф** для клиента без обёртки: спека модуля на закреплённой версии, прочитанная против нашего дампа (`hello capture.hex`, `diff a.hex b.hex`) |
| Живой клиент (`hello <file.hex>`, mitmproxy/Wireshark) | «как реально шлёт» версия, под которую библиотек ещё нет | пруф, когда есть захват |
| `bogdanfinn/tls-client`, базы JA4 (ja4db, FoxIO) | «есть ли вообще такой профиль» и сверка JA4 | кросс-проверка |
| The `curl-impersonate` bundle (the wrapper) | wrapper flags: h2 preface, header set, TLS lists, `--ech`, `--tls-permute-extensions`, `--tls-key-shares-limit` | **the source of truth**: the wrapper is the client |
| Its own captures (`tests/signatures/*.yaml` at the bundle tag) | ciphers, extension set and order, groups, shares, JA3/JA4 | proof (someone else's recording of the client) |
| uTLS (pinned to `v1.8.2`, `tools/fingerprint/utls`) | reference hello specs: lists, order, curves, schemes, ALPN, extension bodies (ALPS, certificate compression, `psk_key_exchange_modes`, `record_size_limit`, delegated credentials, channel ID stub) | **the source of the spec, not proof**: uTLS lacks what the wrapper sends — and **the source of truth instead of the wrapper** when there is no wrapper at all |
| The same spec, captured by our dumper (`go run . dump <HelloSpec> -o capture.hex`; the `HelloGolang` library builds through `crypto/tls`, so it needs `-handshake`) | the hello bytes: extension bodies, order, key shares, ALPN, declared lengths | **proof** for a client without a wrapper: the module's spec at the pinned version, read against our dump (`hello capture.hex`, `diff a.hex b.hex`) |
| A live client (`hello <file.hex>`, mitmproxy/Wireshark) | "how it really sends" for a version no library covers yet | proof, when a capture exists |
| `bogdanfinn/tls-client`, JA4 databases (ja4db, FoxIO) | "does such a profile exist at all" and JA4 cross-checking | cross-check |
Когда обёртки в бандле нет — так обстоит с девятью записями (`chrome87`, `chrome72`,
`chrome70`, `chrome115pq`, `firefox120`, `firefox105`, `firefox99`, `firefox65`,
`go127`), — эталоном становится сама спека uTLS: литерал в модуле на закреплённой
версии `v1.8.2`, прочитанный против захвата, который снимает наш дампер. Два
следствия видны прямо в записи. HTTP-идентичность у такой записи — **минимум**:
у библиотеки нет HTTP-слоя, поэтому там ровно `User-Agent` своей версии и
`accept-encoding`, без `sec-ch-*` и без приоритета на h1, — и это не замер, а
объявленная граница. Преамбула h2 остаётся гиперовской по умолчанию: своей у
спеки нет, а выдумывать её значило бы уже не транскрибировать. В стенде такие
строки помечены `utls:HelloX` в `PROFILES`, и применяются к ним только `hello` и
`hello-diff` — байты против байтов; `captures`, `echo`, `echo-diff`, `headers`,
`headers-diff` и `flags` их пропускают, потому что сравнивать не с чем и флагов
обёртки у них нет.
When the bundle has no wrapper — which is the case for nine records (`chrome87`,
`chrome72`, `chrome70`, `chrome115pq`, `firefox120`, `firefox105`, `firefox99`,
`firefox65`, `go127`) — the reference becomes the uTLS spec itself: the literal in
the module at the pinned version `v1.8.2`, read against a capture taken by our
dumper. Two consequences are visible right in the record. The HTTP identity of such a
record is a **minimum**: the library has no HTTP layer, so there is exactly the
`User-Agent` of its version and `accept-encoding`, with no `sec-ch-*` and no h1
priority — and that is not a measurement but a declared boundary. The h2 preface
stays hyper's default: the spec has none of its own, and inventing one would already
stop being transcription. In the harness such rows are marked `utls:HelloX` in
`PROFILES`, and only `hello` and `hello-diff` apply to them — bytes against bytes;
`captures`, `echo`, `echo-diff`, `headers`, `headers-diff` and `flags` skip them,
because there is nothing to compare against and they have no wrapper flags.
Инструменты: `python tools/fingerprint/fingerprint.py flags <код>` (флаги обёртки),
`... versions` (JA4 и подвижность каждой версии бандла: где тасуется JA3, где
монетка padding'а), `... utls` (все профили библиотеки uTLS против ближайшей
обёртки бандла — что именно шлёт клиент на uTLS), `go run . dump <HelloSpec> -o
capture.hex` в `tools/fingerprint/utls` (спека uTLS), `cargo run --release
--example tls_fingerprint -- hello capture.hex` (чтение любого захвата),
`... -- diff a.hex b.hex` (сравнение двух любых захватов по полям, без сети) и
`--legend` (что мы шлём сейчас). У строк `utls:<HelloX>` в `PROFILES` пара в
таблице `UTLS_PAIRS` — это `ours:<код>`, то есть сверка спеки с нашей записью.
Tools: `python tools/fingerprint/fingerprint.py flags <code>` (wrapper flags),
`... versions` (JA4 and the mobility of each bundle version: where JA3 shuffles,
where the padding coin lands), `... utls` (all profiles of the uTLS library against
the nearest bundle wrapper — what exactly the uTLS client sends), `go run . dump
<HelloSpec> -o capture.hex` in `tools/fingerprint/utls` (the uTLS spec), `cargo run
--release --example tls_fingerprint -- hello capture.hex` (reading any capture),
`... -- diff a.hex b.hex` (comparing any two captures field by field, without the
network) and `--legend` (what we send right now). The `utls:<HelloX>` rows in
`PROFILES` have a counterpart in the `UTLS_PAIRS` table — it is `ours:<code>`, that
is, a check of the spec against our record.
Бандл ищется по `$CURL_IMPERSONATE_DIR`, затем в `~/Downloads`; путь можно задать
`--bundle DIR`. Имя каталога бандла несёт версию (`curl-impersonate-v2.2.2.x86_64-win32`)
— из него `capture_ref()` берёт тег форка, поэтому каталог не переименовывается.
The bundle is looked up through `$CURL_IMPERSONATE_DIR`, then in `~/Downloads`; the
path can be set with `--bundle DIR`. The bundle directory name carries the version
(`curl-impersonate-v2.2.2.x86_64-win32`) — `capture_ref()` takes the fork tag from
it, which is why the directory is not renamed.
В записи `source` называет релиз источника: обновление источника = осознанный
пересмотр пинов, а не молчаливая смена формы.
In a record, `source` names the release of the source: updating the source is a
deliberate revision of the pins, not a silent change of the shape.
## 3. Процедура
## 3. Procedure
1. **Выбрать клиента и поколение.** Сверить, что форма новая: `--legend` и
`fingerprint.py all` показывают ключ каждой существующей записи, и запись с тем
же JA4 — это тот же клиент.
2. **Добыть источник.** `flags <код>` → преамбула, заголовки, флаги; захват форка,
если он есть; спека uTLS, если обёртки мало, — а если обёртки у клиента нет
вовсе, то спека и есть эталон: литерал на закреплённой версии читается против
захвата, который снимает дампер (§2), и запись транскрибируется с этой пары;
живой захват, если версии нет ни у кого. Проверить, что форма совпадает у двух
источников там, где они пересекаются.
3. **Написать запись** в файл своего семейства (`shapes/chrome.rs`, `shapes/firefox.rs`, …)
и добавить её имя в таблицу `SHAPES` в `shapes/mod.rs` — порядок там и в `ALL`
один и тот же. Списки — сырые IANA-идентификаторы в порядке
провода, чтобы запись читалась прямо из захвата. Если форма совпадает с
существующей (Edge — это Chromium, Safari 18.0 — hello 15.5), **ссылаться на её
списки**, а не копировать: отличаться должны идентичность и преамбула.
Поведенческие признаки (`grease`, `ech`, `permute_extensions`, `priority_on_h1`,
`pq`, `key_share_groups`, `padding_to`) — тоже данные записи, а не правило по
семейству.
4. **Зарегистрировать**: вариант в `TlsFingerprint`, строка в `ALL`, `code`,
`token`, `label`, при необходимости `parse`. Решить про `DEFAULT_SET` — семь
слотов, каждый отвечает на свой вопрос (контрольная форма, новейший в каждой
семье, пара на границе поколений, по одной закрытой форме на семейство ключей).
5. **Прибить ожидания** в `tests.rs`: JA3 **и** JA4 обоих пиннутых билдеров (1.3 и
1.2), взятые **из источника** — константа, скопированная из нашего же дампа,
закрепляет нашу ошибку и прячет ровно то различие, ради которого тест есть.
Дописать строку в таблицы, которые перечисляют все профили (идентичности, доли
ключа, преамбулы h2, метки, токены). Если форма заявляет кодпоинт, которого
провайдер не умеет, — назвать его в `UNIMPLEMENTED` с причиной.
Если профилю нужно расширение, которого rustls не умеет, — патч в `vendor/rustls`
плюс строка в `vendor/rustls/README-PATCH.md` и перегенерированный `PATCH.diff`
(то же для `h2`/`hyper`, если дело в преамбуле).
6. **Прогнать лестницу** (§3.1) и закоммитить код, тесты и документацию одним
куском: `README.md` (список профилей), этот документ, `tools/fingerprint/README.md`
(если появилось новое расхождение). Запись без обёртки — `utls:HelloX` в
`PROFILES` и строка `ours:<код>` в `UTLS_PAIRS` — добавляется тем же куском,
иначе сверять спеку будет не с чем.
1. **Pick the client and the generation.** Verify that the shape is new: `--legend`
and `fingerprint.py all` show the key of every existing record, and a record with
the same JA4 is the same client.
2. **Obtain the source.** `flags <code>` → preface, headers, flags; the fork's
capture, if there is one; the uTLS spec, if the wrapper is not enough — and if the
client has no wrapper at all, then the spec is the reference: the literal at the
pinned version is read against a capture taken by the dumper (§2), and the record
is transcribed from that pair; a live capture, if no one has the version. Check
that the shape agrees across the two sources where they overlap.
3. **Write the record** in the file of its family (`shapes/chrome.rs`, `shapes/firefox.rs`, …)
and add its name to the `SHAPES` table in `shapes/mod.rs` — the order there and in
`ALL` is the same. The lists are raw IANA identifiers in wire order, so that the
record can be read straight out of a capture. If the shape coincides with an
existing one (Edge is Chromium, Safari 18.0 is the 15.5 hello), **reference its
lists** rather than copy them: what must differ is the identity and the preface.
Behavioural traits (`grease`, `ech`, `permute_extensions`, `priority_on_h1`,
`pq`, `key_share_groups`, `padding_to`) are record data too, not a per-family
rule.
4. **Register it**: a variant in `TlsFingerprint`, a row in `ALL`, `code`,
`token`, `label`, and `parse` if needed. Decide about `DEFAULT_SET` — seven
slots, each answering its own question (the baseline shape, the newest in each
family, a pair at a generation boundary, one closed shape per key family).
5. **Nail down the expectations** in `tests.rs`: JA3 **and** JA4 of both pinned
builders (1.3 and 1.2), taken **from the source** — a constant copied from our own
dump pins our own mistake and hides exactly the difference the test exists for.
Add a row to the tables that enumerate all profiles (identities, key shares, h2
prefaces, labels, tokens). If a shape advertises a code point the provider cannot
do, name it in `UNIMPLEMENTED` with a reason.
If a profile needs an extension rustls cannot do, that is a patch in `vendor/rustls`
plus a row in `vendor/rustls/README-PATCH.md` and a regenerated `PATCH.diff`
(the same for `h2`/`hyper` if the preface is what is at stake).
6. **Run the ladder** (§3.1) and commit the code, tests and documentation as one
piece: `README.md` (the profile list), this document, `tools/fingerprint/README.md`
(if a new divergence appeared). A record without a wrapper — `utls:HelloX` in
`PROFILES` and an `ours:<code>` row in `UTLS_PAIRS` — is added in the same piece,
otherwise there will be nothing to check the spec against.
### 3.1 Лестница проверок
### 3.1 The verification ladder
| Ступень | Что проверяет | Чего не видит |
| Step | What it checks | What it does not see |
|---|---|---|
| `cargo test --workspace` + `clippy` | гейты §4 и пины JA3/JA4 | ничего про провод |
| `fingerprint.py captures <код>` | дамп против собственной записи форка: шифры, набор и порядок расширений, группы, доли, JA4 | что бандл шлёт сегодня; захват — чужая выборка |
| `fingerprint.py echo-diff <код>` | оба клиента против `tls.peet.ws`: хеши, akamai-строка, каждый кадр преамбулы, порядок псевдозаголовков, каждый заголовок | тела расширений и регистр имён; это мнение сервиса о прочитанном |
| `fingerprint.py hello-diff <код>` | **байты обоих клиентов** через один локальный слушатель: тела расширений, длина padding, список сжатия, доли ключа; у записи без обёртки (`utls:` в `PROFILES`) противник — байты спеки, снятые дампером | что сервер с ними делает |
| `fingerprint.py headers-diff <код>` | блок запроса HTTP/1.1: имена с регистром, порядок, значения | ничего про TLS и h2 |
| `live` / `liveany <код>` | настоящие рукопожатия: базовые хосты, `hub.docker.com` и `danbooru.donmai.us` (SCT в certificate entry), `standby-rezka.tv` | — |
| `peet <код>` | akamai-отпечаток преамбулы | — |
| `diff <a.hex> <b.hex>` | два любых захвата — наш, бандла, uTLS, живого браузера — по полям, без сети | что сервер с ними делает |
| `cargo test --workspace` + `clippy` | the §4 gates and the JA3/JA4 pins | anything about the wire |
| `fingerprint.py captures <code>` | the dump against the fork's own record: ciphers, extension set and order, groups, shares, JA4 | what the bundle sends today; the capture is someone else's sample |
| `fingerprint.py echo-diff <code>` | both clients against `tls.peet.ws`: hashes, akamai string, every preface frame, pseudo-header order, every header | extension bodies and the case of names; it is the service's opinion of what it read |
| `fingerprint.py hello-diff <code>` | **the bytes of both clients** through one local listener: extension bodies, padding length, compression list, key shares; for a record without a wrapper (`utls:` in `PROFILES`) the opponent is the spec bytes taken by the dumper | what the server does with them |
| `fingerprint.py headers-diff <code>` | the HTTP/1.1 request block: names with case, order, values | anything about TLS and h2 |
| `live` / `liveany <code>` | real handshakes: base hosts, `hub.docker.com` and `danbooru.donmai.us` (SCT in the certificate entry), `standby-rezka.tv` | — |
| `peet <code>` | the akamai fingerprint of the preface | — |
| `diff <a.hex> <b.hex>` | any two captures — ours, the bundle's, uTLS, a live browser — field by field, without the network | what the server does with them |
`fingerprint.py all` заканчивается **таблицей вердикта**: строка на профиль,
какие сравнения расходились и что из этого объясняет сам клиент записи. Судит
она по замеру, а не по объявлению: hello профиля снимается 24 раза, поэтому
уезжающий *порядок* расширений отмечает тасующую форму, а уезжающий *набор* —
монетку padding'а; обёртка с `--tls-key-shares-limit` объясняет свои доли ключа.
Всё остальное печатается как `to look at` — судить о нём может только список
намеренных расхождений в `tools/fingerprint/README.md`. Полный прогон идёт по
двадцати восьми профилям: девятнадцать строк бандла дают по себе те же `13 clean,
6 explained by their own client, 0 to look at`, что и раньше, а девять строк из
uTLS судятся одним сравнением `hello` — флагов обёртки у них нет, объяснять там
нечего, и всё, что прощает список, — это длина GREASE-тела у `firefox120` и
вторая доля ключа `chrome115pq` (§5).
`fingerprint.py all` ends with a **verdict table**: one row per profile, which
comparisons diverged and which of them the record's own client explains. It judges by
measurement, not by declaration: a profile's hello is taken 24 times, so a drifting
extension *order* marks a shuffling shape, while a drifting *set* marks the padding
coin; a wrapper with `--tls-key-shares-limit` explains its own key shares. Everything
else is printed as `to look at` — only the list of intentional divergences in
`tools/fingerprint/README.md` can rule on it. A full run goes over twenty-eight
profiles: the nineteen bundle rows produce the same `13 clean, 6 explained by their
own client, 0 to look at` as before, while the nine uTLS rows are judged by a single
`hello` comparison — they have no wrapper flags, there is nothing to explain there,
and all the list forgives is the GREASE body length in `firefox120` and the second
key share of `chrome115pq` (§5).
Профиль считается сделанным, когда `hello-diff` говорит `SAME`, либо когда каждое
оставшееся различие названо в `tools/fingerprint/README.md` и в §5 этого документа.
A profile is considered done when `hello-diff` says `SAME`, or when every remaining
difference is named in `tools/fingerprint/README.md` and in §5 of this document.
Про `standby-rezka.tv`: хост отвечает `fatal alert: IllegalParameter` **всем**
формам, которые гласят GREASE, и `OK` тем, которые не гласят. Это поведение хоста,
а не регрессия записи: набор падающих форм в точности совпадает с набором гласящих,
и байты этих записей не менялись. Хост остаётся в живом свипе именно поэтому.
About `standby-rezka.tv`: the host answers `fatal alert: IllegalParameter` to **all**
shapes that advertise GREASE, and `OK` to those that do not. This is host behaviour,
not a regression of the records: the set of failing shapes exactly matches the set of
advertising ones, and the bytes of those records have not changed. That is precisely
why the host stays in the live sweep.
## 4. Гейты, которые падают сами
## 4. Gates that fail on their own
| Тест | Что ловит |
| Test | What it catches |
|---|---|
| `fingerprint_table_is_total` | вариант без записи и запись без варианта, порядок таблицы |
| `profile_names_are_unique_lowercase_and_versioned` | дубль имени, имя без версии, имя не в нижнем регистре |
| `fingerprint_parses_known_values_and_rejects_others` | приём нового имени и отказ именам обёрток и без версии |
| `default_set_is_a_subset_of_all` | набор по умолчанию: непустой, без дублей, из `ALL` |
| `the_baseline_is_the_only_shape_that_impersonates_nobody` | запись, которая ничего не изображает, но не `rustls` |
| `every_advertised_code_point_is_served_or_named` | профиль ⊆ провайдер: каждый шифр, группа и схема либо обслуживается, либо названа в `UNIMPLEMENTED` с причиной; устаревшая запись тоже валит тест |
| `the_advertised_group_list_opens_with_the_group_we_share` | список групп, открывающийся не той группой, которой шлётся шар |
| `only_the_chromium_profiles_shuffle_their_extension_order` | перемешивание там, где его нет, и его отсутствие там, где есть; неперемешивающие формы обязаны быть байт-в-байт воспроизводимы |
| `the_ech_shapes_carry_the_grease_extension_and_the_others_do_not` | ECH у тех, чья обёртка называет `--ech true`, и у `firefox120` из спеки uTLS; длина тела — из четырёх значений BoringSSL, а у `firefox120` источник пиннует одну (записано в §5) |
| `every_identity_is_spelled_the_way_its_client_writes_it` | `User-Agent` и написание имён заголовков |
| `every_profile_sends_the_key_shares_its_wrapper_asks_for` | доли ключа против `--tls-key-shares-limit` |
| `every_h2_preface_matches_the_wrapper_it_copies` | SETTINGS с порядком, окно, порядок псевдозаголовков, приоритет |
| `display_labels_name_the_pinned_version`, `fingerprint_tokens_are_stable` | метка без версии, изменение токена (Rule 4) |
| `bundle_versions_match_their_ja3` / `_ja4`, `chrome_146_matches_the_utls_list_it_is_derived_from` | пины: любое изменение формы — расширение добавлено/убрано, шифр переставлен, padding потерян |
| `utls_shapes_match_their_ja3` | пины девяти записей из uTLS: JA3 (у тасующей `chrome115pq` — с сортированным порядком расширений) и JA4, взятые с захватов в `target/fingerprint/utls-ladder/`; шифр переставлен, расширение добавлено/убрано, группа потеряна — тест падает |
| `profiles_that_share_a_tls_shape_send_the_same_hello` | запись, которая утверждает общие списки, а шлёт своё |
| `fingerprint_table_is_total` | a variant without a record and a record without a variant, the order of the table |
| `profile_names_are_unique_lowercase_and_versioned` | a duplicate name, a name without a version, a name that is not lowercase |
| `fingerprint_parses_known_values_and_rejects_others` | acceptance of a new name and rejection of wrapper names and names without a version |
| `default_set_is_a_subset_of_all` | the default set: non-empty, without duplicates, drawn from `ALL` |
| `the_baseline_is_the_only_shape_that_impersonates_nobody` | a record that impersonates nothing but is not `rustls` |
| `every_advertised_code_point_is_served_or_named` | profile ⊆ provider: every cipher, group and scheme is either served or named in `UNIMPLEMENTED` with a reason; a stale record fails the test too |
| `the_advertised_group_list_opens_with_the_group_we_share` | a group list that opens with a group other than the one the share is sent for |
| `only_the_chromium_profiles_shuffle_their_extension_order` | shuffling where there is none, and its absence where there is; non-shuffling shapes must be reproducible byte for byte |
| `the_ech_shapes_carry_the_grease_extension_and_the_others_do_not` | ECH in those whose wrapper names `--ech true`, and in `firefox120` from the uTLS spec; the body length comes from four BoringSSL values, while for `firefox120` the source pins one (recorded in §5) |
| `every_identity_is_spelled_the_way_its_client_writes_it` | `User-Agent` and the spelling of header names |
| `every_profile_sends_the_key_shares_its_wrapper_asks_for` | key shares against `--tls-key-shares-limit` |
| `every_h2_preface_matches_the_wrapper_it_copies` | SETTINGS with order, window, pseudo-header order, priority |
| `display_labels_name_the_pinned_version`, `fingerprint_tokens_are_stable` | a label without a version, a change of a token (Rule 4) |
| `bundle_versions_match_their_ja3` / `_ja4`, `chrome_146_matches_the_utls_list_it_is_derived_from` | the pins: any change of a shape — an extension added/removed, a cipher reordered, padding lost |
| `utls_shapes_match_their_ja3` | the pins of the nine uTLS records: JA3 (for the shuffling `chrome115pq`, with the extension order sorted) and JA4, taken from the captures in `target/fingerprint/utls-ladder/`; a cipher reordered, an extension added/removed, a group lost — the test fails |
| `profiles_that_share_a_tls_shape_send_the_same_hello` | a record that claims shared lists but sends its own |
## 5. Намеренные расхождения
## 5. Intentional divergences
Список и причины — в `tools/fingerprint/README.md`, раздел «Differences the tool
will keep reporting». Коротко: тело GREASE-ECH пересобирается каждое соединение
(сравнивается объявленная длина), padding у `chrome123`/`chrome131android` —
монетка на соединение (у формы два ключа), порядок расширений у Chrome 110+
тасуется (JA4 совпадает, JA3 нет), у `tor145` на одну долю ключа больше, чем у
браузера в захвате форка, — запись следует своей обёртке.
The list and the reasons are in `tools/fingerprint/README.md`, section "Differences the tool
will keep reporting". In short: the GREASE-ECH body is rebuilt on every connection
(the declared length is what gets compared), padding in `chrome123`/`chrome131android` is a
per-connection coin (the shape has two keys), the extension order of Chrome 110+
shuffles (JA4 matches, JA3 does not), and `tor145` has one key share more than the
browser in the fork's capture — the record follows its own wrapper.
У девяти записей, транскрибированных из спек uTLS, расхождения свои, и все четыре
названы здесь, чтобы читатель не искал их в другом месте:
The nine records transcribed from uTLS specs have divergences of their own, and all four
are named here so the reader does not have to look for them elsewhere:
* **`firefox120` и длина GREASE-ECH.** Спека пиннует длину тела
`encrypted_client_hello` (`CandidatePayloadLens = {223}`, то есть 239 байт
payload'а и 281-байтовое тело расширения на проводе), а наш билдер, как и во
всех остальных записях, тянет её из четырёх значений BoringSSL (тела
186/218/250/282). Хеши от этого не двигаются, а `hello-diff` против захвата
спеки печатает расхождение длины на каждом броске — это и есть то, что список
намеренных расхождений в `tools/fingerprint/README.md` прощает.
* **`chrome115pq` и вторая доля ключа.** Спека шлёт шары по
`X25519Kyber768Draft00` (25497) и X25519, а у провайдера группы для этого
черновика нет (§6), поэтому запись объявляет 25497 ради списка групп, а шар
шлёт только по X25519. `hello-diff` печатает одну долю там, где в захвате их
две, — это расхождение создал провайдер, а не транскрипция, и JA3/JA4 (они
читают список групп, а не доли) от него не двигаются. Второе следствие той же
дыры — длина: без тела гибридной доли наш hello короче исходных 1524 байт и
попадает под 512-байтовый порог padding'а, под который настоящий Chrome 115
никогда не попадает, поэтому и слота padding в записи нет (тела расширений и
набор остаются захвата, а отличается только тело доли ключа).
* **Никакой преамбулы h2.** Все девять шлют HTTP/2 с гиперовскими настройками по
умолчанию: своей преамбулы в спеке нет, а выдумывать её — уже не транскрипция.
У остальных записей преамбула снята с обёртки, и различие это видно только на
проводе: `hello` преамбулу не несёт, а `echo` для девяти не запускается.
* **HTTP-идентичность — минимум, а не замер.** У библиотеки нет HTTP-слоя, поэтому
в записи ровно `User-Agent` своей версии и `accept-encoding` (`gzip, deflate, br`
у браузеров, `gzip` у `go127`) в написании самого клиента: Chromium'овы имена —
в нижнем регистре, Firefox'овы и `go127` — с большой буквы. Приоритет на h1 не
объявляет ни одна из девяти.
* **`firefox120` and the GREASE-ECH length.** The spec pins the length of the
`encrypted_client_hello` body (`CandidatePayloadLens = {223}`, that is 239 bytes
of payload and a 281-byte extension body on the wire), while our builder, as in
all the other records, draws it from four BoringSSL values (bodies
186/218/250/282). The hashes do not move because of this, but `hello-diff` against
the spec's capture prints a length divergence on every throw — and that is what
the list of intentional divergences in `tools/fingerprint/README.md` forgives.
* **`chrome115pq` and the second key share.** The spec sends shares for
`X25519Kyber768Draft00` (25497) and X25519, while the provider has no group for
that draft (§6), so the record advertises 25497 for the sake of the group list but
sends a share only for X25519. `hello-diff` prints one share where the capture has
two — the provider created this divergence, not the transcription, and JA3/JA4
(they read the group list, not the shares) do not move because of it. The second
consequence of the same hole is the length: without the hybrid share's body our
hello is shorter than the original 1524 bytes and falls under the 512-byte padding
threshold, which a real Chrome 115 never falls under, which is also why the record
has no padding slot (the extension bodies and the set remain the capture's, and
only the key share body differs).
* **No h2 preface at all.** All nine send HTTP/2 with hyper's default settings: the
spec has no preface of its own, and inventing one is no longer transcription. The
other records have their preface taken from the wrapper, and the difference is
visible only on the wire: `hello` does not carry the preface, and `echo` is not
run for the nine.
* **The HTTP identity is a minimum, not a measurement.** The library has no HTTP
layer, so the record holds exactly the `User-Agent` of its version and
`accept-encoding` (`gzip, deflate, br` for the browsers, `gzip` for `go127`) in the
client's own spelling: Chromium's names are lowercase, Firefox's and `go127`'s are
capitalized. None of the nine advertises an h1 priority.
Отдельно: `chrome87` отвечает и за Chrome 83 — спеки этих версий совпадают
целиком, — а `chrome70` за 360Browser 11.0, где совпадают тела расширений и JA4,
а отличается порядок. Это тот же случай «одна запись на форму», что и Chrome
133–146 в §1, а не расхождение с источником.
Separately: `chrome87` also covers Chrome 83 — the specs of those versions coincide
entirely — while `chrome70` covers 360Browser 11.0, where the extension bodies and
JA4 coincide and only the order differs. This is the same "one record per shape" case
as Chrome 133–146 in §1, not a divergence from the source.
## 6. Что вне области
## 6. What is out of scope
* **QUIC/HTTP-3.** В `vendor/rustls-rustcrypto/src/quic.rs` заглушки, у всех
TLS 1.3-сьютов `quic: None`. Пока это так, ни один h3-профиль невозможен,
независимо от `quinn`/`h3`. Порядок работ, если за это браться: header
protection по RFC 9001 §5.4 с тест-векторами §A.2, `quinn` без дефолтного
`rustls-ring`, патч transport parameters, фича `http3` (по умолчанию выключена —
Rule 2).
* **Настоящий ECH по конфигурации из DNS.** Сознательно не делается: он делает
hello зависимым от *хоста*, тогда как остальные поля записи — от клиента, и
обёртка его не шлёт. Для диагностики он ничего не даёт (цензор не отличает
GREASE-ECH от настоящего по построению), а стоит DNS-запроса в классифицируемом
пути. Форма — GREASE, как у обёрток.
* **Запись-уровневые трюки** (дробление ClientHello по records, record size limit,
middlebox CCS) — известное расхождение, не воспроизводится.
* **Delegated credentials (34)** — не заявлять как обслуживаемый кодпоинт:
провайдер подписывать ими не умеет. Расширение в записи — другое дело, и три
записи Firefox из uTLS (`firefox99`, `firefox105`, `firefox120`) его объявляют:
так написана спека, а тело — её собственные байты.
* **Пробелы провайдера** (P-521, DHE, SHA-1, CBC, `X25519Kyber768Draft00`
`0x6399`, который объявляет `chrome115pq`) — не ошибка записи, а строка в
`UNIMPLEMENTED` с причиной: гейт §4 проверяет и обратное, что названное больше
не является устаревшим.
* **QUIC/HTTP-3.** In `vendor/rustls-rustcrypto/src/quic.rs` there are stubs, and
every TLS 1.3 suite has `quic: None`. As long as that is so, no h3 profile is
possible, regardless of `quinn`/`h3`. The order of work, if one takes this on:
header protection per RFC 9001 §5.4 with the §A.2 test vectors, `quinn` without the
default `rustls-ring`, a transport parameters patch, and an `http3` feature
(off by default — Rule 2).
* **Real ECH configured from DNS.** Deliberately not done: it makes the hello depend
on the *host*, whereas the rest of a record's fields depend on the client, and the
wrapper does not send it. It adds nothing for diagnostics (a censor cannot tell
GREASE-ECH from a real one by construction), while it costs a DNS request in the
classified path. The shape is GREASE, as in the wrappers.
* **Record-level tricks** (splitting the ClientHello across records, record size limit,
middlebox CCS) — a known divergence, not reproduced.
* **Delegated credentials (34)** — do not advertise it as a served code point: the
provider cannot sign with them. The extension in a record is another matter, and three
Firefox records from uTLS (`firefox99`, `firefox105`, `firefox120`) do advertise it:
that is how the spec is written, and the body is its own bytes.
* **Provider gaps** (P-521, DHE, SHA-1, CBC, `X25519Kyber768Draft00`
`0x6399`, which `chrome115pq` advertises) — not a record error but a row in
`UNIMPLEMENTED` with a reason: the §4 gate checks the converse too, that what is named
is no longer stale.
## 7. Решения, которые стоит знать
## 7. Decisions worth knowing
* **Данные вместо кода.** Билдеры на каждый профиль (~100 строк каждый) заменены
таблицей: ошибка в одной константе была двадцатью местами, где её можно
сделать, — стало одним.
* **Ключ, а не байты.** Цензор несёт таблицу JA4-ключей, поэтому форма попадает
под блок по ключу, а не по порядку расширений: выход из таблицы — другое
*поколение*, а не другая перестановка.
* **GREASE-ECH, а не настоящий.** Настоящий `ECHConfigList` curl берёт только через
DoH или `--ecl:`, и ни одна обёртка их не передаёт; самодельное тело заставляло
Google и Cloudflare отвечать `DecodeError`, а путь rustls (`EchMode::Grease` через
`net::hpke`) принят всеми хостами свипа.
* **Padding — частота, а не форма.** `chrome123` и `chrome131android` паддят на
одном соединении из четырёх (самое короткое тело ECH), и это воспроизведено;
остальные три длины уходят без padding'а — ровно как клиент.
* **Перемешивание — распределение.** Побайтового равенства с настоящим Chrome 110+
не будет никогда: у него форма новая на каждом соединении. Сравнивать можно JA4
и множество расширений.
* **Спека вместо обёртки, когда обёртки нет.** Обёртка остаётся источником истины,
но её отсутствие — не повод не иметь профиля: девять записей транскрибированы со
спек uTLS и сверяются с байтами, которые снимает с них наш дампер (§2). Что при
этом приходится объявить, а не измерить, — HTTP-слой: он у библиотеки
отсутствует, поэтому идентичность у этих записей минимальная, а преамбула h2
гиперовская. Такая граница названа в самой записи и в §5, чтобы никто позже не
принял её за замер.
* **Data instead of code.** Per-profile builders (~100 lines each) have been replaced
by a table: an error in one constant used to be twenty places to make it — now it
is one.
* **Key, not bytes.** A censor carries a table of JA4 keys, so a shape falls under a
block by key rather than by extension order: leaving the table means a different
*generation*, not a different permutation.
* **GREASE-ECH, not real.** curl obtains a real `ECHConfigList` only via DoH or
`--ecl:`, and no wrapper passes either; a hand-made body made Google and Cloudflare
answer `DecodeError`, whereas rustls's path (`EchMode::Grease` through `net::hpke`)
is accepted by every host in the sweep.
* **Padding is a frequency, not a shape.** `chrome123` and `chrome131android` pad on
one connection in four (the shortest ECH body), and this is reproduced; the other
three lengths go out without padding — exactly like the client.
* **Shuffling is a distribution.** Byte-for-byte equality with a real Chrome 110+
will never happen: its shape is new on every connection. What can be compared is
JA4 and the set of extensions.
* **A spec instead of a wrapper when there is no wrapper.** The wrapper remains the
source of truth, but its absence is no reason not to have a profile: nine records
are transcribed from uTLS specs and are checked against the bytes our dumper takes
from them (§2). What has to be declared rather than measured in that case is the
HTTP layer: the library lacks it, so the identity of these records is minimal and
the h2 preface is hyper's. Such a boundary is named in the record itself and in
§5, so that no one later mistakes it for a measurement.

75
docs/CI.md Normal file
View file

@ -0,0 +1,75 @@
# CI and release
Reference for `.github/workflows/`. Everything needed for local work is in `AGENTS.md`;
this file is read when changing a dependency, an installer or the build matrix. The
workflow files are the source of truth — only what they do not make obvious is below.
## `check.yml` — on every push and PR
Exact commands (all `--locked`):
```
cargo build --workspace --locked
cargo test --workspace --locked
cargo clippy --workspace --all-targets --locked -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --locked --no-deps
```
| Job | What it checks beyond that |
| --- | --- |
| `check` (ubuntu) | installer smoke tests: `install.sh` under `dash` and under BusyBox against a local release fixture, comparing the published `SHA256SUMS.txt` line and the `--version` of the installed file; `shellcheck -s sh --severity=warning`; mirror-list parity between `install.sh` and `install.ps1` (a host added to one file and forgotten in the other is a source a whole platform cannot reach) |
| `windows` | the same build/test/clippy on `windows-2025`: `cfg(windows)` is dead on the Linux runner, so the Win32 console-mode probe, the keyboard layouts and the Windows adapters compile nowhere else |
| `artifacts` | the `actions/upload-artifact` + `download-artifact` pair — they are bumped together, and a mismatch shows up only on a tag |
| `policy` | pure Rust: `cargo tree --workspace --target all -e normal,build -i <crate>` over the ban list, then `cargo deny` |
Every job sets `timeout-minutes`: a stalled `cross` pull or a hung installer MUST NOT hold
a runner for the six-hour default.
## `release.yml` — on a `v*` tag (and manually)
1. **`version`** — fails in seconds on what would otherwise fail the whole matrix: the tag
MUST match both `workspace.package.version` and the `VERSION` constant in each
installer. An installer one release behind downloads the previous tag — and its own
check passes, because it compares the download against its own constant.
2. **`build`** — 11 targets: Windows (x86_64, win7), Linux musl (x86_64, arm64, armv7,
mipsel, mips), macOS (intel, arm64), Android (arm64, armv7). Some rows build through
`cross` in a container. Three rows build on `nightly-2026-09-17` with `-Z build-std`
(win7 and both MIPS — the flag is nightly-only; `+toolchain` overrides
`rust-toolchain.toml`, so the rest of the matrix stays on 1.98.1). That date lives in
five places — move them together. Every built binary is smoke-tested by running it
(`--version` and `--legend`); foreign architectures run under `qemu-user`.
3. **Packing** — three rows are additionally compressed with UPX pinned to **4.2.4**: the
5.x unpack stub needs `memfd_create`, i.e. Linux ≥ 3.17. Only router targets are packed
(armv7, mipsel, mips); desktop builds are not — the gain there does not pay for the
unpack delay on every start.
4. **`release`** — a draft first, then `gh release edit --draft=false --latest`, so a run
that fails in between leaves a draft rather than half a release. Publishes
`SHA256SUMS.txt` and counts its assets (11 plain + 3 packed), because
`fail_on_unmatched_files` catches only a pattern that matches nothing at all.
Third-party actions (`softprops/action-gh-release`, `crazy-max/ghaction-upx`) are pinned
by commit: a tag can be moved, and one of them publishes the release from a job holding
`contents: write`. `actions/*` stay on their major tags — they come from the platform the
workflow runs on.
## Supply chain
- `cargo-deny` **0.20.2**, config `deny.toml`, four checks: `licenses` (the repository
redistributes four patched forks, so a new licence carrying an obligation we cannot meet
MUST fail here rather than in a release note), `sources` (crates.io only: a git
dependency would make the ten cross builds irreproducible), `advisories` (RustSec;
`yanked = "deny"`, because a withdrawn release is not merely old), `bans`
(`wildcards = "deny"`, with `allow-wildcard-paths` so the intra-workspace path
dependencies stay legal; `multiple-versions = "warn"`, because nine crates in the lock
legitimately ship two majors). Two exceptions are documented in the file itself.
- The pure-Rust rule is **deliberately not expressed** in `deny.toml`: `bans` evaluates the
resolved lock, where `ring` and `cc` hang as optional `rustls-webpki` edges that no build
enables. So the ban is asked of `cargo tree`, which is feature- and target-resolved.
- `.github/dependabot.yml` moves GitHub Actions only: weekly, branch `rust`, limit 3 PRs,
prefix `ci`.
## Nothing needs running by hand
Neither `policy` nor the installer smoke tests are run manually before a push. Three
things actually fail the job: a new dependency, an installer edit, and a broken doc link
(`cargo doc` with `-D warnings` is the only thing that reads intra-doc links at all).

View file

@ -1,30 +1,30 @@
# Оптимизация размера бинарника (Binary Size Optimization)
# Binary Size Optimization
В данном документе зафиксированы архитектурные решения и профили компиляции, примененные для минимизации размера исполняемого файла `dpi-detector` на десктопных и встраиваемых платформах (Windows, Linux x86_64, ARM, MIPS / OpenWrt / Keenetic).
This document records the architectural decisions and compilation profiles applied to minimize the size of the `dpi-detector` executable on desktop and embedded platforms (Windows, Linux x86_64, ARM, MIPS / OpenWrt / Keenetic).
---
## 1. Сводные результаты (Windows x86_64)
## 1. Summary Results (Windows x86_64)
| Метрика | До оптимизации | После оптимизации | Разница |
| Metric | Before optimization | After optimization | Difference |
|---|---|---|---|
| **Полный размер файла `.exe`** | **4 662 784 байт (4.45 МБ)** | **3 333 632 байт (3.17 МБ)** | **-1 329 152 байт (-28.5%)** |
| **Секция кода `.text`** | 2 772 480 байт | 2 248 192 байт | **-524 288 байт (-18.9%)** |
| **Секция данных `.rdata`** | 861 184 байт | 798 208 байт | **-62 976 байт (-7.3%)** |
| **Секция ресурсов `.rsrc` (иконка)** | 944 128 байт | 198 144 байт | **-745 984 байт (-79.0%)** |
| **Total `.exe` file size** | **4 662 784 bytes (4.45 MB)** | **3 333 632 bytes (3.17 MB)** | **-1 329 152 bytes (-28.5%)** |
| **Code section `.text`** | 2 772 480 bytes | 2 248 192 bytes | **-524 288 bytes (-18.9%)** |
| **Data section `.rdata`** | 861 184 bytes | 798 208 bytes | **-62 976 bytes (-7.3%)** |
| **Resource section `.rsrc` (icon)** | 944 128 bytes | 198 144 bytes | **-745 984 bytes (-79.0%)** |
---
## 2. Примененные меры
## 2. Applied Measures
### 2.1. Оптимизация ресурсов Windows (`crates/dpi-detector/assets/icon.ico`)
* **Проблема:** В `.ico` файле находился несжатый BMP 256x256 (270 КБ) и фрейм 512x512 (531 КБ), который не используется Windows Shell. Иконка занимала 920 КБ (20% всего бинарника).
* **Решение:** Файл пересобран со стандартной матрицей четких разрешений: `16x16, 24x24, 32x32, 48x48, 64x64, 128x128, 256x256` со сжатием PNG.
* **Результат:** Размер `.ico` снижен с 920 КБ до 191 КБ, экономия в секции `.rsrc` составила **~746 КБ**.
### 2.1. Windows Resource Optimization (`crates/dpi-detector/assets/icon.ico`)
* **Problem:** The `.ico` file contained an uncompressed 256x256 BMP (270 KB) and a 512x512 frame (531 KB) that is not used by the Windows Shell. The icon occupied 920 KB (20% of the entire binary).
* **Solution:** The file was rebuilt with the standard matrix of crisp resolutions: `16x16, 24x24, 32x32, 48x48, 64x64, 128x128, 256x256` with PNG compression.
* **Result:** The `.ico` size was reduced from 920 KB to 191 KB; the savings in the `.rsrc` section amounted to **~746 KB**.
### 2.2. Профиль компилятора: `opt-level = "z"`
* **Файл:** `Cargo.toml`
* **Настройка:**
### 2.2. Compiler Profile: `opt-level = "z"`
* **File:** `Cargo.toml`
* **Setting:**
```toml
[profile.release]
opt-level = "z"
@ -33,45 +33,46 @@
panic = "abort"
strip = true
```
* **Механика:** Режим `"z"` заставляет LLVM агрессивно отсекать раздувание кода (loop unrolling, векторизацию, избыточный inlining функций), ориентируясь на минимальный вес инструкций.
* **Результат:** Сжатие секции машинного кода `.text` на **~500 КБ**.
* **Mechanics:** The `"z"` mode makes LLVM aggressively cut code bloat (loop unrolling, vectorization, excessive function inlining), targeting the minimum instruction weight.
* **Result:** Compression of the machine code section `.text` by **~500 KB**.
* **Rejected:** `opt-level = "s"` — the previous variant, superseded by `"z"`. In `docs/REFACTORING.md` it is still named as the active one; `"z"` is what gets built, do not revert.
### 2.3. Устранение дубликатов зависимостей в `Cargo.lock`
* **`crossterm`:** Обновлен до `0.29` в корневом `Cargo.toml`. Устранена двойная компиляция версий `0.28.1` (из `dpi-detector`) и `0.29.0` (из `comfy-table`).
* **`webpki-roots`:** Переключен на версию `1.0`. Устранена зависимость-переходник `0.26.11`.
* **Результат:** Уменьшение объема дублирующегося кода, ускорение времени сборки.
### 2.3. Eliminating Duplicate Dependencies in `Cargo.lock`
* **`crossterm`:** Updated to `0.29` in the root `Cargo.toml`. Double compilation of versions `0.28.1` (from `dpi-detector`) and `0.29.0` (from `comfy-table`) has been eliminated.
* **`webpki-roots`:** Switched to version `1.0`. The `0.26.11` shim dependency has been eliminated.
* **Result:** Reduced volume of duplicated code, faster build times.
### 2.4. Минимизация фичей CLI-парсера (`clap`)
* **Файл:** `Cargo.toml`
* **Было:** `clap = { version = "4.5", features = ["derive", "cargo"] }`
* **Стало:** `clap = { version = "4.5", default-features = false, features = ["std", "derive", "help", "usage"] }`
* **Механика:** Отключены тяжелые алгоритмы нечеткого поиска опечаток (таблицы расстояния Левенштейна в `suggestions`), контекстное форматирование ошибок и макросы версионирования.
* **Результат:** Экономия **~35 КБ** в машинном коде `clap_builder`.
### 2.4. Minimizing CLI Parser Features (`clap`)
* **File:** `Cargo.toml`
* **Before:** `clap = { version = "4.5", features = ["derive", "cargo"] }`
* **After:** `clap = { version = "4.5", default-features = false, features = ["std", "derive", "help", "usage"] }`
* **Mechanics:** Disabled the heavy fuzzy typo-search algorithms (Levenshtein distance tables in `suggestions`), contextual error formatting and versioning macros.
* **Result:** Savings of **~35 KB** in `clap_builder` machine code.
### 2.5. Однопоточный рантайм Tokio (`current_thread`)
* **Файлы:** `Cargo.toml`, `crates/dpi-detector/src/main.rs`
* **Механика:** Заменен многопоточный планировщик `rt-multi-thread` на легковесный однопоточный `current_thread` (`features = ["rt"]`). Для сетевого I/O на epoll/IOCP один поток эффективно обслуживает десятки одновременных сокетов.
* **Результат:**
* Срезает **~50–120 КБ** машинного кода (удален work-stealing шедулер, потоковый пул и межпоточные очереди).
* Значительно снижает потребление оперативной памяти (RSS) на 1–2 ядерных роутерах (каждому потоку ОС не выделяется отдельный стек 1–2 МБ).
### 2.5. Single-Threaded Tokio Runtime (`current_thread`)
* **Files:** `Cargo.toml`, `crates/dpi-detector/src/main.rs`
* **Mechanics:** The multi-threaded `rt-multi-thread` scheduler was replaced with the lightweight single-threaded `current_thread` (`features = ["rt"]`). For network I/O on epoll/IOCP a single thread efficiently serves dozens of simultaneous sockets.
* **Result:**
* Cuts **~50–120 KB** of machine code (the work-stealing scheduler, thread pool and inter-thread queues are removed).
* Significantly reduces RAM consumption (RSS) on 1–2 core routers (no separate 1–2 MB OS stack is allocated per thread).
### 2.6. Вырезание таблиц раскрутки стека на Linux (`-C force-unwind-tables=no`)
* **Файл:** `.github/workflows/release.yml`
* **Механика:** При `panic = "abort"` таблицы раскрутки стека (`.eh_frame` и `.eh_frame_hdr`) не используются во время работы программы. Флаг полностью отключает их генерацию для всех Linux/MIPS/ARM таргетов.
* **Результат:** Экономия **~300 КБ** в ELF-бинарниках для MIPS и ARM.
### 2.6. Cutting Stack Unwind Tables on Linux (`-C force-unwind-tables=no`)
* **File:** `.github/workflows/release.yml`
* **Mechanics:** With `panic = "abort"` the stack unwind tables (`.eh_frame` and `.eh_frame_hdr`) are not used while the program runs. The flag completely disables their generation for all Linux/MIPS/ARM targets.
* **Result:** Savings of **~300 KB** in ELF binaries for MIPS and ARM.
### 2.7. Сохранение формата `config.yml` (YAML)
* Формат YAML сознательно сохранен ради удобства пользователя: поддержка комментариев (`#`), отсутствие жестких ограничений на завершающие запятые, а также 100% совместимость с уже существующими файлами конфигурации.
### 2.7. Keeping the `config.yml` Format (YAML)
* The YAML format was deliberately kept for user convenience: comment support (`#`), no strict restrictions on trailing commas, as well as 100% compatibility with already existing configuration files.
---
## 3. Специфика для роутеров (MIPS, ARM, OpenWrt, Keenetic, Entware)
## 3. Router Specifics (MIPS, ARM, OpenWrt, Keenetic, Entware)
1. **Почему НЕ используется упаковщик UPX:**
* На роутерах файловая система SquashFS сжимает бинарники по алгоритму **XZ / LZMA** непосредственно на Flash-памяти.
* Обычный Linux ELF подгружается ядром постранично (4 КБ demand-paging через `mmap`).
* UPX-бинарник при запуске обязан полностью распаковать себя в оперативную память (RAM). На роутерах с 64–128 МБ RAM это мгновенно раздувает RSS на несколько мегабайт и провоцирует OOM-killer.
2. **100% автономность (Static Musl):**
* Сборки под `mipsel-unknown-linux-musl`, `mips-unknown-linux-musl` и `armv7-unknown-linux-musleabihf` собираются со статическим Musl libc (`+crt-static`), что позволяет запускать утилиту на любых прошивках без установки внешних библиотек в `/opt/lib`.
3. **Сохранение DoH HTTP/2 (`h2`):**
* Стек HTTP/2 сохранен для аутентичной эмуляции запросов современных браузеров к DoH-резолверам (Cloudflare, Google, AdGuard, Quad9).
1. **UPX is a fallback, not the default:**
* The plain binary is what the installer prefers. Three router targets (`armv7`, `mipsel`, `mips`) additionally ship a `-upx` variant, and `install.sh` switches to it only when the target has less than 200 MB free (`DPI_UPX=1` forces it, `DPI_UPX=0` forbids it).
* On routers the SquashFS filesystem compresses binaries with the **XZ / LZMA** algorithm directly on the Flash memory, so the plain ELF is already stored compactly.
* A normal Linux ELF is loaded by the kernel page by page (4 KB demand-paging via `mmap`), while a UPX binary must fully unpack itself into RAM on startup. On routers with 64–128 MB RAM this instantly inflates RSS by several megabytes and provokes the OOM-killer — which is why UPX is reserved for flash-tight devices, where the trade is deliberate.
2. **100% self-containment (Static Musl):**
* Builds for `mipsel-unknown-linux-musl`, `mips-unknown-linux-musl` and `armv7-unknown-linux-musleabihf` are built with static Musl libc (`+crt-static`), which allows running the utility on any firmware without installing external libraries into `/opt/lib`.
3. **Keeping DoH HTTP/2 (`h2`):**
* The HTTP/2 stack is kept for authentic emulation of modern browser requests to DoH resolvers (Cloudflare, Google, AdGuard, Quad9).

View file

@ -1,293 +1,305 @@
# План рефакторинга
# Refactoring plan
Документ описывает работу по структуре кодовой базы. Фазы P0–P8 **выполнены**
(их коммиты — в §2), P9 решено не делать; раздел §1 остаётся списком инвариантов,
а §4 — историей фаз: что именно менялось, чем проверялось и какие отклонения были
приняты сознательно. Каждая фаза задумывалась как один коммит, который можно
откатить целиком, не меняя поведения (кроме фаз, где смена поведения — сама цель).
This document describes work on the structure of the codebase. Phases P0–P8 are
**done** (their commits are in §2), P9 was deliberately decided against; section §1
remains a list of invariants, and §4 is the history of the phases: what exactly
changed, how it was verified, and which deviations were accepted knowingly. Each
phase was conceived as a single commit that can be reverted as a whole without
changing behaviour (except for the phases where the behaviour change is the goal
itself).
Проект в статусе `5.0.0-alpha`: жёстких контрактов, которые нельзя менять, нет.
CLI, форма `--json` и раскладка модулей меняются свободно — важно лишь, чтобы
кодовая база становилась меньше и понятнее, а документация не описывала форму,
которую бинарник уже не выдаёт.
The project is at status `5.0.0-alpha`: there are no rigid contracts that cannot be
changed. The CLI, the `--json` shape and the module layout change freely — all that
matters is that the codebase becomes smaller and clearer, and that the documentation
does not describe a shape the binary no longer produces.
## 1. Инварианты проекта
## 1. Project invariants
Их нельзя нарушать ни в одной фазе:
They MUST NOT be violated in any phase:
* **Только Rust.** Никаких крейтов с C/C++ сборкой (`openssl`, `curl-sys`,
`ring`-совместимые обёртки) — целевые устройства это роутеры
`mipsel-unknown-linux-musl` без динамического линкера.
* **Бюджет памяти.** Релизный бинарник 3–6 МБ (`opt-level = "s"`, `lto = true`,
`codegen-units = 1`, `panic = "abort"`, `strip = true`), RSS 3–6 МБ. Никаких
неограниченных аллокаций, никаких временных файлов без явного аргумента CLI.
* **Классификация стадий.** Каждая проба TCP/TLS обёрнута в `DpiProbeStream`;
сброс или EOF после ClientHello — это `DpiStatus::TlsRst`, таймаут на connect —
* **Rust only.** No crates with a C/C++ build (`openssl`, `curl-sys`,
`ring`-compatible wrappers) — the target devices are
`mipsel-unknown-linux-musl` routers with no dynamic linker.
* **Memory budget.** Release binary 3–6 MB (`opt-level = "z"`, `lto = true`,
`codegen-units = 1`, `panic = "abort"`, `strip = true`), RSS 3–6 MB. No
unbounded allocations, no temporary files without an explicit CLI argument.
* **Stage classification.** Every TCP/TLS probe is wrapped in `DpiProbeStream`;
a reset or EOF after ClientHello is `DpiStatus::TlsRst`, a connect timeout is
`DpiStatus::SynDropped`.
* **Бейджи и протоколы — латиницей** во всех языках (`OK`, `BLOCKED`, `TLS RST`,
`SNI`, `ClientHello`); переводятся только заголовки, баннеры, меню и `--legend`.
* **Все тексты интерфейса — в `dpi-core::i18n`** (четыре блока: `En`, `Ru`,
`Zh`, `Fa`); ни одна строка для человека не пишется на месте вызова.
* **TUI рисуется только через `render::frame_repaint`** с `render::frame_home`:
ручное добивание строки до фиксированной ширины запрещено (CJK-шрифт считает
`│`, `↑`, `←` за две колонки), экран не очищается и курсор не уводится в `(0,0)`.
* **Badges and protocols are Latin-script** in every language (`OK`, `BLOCKED`,
`TLS RST`, `SNI`, `ClientHello`); only headings, banners, menus and `--legend`
are translated.
* **All interface text lives in `dpi-detector::i18n`** (four blocks: `En`, `Ru`,
`Zh`, `Fa`); not a single human-facing string is written at the call site. There
is no text in the core — see phase **P5** below.
* **The TUI draws only through `render::frame_repaint`** with `render::frame_home`:
manually padding a line to a fixed width is forbidden (a CJK font counts
`│`, `↑`, `←` as two columns), the screen is not cleared and the cursor is not
moved to `(0,0)`.
## 2. Что уже сделано
## 2. What has already been done
Все фазы плана, кроме P9 (типизированный конфиг), выполнены. Один коммит — одна
фаза, кроме отмеченных исключений.
All phases of the plan except P9 (typed config) are done. One commit — one phase,
except for the noted exceptions.
| Коммит | Фаза | Содержание |
| Commit | Phase | Content |
| --- | --- | --- |
| `9339a6b` | — | Общие хелперы в одно место, `main.rs` 1778 → 670 (появились `runner.rs`, `terminal.rs`, `menu.rs`) |
| `3fc734a` | — | Один `dial_tcp`, именованные загрузчики целей, системные запросы ушли в `spawn_blocking` |
| `60c682a` | — | Задокументировано отклонение по версиям TLS в закреплённом ClientHello |
| `83ca592` | — | Меню и экран теста 6 рисуются общим `panel_to_string`, таблица тестов берётся из `Messages::menu_test_label` |
| `2abd399` | — | README и правила больше не подают форму `--json` и бейджи как замороженный контракт |
| `1722e16` | — | ~150 комментариев описывают поведение, а не «зеркало» выведенного из эксплуатации прототипа |
| `21bb1e8` | — | Записан этот план |
| `1aebf22` | P1.5 (часть), P4 (часть) | Удалён мёртвый `probe/tls.rs` (196 строк), `TCP_NODELAY` включается одним хелпером на всех дозвонах |
| `bdd9186` | **P1** | `net/version.rs` ушёл в `dpi-detector::update`; ядро перестало зависеть от `i18n` |
| `6337df1` | **P1.5** | Публичная поверхность ядра: 30 реэкспортов `dns/mod.rs` → 3, `RustlsConnector`/`DpiTlsConnector` понижены до `pub(crate)`, удалены `DpiProbeStream::{into_inner,get_ref,get_mut}`, `QTYPE_{NS,SOA,PTR,MX}`, `Language::{code,name,is_rtl}`, три мёртвых аксессора `profile`, включён `#![warn(unreachable_pub)]` |
| `50afd0e` | **P2** | `dns/availability.rs` (1146) → `probe/dns_avail.rs`; ядро потеряло рёбра `dns → probe` и `dns → classify` |
| `6ff72e5` | **P2.5** | `detail` стал enum'ом `classify::Detail` с `code()`; `classify/constants.rs` удалён, классификация больше не разбирает прозу, `i18n::detail_text` — исчерпывающий `match` |
| `81bd6bc` | **P2.6** | `--json` собирается структурами `src/json.rs`; `runner.rs` больше не знает `json!` |
| `9339a6b` | — | Common helpers in one place, `main.rs` 1778 → 670 (`runner.rs`, `terminal.rs`, `menu.rs` appeared) |
| `3fc734a` | — | A single `dial_tcp`, named target loaders, system queries moved into `spawn_blocking` |
| `60c682a` | — | Documented the deviation on TLS versions in the pinned ClientHello |
| `83ca592` | — | The menu and the test 6 screen are drawn by the shared `panel_to_string`, the test table comes from `Messages::menu_test_label` |
| `2abd399` | — | The README and the rules no longer present the `--json` shape and the badges as a frozen contract |
| `1722e16` | — | ~150 comments describe behaviour rather than "mirroring" the decommissioned prototype |
| `21bb1e8` | — | This plan was written down |
| `1aebf22` | P1.5 (part), P4 (part) | Removed the dead `probe/tls.rs` (196 lines), `TCP_NODELAY` is enabled by a single helper on every dial |
| `bdd9186` | **P1** | `net/version.rs` moved to `dpi-detector::update`; the core no longer depends on `i18n` |
| `6337df1` | **P1.5** | Public surface of the core: 30 re-exports of `dns/mod.rs` → 3, `RustlsConnector`/`DpiTlsConnector` lowered to `pub(crate)`, removed `DpiProbeStream::{into_inner,get_ref,get_mut}`, `QTYPE_{NS,SOA,PTR,MX}`, `Language::{code,name,is_rtl}`, three dead `profile` accessors, `#![warn(unreachable_pub)]` enabled |
| `50afd0e` | **P2** | `dns/availability.rs` (1146) → `probe/dns_avail.rs`; the core lost the `dns → probe` and `dns → classify` edges |
| `6ff72e5` | **P2.5** | `detail` became the enum `classify::Detail` with `code()`; `classify/constants.rs` removed, classification no longer parses prose, `i18n::detail_text` is an exhaustive `match` |
| `81bd6bc` | **P2.6** | `--json` is assembled by the structures in `src/json.rs`; `runner.rs` no longer knows about `json!` |
| `8724e7e` | **P2.7** | `i18n/mod.rs` (1711) → `mod.rs` + `messages.rs` + `en/ru/zh/fa` |
| `750e185` | **P4 + P4.5 + P7** | `net/netinfo.rs` (988) → `http_client` + `public_ip` + `sysinfo/{os,bypass}`, Cymru уехал в `probe/cymru.rs` (разорван цикл `net → dns`); девять TLS-фабрик → `TlsProfile` + `create_tls_config`; паники ядра обоснованы или убраны |
| `c7660fb` | **P3** | `render.rs` (3013) и `menu.rs` (1532) → `tui/{backend,widgets,progress,input,screens}` + `views/*`; markup-слой удалён |
| `ce546e3` | **P6** | CI-гейт `.github/workflows/check.yml` (build + test + clippy на push и PR) |
| `0dc0fa7` | **P5** | `i18n` переехал в бинарник; `Messages` похудел с 213 полей до 206 (мёртвые строки и рантайм-тест покрытия удалены) |
| `ac53995` | **P8** | `selection_flags` → `TestSelection::parse` с именованными полями |
| `750e185` | **P4 + P4.5 + P7** | `net/netinfo.rs` (988) → `http_client` + `public_ip` + `sysinfo/{os,bypass}`, Cymru moved to `probe/cymru.rs` (the `net → dns` cycle is broken); nine TLS factories → `TlsProfile` + `create_tls_config`; core panics justified or removed |
| `c7660fb` | **P3** | `render.rs` (3013) and `menu.rs` (1532) → `tui/{backend,widgets,progress,input,screens}` + `views/*`; the markup layer removed |
| `ce546e3` | **P6** | CI gate `.github/workflows/check.yml` (build + test + clippy on push and PR) |
| `0dc0fa7` | **P5** | `i18n` moved into the binary; `Messages` slimmed from 213 fields to 206 (dead strings and the runtime coverage test removed) |
| `ac53995` | **P8** | `selection_flags` → `TestSelection::parse` with named fields |
Отклонения от плана, зафиксированные сознательно:
Deviations from the plan, recorded deliberately:
* **P4, P4.5 и P7-core легли одним коммитом** (`750e185`): они связаны через
`net/http_client.rs` и новый `TlsProfile`, по отдельности ни одна часть не
собирается.
* **P4.5 не сократил файл**: `net/tls.rs` 273 → 296 строк. Сократилась поверхность
(девять фабрик → одна), а два крипто-провайдера, insecure-верификатор и разбор
версий остались на месте.
* **`profile::blockpage_signatures` удалён** в P1.5: после удаления `probe/tls.rs`
его не читал никто, а список подписей остаётся в истории git до появления
потребителя.
* **`Messages` похудел**: семь полей не читал никто (`banner_subtitle`, `stage`,
`bytes`, `duration`, `bypass_tools`, `gateway`, `detail_write_timeout`), пять
читал только тест покрытия. Сам тест снят: четыре литерала в четырёх языках и так
не компилируются, если поле забыли.
* **P7 не сводится к нулю паник**: осталось 11 `unwrap`/`expect` вне тестов, у
каждого написано, почему он невозможен (провайдер TLS, константный HTTP-запрос,
таблица спецификаций отпечатков, `serde_yaml` на пустом маппинге, пример-утилита).
* **P4 разложился иначе, чем в плане**: вместо `sysinfo/{os,bypass}.rs` появились
ещё `net/public_ip.rs` и `net/sysinfo/mod.rs`.
* **P4, P4.5 and P7-core landed in a single commit** (`750e185`): they are coupled
through `net/http_client.rs` and the new `TlsProfile`, and on their own not one
part compiles.
* **P4.5 did not shrink the file**: `net/tls.rs` 273 → 296 lines. The surface
shrank (nine factories → one), while the two crypto providers, the insecure
verifier and the version parsing stayed in place.
* **`profile::blockpage_signatures` was removed** in P1.5: after `probe/tls.rs`
was deleted nobody read it, and the signature list remains in git history until
a consumer appears.
* **`Messages` slimmed down**: seven fields were read by nobody (`banner_subtitle`,
`stage`, `bytes`, `duration`, `bypass_tools`, `gateway`, `detail_write_timeout`),
five were read only by the coverage test. The test itself was dropped: four
literals in four languages will not compile anyway if a field is forgotten.
* **P7 does not reduce panics to zero**: 11 `unwrap`/`expect` remain outside tests,
each with a written reason why it is impossible (the TLS provider, a constant
HTTP request, the fingerprint spec table, `serde_yaml` on an empty mapping, an
example utility).
* **P4 decomposed differently from the plan**: instead of `sysinfo/{os,bypass}.rs`,
`net/public_ip.rs` and `net/sysinfo/mod.rs` also appeared.
## 3. Целевая слоистость
## 3. Target layering
Сейчас в ядре три цикла, у каждого ровно один виновный импорт:
Right now the core has three cycles, each with exactly one guilty import:
* `net → dns` — `net/netinfo.rs:21` (`query_doh_txt`);
* `dns → probe` — `dns/availability.rs:23` (`fake_ip_type`);
* `dns → classify` — `dns/availability.rs:98-99` (обе строки внутри `availability`).
* `dns → classify` — `dns/availability.rs:98-99` (both lines are inside `availability`).
Цель — ацикличный граф:
The goal is an acyclic graph:
```
classify ← net ← dns ← probe
(лист: стадии и вердикты) (7 диагностических тестов)
config, profile, i18n — сбоку; бинарник — только представление и оркестрация
(leaf: stages and verdicts) (7 diagnostic tests)
config, profile, i18n — off to the side; the binary is presentation and orchestration only
```
## 4. Фазы
## 4. Phases
### P0 — Референсный снимок вывода ✅
### P0 — Reference output snapshot ✅
* Снять `--json` для `-t 1`, `-t 2 -d example.com`, `-t 5` в файл и набор ключей
(`jq 'paths(scalars)|join(".")'`) — это **эталон для глаз**, не контракт.
* Зачем: P2.5 и P2.6 меняют форму `detail` и форму JSON; сравнение должно быть
осознанным, а не побайтовым.
* Риск: нет. Ничего не блокирует.
* Capture `--json` for `-t 1`, `-t 2 -d example.com`, `-t 5` into a file and the
set of keys (`jq 'paths(scalars)|join(".")'`) — this is a **reference for the
eyes**, not a contract.
* Why: P2.5 and P2.6 change the shape of `detail` and the shape of the JSON; the
comparison must be deliberate, not byte-for-byte.
* Risk: none. Nothing blocks.
### P1 — `net/version.rs` уходит из ядра в бинарник ✅ (`bdd9186`)
### P1 — `net/version.rs` leaves the core for the binary ✅ (`bdd9186`)
* Что: 244 строки — `fetch_latest_version` (GitHub Releases API), `version_badge_lang`,
* What: 244 lines — `fetch_latest_version` (GitHub Releases API), `version_badge_lang`,
`ReleaseInfo`, `CURRENT_VERSION`, `is_newer` → `crates/dpi-detector/src/update.rs`.
* Почему: это сервисная утилита уровня приложения, и это **единственный** модуль
ядра, который вообще использует `i18n` (`net/version.rs:1,160`), а также тянет
`netinfo::http_get_text`.
* Цена: два импорта в бинарнике (`main.rs:9`, `menu.rs:17`) и правка `net/mod.rs`.
* Проверка: гейт + живой запуск меню (бейдж версии в баннере).
* Why: it is an application-level service utility, and it is the **only** module
of the core that uses `i18n` at all (`net/version.rs:1,160`), and it also pulls
in `netinfo::http_get_text`.
* Cost: two imports in the binary (`main.rs:9`, `menu.rs:17`) and an edit to `net/mod.rs`.
* Verification: the gate + a live run of the menu (version badge in the banner).
### P1.5 — Публичная поверхность ядра ✅ (`6337df1`)
### P1.5 — Public surface of the core ✅ (`6337df1`)
Аудит показал ~63 `pub`-элемента без единой ссылки вне своего файла. У библиотечного
крейта такие вещи не ловятся компилятором, поэтому чистка + защита от повторения:
The audit showed ~63 `pub` items with not a single reference outside their own file.
In a library crate the compiler does not catch such things, hence a cleanup plus
protection against recurrence:
* **Сделано** (`1aebf22`): `probe/tls.rs` удалён целиком.
* Осталось — понизить до `pub(crate)` либо удалить: `net/version.rs:154` `version_badge`
(зовётся только из своего теста), 27 неиспользуемых реэкспортов `dns/mod.rs:10-17`
(живой через реэкспорт — один, `parse_socks_proxy`),
* **Done** (`1aebf22`): `probe/tls.rs` removed entirely.
* Remaining — lower to `pub(crate)` or remove: `net/version.rs:154` `version_badge`
(called only from its own test), 27 unused re-exports of `dns/mod.rs:10-17`
(exactly one is live through a re-export, `parse_socks_proxy`),
`net/tls.rs` `create_insecure_dpi_tls_config_tls13/tls12`,
`probe/connector.rs` `new_insecure_tls13`/`new_insecure_tls12`/`new_verifying`,
`classify/stream.rs` `ProbeState` и `DpiProbeStream::{into_inner,get_ref,get_mut}`,
`dns/wire.rs` `QTYPE_NS/SOA/PTR/MX`, `net/cert_compression.rs` `covers` (только тесты),
`classify/stream.rs` `ProbeState` and `DpiProbeStream::{into_inner,get_ref,get_mut}`,
`dns/wire.rs` `QTYPE_NS/SOA/PTR/MX`, `net/cert_compression.rs` `covers` (tests only),
`i18n` `Language::{code,name,is_rtl}`,
`profile` `default_resolvers`/`default_doh`/`blockpage_signatures` (только тесты),
`lib.rs` алиасы `PhaseSwitch`/`BlocksSwitch`.
* Защита: включить `#![warn(unreachable_pub)]` в `dpi-core/src/lib.rs` и разобрать
выдачу, чтобы поверхность снова не расползлась.
`profile` `default_resolvers`/`default_doh`/`blockpage_signatures` (tests only),
`lib.rs` aliases `PhaseSwitch`/`BlocksSwitch`.
* Protection: `unreachable_pub` is enforced from the workspace lint table
(`Cargo.toml`, `[workspace.lints.rust]`), which `dpi-core` opts into with
`[lints] workspace = true`; work through the output so the surface does not
creep again. The binary crate carries a dated local `allow` for it until the
`pub` → `pub(crate)` sweep over `dpi-detector` lands — its modules are private,
so the lint fires on every `pub` item there.
### P2 — Тест 1 переезжает из `dns/` в `probe/` ✅ (`50afd0e`)
### P2 — Test 1 moves from `dns/` to `probe/` ✅ (`50afd0e`)
* `dns/availability.rs` (1146 строк, тест 1) → `probe/dns_avail.rs`; `dns/` остаётся
чистым протоколом (`wire`, `udp`, `doh`, `dot`, `socks`, `resolve`, `types`).
* Убирает рёбра `dns → probe` и `dns → classify`.
* Цена: 9 импортов в бинарнике (`render.rs`, `runner.rs`) и `dns/mod.rs:11`.
* `dns/availability.rs` (1146 lines, test 1) → `probe/dns_avail.rs`; `dns/` remains
pure protocol (`wire`, `udp`, `doh`, `dot`, `socks`, `resolve`, `types`).
* Removes the `dns → probe` and `dns → classify` edges.
* Cost: 9 imports in the binary (`render.rs`, `runner.rs`) and `dns/mod.rs:11`.
### P2.5 — `detail` из строк в тип ✅ (`6ff72e5`)
### P2.5 — `detail` from strings to a type ✅ (`6ff72e5`)
Сегодня классификация **парсит прозу**: `i18n/details.rs:32,33,51` режет деталь по
`" at "` и снимает `"KB"`, `probe/whitelist.rs:86,225` проверяет
`detail.contains(DET_AT_KB_MARKER)`, а сборка идёт через
`format!("{}{}{}{}", …, DET_AT_KB_MARKER, kb, DET_KB_SUFFIX)` в
`probe/tcp16.rs:177,260,273,286` и `probe/domains.rs:238,241,401,404`.
`DET_*` встречаются в 276 строках, а `classify/constants.rs:44-46` прямо называет
маркер частью wire-формата.
Today classification **parses prose**: `i18n/details.rs:32,33,51` splits the detail on
`" at "` and strips `"KB"`, `probe/whitelist.rs:86,225` checks
`detail.contains(DET_AT_KB_MARKER)`, and assembly goes through
`format!("{}{}{}{}", …, DET_AT_KB_MARKER, kb, DET_KB_SUFFIX)` in
`probe/tcp16.rs:177,260,273,286` and `probe/domains.rs:238,241,401,404`.
`DET_*` occurs in 276 lines, and `classify/constants.rs:44-46` outright calls the
marker part of the wire format.
* Целевое: `enum Detail` в `classify` (`TlsDrop`, `ReadTimeout { kb }`,
`Tcp16Range { kb }`, `Spoof(…)`, `CertIssue(…)`, `DnsError(…)`, …) с `code()` для
JSON и отображением через `i18n::detail_text(Detail)` — исчерпывающий `match`
вместо runtime-теста покрытия.
* Эффект: сравнение вариантов вместо строк, snake_case-токен в JSON, проза только
в i18n; `ALL_DET_DETAILS` и `i18n/details.rs` сокращаются до таблицы вариантов.
* Риск: средний (задевает все пути ошибок). Проверка: гейт + живой `-t 2/3/4/5`
с diff по `--json` против снимка P0.
* Target: `enum Detail` in `classify` (`TlsDrop`, `ReadTimeout { kb }`,
`Tcp16Range { kb }`, `Spoof(…)`, `CertIssue(…)`, `DnsError(…)`, …) with `code()` for
JSON and rendering through `i18n::detail_text(Detail)` — an exhaustive `match`
instead of a runtime coverage test.
* Effect: comparing variants instead of strings, a snake_case token in JSON, prose
only in i18n; `ALL_DET_DETAILS` and `i18n/details.rs` shrink to a table of variants.
* Risk: medium (it touches every error path). Verification: the gate + a live `-t 2/3/4/5`
with a `--json` diff against the P0 snapshot.
### P2.6 — Типизированный `--json` ✅ (`81bd6bc`)
### P2.6 — Typed `--json` ✅ (`81bd6bc`)
* Сейчас `runner.rs` собирает вывод вручную — 14 сайтов `serde_json::json!`.
Отчёты ядра уже структуры (`DnsAvailReport`, `DomainStats`, `BurstReport`,
`TelegramFullReport`), `DpiStatus` уже `Serialize` (`classify/types.rs:30-31`).
* Целевое: `#[derive(Serialize)]` на отчёты + одна обёртка верхнего уровня;
`status` и `detail` уходят в JSON через serde.
* Эффект: схема описана в одном месте; класс ошибок «поле есть в таблице, но нет
в JSON» исчезает.
* Today `runner.rs` assembles the output by hand — 14 sites of `serde_json::json!`.
The core reports are already structs (`DnsAvailReport`, `DomainStats`, `BurstReport`,
`TelegramFullReport`), `DpiStatus` is already `Serialize` (`classify/types.rs:30-31`).
* Target: `#[derive(Serialize)]` on the reports + one top-level wrapper;
`status` and `detail` go into JSON through serde.
* Effect: the schema is described in one place; the class of bugs "the field is in
the table but not in the JSON" disappears.
### P2.7 — `i18n` по файлам языков ✅ (`8724e7e`, позже уехал в бинарник)
### P2.7 — `i18n` split by language files ✅ (`8724e7e`, later moved into the binary)
`i18n/mod.rs` — 1739 строк, `Messages` — 212 полей × 4 блока. Разложить на
`i18n/{mod,en,ru,zh,fa,details}.rs`. Тест паритета полей сохраняется: добавление
языка — это один новый файл.
`i18n/mod.rs` — 1739 lines, `Messages` — 212 fields × 4 blocks. Split into
`i18n/{mod,en,ru,zh,fa,details}.rs`. The field parity test is kept: adding a
language is one new file.
### P3 — Разрезать `render.rs` и `menu.rs` ✅ (`c7660fb`)
### P3 — Split `render.rs` and `menu.rs` ✅ (`c7660fb`)
* `tui/backend.rs` — VT/Win32 FFI, `output_str`, `frame_home`, режимы (`ascii`,
* `tui/backend.rs` — VT/Win32 FFI, `output_str`, `frame_home`, modes (`ascii`,
`plain`, `has_vt`).
* `tui/widgets.rs` — панели и рамки (`panel_with`, `box_chars`, `asc`, `cell_color`,
`status_color`), ширина и перенос (`strip_ansi_len`, `wrap_ansi`), `frame_repaint`.
* `tui/widgets.rs` — panels and frames (`panel_with`, `box_chars`, `asc`, `cell_color`,
`status_color`), width and wrapping (`strip_ansi_len`, `wrap_ansi`), `frame_repaint`.
* `tui/progress.rs` — `LiveProgress`, `Spinner`, `progress_line`, `Refresher`.
* `views/*` — по уже существующим секционным маркерам `// ─── Test N ───`
* `views/*` — along the already existing section markers `// ─── Test N ───`
(`netinfo`, `dns`, `domains`, `tcp`, `whitelist`, `telegram`, `burst`, `summary`).
* `menu.rs` → `tui/screens/{main,burst,legend,post_run}.rs` + `tui/input.rs`
(нормализация раскладок и `nav_key`).
* Жёстко: математика ширины и `frame_repaint` остаются в одном модуле.
* Заодно P3.5: удалить внутренний markup-слой — 16 сайтов `[green]…[/]`
(`render.rs:2238-2289`) и конвертер `markup_to_ansi` (`render.rs:2362-2371`):
писать SGR сразу.
* Проверка: живой TUI в pty (главное меню и экран теста 6) + гейт.
(keyboard-layout normalisation and `nav_key`).
* Hard rule: the width maths and `frame_repaint` stay in one module.
* Also P3.5: remove the internal markup layer — 16 sites of `[green]…[/]`
(`render.rs:2238-2289`) and the `markup_to_ansi` converter (`render.rs:2362-2371`):
write SGR directly.
* Verification: a live TUI in a pty (main menu and the test 6 screen) + the gate.
### P4 — Разделить `net/netinfo.rs` ✅ (`750e185`)
### P4 — Split `net/netinfo.rs` ✅ (`750e185`)
* `net/http_client.rs` — `http_get_text`, `http_get_chain`, `http_get_once`,
`request_once` (нужны и публичному IP, и Cymru).
* `sysinfo/os.rs`, `sysinfo/bypass.rs` — реестр Windows, адаптеры, маршрут,
`request_once` (needed both by public IP and by Cymru).
* `sysinfo/os.rs`, `sysinfo/bypass.rs` — the Windows registry, adapters, route,
`get_system_dns`, `detect_bypass_tools`, `resolv.conf`/`getprop`.
* Разрывает `net → dns` (`net/netinfo.rs:21`) и делает граф ацикличным.
* **Сделано** (`1aebf22`): `TCP_NODELAY` включается на всех дозвонах через
`net::tcp::set_no_delay` — DoH, DoT, SOCKS5, GET в `netinfo`, три пути Telegram и
`dial_tcp`, так что правило из `net/tcp.rs:22` («это требование, а не оптимизация»)
верно для каждого дозвона, а не для четырёх из десяти.
* Breaks `net → dns` (`net/netinfo.rs:21`) and makes the graph acyclic.
* **Done** (`1aebf22`): `TCP_NODELAY` is enabled on every dial through
`net::tcp::set_no_delay` — DoH, DoT, SOCKS5, the GET in `netinfo`, the three
Telegram paths and `dial_tcp`, so the rule from `net/tcp.rs:22` ("это требование, а не оптимизация")
holds for every dial, not for four out of ten.
### P4.5 — Свернуть TLS-фабрики ✅ (`750e185`)
### P4.5 — Collapse the TLS factories ✅ (`750e185`)
`net/tls.rs` — девять `pub fn create_*`; `probe/connector.rs` — семь конструкторов
`net/tls.rs` — nine `pub fn create_*`; `probe/connector.rs` — seven constructors
(`new_insecure`, `…_tls13`, `…_tls13_with`, `…_tls12`, `…_tls12_with`,
`…_versioned_with`, `new_verifying`). Целевое: один
`TlsProfile { fingerprint, version, alpn, verify }` и один
`RustlsConnector::from(profile)`. Обязательные тесты: `grease_version_leads_supported_versions`,
`…_versioned_with`, `new_verifying`). Target: one
`TlsProfile { fingerprint, version, alpn, verify }` and one
`RustlsConnector::from(profile)`. Required tests: `grease_version_leads_supported_versions`,
`bundle_versions_match_their_ja3`, `bundle_versions_match_their_ja4`.
### P5 — `i18n` из ядра в бинарник ✅ (`0dc0fa7`)
### P5 — `i18n` from the core into the binary ✅ (`0dc0fa7`)
Решение принято. Порядок обязателен: после **P1** (иначе ядру всё равно нужен `i18n`
для `net/version.rs`) и после **P2.5/P2.6** (чтобы прозу деталей не пришлось переносить
дважды): сначала `detail` становится типом с `code()`, потом тексты уезжают в
`dpi-detector/src/i18n/`.
Decision made. The order is mandatory: after **P1** (otherwise the core still needs
`i18n` for `net/version.rs`) and after **P2.5/P2.6** (so that the detail prose does
not have to be moved twice): first `detail` becomes a type with `code()`, then the
texts move to `dpi-detector/src/i18n/`.
* Что переезжает: `Messages` (4 блока), `legend_sections*()`, `details.rs`,
* What moves: `Messages` (4 blocks), `legend_sections*()`, `details.rs`,
`fingerprint_label`, `fmt_speed`/`fmt_size`, `Messages::{config_warning, phase_text}`.
* Что остаётся в ядре: `PhaseId`/`ProgressBlock` (языконезависимые идентификаторы) и
константы деталей; `i18n` в бинарнике импортирует их — направление зависимости
становится односторонним.
* Вместе с переносом правятся правила проекта (§1 этого документа и README): точка
входа для новой строки — крейт бинарника, а не ядро.
* Проверка: тесты паритета четырёх блоков (`test_messages_coverage`,
`test_finglish_is_latin_only`, `i18n::details::tests`) переезжают вместе с модулем;
живой прогон меню и `--legend` на двух языках (`--lang en`, `--lang fa`).
* What stays in the core: `PhaseId`/`ProgressBlock` (language-independent identifiers)
and the detail constants; the `i18n` in the binary imports them — the dependency
direction becomes one-way.
* Along with the move, the project rules are amended (§1 of this document and the
README): the entry point for a new string is the binary crate, not the core.
* Verification: the four-block parity tests (`test_messages_coverage`,
`test_finglish_is_latin_only`, `i18n::details::tests`) move together with the module;
a live run of the menu and `--legend` in two languages (`--lang en`, `--lang fa`).
### P6 — CI-гейт ✅ (`ce546e3`)
### P6 — CI gate ✅ (`ce546e3`)
В `.github/workflows/` сейчас только `release.yml` (матрица на 12 целей, по тегу).
Добавить job `check` на push/PR: `cargo test --workspace`,
In `.github/workflows/` there is currently only `release.yml` (a matrix of 12 targets,
on tag). Add a `check` job on push/PR: `cargo test --workspace`,
`cargo clippy --workspace --all-targets`, `cargo build --locked`.
Отдельным пунктом (по решению) — сравнение вендоренного `vendor/rustls` с текущим
релизом crates.io.
As a separate item (by decision) — comparing the vendored `vendor/rustls` with the
current crates.io release.
### P7 — Аудит `unwrap()`/`expect()` вне тестов ✅ (`750e185`, разбор ниже)
### P7 — Audit of `unwrap()`/`expect()` outside tests ✅ (`750e185`, breakdown below)
39 мест в 9 файлах: `profile/mod.rs` 15, `dns/availability.rs` 8, `probe/domains.rs` 5,
`net/tls.rs` 4, `config.rs` 3, `render.rs`/`tcp16.rs`/`http.rs`/`fingerprint.rs` по одному.
При `panic = "abort"` паника убивает прогон целиком: каждый случай либо обосновать
комментарием (инвариант, доказанный конструкцией), либо превратить в `?`/дефолт.
39 places in 9 files: `profile/mod.rs` 15, `dns/availability.rs` 8, `probe/domains.rs` 5,
`net/tls.rs` 4, `config.rs` 3, and one each in `render.rs`/`tcp16.rs`/`http.rs`/`fingerprint.rs`.
With `panic = "abort"` a panic kills the whole run: every case must either be justified
by a comment (an invariant proven by construction) or turned into `?`/a default.
### P8 — `TestSelection` вместо 9-бушного кортежа ✅ (`ac53995`)
### P8 — `TestSelection` instead of a 9-bool tuple ✅ (`ac53995`)
`selection_flags` (`main.rs:34-46`) возвращает девять `bool`; перестановка в такой
кортеж компилируется молча. Ввести `struct TestKind`/битовый набор, потребителей —
`menu.rs`, `runner.rs`.
`selection_flags` (`main.rs:34-46`) returns nine `bool`s; a transposition in such a
tuple compiles silently. Introduce a `struct TestKind`/bit set, with `menu.rs` and
`runner.rs` as consumers.
### P9 — Типизированный конфиг (решено: не делаем)
### P9 — Typed config (decided: not doing it)
Слой терпимости к «грязным» ручным `config.yml` (числа строкой, регистр и вложенность
ключей, неизвестные ключи как предупреждения) остаётся: он покрыт тестами, включая
паритет с `config.yml`, и удаление сэкономило бы ~300 строк ценой отказа принимать
конфиги, которые сегодня работают. Решение принято 2026-09-14: типизацию не делаем.
The tolerance layer for "dirty" hand-written `config.yml` (numbers as strings, key
case and nesting, unknown keys as warnings) stays: it is covered by tests, including
parity with `config.yml`, and removing it would save ~300 lines at the cost of
refusing to accept configs that work today. Decision made 2026-09-14: no typing.
## 5. Отклонено (не предлагать снова)
## 5. Rejected (do not propose again)
| Предложение | Причина |
| Proposal | Reason |
| --- | --- |
| Трейт `DiagnosticTask` для всех семи тестов | Сигнатуры и отчёты у тестов разные by design (`check_dns_availability(&AppConfig, Option<PhaseProgress>, usize)` против `burst_targets(&AppConfig, &[BurstTarget], &BurstSettings, usize)`), а шов прогресса и отмены уже есть: `PhaseProgress`/`ProgressTick`/`BlockTick` и языконезависимые `PhaseId`/`ProgressBlock` |
| Переименовать `probe/` в `engine/` и завести слой `protocols/` | Перестановка без границы: в `probe/` шесть движков и три мелких примитива (`connector.rs` 91, `http.rs` 172, `tls.rs` 196) |
| Перенести `probe/connector.rs` в `net/` | Трейт принимает и возвращает `DpiProbeStream`, а `net/` сегодня не знает о `classify` (ноль ссылок) — перенос создаёт новое ребро снизу вверх |
| Перенести `probe/http.rs` в `protocols/` | Потребителей вне проб нет, а перенос разносит поддержку проб по двум слоям |
| `enum DiagnosticVerdict` вместо `DpiStatus` | `DpiStatus` уже enum; типизировать нужно было `detail` — сделано в P2.5 (`classify::Detail`) |
| A `DiagnosticTask` trait for all seven tests | The tests have different signatures and reports by design (`check_dns_availability(&AppConfig, Option<PhaseProgress>, usize)` versus `burst_targets(&AppConfig, &[BurstTarget], &BurstSettings, usize)`), and the progress and cancellation seam already exists: `PhaseProgress`/`ProgressTick`/`BlockTick` and the language-independent `PhaseId`/`ProgressBlock` |
| Rename `probe/` to `engine/` and introduce a `protocols/` layer | A rearrangement with no boundary: `probe/` holds six engines and three small primitives (`connector.rs` 91, `http.rs` 172, `tls.rs` 196) |
| Move `probe/connector.rs` into `net/` | The trait takes and returns `DpiProbeStream`, and `net/` today knows nothing about `classify` (zero references) — the move would create a new bottom-up edge |
| Move `probe/http.rs` into `protocols/` | There are no consumers outside the probes, and the move would spread probe support across two layers |
| `enum DiagnosticVerdict` instead of `DpiStatus` | `DpiStatus` is already an enum; it was `detail` that needed typing — done in P2.5 (`classify::Detail`) |
## 6. Как проверять каждую фазу
## 6. How to verify each phase
```bash
cargo test --workspace
cargo clippy --workspace --all-targets # ровно 0 предупреждений
cargo clippy --workspace --all-targets # exactly 0 warnings
```
* Один коммит — одна фаза. Перемещения и правки не смешивать в одном коммите:
перемещение должно читаться как перемещение.
* `cargo fmt` не запускать: в дереве 48 из 54 файлов с CRLF, rustfmt пишет только
LF, `cargo fmt --all --check` ругается на 39 файлов — один прогон перепишет и
переразметит почти всё дерево. Форматировать руками.
* Живая проверка по поверхности: TUI — запуск в pty (меню и экран теста 6);
CLI — бинарник `release-local` с `--json`; для изменений вывода — diff против
снимка P0.
* Быстрая сборка для итераций:
* One commit — one phase. Do not mix moves and edits in one commit:
a move must read as a move.
* Do not run `cargo fmt`: in the tree 48 of 54 files are CRLF, rustfmt writes only
LF, `cargo fmt --all --check` complains about 39 files — a single run would rewrite
and reformat almost the whole tree. Format by hand.
* Live verification by surface: TUI — a run in a pty (menu and the test 6 screen);
CLI — the `release-local` binary with `--json`; for output changes — a diff against
the P0 snapshot.
* Fast build for iterations:
`cargo build --profile release-local --target x86_64-pc-windows-msvc -p dpi-detector`.
* Релизный артефакт собирается только тогда, когда он реально нужен:
* The release artifact is built only when it is really needed:
`RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target x86_64-pc-windows-msvc`.
`dist/` обновляется по явной просьбе.
`dist/` is updated on an explicit request.

View file

@ -2,7 +2,7 @@
#
# Как редактировать:
# * Меняйте ТОЛЬКО третью колонку (fa). Колонку key не трогайте: по ней
# правки вернутся в Rust-исходники (crates/dpi-core/src/i18n/).
# правки вернутся в Rust-исходники (crates/dpi-detector/src/i18n/).
# * Колонка en — эталонный английский текст, не менять.
# * Колонки разделены ОДНИМ символом табуляции (TAB). Табуляции внутри
# текста быть не должно. Строки не переносить.
@ -20,8 +20,8 @@
# и верстку таблиц (RTL). Допустимы только символы: ✓ × ⚠ ↑
# * Не удаляйте и не добавляйте строки; пустые значения недопустимы.
#
# Источник: crates/dpi-core/src/i18n/mod.rs (Messages, legend_sections_fa),
# crates/dpi-core/src/i18n/details.rs (detail.DET_*).
# Источник: crates/dpi-detector/src/i18n/mod.rs (Messages, legend_sections_fa),
# crates/dpi-detector/src/i18n/details.rs (detail.DET_*).
# Формат: key <TAB> en <TAB> fa
key en fa

Can't render this file because it has a wrong number of fields in line 27.