mirror of
https://github.com/Runnin4ik/dpi-detector.git
synced 2026-10-19 09:23:17 +00:00
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:
parent
181634bc39
commit
9863cfa2a7
8 changed files with 824 additions and 505 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,7 +5,6 @@
|
|||
/dpi_detector_crash.log
|
||||
.idea/
|
||||
.vscode/
|
||||
AGENTS.md
|
||||
/vendor/*/target/
|
||||
/vendor/*/Cargo.lock
|
||||
/dist/
|
||||
|
|
|
|||
162
AGENTS.md
Normal file
162
AGENTS.md
Normal 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
63
CONTRIBUTING.md
Normal 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.
|
||||
|
|
@ -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
75
docs/CI.md
Normal 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).
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
Loading…
Add table
Add a link
Reference in a new issue