From 55653ed0bc84d52f077d0a743c3707e160764d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20Sonic?= Date: Thu, 30 Jul 2026 13:12:21 +0300 Subject: [PATCH] feat(cli): headless tglock-cli, GUI behind a feature, deep test coverage (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализует направление PR #15 поверх архитектуры 2.0. Сам PR смерджить нельзя: он патчит src/bypass.rs, src/network.rs и src/ws_proxy.rs, которых больше нет, и правит системный DNS — в 2.0 это не нужно, потому что адреса Telegram зашиты в маршрутах, а SNI остаётся настоящим. Взято разделение GUI/CLI и произвольный bind-адрес, отброшены DNS-менеджмент и проверка root: CLI не требует прав. Closes #10, #17 — GUI не создаёт окно без 3D-ускорения, на машине без монитора и в виртуалке. Причина в WebView под Tauri, поэтому лечится не программным рендером, а бинарём, в котором WebView нет вовсе: при выключенной фиче gui Tauri и фронтенд в сборку не попадают. Отдельная задача CI собирает и гоняет CLI на голом ubuntu без Node.js и без libwebkit2gtk. Структура: - src/lib.rs — ядро (mtproto, proxy, transport, config), без Tauri - src/main.rs — GUI, required-features = ["gui"] - src/bin/cli.rs — headless-бинарь на clap - build.rs вызывает tauri_build только при включённой фиче gui Исправлено по пути: - Определение протокола: SOCKS5 и MTProto различались по первому байту, но is_reserved_init не исключает 0x05, поэтому примерно одно соединение из 256 уезжало в SOCKS5-ветку и умирало. Теперь неоднозначный первый байт решается по полному 64-байтному init и секрету. - Ярлык маршрута в UI: код 2 подписывался как «Cloudflare Worker», хотя это запасной Telegram IP, а системный DNS и настоящий Worker оба показывались как «Автоматический маршрут». Метки переехали в transport::route_label, общий для обоих интерфейсов. - Секрет прокси: под DynamicUser и ProtectHome домашней папки нет, secret_path возвращает None и секрет генерировался заново при каждом старте, ломая всем настроенным клиентам tg://-ссылку. Добавлен --secret-file. - README обещал Rust 1.75+, тогда как Cargo.toml требует 1.88 и CI это проверяет. Это и есть первопричина #3. Политика доступа: прямые не-Telegram соединения разрешены только на loopback, на любом сетевом адресе нужен явный --allow-direct. Правило из ISSUE_AUDIT о том, что LAN не должен становиться открытым SOCKS5, теперь выражено в типе ListenConfig и покрыто тестами. Тесты: 46 в библиотеке + 12 в CLI. Появился сквозной тест туннеля против мок-релея, который реализует сторону Telegram по obfuscated2 — проверяется не внутренняя консистентность, а что реле получает ровно тот открытый текст, который отправил клиент, и обратно. Плюс расписание backoff, фолбэк при всех маршрутах в cooldown, валидация Worker-доменов, отказы SOCKS5, устойчивость секрета к перезапуску и корректная остановка по SIGTERM. Документация: секция CLI в README с юнитом systemd и Dockerfile. Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com> --- .github/workflows/ci.yml | 46 ++++ .github/workflows/release.yml | 69 +++++ Cargo.lock | 210 ++++++++++----- Cargo.toml | 42 ++- README.md | 90 ++++++- build.rs | 6 +- src/bin/cli.rs | 333 +++++++++++++++++++++++ src/config.rs | 135 ++++++++++ src/lib.rs | 11 + src/main.rs | 52 +--- src/mtproto.rs | 219 ++++++++++++++- src/proxy.rs | 492 ++++++++++++++++++++++++++++++++-- src/transport.rs | 323 ++++++++++++++++++++-- 13 files changed, 1862 insertions(+), 166 deletions(-) create mode 100644 src/bin/cli.rs create mode 100644 src/config.rs create mode 100644 src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 957a587..684bb85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,49 @@ jobs: - name: Test run: cargo test --all-targets + headless: + name: Headless CLI (no WebView, no Node) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + # This job deliberately installs no Node.js, no frontend and no + # libwebkit2gtk. It fails the moment anything drags the GUI back into the + # headless build, which is the whole point of issues #10 and #17. + - name: Lint + run: cargo clippy --no-default-features --lib --bins --all-targets -- -D warnings + + - name: Test + run: cargo test --no-default-features --lib --bins + + - name: Build + run: cargo build --release --no-default-features --bin tglock-cli + + - name: Start, advertise a proxy link and stop on SIGTERM + run: | + ./target/release/tglock-cli --help + ./target/release/tglock-cli --version + # --preserve-status makes this assert the shutdown path: a handled + # SIGTERM exits 0, an unhandled one would surface as 143 and fail. + timeout --preserve-status --signal=TERM 5 \ + ./target/release/tglock-cli --port 18080 --secret-file "$PWD/secret" > cli.log 2>&1 + cat cli.log + grep -q 'tg://proxy' cli.log + grep -q '127.0.0.1:18080' cli.log + test "$(stat -c '%a' "$PWD/secret")" = 600 + + - name: Keep the same proxy link across a restart + run: | + first=$(grep -o 'secret=[0-9a-f]*' cli.log) + timeout --preserve-status --signal=TERM 5 \ + ./target/release/tglock-cli --port 18080 --secret-file "$PWD/secret" > restart.log 2>&1 + test "$first" = "$(grep -o 'secret=[0-9a-f]*' restart.log)" + msrv: name: Rust 1.88 compatibility runs-on: macos-latest @@ -59,6 +102,9 @@ jobs: - name: Check locked dependency graph run: cargo check --locked + - name: Check the headless dependency graph too + run: cargo check --locked --no-default-features --lib --bins + frontend: name: Frontend runs-on: macos-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04eb638..9169885 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,75 @@ jobs: - macOS: скачайте универсальный `.dmg` или `.app.tar.gz` - Windows: скачайте `.exe` установщик - Linux: скачайте `.AppImage` или `.deb` + - Сервер или машина без монитора: скачайте `tglock-cli-*` — там нет графического интерфейса releaseDraft: false prerelease: true args: ${{ matrix.args }} + + cli: + name: Headless CLI ${{ matrix.platform }} + needs: publish + strategy: + fail-fast: false + matrix: + include: + - platform: macOS universal + os: macos-14 + rust-targets: aarch64-apple-darwin,x86_64-apple-darwin + asset: tglock-cli-universal-apple-darwin + - platform: Windows x64 + os: windows-latest + rust-targets: x86_64-pc-windows-msvc + asset: tglock-cli-x86_64-pc-windows-msvc.exe + - platform: Linux x64 + os: ubuntu-22.04 + rust-targets: x86_64-unknown-linux-gnu + asset: tglock-cli-x86_64-unknown-linux-gnu + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v6 + + # No Node.js and no desktop libraries: the CLI must build without them. + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust-targets }} + + - name: Build (unix) + if: runner.os != 'Windows' + shell: bash + run: | + IFS=',' read -ra targets <<< "${{ matrix.rust-targets }}" + binaries=() + for target in "${targets[@]}"; do + cargo build --release --locked --no-default-features \ + --bin tglock-cli --target "$target" + binaries+=("target/$target/release/tglock-cli") + done + if [ "${#binaries[@]}" -gt 1 ]; then + lipo -create -output "${{ matrix.asset }}" "${binaries[@]}" + else + cp "${binaries[0]}" "${{ matrix.asset }}" + fi + chmod +x "${{ matrix.asset }}" + ./"${{ matrix.asset }}" --version + + - name: Build (windows) + if: runner.os == 'Windows' + shell: bash + run: | + cargo build --release --locked --no-default-features \ + --bin tglock-cli --target ${{ matrix.rust-targets }} + cp "target/${{ matrix.rust-targets }}/release/tglock-cli.exe" "${{ matrix.asset }}" + ./"${{ matrix.asset }}" --version + + - name: Attach to the release + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref_name }} + prerelease: true + files: ${{ matrix.asset }} diff --git a/Cargo.lock b/Cargo.lock index 998a7af..af7996b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -342,6 +392,52 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -1364,7 +1460,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core", ] [[package]] @@ -1561,6 +1657,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -2122,6 +2224,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.4.0" @@ -2947,6 +3055,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3176,7 +3294,7 @@ dependencies = [ "unicode-segmentation", "url", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", "x11-dl", ] @@ -3439,6 +3557,7 @@ version = "2.0.0-beta.1" dependencies = [ "aes", "cipher", + "clap", "ctr", "futures-util", "native-tls", @@ -3558,6 +3677,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -3944,6 +4064,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" @@ -4171,9 +4297,9 @@ dependencies = [ "webview2-com-macros", "webview2-com-sys", "windows", - "windows-core 0.61.2", - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-core", + "windows-implement", + "windows-interface", ] [[package]] @@ -4195,7 +4321,7 @@ checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.19", "windows", - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -4251,7 +4377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -4263,20 +4389,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -4285,11 +4398,11 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-result", + "windows-strings", ] [[package]] @@ -4298,22 +4411,11 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", "windows-threading", ] -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -4325,17 +4427,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -4365,19 +4456,10 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", ] -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -4387,16 +4469,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -4657,7 +4729,7 @@ dependencies = [ "webkit2gtk-sys", "webview2-com", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", "x11-dl", ] diff --git a/Cargo.toml b/Cargo.toml index dbf183b..5552a2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,12 +5,43 @@ edition = "2021" rust-version = "1.88" description = "Telegram unblock via local WebSocket tunnel" license = "MIT" +autobins = false + +[features] +default = ["gui"] +# The desktop GUI. Turning it off drops Tauri, the system WebView and the +# frontend bundle from the build, which is what makes headless servers and +# machines without a GPU or monitor able to build and run TGLock at all. +gui = ["dep:tauri", "dep:tauri-build", "dep:open"] + +[lib] +name = "tglock" +path = "src/lib.rs" + +[[bin]] +name = "tglock" +path = "src/main.rs" +required-features = ["gui"] + +[[bin]] +name = "tglock-cli" +path = "src/bin/cli.rs" [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = [], optional = true } +open = { version = "5", optional = true } +clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "macros", "sync"] } +tokio = { version = "1", features = [ + "rt-multi-thread", + "net", + "io-util", + "time", + "macros", + "sync", + "signal", +] } tokio-tungstenite = { version = "0.24", features = ["native-tls"] } native-tls = "0.2" futures-util = "0.3" @@ -19,11 +50,6 @@ ctr = "0.9" cipher = "0.4" sha2 = "0.10" rand = "0.8" -open = "5" [build-dependencies] -tauri-build = { version = "2", features = [] } - -[[bin]] -name = "tglock" -path = "src/main.rs" +tauri-build = { version = "2", features = [], optional = true } diff --git a/README.md b/README.md index 3167ea7..889831c 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,9 @@ TGLock — это **локальный SOCKS5-прокси** на твоём к | **Windows 10/11** (x64) | `.exe` installer | | | **macOS** (Apple Silicon + Intel) | universal `.dmg` | | | **Linux** (x86_64) | `.AppImage` / `.deb` | | +| **Сервер / без монитора** (любая ОС) | `tglock-cli-*` | | + +> **🖥 `tglock-cli`** — тот же туннель без графического интерфейса, одним бинарём. Нужен, если окно не создаётся: сервер, контейнер, виртуалка, машина без монитора или без 3D-ускорения. Подробности — [ниже](#-без-графического-интерфейса-tglock-cli). > **🍎 macOS:** пока сборка не нотарифицирована Apple, при первом запуске может понадобиться: > ```bash @@ -108,6 +111,81 @@ Telegram → Настройки → **Продвинутые** → Тип сое Удобно, если дома один комп всегда включён — он становится «домашним Telegram-роутером». +### 🖥 Без графического интерфейса: `tglock-cli` + +Для сервера, виртуалки, контейнера и машины без монитора или без 3D-ускорения. Это отдельный бинарь, в котором **нет ни Tauri, ни системного WebView** — там, где окно просто не создаётся, CLI работает. + +```bash +tglock-cli # 127.0.0.1:1080, только для этого компьютера +tglock-cli --lan # 0.0.0.0:1080, только адреса Telegram +tglock-cli --bind 10.0.0.5 --port 1443 # свой адрес и порт +tglock-cli --worker my-name.workers.dev # резервный маршрут через свой Cloudflare Worker +tglock-cli --help # все флаги +``` + +При запуске печатается готовая `tg://proxy`-ссылка — её можно открыть на любом устройстве в сети, чтобы Telegram настроился сам. Дальше в лог идёт по строке на каждое изменение состояния: сколько соединений, какой дата-центр, какой маршрут живой, сколько сбоев. + +Прав администратора не нужно: TGLock не правит ни системный DNS, ни файл `hosts` — нужные адреса Telegram зашиты в маршрутах, а TLS SNI остаётся настоящим. + +`--lan` и любой другой сетевой адрес пропускают **только** адреса Telegram. Обычным SOCKS5-прокси TGLock становится исключительно по явному `--allow-direct`, и на сетевом адресе это открытый прокси для всего интернета — включайте осознанно. + +#### Юнит для systemd + +```ini +[Unit] +Description=TGLock — Telegram через WebSocket-туннель +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +ExecStart=/usr/local/bin/tglock-cli --lan --secret-file /var/lib/tglock/secret +Restart=on-failure +RestartSec=5s +StateDirectory=tglock +DynamicUser=yes +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +RestrictAddressFamilies=AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo install -m755 tglock-cli-x86_64-unknown-linux-gnu /usr/local/bin/tglock-cli +sudo systemctl enable --now tglock +journalctl -u tglock -f +``` + +`--secret-file` здесь обязателен, и это не украшение: секрет — половина `tg://proxy`-ссылки. Без файла он генерируется заново при каждом старте, и после первого же `systemctl restart` все настроенные клиенты перестанут подключаться. `StateDirectory=tglock` создаёт `/var/lib/tglock` с нужными правами, а сам файл пишется с режимом `600`. + +Остановка по `systemctl stop` приходит как `SIGTERM` — CLI закрывает туннели и выходит с нулевым кодом, а не умирает по `SIGKILL`. + +#### Docker + +```dockerfile +FROM rust:1.88 AS build +WORKDIR /src +COPY . . +RUN cargo build --release --locked --no-default-features --bin tglock-cli + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /src/target/release/tglock-cli /usr/local/bin/tglock-cli +EXPOSE 1080 +ENTRYPOINT ["tglock-cli", "--lan", "--secret-file", "/data/secret"] +``` + +```bash +docker run -d --name tglock -p 1080:1080 -v tglock-data:/data tglock +``` + +Образу не нужны ни Node.js, ни `libwebkit2gtk` — только `ca-certificates` для проверки сертификата Telegram. + --- ## 🔬 Как это работает @@ -262,7 +340,17 @@ npm ci npm run tauri build ``` -Результат — `target/release/tglock` (или `tglock.exe` на Windows). Требуется Rust **stable 1.75+**. +Результат — `target/release/tglock` (или `tglock.exe` на Windows). + +Минимальная версия Rust — **1.88** (`rust-version` в `Cargo.toml`, проверяется отдельной задачей в CI). На более старых тулчейнах зависимости не соберутся: часть из них требует edition 2024. + +### Только CLI, без графики + +```bash +cargo build --release --locked --no-default-features --bin tglock-cli +``` + +Ни Node.js, ни фронтенда, ни `libwebkit2gtk` для этого не нужно — при выключенной фиче `gui` Tauri и системный WebView в сборку не попадают вообще. Именно так CLI собирается на голом сервере. ### Кросс-компиляция через GitHub Actions diff --git a/build.rs b/build.rs index d860e1e..550a60b 100644 --- a/build.rs +++ b/build.rs @@ -1,3 +1,7 @@ fn main() { - tauri_build::build() + // Only the GUI binary needs Tauri's generated context. Without this guard a + // headless build would still require the frontend bundle and the WebView + // toolchain to be present. + #[cfg(feature = "gui")] + tauri_build::build(); } diff --git a/src/bin/cli.rs b/src/bin/cli.rs new file mode 100644 index 0000000..94d6038 --- /dev/null +++ b/src/bin/cli.rs @@ -0,0 +1,333 @@ +//! TGLock without a graphical interface. +//! +//! Built with `--no-default-features` this binary links neither Tauri nor a +//! system WebView, so it runs on servers, in containers and on machines with no +//! GPU or monitor — the cases that make the GUI fail to start at all +//! (by-sonic/tglock#10, by-sonic/tglock#17). + +use clap::Parser; +use std::net::IpAddr; +use std::path::PathBuf; +use std::process::ExitCode; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; +use tglock::config::ListenConfig; +use tglock::{mtproto, proxy, transport}; + +const STATUS_POLL: Duration = Duration::from_secs(1); + +#[derive(Debug, Parser)] +#[command( + name = "tglock-cli", + version, + about = "TGLock без графического интерфейса: локальный MTProto-прокси через WebSocket" +)] +struct Args { + /// Адрес для прослушивания. 127.0.0.1 — только этот компьютер + #[arg(short, long, value_name = "IP", default_value = "127.0.0.1")] + bind: IpAddr, + + /// Порт локального прокси + #[arg(short, long, value_name = "PORT", default_value_t = proxy::DEFAULT_PORT)] + port: u16, + + /// То же, что --bind 0.0.0.0: доступ с других устройств в локальной сети + #[arg(long, conflicts_with = "bind")] + lan: bool, + + /// Домен своего Cloudflare Worker как резервный маршрут. Можно повторять + #[arg(long, value_name = "DOMAIN")] + worker: Vec, + + /// Проксировать и не-Telegram адреса. На сетевом адресе это открытый SOCKS5 + #[arg(long)] + allow_direct: bool, + + /// Файл с секретом прокси. Обязателен для сервиса: иначе после перезапуска + /// секрет будет новым и уже настроенные клиенты перестанут подключаться + #[arg(long, value_name = "PATH")] + secret_file: Option, + + /// Печатать только ошибки + #[arg(short, long)] + quiet: bool, +} + +impl Args { + fn stats(&self) -> Arc { + match &self.secret_file { + Some(path) => proxy::Stats::with_secret(mtproto::load_or_create_secret_at(path)), + None => proxy::Stats::new(), + } + } + + fn listen(&self) -> ListenConfig { + let base = if self.lan { + ListenConfig::lan(self.port) + } else { + ListenConfig::new(self.bind, self.port) + }; + if self.allow_direct { + base.with_allow_direct(true) + } else { + base + } + } + + fn worker_domains(&self) -> String { + self.worker.join(",") + } +} + +fn main() -> ExitCode { + let args = Args::parse(); + let runtime = match tokio::runtime::Runtime::new() { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("tglock-cli: не удалось запустить среду выполнения: {error}"); + return ExitCode::FAILURE; + } + }; + + match runtime.block_on(serve(args)) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("tglock-cli: {error}"); + ExitCode::FAILURE + } + } +} + +async fn serve(args: Args) -> Result<(), String> { + let listen = args.listen(); + let stats = args.stats(); + stats.set_worker_domain(&args.worker_domains()); + + // Bind before printing anything: a busy port must be an error, not a + // daemon that reports success and silently does nothing. + let listener = proxy::bind(listen).await?; + + if !args.quiet { + println!("Слушаю {}", listen.addr); + println!( + "Ссылка для Telegram: {}", + listen.telegram_link(&stats.telegram_secret()) + ); + if listen.allow_direct && !listen.addr.ip().is_loopback() { + println!( + "Внимание: --allow-direct на адресе {} превращает TGLock в открытый SOCKS5-прокси", + listen.addr.ip() + ); + } else if !listen.allow_direct { + println!("Пропускаю только адреса Telegram"); + } + if !args.worker.is_empty() { + println!("Резервные Worker-домены: {}", args.worker_domains()); + } + } + + let server_stats = stats.clone(); + let mut server = + tokio::spawn( + async move { proxy::serve(server_stats, listener, listen.allow_direct).await }, + ); + let watcher = (!args.quiet).then(|| tokio::spawn(watch_status(stats.clone()))); + + let outcome = tokio::select! { + joined = &mut server => joined.map_err(|error| format!("рабочая задача упала: {error}"))?, + signal = shutdown_signal() => { + signal.map_err(|error| format!("обработчик сигналов: {error}"))?; + if !args.quiet { + println!("Получен сигнал остановки, закрываю соединения…"); + } + stats.stop(); + server + .await + .map_err(|error| format!("рабочая задача упала: {error}"))? + } + }; + + if let Some(watcher) = watcher { + watcher.abort(); + } + outcome +} + +/// Print a line whenever the tunnel state changes. +/// +/// This is the text equivalent of the GUI diagnostics tab: without it a daemon +/// gives journald nothing to show when Telegram stops working. +async fn watch_status(stats: Arc) { + let mut previous = None; + loop { + tokio::time::sleep(STATUS_POLL).await; + let current = ( + stats.active.load(Ordering::Relaxed), + stats.ws.load(Ordering::Relaxed), + stats.last_dc.load(Ordering::Relaxed), + stats.last_route.load(Ordering::Relaxed), + stats.ws_failures.load(Ordering::Relaxed), + ); + if previous.as_ref() == Some(¤t) { + continue; + } + let (active, tunnels, dc, route, failures) = current; + println!( + "соединений {active} · туннелей {tunnels} · {} · {} · сбоев {failures}", + if dc > 0 { + format!("DC{dc}") + } else { + "DC не определён".to_owned() + }, + transport::route_label(route) + ); + previous = Some(current); + } +} + +/// Ctrl+C everywhere, plus SIGTERM on unix so `systemctl stop` shuts the +/// tunnel down cleanly instead of killing it. +#[cfg(unix)] +async fn shutdown_signal() -> std::io::Result<()> { + use tokio::signal::unix::{signal, SignalKind}; + + let mut terminate = signal(SignalKind::terminate())?; + tokio::select! { + result = tokio::signal::ctrl_c() => result, + _ = terminate.recv() => Ok(()), + } +} + +#[cfg(not(unix))] +async fn shutdown_signal() -> std::io::Result<()> { + tokio::signal::ctrl_c().await +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + fn parse(args: &[&str]) -> Args { + Args::try_parse_from(std::iter::once("tglock-cli").chain(args.iter().copied())).unwrap() + } + + #[test] + fn command_definition_is_valid() { + Args::command().debug_assert(); + } + + #[test] + fn defaults_to_loopback_on_the_default_port() { + let listen = parse(&[]).listen(); + assert_eq!(listen.addr.to_string(), "127.0.0.1:1080"); + assert!(listen.allow_direct); + } + + #[test] + fn lan_flag_matches_explicit_wildcard_bind() { + assert_eq!( + parse(&["--lan"]).listen(), + parse(&["-b", "0.0.0.0"]).listen() + ); + } + + #[test] + fn lan_does_not_relay_non_telegram_traffic() { + let listen = parse(&["--lan"]).listen(); + assert_eq!(listen.addr.to_string(), "0.0.0.0:1080"); + assert!(!listen.allow_direct); + } + + #[test] + fn allow_direct_is_the_only_way_to_open_a_network_listener() { + assert!(!parse(&["-b", "192.168.1.10"]).listen().allow_direct); + assert!( + parse(&["-b", "192.168.1.10", "--allow-direct"]) + .listen() + .allow_direct + ); + } + + #[test] + fn bind_and_port_are_honoured() { + let listen = parse(&["--bind", "10.0.0.7", "--port", "1443"]).listen(); + assert_eq!(listen.addr.to_string(), "10.0.0.7:1443"); + } + + #[test] + fn ipv6_bind_is_accepted() { + let listen = parse(&["-b", "::1", "-p", "2080"]).listen(); + assert_eq!(listen.addr.to_string(), "[::1]:2080"); + assert!(listen.allow_direct); + } + + #[test] + fn repeated_worker_flags_collapse_into_one_list() { + let args = parse(&["--worker", "a.workers.dev", "--worker", "b.workers.dev"]); + assert_eq!(args.worker_domains(), "a.workers.dev,b.workers.dev"); + } + + #[test] + fn no_worker_flag_means_no_domains() { + assert!(parse(&[]).worker_domains().is_empty()); + } + + #[test] + fn lan_and_explicit_bind_cannot_be_combined() { + assert!(Args::try_parse_from(["tglock-cli", "--lan", "-b", "127.0.0.1"]).is_err()); + } + + #[test] + fn a_pinned_secret_file_survives_a_restart() { + let path = std::env::temp_dir().join(format!( + "tglock-cli-secret-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_file(&path); + + let first = parse(&["--secret-file", path.to_str().unwrap()]) + .stats() + .telegram_secret(); + let second = parse(&["--secret-file", path.to_str().unwrap()]) + .stats() + .telegram_secret(); + + assert_eq!( + first, second, + "a restart must advertise the same tg:// secret" + ); + assert!(first.starts_with("dd")); + + // A corrupted file must not wedge the daemon: it is replaced. + std::fs::write(&path, "garbage").unwrap(); + let third = parse(&["--secret-file", path.to_str().unwrap()]) + .stats() + .telegram_secret(); + assert_ne!(third, first); + let fourth = parse(&["--secret-file", path.to_str().unwrap()]) + .stats() + .telegram_secret(); + assert_eq!(third, fourth, "the replacement must be persisted in turn"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn rejects_malformed_values() { + for bad in [ + vec!["-b", "not-an-ip"], + vec!["-p", "70000"], + vec!["-p", "-1"], + vec!["--unknown"], + ] { + assert!( + Args::try_parse_from(std::iter::once("tglock-cli").chain(bad.iter().copied())) + .is_err(), + "{bad:?} must be rejected" + ); + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..08e6f06 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,135 @@ +//! Listener configuration shared by the GUI and the CLI. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +/// Where the local proxy listens and whether it is allowed to relay anything +/// other than Telegram. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ListenConfig { + pub addr: SocketAddr, + /// Relay non-Telegram destinations as a plain SOCKS5 proxy. + /// + /// Loopback listeners get this for free because only local processes can + /// reach them. A listener the network can reach must opt in explicitly, so + /// that sharing TGLock across a flat never silently turns the machine into + /// an open SOCKS5 relay. + pub allow_direct: bool, +} + +impl ListenConfig { + /// Listener with the default policy for the given address. + pub fn new(ip: IpAddr, port: u16) -> Self { + Self { + addr: SocketAddr::new(ip, port), + allow_direct: ip.is_loopback(), + } + } + + /// `127.0.0.1` — only this machine, non-Telegram traffic relayed. + pub fn loopback(port: u16) -> Self { + Self::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port) + } + + /// `0.0.0.0` — reachable from the local network, Telegram destinations only. + pub fn lan(port: u16) -> Self { + Self::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port) + } + + /// Override the direct-relay policy. Used by `--allow-direct`. + pub fn with_allow_direct(mut self, allow_direct: bool) -> Self { + self.allow_direct = allow_direct; + self + } + + /// Host to advertise in a `tg://proxy` link for this listener. + /// + /// A wildcard bind is not a usable destination, so it is resolved to the + /// address this machine uses to reach the network. + pub fn advertised_host(&self) -> String { + let ip = self.addr.ip(); + if ip.is_unspecified() { + outbound_ip().unwrap_or_else(|| Ipv4Addr::LOCALHOST.to_string()) + } else { + ip.to_string() + } + } + + /// `tg://proxy` link that points Telegram at this listener. + pub fn telegram_link(&self, secret: &str) -> String { + format!( + "tg://proxy?server={}&port={}&secret={}", + self.advertised_host(), + self.addr.port(), + secret + ) + } +} + +/// Local address of the interface that reaches the default route. +/// +/// No packet is sent: connecting a UDP socket only makes the OS pick a route. +fn outbound_ip() -> Option { + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("8.8.8.8:80").ok()?; + Some(socket.local_addr().ok()?.ip().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loopback_relays_direct_traffic() { + let config = ListenConfig::loopback(1080); + assert_eq!(config.addr.to_string(), "127.0.0.1:1080"); + assert!(config.allow_direct); + } + + #[test] + fn lan_restricts_to_telegram_by_default() { + let config = ListenConfig::lan(1080); + assert_eq!(config.addr.to_string(), "0.0.0.0:1080"); + assert!(!config.allow_direct); + } + + #[test] + fn any_routable_address_restricts_to_telegram() { + for ip in ["192.168.1.10", "10.0.0.5", "::"] { + let config = ListenConfig::new(ip.parse().unwrap(), 1080); + assert!( + !config.allow_direct, + "{ip} must not relay non-Telegram traffic without an explicit opt-in" + ); + } + } + + #[test] + fn ipv6_loopback_is_treated_as_local() { + let config = ListenConfig::new("::1".parse().unwrap(), 1080); + assert!(config.allow_direct); + assert_eq!(config.addr.to_string(), "[::1]:1080"); + } + + #[test] + fn allow_direct_override_is_explicit_in_both_directions() { + assert!(ListenConfig::lan(1080).with_allow_direct(true).allow_direct); + assert!( + !ListenConfig::loopback(1080) + .with_allow_direct(false) + .allow_direct + ); + } + + #[test] + fn link_uses_concrete_host_and_port() { + let link = ListenConfig::new("192.168.1.10".parse().unwrap(), 1443).telegram_link("ddaa"); + assert_eq!(link, "tg://proxy?server=192.168.1.10&port=1443&secret=ddaa"); + } + + #[test] + fn wildcard_bind_never_advertises_itself() { + let host = ListenConfig::lan(1080).advertised_host(); + assert_ne!(host, "0.0.0.0"); + assert!(!host.is_empty()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0cd939a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +//! TGLock core: the MTProto/WebSocket transport shared by the desktop GUI and +//! the headless CLI. +//! +//! Nothing in this crate depends on Tauri or on a windowing system, so the +//! `tglock-cli` binary can be built with `--no-default-features` on a server +//! that has neither a GPU nor a monitor. + +pub mod config; +pub mod mtproto; +pub mod proxy; +pub mod transport; diff --git a/src/main.rs b/src/main.rs index ed599f9..06c7ed9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,13 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -mod mtproto; -mod proxy; -mod transport; - use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use tauri::{Manager, State}; +use tglock::config::ListenConfig; +use tglock::{proxy, transport}; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -90,11 +88,7 @@ impl AppState { fn snapshot(&self) -> StatusSnapshot { let data_center = self.stats.last_dc.load(Ordering::Relaxed); - let route = match self.stats.last_route.load(Ordering::Relaxed) { - 1 => "Telegram WebSocket", - 2 => "Cloudflare Worker", - _ => "Автоматический маршрут", - }; + let route = transport::route_label(self.stats.last_route.load(Ordering::Relaxed)); StatusSnapshot { running: self.stats.running.load(Ordering::SeqCst), active_connections: self.stats.active.load(Ordering::Relaxed), @@ -173,10 +167,14 @@ fn start_proxy(state: State<'_, AppState>) -> Result { *state.started_at.lock().unwrap() = Some(Instant::now()); state.log("Запускаю защищённый маршрут…", false); + let listen = if settings.lan_mode { + ListenConfig::lan(settings.port) + } else { + ListenConfig::loopback(settings.port) + }; + let stats = state.stats.clone(); let logs = state.logs.clone(); - let lan_mode = settings.lan_mode; - let port = settings.port; std::thread::spawn(move || { let runtime = match tokio::runtime::Runtime::new() { Ok(runtime) => runtime, @@ -185,7 +183,7 @@ fn start_proxy(state: State<'_, AppState>) -> Result { return; } }; - if let Err(error) = runtime.block_on(proxy::run(stats, lan_mode, port)) { + if let Err(error) = runtime.block_on(proxy::run(stats, listen)) { push_log(&logs, format!("Ошибка подключения: {error}"), true); } }); @@ -202,28 +200,8 @@ fn start_proxy(state: State<'_, AppState>) -> Result { .unwrap_or_else(|| "Не удалось запустить прокси".into())); } - state.log( - format!( - "Прокси запущен на {}:{}", - if settings.lan_mode { - "0.0.0.0" - } else { - "127.0.0.1" - }, - settings.port - ), - false, - ); - let host = if settings.lan_mode { - local_ip().unwrap_or_else(|| "127.0.0.1".into()) - } else { - "127.0.0.1".into() - }; - let _ = open::that(format!( - "tg://proxy?server={host}&port={}&secret={}", - settings.port, - state.stats.telegram_secret() - )); + state.log(format!("Прокси запущен на {}", listen.addr), false); + let _ = open::that(listen.telegram_link(&state.stats.telegram_secret())); state.log("Открываю подключение в Telegram…", false); Ok(state.snapshot()) } @@ -244,12 +222,6 @@ fn push_log(logs: &Arc>>, message: String, error: bool) { }); } -fn local_ip() -> Option { - let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; - socket.connect("8.8.8.8:80").ok()?; - Some(socket.local_addr().ok()?.ip().to_string()) -} - fn main() { tauri::Builder::default() .setup(|app| { diff --git a/src/mtproto.rs b/src/mtproto.rs index 4b4d6a2..8f3bed3 100644 --- a/src/mtproto.rs +++ b/src/mtproto.rs @@ -2,6 +2,7 @@ use aes::Aes256; use cipher::{KeyIvInit, StreamCipher}; use rand::{rngs::OsRng, RngCore}; use sha2::{Digest, Sha256}; +use std::path::Path; #[cfg(not(test))] use std::path::PathBuf; @@ -50,12 +51,13 @@ pub fn generate_secret() -> [u8; 16] { secret } -#[cfg(not(test))] -pub fn load_or_create_secret() -> [u8; 16] { - let Some(path) = secret_path() else { - return generate_secret(); - }; - if let Ok(value) = std::fs::read_to_string(&path) { +/// Reuse the secret stored at `path`, creating it if it is missing or unusable. +/// +/// A daemon needs this: the secret is half of the `tg://proxy` link, so a +/// service that invents a new one on every restart silently invalidates every +/// client that was already configured. +pub fn load_or_create_secret_at(path: &Path) -> [u8; 16] { + if let Ok(value) = std::fs::read_to_string(path) { if let Some(secret) = parse_secret_hex(value.trim()) { return secret; } @@ -65,10 +67,18 @@ pub fn load_or_create_secret() -> [u8; 16] { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - write_secret_file(&path, &secret_hex(&secret)); + write_secret_file(path, &secret_hex(&secret)); secret } +#[cfg(not(test))] +pub fn load_or_create_secret() -> [u8; 16] { + match secret_path() { + Some(path) => load_or_create_secret_at(&path), + None => generate_secret(), + } +} + #[cfg(not(test))] fn secret_path() -> Option { #[cfg(target_os = "windows")] @@ -94,8 +104,8 @@ fn secret_path() -> Option { } } -#[cfg(all(not(test), unix))] -fn write_secret_file(path: &std::path::Path, value: &str) { +#[cfg(unix)] +fn write_secret_file(path: &Path, value: &str) { use std::io::Write; use std::os::unix::fs::OpenOptionsExt; if let Ok(mut file) = std::fs::OpenOptions::new() @@ -109,8 +119,8 @@ fn write_secret_file(path: &std::path::Path, value: &str) { } } -#[cfg(all(not(test), not(unix)))] -fn write_secret_file(path: &std::path::Path, value: &str) { +#[cfg(not(unix))] +fn write_secret_file(path: &Path, value: &str) { let _ = std::fs::write(path, value); } @@ -255,6 +265,66 @@ pub(crate) fn test_client_init(secret: &[u8; 16], dc_index: i16) -> [u8; INIT_LE tests::generate_client_init(secret, PADDED_INTERMEDIATE, dc_index) } +/// One end of an obfuscated2 stream, built the way the real peer builds it. +/// +/// Lets tests assert on the bytes the peer actually observes rather than on the +/// proxy's own view of them, so a mistake that is symmetric inside +/// [`CryptoContext`] still fails the test. +#[cfg(test)] +pub(crate) struct TestPeer { + encrypt: AesCtr, + decrypt: AesCtr, +} + +#[cfg(test)] +impl TestPeer { + pub(crate) fn encrypt(&mut self, data: &mut [u8]) { + self.encrypt.apply_keystream(data); + } + + pub(crate) fn decrypt(&mut self, data: &mut [u8]) { + self.decrypt.apply_keystream(data); + } +} + +/// The Telegram client: its keys come from the init it sent, salted with the +/// shared secret. +#[cfg(test)] +pub(crate) fn test_client_peer(init: &[u8; INIT_LEN], secret: &[u8; 16]) -> TestPeer { + let key = secret_key(&init[KEY_START..KEY_END], secret); + let iv: [u8; 16] = init[KEY_END..IV_END].try_into().unwrap(); + let mut encrypt = AesCtr::new((&key).into(), (&iv).into()); + encrypt.apply_keystream(&mut [0; INIT_LEN]); + + let reversed: Vec = init[KEY_START..IV_END].iter().rev().copied().collect(); + let decrypt_key = secret_key(&reversed[..32], secret); + let decrypt_iv: [u8; 16] = reversed[32..].try_into().unwrap(); + let decrypt = AesCtr::new((&decrypt_key).into(), (&decrypt_iv).into()); + + TestPeer { encrypt, decrypt } +} + +/// The Telegram relay: no shared secret, keys come straight from the init the +/// proxy generated for it. +#[cfg(test)] +pub(crate) fn test_relay_peer(relay_init: &[u8; INIT_LEN]) -> TestPeer { + let key: [u8; 32] = relay_init[KEY_START..KEY_END].try_into().unwrap(); + let iv: [u8; 16] = relay_init[KEY_END..IV_END].try_into().unwrap(); + let mut decrypt = AesCtr::new((&key).into(), (&iv).into()); + decrypt.apply_keystream(&mut [0; INIT_LEN]); + + let reversed: Vec = relay_init[KEY_START..IV_END] + .iter() + .rev() + .copied() + .collect(); + let encrypt_key: [u8; 32] = reversed[..32].try_into().unwrap(); + let encrypt_iv: [u8; 16] = reversed[32..].try_into().unwrap(); + let encrypt = AesCtr::new((&encrypt_key).into(), (&encrypt_iv).into()); + + TestPeer { encrypt, decrypt } +} + #[cfg(test)] mod tests { use super::*; @@ -304,6 +374,133 @@ mod tests { ); } + #[test] + fn accepts_every_supported_protocol_tag() { + let secret = [7; 16]; + for tag in [ABRIDGED, INTERMEDIATE, PADDED_INTERMEDIATE] { + let init = generate_client_init(&secret, tag, 2); + let parsed = parse_client_init(&init, &secret) + .unwrap_or_else(|| panic!("tag {tag:02x?} must be accepted")); + assert_eq!(parsed.dc, 2); + assert!(!parsed.media); + } + } + + #[test] + fn relay_init_carries_the_clients_protocol_tag_and_dc() { + let secret = [3; 16]; + for (tag, dc_index) in [ + (ABRIDGED, 1_i16), + (INTERMEDIATE, -5), + (PADDED_INTERMEDIATE, 203), + ] { + let init = generate_client_init(&secret, tag, dc_index); + let parsed = parse_client_init(&init, &secret).unwrap(); + + // The relay init is freshly generated, never the client's bytes. + assert_ne!(parsed.relay_init, init); + + // Decoding the relay init the way Telegram does must recover the + // same protocol and data centre the client asked for. + let key: [u8; 32] = parsed.relay_init[KEY_START..KEY_END].try_into().unwrap(); + let iv: [u8; 16] = parsed.relay_init[KEY_END..IV_END].try_into().unwrap(); + let mut cipher = AesCtr::new((&key).into(), (&iv).into()); + let mut decoded = parsed.relay_init; + cipher.apply_keystream(&mut decoded); + + assert_eq!(decoded[TAG_START..DC_START], tag); + assert_eq!( + i16::from_le_bytes([decoded[DC_START], decoded[DC_START + 1]]), + dc_index + ); + } + } + + #[test] + fn rejects_data_centers_outside_the_known_range() { + let secret = [11; 16]; + for dc_index in [0_i16, 6, -6, 204, -204, 1000] { + let init = generate_client_init(&secret, INTERMEDIATE, dc_index); + assert!( + parse_client_init(&init, &secret).is_none(), + "DC index {dc_index} must be rejected" + ); + } + } + + #[test] + fn negative_index_marks_media_and_keeps_the_data_center() { + let secret = [13; 16]; + for dc in [1_u16, 2, 3, 4, 5, 203] { + let index = -(dc as i16); + let parsed = + parse_client_init(&generate_client_init(&secret, ABRIDGED, index), &secret) + .unwrap(); + assert_eq!(parsed.dc, dc); + assert!(parsed.media); + + let parsed = + parse_client_init(&generate_client_init(&secret, ABRIDGED, dc as i16), &secret) + .unwrap(); + assert_eq!(parsed.dc, dc); + assert!(!parsed.media); + } + } + + #[test] + fn plaintext_survives_the_trip_to_the_relay_and_back() { + let secret = [42; 16]; + let init = generate_client_init(&secret, ABRIDGED, 2); + let mut parsed = parse_client_init(&init, &secret).unwrap(); + let mut client = test_client_peer(&init, &secret); + let mut relay = test_relay_peer(&parsed.relay_init); + + let request = b"exactly what Telegram must receive".to_vec(); + let mut wire = request.clone(); + client.encrypt(&mut wire); + assert_ne!(wire, request, "the wire must not carry plaintext"); + parsed.crypto.client_to_telegram(&mut wire); + assert_ne!(wire, request, "the upstream wire must not carry plaintext"); + relay.decrypt(&mut wire); + assert_eq!(wire, request); + + let response = b"exactly what the client must receive".to_vec(); + let mut wire = response.clone(); + relay.encrypt(&mut wire); + parsed.crypto.telegram_to_client(&mut wire); + client.decrypt(&mut wire); + assert_eq!(wire, response); + } + + #[test] + fn keystream_advances_across_chunks() { + let secret = [5; 16]; + let init = generate_client_init(&secret, INTERMEDIATE, 3); + let mut parsed = parse_client_init(&init, &secret).unwrap(); + let mut client = test_client_peer(&init, &secret); + let mut relay = test_relay_peer(&parsed.relay_init); + + // A stream cipher is only correct if both ends stay in lockstep across + // arbitrary chunk boundaries, which is how TCP actually delivers data. + let chunks: [&[u8]; 4] = [b"one", b"", b"the third chunk is longer", b"4"]; + for chunk in chunks { + let mut wire = chunk.to_vec(); + client.encrypt(&mut wire); + parsed.crypto.client_to_telegram(&mut wire); + relay.decrypt(&mut wire); + assert_eq!(wire, chunk); + } + } + + #[test] + fn reserved_prefixes_never_leave_the_generator() { + // A relay init that starts with an HTTP verb or a protocol tag would be + // misread by Telegram's frontend. + for _ in 0..2_000 { + assert!(!is_reserved_init(&generate_relay_init(ABRIDGED, 2))); + } + } + #[test] fn parses_persisted_secret() { assert_eq!( diff --git a/src/proxy.rs b/src/proxy.rs index 17f45ee..82fe689 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,3 +1,4 @@ +use crate::config::ListenConfig; use std::net::Ipv4Addr; use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; @@ -8,6 +9,12 @@ use tokio_tungstenite::tungstenite; pub const DEFAULT_PORT: u16 = 1080; const IO_TIMEOUT: Duration = Duration::from_secs(10); +const INIT_LEN: usize = 64; +const SOCKS5_VERSION: u8 = 0x05; +/// How long to wait for a full MTProto init before treating an ambiguous first +/// byte as the start of a SOCKS5 greeting. +const PROTOCOL_PROBE_TIMEOUT: Duration = Duration::from_millis(250); +const PROTOCOL_PROBE_INTERVAL: Duration = Duration::from_millis(5); pub struct Stats { pub running: AtomicBool, @@ -25,6 +32,14 @@ pub struct Stats { impl Stats { pub fn new() -> Arc { + Self::with_secret(initial_secret()) + } + + /// Build with an explicit proxy secret. + /// + /// A daemon must pin this: the secret is half of the `tg://proxy` link, so + /// generating a fresh one on restart breaks every configured client. + pub fn with_secret(secret: [u8; 16]) -> Arc { Arc::new(Self { running: AtomicBool::new(false), active: AtomicU32::new(0), @@ -34,7 +49,7 @@ impl Stats { ws_failures: AtomicU32::new(0), last_route: AtomicU8::new(0), transport: crate::transport::TransportEngine::new(), - secret: initial_secret(), + secret, shutdown: Mutex::new(None), }) } @@ -69,13 +84,28 @@ fn initial_secret() -> [u8; 16] { crate::mtproto::generate_secret() } -pub async fn run(stats: Arc, lan: bool, port: u16) -> Result<(), String> { - let host = if lan { "0.0.0.0" } else { "127.0.0.1" }; - let addr = format!("{}:{}", host, port); - let listener = TcpListener::bind(&addr) +/// Claim the local port. +/// +/// Separated from [`serve`] so a caller can report a port conflict before it +/// tells the user the proxy is running. +pub async fn bind(listen: ListenConfig) -> Result { + TcpListener::bind(listen.addr) .await - .map_err(|e| format!("Port {} busy: {}", port, e))?; + .map_err(|error| format!("Cannot listen on {}: {}", listen.addr, error)) +} +/// Bind and serve until [`Stats::stop`] is called. +pub async fn run(stats: Arc, listen: ListenConfig) -> Result<(), String> { + let listener = bind(listen).await?; + serve(stats, listener, listen.allow_direct).await +} + +/// Accept clients on an already bound listener. +pub async fn serve( + stats: Arc, + listener: TcpListener, + allow_direct: bool, +) -> Result<(), String> { stats.running.store(true, Ordering::SeqCst); let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); *stats.shutdown.lock().unwrap() = Some(shutdown_tx); @@ -90,7 +120,7 @@ pub async fn run(stats: Arc, lan: bool, port: u16) -> Result<(), String> s.active.fetch_add(1, Ordering::Relaxed); s.total.fetch_add(1, Ordering::Relaxed); tasks.spawn(async move { - let _ = handle(stream, &s, !lan).await; + let _ = handle(stream, &s, allow_direct).await; s.active.fetch_sub(1, Ordering::Relaxed); }); } @@ -115,22 +145,67 @@ pub async fn run(stats: Arc, lan: bool, port: u16) -> Result<(), String> // -- SOCKS5 ----------------------------------------------------------------- +#[derive(Debug, Eq, PartialEq)] +enum Protocol { + Socks5, + MtProto, + Empty, +} + async fn handle( s: TcpStream, stats: &Stats, allow_direct: bool, ) -> Result<(), Box> { - let mut first = [0; 1]; - let peeked = tokio::time::timeout(IO_TIMEOUT, s.peek(&mut first)) + match detect_protocol(&s, stats).await? { + Protocol::Socks5 => handle_socks5(s, stats, allow_direct).await, + Protocol::MtProto => handle_mtproto(s, stats).await, + Protocol::Empty => Ok(()), + } +} + +/// Decide which protocol a fresh client is speaking without consuming anything. +/// +/// A SOCKS5 greeting starts with `0x05` — but so does one in every 256 MTProto +/// init packets, because the client fills those 64 bytes at random and +/// `mtproto::is_reserved_init` only avoids `0xef`, `0xee`, `0xdd`, HTTP verbs +/// and the TLS record header. Deciding on the first byte alone therefore sends +/// roughly one connection in 256 down the SOCKS5 path, where it dies. From the +/// outside that looks exactly like Telegram sending messages every other try. +/// +/// When the first byte is ambiguous, wait briefly: a real SOCKS5 client sends a +/// short greeting and then blocks on our reply, so only MTProto produces a full +/// 64-byte init that decodes under our secret. +async fn detect_protocol( + stream: &TcpStream, + stats: &Stats, +) -> Result> { + let mut probe = [0; INIT_LEN]; + let peeked = tokio::time::timeout(IO_TIMEOUT, stream.peek(&mut probe[..1])) .await .map_err(|_| "client protocol detection timeout")??; if peeked == 0 { - return Ok(()); + return Ok(Protocol::Empty); } - if first[0] == 0x05 { - handle_socks5(s, stats, allow_direct).await - } else { - handle_mtproto(s, stats).await + if probe[0] != SOCKS5_VERSION { + return Ok(Protocol::MtProto); + } + + let deadline = tokio::time::Instant::now() + PROTOCOL_PROBE_TIMEOUT; + loop { + if stream.peek(&mut probe).await? == INIT_LEN { + return Ok( + if crate::mtproto::parse_client_init(&probe, &stats.secret).is_some() { + Protocol::MtProto + } else { + Protocol::Socks5 + }, + ); + } + if tokio::time::Instant::now() >= deadline { + return Ok(Protocol::Socks5); + } + tokio::time::sleep(PROTOCOL_PROBE_INTERVAL).await; } } @@ -397,6 +472,132 @@ async fn tcp_relay(a: TcpStream, b: TcpStream) { mod tests { use super::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; + use tokio_tungstenite::tungstenite::Message; + + /// Start the proxy on a kernel-assigned port. + /// + /// Binding first and reading the port back removes the reserve-then-rebind + /// race that a `port 0` helper would otherwise introduce. + async fn start_proxy( + stats: Arc, + allow_direct: bool, + ) -> (u16, tokio::task::JoinHandle>) { + let listener = bind(ListenConfig::loopback(0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { serve(stats, listener, allow_direct).await }); + (port, server) + } + + async fn wait_until(label: &str, mut condition: impl FnMut() -> bool) { + tokio::time::timeout(Duration::from_secs(5), async { + while !condition() { + tokio::time::sleep(Duration::from_millis(2)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {label}")); + } + + /// Perform a SOCKS5 greeting, send `request`, return the 10-byte reply. + async fn socks5_exchange(port: u16, request: &[u8]) -> (TcpStream, [u8; 10]) { + let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + let mut greeting = [0; 2]; + client.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting, [0x05, 0x00]); + client.write_all(request).await.unwrap(); + let mut reply = [0; 10]; + client.read_exact(&mut reply).await.unwrap(); + (client, reply) + } + + fn socks5_ipv4_request(command: u8, ip: [u8; 4], port: u16) -> Vec { + let mut request = vec![0x05, command, 0x00, 0x01]; + request.extend_from_slice(&ip); + request.extend_from_slice(&port.to_be_bytes()); + request + } + + /// A client init whose first byte is not the SOCKS5 version, so the test + /// exercises the unambiguous detection path. + fn unambiguous_client_init(secret: &[u8; 16], dc_index: i16) -> [u8; INIT_LEN] { + loop { + let init = crate::mtproto::test_client_init(secret, dc_index); + if init[0] != SOCKS5_VERSION { + return init; + } + } + } + + fn ambiguous_client_init(secret: &[u8; 16], dc_index: i16) -> [u8; INIT_LEN] { + loop { + let init = crate::mtproto::test_client_init(secret, dc_index); + if init[0] == SOCKS5_VERSION { + return init; + } + } + } + + /// Stand-in for `kwsN.web.telegram.org`: a plaintext WebSocket that behaves + /// like an obfuscated2 relay. + /// + /// Returns the raw init frame it was handed and the plaintext it recovered, + /// so a test can assert on what Telegram would really have seen. + // The handshake callback's error type is tungstenite's own `ErrorResponse`, + // whose size is not ours to change. + #[allow(clippy::result_large_err)] + async fn mock_relay( + listener: TcpListener, + response: Vec, + ) -> Result<(Vec, Vec), String> { + use futures_util::{SinkExt, StreamExt}; + + let (tcp, _) = listener.accept().await.map_err(|e| e.to_string())?; + // Telegram confirms the `binary` subprotocol the proxy asks for, and + // tungstenite refuses a handshake that silently drops it. A mock that + // does not answer it would only ever test the failure path. + let mut websocket = + tokio_tungstenite::accept_hdr_async(tcp, |_: &Request, mut response: Response| { + response.headers_mut().insert( + "Sec-WebSocket-Protocol", + "binary".parse().expect("static header value"), + ); + Ok(response) + }) + .await + .map_err(|e| e.to_string())?; + + let init = match websocket.next().await { + Some(Ok(Message::Binary(data))) => data, + other => return Err(format!("expected an init frame, got {other:?}")), + }; + let header: [u8; INIT_LEN] = init + .as_slice() + .try_into() + .map_err(|_| format!("init frame is {} bytes, not {INIT_LEN}", init.len()))?; + let mut relay = crate::mtproto::test_relay_peer(&header); + + let mut request = Vec::new(); + while request.is_empty() { + match websocket.next().await { + Some(Ok(Message::Binary(mut data))) => { + relay.decrypt(&mut data); + request.extend_from_slice(&data); + } + Some(Ok(_)) => {} + _ => break, + } + } + + let mut wire = response; + relay.encrypt(&mut wire); + websocket + .send(Message::Binary(wire)) + .await + .map_err(|e| e.to_string())?; + Ok((init, request)) + } #[tokio::test] async fn parses_fragmented_ipv4_socks5_handshake() { @@ -451,7 +652,8 @@ mod tests { let stats = Stats::new(); let server_stats = stats.clone(); - let server = tokio::spawn(async move { run(server_stats, false, port).await }); + let server = + tokio::spawn(async move { run(server_stats, ListenConfig::loopback(port)).await }); tokio::time::timeout(Duration::from_secs(2), async { while !stats.running.load(Ordering::SeqCst) { @@ -470,6 +672,263 @@ mod tests { assert!(!stats.running.load(Ordering::SeqCst)); } + #[tokio::test] + async fn parses_domain_and_ipv6_socks5_targets() { + let domain = "web.telegram.org"; + let mut domain_payload = vec![u8::try_from(domain.len()).unwrap()]; + domain_payload.extend_from_slice(domain.as_bytes()); + + for (address_type, payload, expected) in + [(0x03_u8, domain_payload, domain), (0x04, vec![0; 16], "::")] + { + let (mut client, mut server) = tokio::io::duplex(256); + let task = tokio::spawn(async move { read_socks5_request(&mut server).await.unwrap() }); + + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + let mut greeting = [0; 2]; + client.read_exact(&mut greeting).await.unwrap(); + + let mut request = vec![0x05, 0x01, 0x00, address_type]; + request.extend_from_slice(&payload); + request.extend_from_slice(&443_u16.to_be_bytes()); + client.write_all(&request).await.unwrap(); + + assert_eq!(task.await.unwrap(), (expected.to_owned(), 443)); + } + } + + #[tokio::test] + async fn rejects_malformed_and_unsupported_socks5_requests() { + // (request after the greeting, expected reply status, why) + let cases: [(Vec, Option, &str); 5] = [ + ( + vec![0x05, 0x03, 0x00, 0x01, 1, 1, 1, 1, 0x01, 0xbb], + Some(0x07), + "UDP ASSOCIATE is not implemented, so it must be refused rather than half-served", + ), + ( + vec![0x05, 0x02, 0x00, 0x01, 1, 1, 1, 1, 0x01, 0xbb], + Some(0x07), + "BIND is not implemented", + ), + ( + vec![0x05, 0x01, 0x00, 0x09, 1, 1, 1, 1, 0x01, 0xbb], + Some(0x08), + "unknown address type", + ), + ( + vec![0x05, 0x01, 0x00, 0x03, 0x00, 0x01, 0xbb], + None, + "empty domain", + ), + ( + vec![0x04, 0x01, 0x00, 0x01, 1, 1, 1, 1, 0x01, 0xbb], + None, + "wrong protocol version in the request", + ), + ]; + + for (request, expected_status, reason) in cases { + let (mut client, mut server) = tokio::io::duplex(256); + let task = tokio::spawn(async move { read_socks5_request(&mut server).await.is_err() }); + + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + let mut greeting = [0; 2]; + client.read_exact(&mut greeting).await.unwrap(); + client.write_all(&request).await.unwrap(); + + if let Some(status) = expected_status { + let mut reply = [0; 10]; + client.read_exact(&mut reply).await.unwrap(); + assert_eq!(reply[0], 0x05, "{reason}"); + assert_eq!(reply[1], status, "{reason}"); + } + assert!(task.await.unwrap(), "{reason}"); + } + } + + #[tokio::test] + async fn reports_a_busy_port_instead_of_pretending_to_run() { + let taken = bind(ListenConfig::loopback(0)).await.unwrap(); + let port = taken.local_addr().unwrap().port(); + + let error = bind(ListenConfig::loopback(port)).await.unwrap_err(); + assert!( + error.contains(&port.to_string()), + "the error must name the port that is busy, got {error:?}" + ); + } + + #[tokio::test] + async fn mtproto_init_beginning_with_the_socks5_version_is_not_misrouted() { + // One init in 256 starts with 0x05. Routing it to the SOCKS5 handler is + // what makes Telegram work only every other attempt. + let stats = Stats::new(); + let init = ambiguous_client_init(&stats.secret, 2); + let (port, server) = start_proxy(stats.clone(), true).await; + let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + client.write_all(&init).await.unwrap(); + + // Detection must land on MTProto, which records the data centre. The + // SOCKS5 path would instead answer with a handshake reply. + wait_until("the MTProto data centre to be recorded", || { + stats.last_dc.load(Ordering::Relaxed) == 2 + }) + .await; + + stats.stop(); + let _ = server.await.unwrap(); + } + + #[tokio::test] + async fn fragmented_socks5_greeting_is_still_detected() { + let stats = Stats::new(); + let (port, server) = start_proxy(stats.clone(), true).await; + + let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + // Byte-at-a-time, the way a small greeting can actually arrive. + for byte in [0x05, 0x01, 0x00] { + client.write_all(&[byte]).await.unwrap(); + tokio::time::sleep(Duration::from_millis(1)).await; + } + let mut greeting = [0; 2]; + tokio::time::timeout(Duration::from_secs(5), client.read_exact(&mut greeting)) + .await + .expect("the proxy must answer the greeting") + .unwrap(); + assert_eq!(greeting, [0x05, 0x00]); + + stats.stop(); + let _ = server.await.unwrap(); + } + + #[tokio::test] + async fn network_listener_refuses_non_telegram_destinations() { + let stats = Stats::new(); + let (port, server) = start_proxy(stats.clone(), false).await; + + let (_client, reply) = + socks5_exchange(port, &socks5_ipv4_request(0x01, [1, 1, 1, 1], 443)).await; + assert_eq!( + reply[1], 0x02, + "a shared listener must not relay arbitrary destinations" + ); + + stats.stop(); + let _ = server.await.unwrap(); + } + + #[tokio::test] + async fn loopback_listener_relays_direct_destinations() { + let echo = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let echo_port = echo.local_addr().unwrap().port(); + tokio::spawn(async move { + let (mut stream, _) = echo.accept().await.unwrap(); + let mut buffer = [0; 5]; + stream.read_exact(&mut buffer).await.unwrap(); + stream.write_all(&buffer).await.unwrap(); + }); + + let stats = Stats::new(); + let (port, server) = start_proxy(stats.clone(), true).await; + + let (mut client, reply) = + socks5_exchange(port, &socks5_ipv4_request(0x01, [127, 0, 0, 1], echo_port)).await; + assert_eq!(reply[1], 0x00); + + client.write_all(b"hello").await.unwrap(); + let mut echoed = [0; 5]; + client.read_exact(&mut echoed).await.unwrap(); + assert_eq!(&echoed, b"hello"); + + stats.stop(); + let _ = server.await.unwrap(); + } + + #[tokio::test] + async fn tunnels_mtproto_through_a_websocket_relay() { + let relay_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_port = relay_listener.local_addr().unwrap().port(); + let response = b"a reply that only Telegram could have sent".to_vec(); + let relay = tokio::spawn(mock_relay(relay_listener, response.clone())); + + let stats = Stats::new(); + stats.transport.force_local_route(relay_port); + let (port, server) = start_proxy(stats.clone(), false).await; + + let init = unambiguous_client_init(&stats.secret, -4); + let mut peer = crate::mtproto::test_client_peer(&init, &stats.secret); + let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + client.write_all(&init).await.unwrap(); + + let request = b"a request that must reach Telegram unchanged".to_vec(); + let mut wire = request.clone(); + peer.encrypt(&mut wire); + client.write_all(&wire).await.unwrap(); + + let mut received = vec![0; response.len()]; + tokio::time::timeout(Duration::from_secs(10), client.read_exact(&mut received)) + .await + .expect("the relay's answer must come back through the tunnel") + .unwrap(); + peer.decrypt(&mut received); + assert_eq!( + received, response, + "the client must see Telegram's plaintext" + ); + + let (init_frame, relayed) = relay.await.unwrap().unwrap(); + assert_eq!(init_frame.len(), INIT_LEN); + assert_ne!( + init_frame.as_slice(), + init.as_slice(), + "the upstream init must be freshly generated, not the client's own" + ); + assert_eq!( + relayed, request, + "Telegram must receive exactly the client's plaintext" + ); + + assert_eq!(stats.last_dc.load(Ordering::Relaxed), 4); + assert_eq!( + stats.last_route.load(Ordering::Relaxed), + crate::transport::RouteKind::TelegramIp.ui_code() + ); + assert_eq!(stats.ws_failures.load(Ordering::Relaxed), 0); + + stats.stop(); + let _ = server.await.unwrap(); + } + + #[tokio::test] + async fn counts_a_failure_when_no_route_answers() { + let dead = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let dead_port = dead.local_addr().unwrap().port(); + drop(dead); + + let stats = Stats::new(); + stats.transport.force_local_route(dead_port); + let (port, server) = start_proxy(stats.clone(), false).await; + + let init = unambiguous_client_init(&stats.secret, 2); + let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + client.write_all(&init).await.unwrap(); + + wait_until("the failed tunnel to be counted", || { + stats.ws_failures.load(Ordering::Relaxed) > 0 + }) + .await; + assert_eq!( + stats.last_route.load(Ordering::Relaxed), + 0, + "a route must not be reported as working when every attempt failed" + ); + assert_eq!(stats.ws.load(Ordering::Relaxed), 0); + + stats.stop(); + let _ = server.await.unwrap(); + } + #[tokio::test] #[ignore = "requires live Telegram network access"] async fn accepts_mtproto_and_builds_live_media_tunnel() { @@ -479,7 +938,8 @@ mod tests { let stats = Stats::new(); let server_stats = stats.clone(); - let server = tokio::spawn(async move { run(server_stats, false, port).await }); + let server = + tokio::spawn(async move { run(server_stats, ListenConfig::loopback(port)).await }); while !stats.running.load(Ordering::SeqCst) { tokio::task::yield_now().await; } diff --git a/src/transport.rs b/src/transport.rs index b20c370..a3cb5cf 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -9,6 +9,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; const CONNECT_TIMEOUT: Duration = Duration::from_secs(4); const FAILURE_BACKOFF_INITIAL: Duration = Duration::from_secs(30); const FAILURE_BACKOFF_MAX: Duration = Duration::from_secs(30 * 60); +const HTTPS_PORT: u16 = 443; pub type TelegramWebSocket = WebSocketStream>; @@ -29,6 +30,34 @@ impl RouteKind { Self::CloudflareWorker => 4, } } + + pub fn from_ui_code(code: u8) -> Option { + match code { + 1 => Some(Self::TelegramIp), + 2 => Some(Self::AlternateTelegramIp), + 3 => Some(Self::SystemDns), + 4 => Some(Self::CloudflareWorker), + _ => None, + } + } + + /// Human-readable name of the route, shown by both frontends. + pub fn label(self) -> &'static str { + match self { + Self::TelegramIp => "Telegram IP", + Self::AlternateTelegramIp => "Запасной Telegram IP", + Self::SystemDns => "Системный DNS", + Self::CloudflareWorker => "Cloudflare Worker", + } + } +} + +/// Label for a route code as stored in `Stats::last_route`. +/// +/// Code `0` means no tunnel has been established yet, which must never be +/// reported as a working route. +pub fn route_label(ui_code: u8) -> &'static str { + RouteKind::from_ui_code(ui_code).map_or("Маршрут ещё не выбран", RouteKind::label) } #[derive(Clone, Debug, Eq, Hash, PartialEq)] @@ -37,6 +66,24 @@ pub struct Route { pub websocket_host: String, pub path: String, pub kind: RouteKind, + /// TCP port to dial. Always 443 for Telegram and for Cloudflare Workers. + pub port: u16, + /// Wrap the connection in TLS. Always true outside tests. + pub secure: bool, +} + +impl Route { + /// A production route: TLS on 443. + fn https(connect_host: String, websocket_host: String, path: String, kind: RouteKind) -> Self { + Self { + connect_host, + websocket_host, + path, + kind, + port: HTTPS_PORT, + secure: true, + } + } } #[derive(Clone, Debug)] @@ -66,6 +113,24 @@ struct HealthState { pub struct TransportEngine { health: Mutex, worker_domains: Mutex>, + #[cfg(test)] + forced_routes: Mutex>, +} + +#[cfg(test)] +impl TransportEngine { + /// Point every data centre at a local plaintext WebSocket server so the + /// whole tunnel can be exercised without reaching Telegram. + pub(crate) fn force_local_route(&self, port: u16) { + *self.forced_routes.lock().unwrap() = vec![Route { + connect_host: "127.0.0.1".to_owned(), + websocket_host: format!("127.0.0.1:{}", port), + path: "/apiws".to_owned(), + kind: RouteKind::TelegramIp, + port, + secure: false, + }]; + } } impl TransportEngine { @@ -159,17 +224,25 @@ impl TransportEngine { } fn routes_for_key(&self, key: DcKey) -> Vec { + #[cfg(test)] + { + let forced = self.forced_routes.lock().unwrap(); + if !forced.is_empty() { + return forced.clone(); + } + } + let mut routes = routes_for_dc(key.dc, key.media); let Some(destination) = telegram_ips(key.dc).first() else { return routes; }; for domain in self.worker_domains.lock().unwrap().iter() { - routes.push(Route { - connect_host: domain.clone(), - websocket_host: domain.clone(), - path: format!("/apiws?dst={}&dc={}", destination, key.dc), - kind: RouteKind::CloudflareWorker, - }); + routes.push(Route::https( + domain.clone(), + domain.clone(), + format!("/apiws?dst={}&dc={}", destination, key.dc), + RouteKind::CloudflareWorker, + )); } routes } @@ -234,23 +307,23 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec { for websocket_host in &websocket_hosts { for (index, ip) in ips.iter().enumerate() { - routes.push(Route { - connect_host: (*ip).to_owned(), - websocket_host: websocket_host.clone(), - path: "/apiws".to_owned(), - kind: if index == 0 { + routes.push(Route::https( + (*ip).to_owned(), + websocket_host.clone(), + "/apiws".to_owned(), + if index == 0 { RouteKind::TelegramIp } else { RouteKind::AlternateTelegramIp }, - }); + )); } - routes.push(Route { - connect_host: websocket_host.clone(), - websocket_host: websocket_host.clone(), - path: "/apiws".to_owned(), - kind: RouteKind::SystemDns, - }); + routes.push(Route::https( + websocket_host.clone(), + websocket_host.clone(), + "/apiws".to_owned(), + RouteKind::SystemDns, + )); } routes } @@ -258,7 +331,7 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec { async fn connect_route(route: &Route) -> Result { let tcp = tokio::time::timeout( CONNECT_TIMEOUT, - TcpStream::connect((route.connect_host.as_str(), 443)), + TcpStream::connect((route.connect_host.as_str(), route.port)), ) .await .map_err(|_| "TCP connect timeout".to_owned())? @@ -266,7 +339,8 @@ async fn connect_route(route: &Route) -> Result { tcp.set_nodelay(true) .map_err(|error| format!("TCP_NODELAY: {}", error))?; - let url = format!("wss://{}{}", route.websocket_host, route.path); + let scheme = if route.secure { "wss" } else { "ws" }; + let url = format!("{}://{}{}", scheme, route.websocket_host, route.path); let mut request = url .as_str() .into_client_request() @@ -278,6 +352,19 @@ async fn connect_route(route: &Route) -> Result { .map_err(|error| format!("WebSocket protocol header: {}", error))?, ); + if !route.secure { + // Only reachable from tests, which run a local WebSocket server without + // a certificate. Production routes are always built by `Route::https`. + return tokio::time::timeout( + CONNECT_TIMEOUT, + tokio_tungstenite::client_async(request, MaybeTlsStream::Plain(tcp)), + ) + .await + .map_err(|_| "WebSocket timeout".to_owned())? + .map(|(websocket, _)| websocket) + .map_err(|error| format!("WebSocket handshake: {}", error)); + } + // The URI host remains the real Telegram hostname even when the TCP socket // is opened to a pinned IP. Native TLS therefore validates Telegram's // certificate and sends the correct SNI. @@ -373,6 +460,202 @@ mod tests { ); } + #[test] + fn every_production_route_is_tls_on_443() { + let engine = TransportEngine::new(); + engine.set_worker_domains(&["fallback.workers.dev".to_owned()]); + for dc in [1, 2, 3, 4, 5, 203] { + for media in [false, true] { + let routes = engine.routes_for_key(DcKey { dc, media }); + assert!(!routes.is_empty(), "DC{dc} must have at least one route"); + for route in routes { + assert_eq!(route.port, 443, "{route:?}"); + assert!(route.secure, "{route:?}"); + } + } + } + } + + #[test] + fn every_data_center_offers_a_pinned_ip_and_a_dns_route() { + for dc in [1, 2, 3, 4, 5, 203] { + let routes = routes_for_dc(dc, false); + assert!( + routes + .iter() + .any(|route| route.kind == RouteKind::TelegramIp), + "DC{dc} must keep a pinned-IP route so a poisoned DNS answer is survivable" + ); + assert!( + routes + .iter() + .any(|route| route.kind == RouteKind::SystemDns), + "DC{dc} must keep a DNS route so a stale pinned IP is survivable" + ); + } + } + + #[test] + fn backoff_grows_with_each_failure_and_stops_at_the_ceiling() { + let engine = TransportEngine::new(); + let route = routes_for_dc(2, false)[0].clone(); + + // 30s doubling per failure, flattening at the 30-minute ceiling. + let expected_seconds = [30, 60, 120, 240, 480, 960, 1800, 1800, 1800, 1800]; + for (index, expected) in expected_seconds.iter().enumerate() { + let attempt = u32::try_from(index).unwrap() + 1; + let before = Instant::now(); + engine.record_failure(&route); + + let health = engine.health.lock().unwrap(); + let entry = health.routes.get(&route).unwrap(); + assert_eq!(entry.failures, attempt); + assert_eq!( + entry.retry_at.saturating_duration_since(before).as_secs(), + *expected, + "attempt {attempt} must wait {expected}s" + ); + } + assert_eq!( + *expected_seconds.last().unwrap(), + FAILURE_BACKOFF_MAX.as_secs(), + "the schedule must flatten at the declared ceiling" + ); + } + + #[test] + fn success_clears_the_penalty_accumulated_by_failures() { + let engine = TransportEngine::new(); + let key = DcKey { + dc: 2, + media: false, + }; + let route = routes_for_dc(2, false)[0].clone(); + + engine.record_failure(&route); + engine.record_failure(&route); + assert!(!engine.ordered_candidates(key).contains(&route)); + + engine.record_success(key, &route); + assert!(!engine.health.lock().unwrap().routes.contains_key(&route)); + assert_eq!(engine.ordered_candidates(key)[0], route); + } + + #[test] + fn all_routes_cooling_down_still_yields_the_soonest_retry() { + let engine = TransportEngine::new(); + let key = DcKey { + dc: 5, + media: false, + }; + let routes = routes_for_dc(5, false); + + // Fail the first route once and the rest twice, so the first one is the + // one that becomes available again soonest. + engine.record_failure(&routes[0]); + for route in &routes[1..] { + engine.record_failure(route); + engine.record_failure(route); + } + + let candidates = engine.ordered_candidates(key); + assert_eq!( + candidates.len(), + 1, + "a fully cooling table must offer exactly one retry, not give up" + ); + assert_eq!(candidates[0], routes[0]); + } + + #[test] + fn worker_domains_are_rejected_unless_they_are_plain_hostnames() { + let engine = TransportEngine::new(); + engine.set_worker_domains(&[ + "https://scheme.workers.dev".to_owned(), + "with.a/path".to_owned(), + "no-dot".to_owned(), + "-leading.workers.dev".to_owned(), + "trailing-.workers.dev".to_owned(), + "under_score.workers.dev".to_owned(), + "spaces here.dev".to_owned(), + String::new(), + "good.workers.dev".to_owned(), + ]); + + let workers: Vec<_> = engine + .routes_for_key(DcKey { + dc: 2, + media: false, + }) + .into_iter() + .filter(|route| route.kind == RouteKind::CloudflareWorker) + .collect(); + assert_eq!(workers.len(), 1, "only the valid hostname may survive"); + assert_eq!(workers[0].websocket_host, "good.workers.dev"); + } + + #[test] + fn worker_domains_are_replaced_not_appended() { + let engine = TransportEngine::new(); + let key = DcKey { + dc: 2, + media: false, + }; + engine.set_worker_domains(&["first.workers.dev".to_owned()]); + engine.set_worker_domains(&["second.workers.dev".to_owned()]); + + let workers: Vec<_> = engine + .routes_for_key(key) + .into_iter() + .filter(|route| route.kind == RouteKind::CloudflareWorker) + .collect(); + assert_eq!(workers.len(), 1); + assert_eq!(workers[0].websocket_host, "second.workers.dev"); + } + + #[test] + fn worker_is_the_last_resort() { + let engine = TransportEngine::new(); + engine.set_worker_domains(&["fallback.workers.dev".to_owned()]); + let key = DcKey { + dc: 2, + media: false, + }; + let candidates = engine.ordered_candidates(key); + assert_eq!( + candidates.last().unwrap().kind, + RouteKind::CloudflareWorker, + "third-party infrastructure must never be tried before Telegram itself" + ); + } + + #[test] + fn route_codes_and_labels_round_trip() { + for kind in [ + RouteKind::TelegramIp, + RouteKind::AlternateTelegramIp, + RouteKind::SystemDns, + RouteKind::CloudflareWorker, + ] { + assert_eq!(RouteKind::from_ui_code(kind.ui_code()), Some(kind)); + assert_eq!(route_label(kind.ui_code()), kind.label()); + } + } + + #[test] + fn code_zero_is_never_reported_as_a_working_route() { + assert_eq!(RouteKind::from_ui_code(0), None); + assert_eq!(RouteKind::from_ui_code(9), None); + for kind in [ + RouteKind::TelegramIp, + RouteKind::AlternateTelegramIp, + RouteKind::SystemDns, + RouteKind::CloudflareWorker, + ] { + assert_ne!(route_label(0), kind.label()); + } + } + #[tokio::test] #[ignore = "requires live Telegram network access"] async fn connects_to_all_production_data_centers() {