1 Commits

Author SHA1 Message Date
Никита Sonic 05ce9566a8 fix: маршруты, медиа и Worker; Android APK и статический ARM64 CLI (#60)
* fix: address routing, media, worker and platform issues

* ci: use available Android tools and verify Windows CLI artifact

* fix(gui): return a result from asynchronous stop command

* test(android): verify installed APK and proxy lifecycle on emulator

* fix(media): use native MTProto for CDN203 and retain recent diagnostics

* Fix owned WebSocket split after transport boxing

* Clarify CDN transport and connection status guarantees

* Recognize accessible Android power button labels

* chore(release): prepare 2.0.0-beta.15 with verified draft publication

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
2026-09-19 16:04:39 +03:00
78 changed files with 4752 additions and 765 deletions
+110
View File
@@ -0,0 +1,110 @@
name: Android APK
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: android-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
apk:
name: Android ${{ matrix.arch }} debug APK
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
target: aarch64
rust-target: aarch64-linux-android
abi: arm64-v8a
- arch: x86_64
target: x86_64
rust-target: x86_64-linux-android
abi: x86_64
runs-on: ubuntu-22.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- uses: android-actions/setup-android@v3
with:
packages: platform-tools
log-accepted-android-sdk-licenses: false
- name: Install Android SDK and NDK
run: |
sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;28.0.13004108"
echo "NDK_HOME=$ANDROID_HOME/ndk/28.0.13004108" >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@1.88.0
with:
targets: ${{ matrix.rust-target }}
- uses: Swatinem/rust-cache@v2
with:
key: android-${{ matrix.target }}
- name: Install frontend dependencies
run: npm ci
# Tauri regenerates only ignored machine-specific Gradle glue. The
# checked-in Activity, service, manifest and Gradle project stay intact.
- name: Build installable APK with the bundled frontend
run: |
chmod +x gen/android/gradlew
npm run tauri -- android build --debug --apk --target ${{ matrix.target }} --ci
- name: Check APK signature and packaged Rust engine
run: |
apk=$(find gen/android/app/build/outputs/apk -name '*.apk' -print -quit)
test -n "$apk"
"$ANDROID_HOME/build-tools/36.0.0/apksigner" verify "$apk"
unzip -l "$apk" | grep 'lib/${{ matrix.abi }}/libtglock_lib.so'
- uses: actions/upload-artifact@v4
with:
name: tglock-android-${{ matrix.arch }}-debug
path: gen/android/app/build/outputs/apk/**/*.apk
if-no-files-found: error
retention-days: 14
emulator:
name: Android 15 emulator smoke
needs: apk
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v4
with:
name: tglock-android-x86_64-debug
path: emulator-apk
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Launch and exercise the native app
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
arch: x86_64
target: google_apis
profile: pixel_2
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
script: timeout 180 python3 scripts/android_smoke.py emulator-apk
- uses: actions/upload-artifact@v4
if: always()
with:
name: android-emulator-smoke-evidence
path: android-smoke-evidence/
if-no-files-found: warn
retention-days: 14
+59
View File
@@ -0,0 +1,59 @@
name: Static ARM64 CLI
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
workflow_call:
permissions:
contents: read
jobs:
static-cli:
name: ARM64 musl build and runtime checks
runs-on: ubuntu-24.04-arm
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
CC_aarch64_unknown_linux_musl: musl-gcc
steps:
- uses: actions/checkout@v6
- name: Install musl toolchain
run: sudo apt-get update && sudo apt-get install -y musl-tools binutils
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-musl
- name: Test the static headless target
run: cargo test --locked --target aarch64-unknown-linux-musl --no-default-features --features cli --lib --bins
- name: Build static CLI
run: cargo build --locked --release --target aarch64-unknown-linux-musl --no-default-features --features cli --bin tglock-cli
- name: Verify architecture and static linkage
run: |
binary=target/aarch64-unknown-linux-musl/release/tglock-cli
readelf -h "$binary" | grep -q 'Machine:.*AArch64'
if readelf -l "$binary" | grep -q INTERP; then
echo 'A static router binary must not have a dynamic interpreter'; exit 1
fi
if readelf -d "$binary" | grep -q NEEDED; then
echo 'A static router binary must not require shared libraries'; exit 1
fi
cp "$binary" tglock-cli-aarch64-unknown-linux-musl
sha256sum tglock-cli-aarch64-unknown-linux-musl > tglock-cli-aarch64-unknown-linux-musl.sha256
- name: Start, persist the secret, stop and restart
run: |
binary=./tglock-cli-aarch64-unknown-linux-musl
"$binary" --version
timeout --preserve-status --signal=TERM 5 "$binary" --port 18080 --secret-file "$RUNNER_TEMP/tglock-secret" > first.log 2>&1
grep -q '127.0.0.1:18080' first.log
grep -q 'tg://proxy' first.log
test "$(stat -c '%a' "$RUNNER_TEMP/tglock-secret")" = 600
timeout --preserve-status --signal=TERM 5 "$binary" --port 18080 --secret-file "$RUNNER_TEMP/tglock-secret" > second.log 2>&1
test "$(grep -o 'secret=[0-9a-f]*' first.log)" = "$(grep -o 'secret=[0-9a-f]*' second.log)"
- uses: actions/upload-artifact@v4
with:
name: tglock-cli-aarch64-unknown-linux-musl
path: |
tglock-cli-aarch64-unknown-linux-musl
tglock-cli-aarch64-unknown-linux-musl.sha256
if-no-files-found: error
+27
View File
@@ -9,6 +9,27 @@ permissions:
contents: read contents: read
jobs: jobs:
windows-core:
name: Windows headless core
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Lint CLI and core
run: cargo clippy --locked --no-default-features --features cli --all-targets -- -D warnings
- name: Test CLI and core
run: cargo test --locked --no-default-features --features cli --lib --bins
- name: Build CLI for manual Windows verification
run: cargo build --locked --no-default-features --features cli --bin tglock-cli
- uses: actions/upload-artifact@v4
with:
name: tglock-cli-windows-debug
path: target/debug/tglock-cli.exe
if-no-files-found: error
retention-days: 14
rust: rust:
name: Rust checks name: Rust checks
runs-on: macos-latest runs-on: macos-latest
@@ -210,3 +231,9 @@ jobs:
- name: Type-check and build - name: Type-check and build
run: npm ci && npm run build run: npm ci && npm run build
- name: Worker contract and failure-path tests
run: npm run test:worker
- name: Offline protocol probe parser checks
run: node scripts/probe_proxy.mjs --self-test
+24 -1
View File
@@ -111,7 +111,8 @@ jobs:
- **Сервер, контейнер, машина без монитора** — `tglock-cli-*`: то же самое, но без графического интерфейса - **Сервер, контейнер, машина без монитора** — `tglock-cli-*`: то же самое, но без графического интерфейса
Если Windows ругается на файл — [почему так и что с этим делать](https://github.com/by-sonic/tglock#-антивирус-ругается-smartscreen-предупреждает-virustotal-показывает-детекты). Если Windows ругается на файл — [почему так и что с этим делать](https://github.com/by-sonic/tglock#-антивирус-ругается-smartscreen-предупреждает-virustotal-показывает-детекты).
releaseDraft: false # Publish only after every platform, CLI and exact-commit APK is verified.
releaseDraft: true
prerelease: false prerelease: false
args: ${{ matrix.args }} args: ${{ matrix.args }}
@@ -144,6 +145,27 @@ jobs:
test -f "$exe" || { echo "нет /usr/bin/tglock:"; dpkg-deb -c "$deb" | grep '/bin/'; exit 1; } test -f "$exe" || { echo "нет /usr/bin/tglock:"; dpkg-deb -c "$deb" | grep '/bin/'; exit 1; }
python3 scripts/verify_bundle_binary.py "$exe" python3 scripts/verify_bundle_binary.py "$exe"
arm64-build:
needs: guard
uses: ./.github/workflows/arm64.yml
permissions:
contents: read
arm64-publish:
name: Attach verified ARM64 musl CLI
needs: [publish, arm64-build]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: tglock-cli-aarch64-unknown-linux-musl
path: arm64-cli
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
draft: true
files: arm64-cli/*
cli: cli:
name: Headless CLI ${{ matrix.platform }} name: Headless CLI ${{ matrix.platform }}
needs: publish needs: publish
@@ -209,5 +231,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
draft: true
prerelease: false prerelease: false
files: ${{ matrix.asset }} files: ${{ matrix.asset }}
+2 -1
View File
@@ -2,7 +2,8 @@
/dist /dist
/dist-ui /dist-ui
/node_modules /node_modules
/gen /gen/*
!/gen/android/
/tools /tools
*.zip *.zip
.claude/ .claude/
Generated
+576 -125
View File
@@ -108,6 +108,137 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "atk" name = "atk"
version = "0.18.2" version = "0.18.2"
@@ -203,6 +334,19 @@ dependencies = [
"objc2", "objc2",
] ]
[[package]]
name = "blocking"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.4" version = "8.0.4"
@@ -448,6 +592,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.1" version = "0.18.1"
@@ -483,7 +636,7 @@ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"core-foundation", "core-foundation",
"core-graphics-types", "core-graphics-types",
"foreign-types 0.5.0", "foreign-types",
"libc", "libc",
] ]
@@ -833,6 +986,33 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -860,6 +1040,26 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.5.0"
@@ -913,15 +1113,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -929,7 +1120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared 0.3.1", "foreign-types-shared",
] ]
[[package]] [[package]]
@@ -943,12 +1134,6 @@ dependencies = [
"syn 3.0.3", "syn 3.0.3",
] ]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@@ -996,6 +1181,19 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.33" version = "0.3.33"
@@ -1350,6 +1548,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@@ -1939,23 +2143,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -2236,59 +2423,27 @@ version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
dependencies = [ dependencies = [
"dunce",
"is-wsl", "is-wsl",
"libc", "libc",
] ]
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -2314,6 +2469,12 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -2337,6 +2498,16 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -2402,6 +2573,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.33"
@@ -2447,6 +2629,20 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -2605,6 +2801,19 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -2708,6 +2917,20 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -2736,6 +2959,40 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -2751,15 +3008,6 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -2817,29 +3065,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.36.1" version = "0.36.1"
@@ -3183,6 +3408,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3429,6 +3660,44 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.19",
"url",
"windows",
"zbus",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -3553,24 +3822,27 @@ dependencies = [
[[package]] [[package]]
name = "tglock" name = "tglock"
version = "2.0.0-beta.14" version = "2.0.0-beta.15"
dependencies = [ dependencies = [
"aes", "aes",
"cipher", "cipher",
"clap", "clap",
"ctr", "ctr",
"futures-util", "futures-util",
"native-tls",
"open",
"rand", "rand",
"rcgen",
"rustls",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-opener",
"tokio", "tokio",
"tokio-rustls",
"tokio-tungstenite", "tokio-tungstenite",
"toml 0.8.2", "toml 0.8.2",
"webpki-roots 0.26.11",
] ]
[[package]] [[package]]
@@ -3696,12 +3968,12 @@ dependencies = [
] ]
[[package]] [[package]]
name = "tokio-native-tls" name = "tokio-rustls"
version = "0.3.1" version = "0.26.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
dependencies = [ dependencies = [
"native-tls", "rustls",
"tokio", "tokio",
] ]
@@ -3713,10 +3985,12 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"log", "log",
"native-tls", "rustls",
"rustls-pki-types",
"tokio", "tokio",
"tokio-native-tls", "tokio-rustls",
"tungstenite", "tungstenite",
"webpki-roots 0.26.11",
] ]
[[package]] [[package]]
@@ -3904,9 +4178,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
@@ -3956,8 +4242,9 @@ dependencies = [
"http", "http",
"httparse", "httparse",
"log", "log",
"native-tls",
"rand", "rand",
"rustls",
"rustls-pki-types",
"sha1", "sha1",
"thiserror 1.0.69", "thiserror 1.0.69",
"utf-8", "utf-8",
@@ -3975,6 +4262,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "unic-char-property" name = "unic-char-property"
version = "0.9.0" version = "0.9.0"
@@ -4028,6 +4326,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4083,12 +4387,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@@ -4289,6 +4587,24 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -4488,6 +4804,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -4756,6 +5081,15 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
@@ -4779,6 +5113,76 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 3.0.3",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.55" version = "0.8.55"
@@ -4820,6 +5224,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"
@@ -4858,3 +5268,44 @@ name = "zmij"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zvariant"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 3.0.3",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.3",
"winnow 1.0.4",
]
+11 -6
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tglock" name = "tglock"
version = "2.0.0-beta.14" version = "2.0.0-beta.15"
edition = "2021" edition = "2021"
rust-version = "1.88" rust-version = "1.88"
description = "Telegram unblock via local WebSocket tunnel" description = "Telegram unblock via local WebSocket tunnel"
@@ -12,7 +12,7 @@ default = ["gui"]
# The desktop GUI. Turning it off drops Tauri, the system WebView and the # 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 # 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. # machines without a GPU or monitor able to build and run TGLock at all.
gui = ["dep:tauri", "dep:tauri-build", "dep:open"] gui = ["dep:tauri", "dep:tauri-build", "dep:tauri-plugin-opener"]
# The headless binary. Deliberately NOT in `default`. # The headless binary. Deliberately NOT in `default`.
# #
# When both binaries exist in one build, the Tauri bundler picks the wrong one # When both binaries exist in one build, the Tauri bundler picks the wrong one
@@ -23,8 +23,9 @@ gui = ["dep:tauri", "dep:tauri-build", "dep:open"]
cli = ["dep:clap", "dep:toml"] cli = ["dep:clap", "dep:toml"]
[lib] [lib]
name = "tglock" name = "tglock_lib"
path = "src/lib.rs" path = "src/lib.rs"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]] [[bin]]
name = "tglock" name = "tglock"
@@ -38,7 +39,7 @@ required-features = ["cli"]
[dependencies] [dependencies]
tauri = { version = "2", features = [], optional = true } tauri = { version = "2", features = [], optional = true }
open = { version = "5", optional = true } tauri-plugin-opener = { version = "2", optional = true }
clap = { version = "4", features = ["derive"], optional = true } clap = { version = "4", features = ["derive"], optional = true }
# Файл конфигурации CLI. TOML, а не JSON, потому что файл правят руками и в нём # Файл конфигурации CLI. TOML, а не JSON, потому что файл правят руками и в нём
# нужны комментарии. # нужны комментарии.
@@ -54,8 +55,10 @@ tokio = { version = "1", features = [
"sync", "sync",
"signal", "signal",
] } ] }
tokio-tungstenite = { version = "0.24", features = ["native-tls"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
native-tls = "0.2" # Explicit ring provider supports Android and static musl without OpenSSL.
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
webpki-roots = "0.26"
futures-util = "0.3" futures-util = "0.3"
aes = "0.8" aes = "0.8"
ctr = "0.9" ctr = "0.9"
@@ -67,6 +70,8 @@ rand = "0.8"
tauri-build = { version = "2", features = [], optional = true } tauri-build = { version = "2", features = [], optional = true }
[dev-dependencies] [dev-dependencies]
rcgen = "0.13"
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
# `start_paused` в тестах: таймаут ожидания запроса от клиента — десять секунд, # `start_paused` в тестах: таймаут ожидания запроса от клиента — десять секунд,
# и ждать их по-настоящему в тесте нельзя. В сборку не попадает: dev-зависимости # и ждать их по-настоящему в тесте нельзя. В сборку не попадает: dev-зависимости
# участвуют только в тестах. # участвуют только в тестах.
+24 -10
View File
@@ -58,6 +58,10 @@
TGLock — это **локальный прокси** на твоём компьютере: принимает и MTProto, и SOCKS5. Он перехватывает соединения Telegram, заворачивает их в WebSocket и отправляет на веб-инфраструктуру Telegram — по нескольким маршрутам сразу, переключаясь на следующий, если текущий перестал отвечать. Провайдер видит обычный HTTPS. TGLock — это **локальный прокси** на твоём компьютере: принимает и MTProto, и SOCKS5. Он перехватывает соединения Telegram, заворачивает их в WebSocket и отправляет на веб-инфраструктуру Telegram — по нескольким маршрутам сразу, переключаясь на следующий, если текущий перестал отвечать. Провайдер видит обычный HTTPS.
Для CDN DC203 есть отдельный маршрут MTProto TCP к его закреплённому адресу:
этот CDN может не принимать WebSocket. Если его IP блокируется, остаётся
настроенный пользователем Worker. Диагностика показывает выбранный транспорт.
**Кому подойдёт:** **Кому подойдёт:**
- 📱 Telegram открывается через раз, сообщения уходят с задержкой, фото и видео не грузятся - 📱 Telegram открывается через раз, сообщения уходят с задержкой, фото и видео не грузятся
@@ -70,7 +74,7 @@ TGLock — это **локальный прокси** на твоём компь
-**Голосовые и видеозвонки.** Они идут по UDP, а TGLock проксирует только TCP. Со звонками ничего не изменится -**Голосовые и видеозвонки.** Они идут по UDP, а TGLock проксирует только TCP. Со звонками ничего не изменится
-**Всё, кроме Telegram.** YouTube, Discord, Instagram, ChatGPT работать не начнут: TGLock разворачивает только MTProto — протокол, который больше нигде не используется -**Всё, кроме Telegram.** YouTube, Discord, Instagram, ChatGPT работать не начнут: TGLock разворачивает только MTProto — протокол, который больше нигде не используется
- **Android и iOS.** Своего приложения нет. Телефон можно подключить к TGLock на компьютере через [LAN-режим](#-lan-режим--один-прокси-на-всю-квартиру) - **Android:** экспериментальный APK и инструкция проверки — [docs/ANDROID.md](docs/ANDROID.md). Работа в фоне на физических устройствах требует проверки. **iOS:** отдельного приложения нет; доступен [LAN-режим](#-lan-режим--один-прокси-на-всю-квартиру).
**Чем отличается от VPN:** TGLock работает **только с Telegram**. Остальной трафик идёт напрямую — ничего не замедляется, мобильный трафик не расходуется впустую. **Чем отличается от VPN:** TGLock работает **только с Telegram**. Остальной трафик идёт напрямую — ничего не замедляется, мобильный трафик не расходуется впустую.
@@ -90,6 +94,10 @@ TGLock — это **локальный прокси** на твоём компь
Все сборки весят единицы мегабайт. Исключение — `.AppImage`: он несёт своё окружение и поэтому крупный. Все сборки весят единицы мегабайт. Исключение — `.AppImage`: он несёт своё окружение и поэтому крупный.
Дополнительные проверочные сборки доступны в артефактах GitHub Actions:
[Android ARM64 APK](docs/ANDROID.md) и [статический ARM64 CLI для OpenWrt](docs/OPENWRT.md).
Они не означают, что выпущен новый релиз.
> **🖥 `tglock-cli`** — тот же туннель без графического интерфейса, одним бинарём. Нужен там, где окно не создаётся: сервер, контейнер, виртуалка, машина без монитора. Доступен начиная с `v2.0.0-beta.2`. Если ты скачал beta.2 и приложение не открывалось — это была ошибка сборки, исправлено в beta.3. Подробности — [ниже](#-без-графического-интерфейса-tglock-cli). > **🖥 `tglock-cli`** — тот же туннель без графического интерфейса, одним бинарём. Нужен там, где окно не создаётся: сервер, контейнер, виртуалка, машина без монитора. Доступен начиная с `v2.0.0-beta.2`. Если ты скачал beta.2 и приложение не открывалось — это была ошибка сборки, исправлено в beta.3. Подробности — [ниже](#-без-графического-интерфейса-tglock-cli).
### 🛡 Антивирус ругается, SmartScreen предупреждает, VirusTotal показывает детекты ### 🛡 Антивирус ругается, SmartScreen предупреждает, VirusTotal показывает детекты
@@ -296,12 +304,12 @@ Telegram Desktop / mobile (через LAN)
1. **Локальный прокси** принимает соединения Telegram: и MTProto (по ссылке `tg://proxy`), и SOCKS5. 1. **Локальный прокси** принимает соединения Telegram: и MTProto (по ссылке `tg://proxy`), и SOCKS5.
2. Из первых 64 байт `obfuscated2`-пакета **расшифровывается номер DC** — AES-256-CTR, ключ в байтах `[8..40]`, IV в `[40..56]`, индекс DC — `i16` в `[60..62]`. Отрицательное значение означает медиа-соединение. 2. Из первых 64 байт `obfuscated2`-пакета **расшифровывается номер DC** — AES-256-CTR, ключ в байтах `[8..40]`, IV в `[40..56]`, индекс DC — `i16` в `[60..62]`. Отрицательное значение означает медиа-соединение.
3. Трафик заворачивается в **WebSocket** к `kws{dc}.web.telegram.org` — это тот же домен, через который работает Telegram Web в браузере. 3. Основной трафик заворачивается в **WebSocket** к `kws{dc}.web.telegram.org` — это тот же домен, через который работает Telegram Web в браузере. Для CDN DC203 сначала используется обычный обфусцированный MTProto TCP к закреплённому CDN IP; его WebSocket endpoint может быть недоступен.
4. **Маршрут выбирается каскадом**, и это главное отличие 2.0 от первой версии. Один домен может резолвиться в недоступный адрес, поэтому по очереди пробуются: закреплённые IP Telegram, их дублёры `kwsN-1`, системный DNS и — если ты его настроил — твой собственный Cloudflare Worker. Упавший маршрут уходит в cooldown с удвоением задержки, удачный запоминается для этого DC. Системный DNS и файл `hosts` при этом **не изменяются**: TCP-соединение идёт на закреплённый IP, а TLS SNI и заголовок `Host` остаются настоящими, поэтому сертификат Telegram проверяется как обычно. 4. **Маршруты проверяются с ограниченным параллелизмом:** сначала запомненный или основной Telegram-маршрут, затем настроенный Worker и резервные адреса. Зависший IP не задерживает все остальные попытки. Упавший маршрут уходит в cooldown с удвоением задержки; при отказе всех маршрутов новые подключения соблюдают эту паузу. Для CDN DC203 сохраняется его собственный адрес. Системный DNS и файл `hosts` **не изменяются**: TCP-соединение идёт на выбранный IP, а TLS SNI и заголовок `Host` остаются настоящими, поэтому сертификат Telegram проверяется как обычно.
5. Провайдер видит **TLS-handshake к `web.telegram.org`** — легитимный HTTPS, MTProto в нём не виден. 5. На прямых WebSocket-маршрутах провайдер видит **TLS-handshake к `web.telegram.org`**; на Worker-маршруте — домен Worker. Прямой CDN TCP не использует TLS и остаётся доступным для блокировки по IP.
6. Весь остальной трафик (не-Telegram) проходит **напрямую** — без замедления. 6. Весь остальной трафик (не-Telegram) проходит **напрямую** — без замедления.
> Интерфейс различает три состояния и не выдаёт одно за другое: **«Защита включена»** — локальный порт открыт, туннеля пока нет; **«Ищем новый маршрут»** — попытки были неудачными, идёт перебор; **«Telegram на связи»** — есть установленный туннель, то есть WebSocket-рукопожатие уже прошло. Смешивание первого и третьего состояния и было основной причиной жалоб «прокси подключён, а Telegram не работает». > Интерфейс различает три состояния: **«Защита включена»** — локальный порт открыт, туннеля пока нет; **«Ищем новый маршрут»** — попытки были неудачными, идёт перебор; **«Telegram на связи»** — есть установленный транспорт: WebSocket после upgrade либо открытое TCP-соединение с CDN. Само это состояние ещё не подтверждает ответ MTProto, авторизацию аккаунта или передачу сообщений.
📖 **Архитектура 2.0, различение протоколов и честный список ограничений** — [docs/ARCHITECTURE_V2.md](docs/ARCHITECTURE_V2.md). Запасной маршрут через свой Cloudflare Worker, со скриптом и пошаговой установкой — [docs/CLOUDFLARE_WORKER.md](docs/CLOUDFLARE_WORKER.md). Разбор всех issue и того, что в них было обещано зря — [docs/ISSUE_AUDIT.md](docs/ISSUE_AUDIT.md). Черновик статьи про переход v1 → v2 лежит в [HABR.md](HABR.md) — цифры там описывают код на момент написания, документацией он не является. 📖 **Архитектура 2.0, различение протоколов и честный список ограничений** — [docs/ARCHITECTURE_V2.md](docs/ARCHITECTURE_V2.md). Запасной маршрут через свой Cloudflare Worker, со скриптом и пошаговой установкой — [docs/CLOUDFLARE_WORKER.md](docs/CLOUDFLARE_WORKER.md). Разбор всех issue и того, что в них было обещано зря — [docs/ISSUE_AUDIT.md](docs/ISSUE_AUDIT.md). Черновик статьи про переход v1 → v2 лежит в [HABR.md](HABR.md) — цифры там описывают код на момент написания, документацией он не является.
@@ -356,12 +364,16 @@ TGLock же отправляет трафик на **`web.telegram.org`** — о
<details> <details>
<summary><b>Работает ли на iPhone или Android?</b></summary> <summary><b>Работает ли на iPhone или Android?</b></summary>
Своего приложения под Android и iOS нет — TGLock только для desktop. Есть два обходных пути: Для Android есть [экспериментальный APK](docs/ANDROID.md): локальный MTProto-прокси
на телефоне, без VpnService. Для iOS отдельного приложения нет. Для обеих платформ
также доступны два варианта:
1. **LAN-режим на компьютере.** Включи галочку LAN, и в настройках Telegram на телефоне укажи прокси с IP компьютера. Работает, пока компьютер включён и телефон в той же сети. 1. **LAN-режим на компьютере.** Включи галочку LAN, и в настройках Telegram на телефоне укажи прокси с IP компьютера. Работает, пока компьютер включён и телефон в той же сети.
2. **`tglock-cli` на своём VPS.** Headless-бинарь запускается как systemd-сервис, слушает `0.0.0.0` и пропускает только адреса Telegram — тогда телефон работает откуда угодно, а не только из дома. См. [раздел про CLI](#-без-графического-интерфейса-tglock-cli). 2. **`tglock-cli` на своём VPS.** Headless-бинарь запускается как systemd-сервис, слушает `0.0.0.0` и пропускает только адреса Telegram — тогда телефон работает откуда угодно, а не только из дома. См. [раздел про CLI](#-без-графического-интерфейса-tglock-cli).
Поддержка Android обсуждается в [#9](https://github.com/by-sonic/tglock/issues/9), сроков нет: Tauri 2 умеет собирать под Android, но перехват трафика там делается через `VpnService` — это другая архитектура, а не пересборка того же кода. Проверка Android продолжается в [#9](https://github.com/by-sonic/tglock/issues/9).
Особенно важна работа после сворачивания на HyperOS и других прошивках с
ограничениями фоновых процессов; сборка APK сама по себе этого не проверяет.
</details> </details>
<details> <details>
@@ -449,7 +461,7 @@ TGLock определяет Telegram по IP получателя и завор
| **Tauri 2** | Нативная оболочка для GUI. Опциональна: за фичей `gui`, в CLI не входит | | **Tauri 2** | Нативная оболочка для GUI. Опциональна: за фичей `gui`, в CLI не входит |
| **TypeScript + Vite** | Интерфейс, внутренняя навигация и строгая типизация | | **TypeScript + Vite** | Интерфейс, внутренняя навигация и строгая типизация |
| **tokio** | Async I/O, обработка сигналов для корректной остановки сервиса | | **tokio** | Async I/O, обработка сигналов для корректной остановки сервиса |
| **tokio-tungstenite** | WebSocket-клиент с TLS поверх `native-tls` | | **tokio-tungstenite** | WebSocket-клиент с rustls, ring и встроенными WebPKI roots |
| **aes** + **ctr** | Расшифровка MTProto `obfuscated2` init-пакета | | **aes** + **ctr** | Расшифровка MTProto `obfuscated2` init-пакета |
| **clap** | Разбор аргументов `tglock-cli` | | **clap** | Разбор аргументов `tglock-cli` |
@@ -488,7 +500,7 @@ cargo test --all-targets
cargo test --no-default-features --features cli --lib --bins cargo test --no-default-features --features cli --lib --bins
``` ```
Тестов 59: разбор `obfuscated2`, каскад маршрутов и его cooldown, протокольные отказы SOCKS5, устойчивость секрета к перезапуску, плюс сквозной тест туннеля против мок-сервера, который реализует сторону Telegram и проверяет, что до неё доходит ровно тот открытый текст, который отправил клиент. Единственный тест с пометкой `#[ignore]` — тот, что требует живой сети. Проверяются `obfuscated2`, выбор маршрутов и cooldown, отказы SOCKS5, сохранение и миграция секрета, двунаправленный туннель, TLS-сертификаты и SNI. `npm run test:worker` проверяет скрипт Cloudflare. Android и ARM64 CLI собираются отдельными заданиями; результаты и ограничения проверки перечислены в [аудите issues](docs/ISSUES_2026-09-19.md). Тесты с `#[ignore]` требуют живой сети.
### Свой релиз через GitHub Actions ### Свой релиз через GitHub Actions
@@ -505,7 +517,9 @@ cargo test --no-default-features --features cli --lib --bins
- **Присылаешь PR** — перед отправкой прогони проверки выше, они те же, что в CI. Небольшие PR ревьюятся быстрее. - **Присылаешь PR** — перед отправкой прогони проверки выше, они те же, что в CI. Небольшие PR ревьюятся быстрее.
- **Не работает после релиза** — это регрессия, пиши сразу, такие вещи в приоритете. - **Не работает после релиза** — это регрессия, пиши сразу, такие вещи в приоритете.
Известные ограничения, о которых не нужно открывать issue: звонки (UDP), сервисы кроме Telegram (только MTProto), Android и iOS (обсуждается в [#9](https://github.com/by-sonic/tglock/issues/9)). Известные ограничения: звонки (UDP), сервисы кроме Telegram (только MTProto),
отсутствие отдельного приложения iOS. Android пока экспериментальный;
результаты проверки устройств собираются в [#9](https://github.com/by-sonic/tglock/issues/9).
--- ---
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "TGLock desktop window", "description": "TGLock application window",
"windows": ["main"], "windows": ["main"],
"permissions": ["core:default"] "permissions": ["core:default"]
} }
+76
View File
@@ -0,0 +1,76 @@
# Android (experimental)
Issue #9 is implemented as a Tauri Android application sharing the current Rust
proxy engine and UI with the desktop application. The old Android PR scaffold is
retained, but its stale engine and desktop code are not imported.
## Install a test APK
Open this PR's **Android APK** check, then the workflow run's **Artifacts** section.
Download `tglock-android-arm64-debug`, unzip it, and install the `.apk` on an
ARM64 Android 7.0+ device. GitHub requires signing in to download CI artifacts.
No compiler or Android Studio is needed on the phone. The artifact expires after
14 days; maintainers can rerun the workflow to create a fresh build.
This is an automatically debug-signed test build, not a Play Store release.
Different CI runs can use different debug signing keys: if Android rejects an
update because the signatures differ, uninstall the previous test build first.
Uninstalling deletes settings and changes the proxy secret, so reconnect Telegram
with the new link. A future production release needs a stable signing key.
1. Open TGLock and press **Включить защиту**.
2. Accept the proxy in Telegram when prompted. If opening Telegram fails, return
to TGLock and use **Открыть Telegram** or **Скопировать ссылку**.
3. Keep LAN access off when Telegram runs on this same phone (`127.0.0.1`).
4. To stop, return through the ongoing notification and press **Выключить**.
## Lifecycle and limitations
The native foreground service starts only for an explicitly started proxy and
stops when its Rust accept loop finishes, including a normal Stop action. It is
not stopped by Activity destruction or rotation. Android 14+ declares the
`specialUse` service type, its dedicated permission, and a subtype describing the
user-controlled local proxy. Notification permission denial does not prevent the
foreground service from running; Android still exposes it in its task manager.
The service uses `START_NOT_STICKY`: after Android kills the process or the user
force-stops it, it does not restart a notification without a Rust engine. Open
TGLock and enable protection again. No boot receiver or automatic background
restart is installed. Vendor battery management and network changes may still
interrupt connections. The app does not claim to be a device-wide VPN.
The proxy secret lives in the app's private configuration directory. Desktop
installs migrate an existing valid legacy secret, preserving saved Telegram
links. The link-copy control intentionally contains this secret; the public
report-copy control includes only counters, port, route and mode.
## Build and verification
CI uses Java 17, Android SDK 36, NDK 28, Rust 1.88, and the locked npm/Rust
manifests. `npm run tauri -- android build --debug --apk --target aarch64 --ci`
bundles the frontend inside a signed APK; no development web server is required.
CI verifies the signature and the presence of each architecture's Rust library.
The x86_64 build is installed on an Android 15 emulator. The bounded smoke checks
Activity launch, process survival, and crash/ANR logs. When UIAutomator exposes
the WebView buttons, it also checks Start, five seconds in the background, Stop,
restart, and explicit force-stop/relaunch. It verifies the foreground service and
performs a real SOCKS5 greeting through ADB port forwarding to the Rust listener.
If buttons are inaccessible after a bounded wait, CI explicitly reports the
lifecycle checks as skipped; a launch-only pass is not lifecycle evidence.
`android-emulator-smoke-evidence` retains the exact result, UI dumps, service
state and logcat. This does not test automatic low-memory eviction, battery
behavior, Telegram connectivity, or a physical phone.
Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's
machine-specific generated glue, SDK paths, native build output and signing files
remain ignored. Run the same build command locally after installing Tauri's
[Android prerequisites](https://v2.tauri.app/start/prerequisites/#android).
No physical-phone or Telegram end-to-end test has been performed by this change.
Before promoting it beyond an experimental APK, test Android 13 notification
permission grant/denial, Android 14+ service startup, Start/Stop/restart, switching
to Telegram for at least 10 minutes, rotation, Activity recreation, process death,
Wi-Fi/mobile-data handover, and restoration with the same persisted secret.
Native bridging follows [Tauri mobile plugins](https://v2.tauri.app/develop/plugins/develop-mobile/)
and uses [Tauri opener](https://v2.tauri.app/plugin/opener/) for `tg://` links.
+28 -10
View File
@@ -201,22 +201,38 @@ Ping приходит в читающую половину, а отвечает
1. сохранённый успешный маршрут; 1. сохранённый успешный маршрут;
2. точный Telegram IP с `kwsN` или `kwsN-1` в TLS SNI и WebSocket Host; 2. точный Telegram IP с `kwsN` или `kwsN-1` в TLS SNI и WebSocket Host;
3. дополнительный Telegram IP, если он определён; 3. явно настроенный пользователем Cloudflare Worker;
4. системный DNS; 4. дополнительные Telegram IP и варианты хоста;
5. явно настроенный пользователем Cloudflare Worker. 5. системный DNS (кроме CDN DC203).
Поддерживаются DC1–5 и media/CDN DC203. DC203 использует WebSocket-host DC2, Поддерживаются DC1–5 и CDN DC203. Для DC203 первым используется обычный MTProto
но подключается к собственному IP. TCP к закреплённому `91.105.192.100:443`; этот сервер может принимать MTProto,
не принимая TLS/WebSocket. Резерв через Worker соединяется с тем же IP.
WebSocket-попытки также сохраняют закреплённый адрес. DNS fallback на DC2 для DC203 запрещён:
успешный WebSocket handshake с другим DC не доставляет CDN-запрос в нужный
датацентр. Worker также получает именно IP DC203.
После ошибки маршрут получает exponential cooldown от 30 секунд до 30 минут. После ошибки маршрут получает exponential cooldown от 30 секунд до 30 минут.
Успешный маршрут становится первым для следующего соединения того же DC и Успешный маршрут становится первым для следующего соединения того же DC и
типа трафика. типа трафика. Попытки запускаются с интервалом 250 мс, не больше трёх
одновременно на соединение; остальные отменяются после первого успеха.
Если все маршруты на паузе, возвращается причина и время до повторной
попытки. Новое подключение клиента не обходит cooldown. Ошибка upstream
после handshake также снимает предпочтение маршрута и добавляет cooldown.
## TLS policy ## TLS policy
Проверка сертификатов и hostname никогда не отключается. При подключении к Проверка сертификатов и hostname никогда не отключается. При подключении к
заданному Telegram IP TCP destination отделён от URI host: TLS продолжает заданному Telegram IP TCP destination отделён от URI host: TLS продолжает
проверять сертификат настоящего `kws*.web.telegram.org`. проверять сертификат настоящего `kws*.web.telegram.org`. Используется rustls
с провайдером ring и встроенными WebPKI roots; системный OpenSSL для ядра
и CLI не нужен. Собственные корневые сертификаты ОС автоматически не
подхватываются. Тесты проверяют доверенный сертификат, SNI, неверное имя
хоста и недоверенного издателя.
Это относится к TLS-маршрутам. Прямой CDN TCP — отдельный транспорт MTProto,
а не TLS с отключённой проверкой. Он доступен только для точного назначения
DC203. Для произвольных адресов такой обход политики не добавляется.
Cloudflare Worker принимается только как пользовательская настройка. TGLock Cloudflare Worker принимается только как пользовательская настройка. TGLock
не загружает и не скрывает публичные списки чужих доменов. не загружает и не скрывает публичные списки чужих доменов.
@@ -229,9 +245,11 @@ Worker должен принимать WebSocket на:
/apiws?dst=<telegram-ip>&dc=<dc-id> /apiws?dst=<telegram-ip>&dc=<dc-id>
``` ```
и проксировать бинарные frames в TCP `<telegram-ip>:443`. Рекомендуется и проксировать бинарные frames в TCP `<telegram-ip>:443`. Ответ 101 отправляется
добавить собственную авторизацию до стабильного релиза; поэтому Worker после открытия upstream TCP; таймаут/отказ — HTTP 504/502. Список назначений
остаётся расширенной опцией alpha-версии. фиксирован, текстовые сообщения отклоняются. Очередь входящих записей ограничена
1 МиБ и 256 сообщениями. Авторизация токеном требует собственного клиента:
штатный TGLock токен Worker пока не передаёт.
Запись в сокет Telegram обязана быть последовательной: следующий чанк уходит Запись в сокет Telegram обязана быть последовательной: следующий чанк уходит
после того, как записан предыдущий, и только когда писатель к этому готов после того, как записан предыдущий, и только когда писатель к этому готов
+11 -6
View File
@@ -37,11 +37,13 @@
### Проверка, что воркер жив ### Проверка, что воркер жив
Открой в браузере `https://tglock.имя.workers.dev/apiws`. Должно вернуться `expected a websocket upgrade` — это правильный ответ: значит код развёрнут и работает, просто браузер пришёл обычным запросом. Открой в браузере `https://tglock.имя.workers.dev/apiws`. Должно вернуться `expected a websocket upgrade` (HTTP 426). Это проверяет только публикацию скрипта. Соединение Worker → Telegram обычный GET **не проверяет**.
Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю. Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю.
> **Разворачивал воркер до 2.0.0-beta.14 — обнови скрипт.** В прежней версии запись в сокет Telegram шла без ожидания предыдущей и без backpressure. Пока в клиенте голодала отправка, через воркер не проходило настоящего потока вверх и это не проявлялось; после того как голодание починили в beta.12, поток появился. > **Обнови скрипт при переходе с beta.14.** Теперь Worker ждёт открытия TCP к Telegram перед ответом 101: отказ возвращает 502, таймаут за 3 секунды — 504. Очередь записи ограничена 1 МиБ / 256 сообщениями; переполнение закрывает соединение с кодом 1009, ошибки сокета — 1011. Старый Worker мог показать успешное соединение ещё до попытки подключения к Telegram и не ограничивал очередь сообщений.
Для установки через Wrangler есть [`worker/wrangler.toml`](../worker/wrangler.toml). Из каталога `worker` можно выполнить `npx wrangler deploy` в своём Cloudflare-аккаунте. Эта команда публикует Worker; локальные тесты ничего не публикуют.
## Подключение в TGLock ## Подключение в TGLock
@@ -54,13 +56,13 @@ tglock-cli --worker tglock.имя.workers.dev
tglock-cli --worker первый.workers.dev --worker второй.workers.dev tglock-cli --worker первый.workers.dev --worker второй.workers.dev
``` ```
Worker всегда пробуется **последним**, после всех маршрутов Telegram. Пока обычные маршруты живы, трафик через него не пойдёт, и это осознанно: чужая инфраструктура в цепочке — это лишнее звено, а не улучшение. Настроенный Worker участвует в ограниченном параллельном переборе маршрутов: его проверка начинается вслед за первым Telegram-маршрутом, без ожидания всех таймаутов. Успешный маршрут запоминается. При быстром ответе Telegram Worker не нужен; при медленном прямом маршруте может победить Worker. Поэтому добавляй только свой домен или домен доверенного оператора.
## Ограничение доступа ## Ограничение доступа
Адрес воркера сам по себе секрет, но лучше поставить токен: **Settings → Variables → Add variable**, имя `TGLOCK_TOKEN`, значение — любая длинная строка. Адрес Worker не является механизмом авторизации. Для своего клиента можно задать секрет `TGLOCK_TOKEN` в настройках Cloudflare и передавать его параметром `token`.
Пока переменная не задана, проверка токена выключена. Когда задана — воркер начнёт отвечать `403` без параметра `?token=`. Клиент TGLock этот параметр пока не отправляет, так что включать токен есть смысл, если ты правишь и сам скрипт, и адрес. Штатный клиент TGLock параметр `token` пока не отправляет. **Не задавай TGLOCK_TOKEN для штатного клиента:** это приведёт к HTTP 403. При отсутствии переменной любой, кто знает домен, может использовать Worker для разрешённых адресов Telegram и расходовать лимиты твоего аккаунта.
Независимо от токена воркер соединяется **только** с семью адресами Telegram, которые запрашивает TGLock. Любой другой `dst` получает `403`, так что открытым TCP-прокси он не станет. Независимо от токена воркер соединяется **только** с семью адресами Telegram, которые запрашивает TGLock. Любой другой `dst` получает `403`, так что открытым TCP-прокси он не станет.
@@ -76,6 +78,7 @@ Sec-WebSocket-Protocol: binary
- `dst` — адрес Telegram, к которому нужно подключиться по TCP на порт 443; - `dst` — адрес Telegram, к которому нужно подключиться по TCP на порт 443;
- `dc` — номер датацентра, для логов; - `dc` — номер датацентра, для логов;
- **подпротокол `binary` обязательно нужно подтвердить в ответе** — без этого клиент разорвёт рукопожатие; - **подпротокол `binary` обязательно нужно подтвердить в ответе** — без этого клиент разорвёт рукопожатие;
- ответ 101 означает, что TCP к разрешённому Telegram IP уже открыт; HTTP 502/504 означает отказ/таймаут этого подключения;
- дальше бинарные frames пересылаются в обе стороны без изменений; - дальше бинарные frames пересылаются в обе стороны без изменений;
- TLS до самого воркера обеспечивает Cloudflare. - TLS до самого воркера обеспечивает Cloudflare.
@@ -85,4 +88,6 @@ Sec-WebSocket-Protocol: binary
Скрипт написан по контракту, вычитанному из исходников клиента, и путь с параметрами закреплён тестом `connects_through_the_documented_worker_contract` — он поднимает локальный сервер, который ведёт себя ровно так, как описано выше, и проверяет, что туннель через него поднимается и данные доходят в обе стороны. Скрипт написан по контракту, вычитанному из исходников клиента, и путь с параметрами закреплён тестом `connects_through_the_documented_worker_contract` — он поднимает локальный сервер, который ведёт себя ровно так, как описано выше, и проверяет, что туннель через него поднимается и данные доходят в обе стороны.
Чего этот тест не проверяет: развёрнутый воркер в самом Cloudflare. Если что-то не сойдётся с их API — [открой issue](https://github.com/by-sonic/tglock/issues/new), поправлю. `npm run test:worker` дополнительно исполняет настоящий файл Worker с заменой платформенных API: проверяет готовность TCP, отказ, таймаут, обе стороны передачи, порядок записи, переполнение и закрытие сокета. API `opened` и `close()` описаны в [документации Cloudflare](https://developers.cloudflare.com/workers/runtime-apis/tcp-sockets/).
Эти тесты не проверяют развёрнутый Worker в Cloudflare и доступность Telegram из конкретного региона. Если ошибка остаётся, приложи версию клиента, диагностическую строку и HTTP/close-код Worker. Секрет прокси и приватные адреса Worker перед публикацией убери.
+78
View File
@@ -0,0 +1,78 @@
# Открытые issues: исправления и проверка, 19 сентября 2026
База: `8617d25`, версия `2.0.0-beta.14`. Проверены все семь открытых issues
и существующие PR #36 (Android), #49 (секрет GUI). Эта работа готовится как PR,
без слияния, публикации релиза и автоматического закрытия жалоб.
## Что изменено
| Issue | Подтверждённая проблема / выполненная работа | Проверка и предел вывода |
|---|---|---|
| #59 — 11 тысяч неудачных соединений | Все маршруты в cooldown раньше приводили к немедленной новой попытке. Теперь пауза соблюдается; зависшие соединения не блокируют резервные | Тесты времени, числа параллельных попыток, отмены проигравших. Доступность Telegram у автора issue не проверена |
| #58 — Worker, туннели 0/1 | Worker отвечал 101 до открытия TCP; ошибки после handshake терялись | Worker 502/504 и диагностические WebSocket close-коды, Rust-тесты раннего закрытия и reset. HTTP 426 проверяет только публикацию Worker |
| #50 — ни один маршрут не работает | Последовательные TCP/TLS таймауты задерживали резерв; кратковременные туннели ошибочно сохраняли предпочтение маршрута | Ограниченный параллелизм, cooldown после upstream failure, ошибки Worker. Нельзя обещать обход, когда недоступны и Telegram, и собственный Worker |
| #42 — Android через LAN | Прежние duplex-исправления уже в main; потеря ошибок upstream скрывала дальнейший отказ | Независимый Android-compatible криптографический вектор, фрагментированные init и одновременная передача 128/256 КиБ, диагностика разрывов. Нужна проверка Play Market-клиента в сети репортёра |
| #32 — медиа | DC203 fallback через DNS попадал в DC2, хотя CDN имеет свой DC и ключи; на закреплённом CDN IP WebSocket не отвечает, но обычный MTProto TCP работает | DC203 использует точный CDN IP по TCP с преобразованием transport-шифрования; Worker остаётся резервом. Проверка живым req_pq/resPQ и тесты обеих сторон. Реальные фотографии аккаунта репортёра не проверены |
| #53 — статическая aarch64 | Системный OpenSSL мешал самостоятельной musl-сборке | rustls + ring, ARM64 CI с проверкой ELF и запуском CLI, артефакт и OpenWrt-инструкция. Физический NanoPi R4S не проверен |
| #9 — Android | Старый PR отстал от main, сборка требовала ручной настройки; жизненный цикл сервиса мог останавливать прокси или оставлять ложное уведомление | Перенос актуального ядра, foreground service по состоянию прокси, APK CI, постоянный секрет в каталоге приложения. HyperOS и длительная фоновая работа требуют устройства |
Дополнительно: журнал больше не замолкает после первых 64 разных событий;
Unicode-секрет больше не вызывает panic; GUI сохраняет секрет
в каталоге приложения с миграцией прежнего файла; npm lock обновляет уязвимый
транзитивный `nanoid` без смены версии приложения.
## Протокол и доверие
- TLS проверяет имя из WebSocket URI даже при подключении к закреплённому IP.
Переход на rustls использует встроенные WebPKI roots; системные пользовательские
корневые сертификаты автоматически не импортируются.
- Прямой маршрут CDN203 использует обычный MTProto TCP к одному закреплённому
адресу, без TLS-обёртки. Ключи и шифрование содержимого Telegram не меняются;
снимается только transport-obfuscation локального прокси и накладывается
transport-obfuscation сервера. Если провайдер блокирует CDN IP, нужен Worker.
- [Telegram WebSocket](https://core.telegram.org/mtproto/transports#websocket)
является потоком байтов. Границы TCP read и WS messages не обязаны совпадать
с MTProto-пакетами; дополнительный парсер пакетов не добавлялся.
- [CDN DC](https://core.telegram.org/cdn) требует соответствующего назначения;
успешный handshake другого DC не доказывает работоспособность медиа.
- Worker остаётся опциональной инфраструктурой пользователя. Домен не является
авторизацией; `TGLOCK_TOKEN` не поддерживается штатным клиентом.
## Воспроизводимые проверки
```text
npm ci
npm run test:worker
npm run build
cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked --all-targets
cargo clippy --locked --no-default-features --features cli --all-targets -- -D warnings
cargo test --locked --no-default-features --features cli --lib --bins
```
CI дополнительно проверяет MSRV 1.88, состав macOS GUI bundle, статическую
ARM64 musl-сборку, подпись/содержимое Android debug APK и запуск на Android 15
эмуляторе. Пропуск UI lifecycle отмечается отдельно от успешного запуска. Конкретные результаты
и ссылки на прогоны фиксируются в описании PR после завершения CI.
Worker: `npm run test:worker` исполняет настоящий deployment-файл с подменой
только Cloudflare API; `wrangler deploy --dry-run` проверяет сборку без публикации.
Это не тест живого Cloudflare-аккаунта. Отдельный тест Rust поднимает локальный
WebSocket и проверяет совместимость клиента с контрактом Worker.
Для проверки реального бинаря без Telegram-аккаунта добавлен
[независимый Node.js probe](LIVE_PROBE.md). Он проверяет `req_pq_multi → resPQ`,
nonce и целостность ответа; это ещё не авторизация и не скачивание медиа.
## Что нужно проверить на устройствах
Android: установка APK, старт/стоп, ссылка в Telegram, сохранение секрета после
перезапуска, передача текста/медиа, работа в фоне 15 минут и политика батареи.
OpenWrt: запуск CLI на NanoPi R4S, подключение нескольких устройств по LAN,
перезапуск сервиса с прежним секретом. Инструкции: [Android](ANDROID.md),
[OpenWrt](OPENWRT.md).
Issues о пользовательских симптомах остаются открыты до этой проверки.
Наличие исправленного дефекта и зелёных регрессий не доказывает, что в конкретной
сети отсутствует дополнительная блокировка.
+3
View File
@@ -1,5 +1,8 @@
# TGLock 2.0 issue audit # TGLock 2.0 issue audit
Актуальный разбор семи открытых issues и границ проверки:
[19 сентября 2026](ISSUES_2026-09-19.md). Ниже сохранены исторические записи.
Проверено 29 июля 2026 года: все 15 issues и 5 pull requests, существовавшие Проверено 29 июля 2026 года: все 15 issues и 5 pull requests, существовавшие
в репозитории на момент аудита. в репозитории на момент аудита.
+61
View File
@@ -0,0 +1,61 @@
# Manual protocol probe
`scripts/probe_proxy.mjs` uses only Node.js built-ins and is independent of the
Rust transport helpers. It connects to an already running TGLock listener at
`127.0.0.1`, sends one unauthenticated `req_pq_multi` through a secret-protected
obfuscated2 padded-intermediate stream, and validates the returned `resPQ`, its
request nonce, and the lengths of its TL fields. It stops before creating an
authorization key. No Telegram account, API ID, API hash or login is needed.
The secret is read from an explicitly supplied file and is never printed. Use
the same secret file as the running CLI. Do not paste proxy links or secret
values into public logs.
```sh
# Offline validation first: no network access.
node scripts/probe_proxy.mjs --self-test
# Start a local CLI separately, using a persistent secret file.
tglock-cli --port 18080 --secret-file /private/path/tglock-secret
# Ordinary DC, media route, and CDN route; run each explicitly.
node scripts/probe_proxy.mjs --port 18080 --secret-file /private/path/tglock-secret --dc 2
node scripts/probe_proxy.mjs --port 18080 --secret-file /private/path/tglock-secret --dc -4 --fragment-size 7
node scripts/probe_proxy.mjs --port 18080 --secret-file /private/path/tglock-secret --dc 203
```
On Windows, supply the downloaded CLI executable and a Windows file path in the
same commands. Supported DC values are `1` through `5` and `203`; a negative
value requests a media route. `--timeout-ms` defaults to 15000 and is bounded at
120000. The response is bounded at 2 MiB. `--fragment-size 7` sends small writes
with 2 ms gaps to exercise stream fragmentation; TCP can still combine writes.
A successful JSON report contains `response: "resPQ"`, `nonceMatches: true`, the
requested DC, the public RSA fingerprint count, and elapsed time. Exit status 1
means connection, timeout, decryption/framing or response validation failed.
The ordinary CI suite does not run this live probe.
Success demonstrates a correctly relayed protocol exchange. It does **not**
authenticate the responding server, prove its DC identity, log into an account,
or verify message sending, media downloads or Android lifecycle. In particular,
`requestedDc` describes the request, not an independently confirmed backend.
## Isolate CDN transport failures
To distinguish an unavailable CDN WebSocket endpoint from an unavailable CDN
TCP connection, explicitly run:
```sh
node scripts/probe_proxy.mjs --direct-cdn --dc 203
```
This optional mode bypasses local TGLock and connects **only** to the pinned
Telegram CDN address `91.105.192.100:443`, using raw obfuscated2 TCP without a
proxy secret or TLS. It supports no arbitrary host. It performs the same single
unauthenticated exchange and still does not prove account or media operation.
The parser accepts trailing bytes after the complete `resPQ` TL object because
live CDN replies can include random padding in the declared message length.
Protocol references: [handshake initiation](https://core.telegram.org/mtproto/auth_key),
[obfuscated transports](https://core.telegram.org/mtproto/mtproto-transports),
and [unencrypted messages](https://core.telegram.org/mtproto/description#unencrypted-message).
+93
View File
@@ -0,0 +1,93 @@
# CLI для ARM64 / OpenWrt
Цель сборки — `aarch64-unknown-linux-musl`: ARM64 Linux, статический бинарь без
зависимости от glibc, OpenSSL или WebView. Это подходит для 64-битной прошивки
NanoPi R4S и других ARM64-роутеров. MIPS и 32-битный ARM требуют другой сборки.
Проверьте архитектуру прошивки командой `uname -m`: ожидается `aarch64`.
Workflow **Static ARM64 CLI** собирает и запускает тесты на ARM64 runner,
проверяет ELF (нет `INTERP` и `NEEDED`), запуск CLI, завершение по SIGTERM и
сохранение секрета между запусками. Артефакт содержит бинарь и SHA-256.
Release workflow прикладывает такой же проверенный артефакт к будущим релизам.
Наличие сборки в PR не означает, что уже опубликован новый релиз.
Это проверка ARM64 Linux, а не испытание конкретной прошивки OpenWrt или
доступности Telegram через вашего провайдера.
## Установка
Скачайте артефакт успешного запуска workflow нужного PR либо файл
`tglock-cli-aarch64-unknown-linux-musl` из релиза, если он там опубликован.
Сверьте SHA-256, скопируйте бинарь на роутер и выполните:
```sh
chmod 755 /usr/bin/tglock-cli
/usr/bin/tglock-cli --version
mkdir -p /etc/tglock
chmod 700 /etc/tglock
```
Создайте `/etc/tglock/tglock.toml`:
```toml
port = 1080
lan = true
secret_file = "/etc/tglock/secret"
# worker = ["your-name.workers.dev"]
```
`lan = true` нужен для телефонов и компьютеров в домашней сети. По умолчанию
CLI слушает только loopback. Секрет создаётся при первом запуске и сохраняется
в указанном файле; он не должен теряться при перезагрузке или обновлении.
```sh
chmod 600 /etc/tglock/tglock.toml
/usr/bin/tglock-cli --config /etc/tglock/tglock.toml
```
В Telegram выберите MTProto и используйте LAN-адрес роутера, порт `1080` и
секрет из напечатанной ссылки. `127.0.0.1` на телефоне означает сам телефон.
Разрешайте входящий TCP `1080` только из доверенной LAN; не публикуйте порт
в WAN. Звонки через UDP эта сборка не реализует.
## Сервис procd
После проверки ручного запуска сохраните `/etc/init.d/tglock`:
```sh
#!/bin/sh /etc/rc.common
START=95
STOP=10
USE_PROCD=1
start_service() {
procd_open_instance
procd_set_param command /usr/bin/tglock-cli --config /etc/tglock/tglock.toml
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
```
```sh
chmod 755 /etc/init.d/tglock
/etc/init.d/tglock enable
/etc/init.d/tglock start
logread -e tglock
```
Обновление: остановите сервис, замените бинарь после проверки контрольной суммы,
сохраните `/etc/tglock`, запустите сервис снова. Для отмены автозапуска используйте
`/etc/init.d/tglock stop` и `/etc/init.d/tglock disable`.
## TLS и маршруты
Встроенный набор доверенных корневых сертификатов webpki обновляется вместе с
бинарём. Проверка сертификата и имени включена: при подключении к закреплённому
IP имя Telegram по-прежнему используется для SNI и проверки сертификата.
На роутере должно быть установлено правильное время.
Если все Telegram IP недоступны, нужен доступный маршрут через собственный
[Cloudflare Worker](CLOUDFLARE_WORKER.md). Статическая сборка сама по себе не
устраняет блокировку всех внешних маршрутов.
+72 -46
View File
@@ -1,38 +1,64 @@
# Как выпускать релиз # Как выпускать релиз
## Порядок важен ## Подготовка через PR
Токен GitHub Actions создаёт релиз **только на HEAD ветки по умолчанию**. Если тег 1. Поднять версию согласованно в шести файлах:
отстанет от `main` хотя бы на один коммит, публикация упадёт с ошибкой - `Cargo.toml` — версия пакета `tglock`;
`Resource not accessible by integration` — сообщение про права, хотя права в - `Cargo.lock` — версия только пакета `tglock`, без обновления зависимостей;
порядке и дело в положении тега. - `tauri.conf.json` — версия приложения и имён установщиков;
- `package.json` — версия frontend-пакета;
- `package-lock.json` — верхняя версия и `packages[""].version`;
- `ui/main.ts` — версия копируемого диагностического отчёта.
2. Подготовить описание изменений и проверок, отдельно указав экспериментальные
платформы и непроверенные сценарии. Android получает `versionName`/`versionCode`
из конфигурации Tauri при сборке; сгенерированный `tauri.properties` не коммитится.
3. Проверить финальные изменения PR и слить его в `main` с соблюдением обязательных
проверок ветки. Дождаться зелёных проверок и Android workflow на итоговом HEAD
`main` до создания тега. APK предыдущего PR-коммита не заменяет артефакт этого
коммита: `headSha` Android run должен совпадать с коммитом будущего тега.
Отсюда единственное жёсткое правило: **тег ставится последним, и пока идёт ## Тег и сборка
релиз, в `main` не пушим.**
## Шаги Release workflow этого репозитория допускает тег только на текущем HEAD ветки
по умолчанию. Guard сохраняет прежнюю защиту от выпуска другого коммита:
ранее расхождение тега и `main` сопровождалось ошибкой публикации
`Resource not accessible by integration`.
1. Влить в `main` всё, что должно попасть в релиз, и дождаться зелёного CI. 1. Сверить `git ls-remote origin refs/heads/main` и локальный
2. Поднять версию **в двух местах**`Cargo.toml` и `tauri.conf.json`. Они `git rev-parse HEAD` после перехода на финальный коммит `main`.
должны совпадать: имена файлов бандла берутся из `tauri.conf.json`. 2. Убедиться, что выбранная версия и тег ещё не существуют, затем поставить
3. Закоммитить подъём версии и запушить в `main`. аннотированный тег на этот коммит:
4. Убедиться, что больше ничего не уедет: `git ls-remote origin refs/heads/main`
должен совпасть с локальным `git rev-parse HEAD`.
5. Поставить аннотированный тег на этот же коммит и запушить его:
```bash ```bash
git tag -a v2.0.0-beta.N -m "TGLock 2.0.0-beta.N" git tag -a v2.0.0-beta.N -m "TGLock 2.0.0-beta.N"
git push origin v2.0.0-beta.N git push origin v2.0.0-beta.N
``` ```
6. **Ничего не пушить в `main`, пока сборка не закончится.** Правки README, 3. Пока релиз собирается, не добавлять коммиты в `main`. Guard проверяет HEAD
документации, чего угодно — после публикации релиза. в начале работы и не устраняет гонку после проверки.
4. Дождаться **всех** jobs Release. Workflow создаёт **draft** и сохраняет его
черновиком при загрузке GUI, CLI и ARM64. Частично загруженный выпуск не должен
становиться общедоступным до проверок.
## Проверить, что выпустили Ручной `workflow_dispatch` запускайте на релизном теге, а не на ветке: часть
загрузчиков использует `github.ref_name` как имя релиза.
Зелёный workflow — это ещё не доказательство. В бетах 2 и 3 сборка была зелёной, ## Проверка артефактов и публикация
а в приложение попадал headless-бинарь вместо графического. Поэтому проверяем
содержимое, а не имя файла: Зелёный workflow сам по себе недостаточен. В бетах 2 и 3 в GUI-бандл попадал
headless-бинарь, поэтому проверяются содержимое и происхождение:
- Windows: GUI `.exe` и `tglock-cli-x86_64-pc-windows-msvc.exe`.
- macOS: универсальные `.dmg`, `.app.tar.gz` и CLI.
- Linux x64: `.AppImage`, `.deb` и CLI.
- ARM64 Linux: `tglock-cli-aarch64-unknown-linux-musl` и его `.sha256`;
workflow проверяет ELF без динамических зависимостей и запуск бинаря.
- Android: скачать ARM64 debug APK из успешного Android run с `headSha`,
совпадающим с коммитом тега. Сохранить имя с версией и явной пометкой
`android-arm64-debug`, прикрепить APK к тому же draft. Указать экспериментальный
статус и ограничения debug-подписи из [ANDROID.md](ANDROID.md).
Пример проверки скачанного macOS-бандла:
```bash ```bash
gh release download vX.Y.Z --repo by-sonic/tglock -p 'TGLock_universal.app.tar.gz' -D /tmp/check gh release download vX.Y.Z --repo by-sonic/tglock -p 'TGLock_universal.app.tar.gz' -D /tmp/check
@@ -40,35 +66,35 @@ tar -xzf /tmp/check/TGLock_universal.app.tar.gz -C /tmp/check
python scripts/verify_bundle_binary.py /tmp/check/TGLock.app/Contents/MacOS/tglock python scripts/verify_bundle_binary.py /tmp/check/TGLock.app/Contents/MacOS/tglock
``` ```
Скрипт ищет внутри бинаря маркеры GUI (`ipc.localhost`, `wry`) и маркеры CLI Проверьте версию скачанного CLI и хотя бы один реальный протокольный обмен
(`allow-direct`, `secret-file`) и ругается, если в бандле оказался не тот. через него по [LIVE_PROBE.md](LIVE_PROBE.md). Зафиксируйте, какие платформы
исполнены локально, а какие проверены CI. Секрет локального прокси в заметки и
публичные артефакты не включается.
## Если публикация всё-таки упала с `Resource not accessible by integration` Когда набор файлов полон, подписи/контрольные суммы и версии сверены, а release
notes готовы, опубликуйте draft. Например:
Значит, тег разошёлся с `main`. Сверьте: ```bash
gh release edit vX.Y.Z --repo by-sonic/tglock --draft=false --notes-file release-notes.md
```
Исторически `v2.0.0-beta.*` в этом репозитории публикуются с `prerelease=false`;
workflow сохраняет эту настройку. После публикации проверьте публичную страницу
релиза, ссылки скачивания и список файлов. Краткий пост об обновлении должен
ссылаться на опубликованный релиз и отделять проверенные исправления от
экспериментальных платформ.
## Если сборка или публикация упала
Сначала прочитайте ошибку и сверяйте коммит тега, `main`, workflow run и версии.
Не делайте вывод о причине только из текста `Resource not accessible by
integration`: он может относиться и к правам токена.
```bash ```bash
git ls-remote origin refs/heads/main 'refs/tags/vX.Y.Z^{}' git ls-remote origin refs/heads/main 'refs/tags/vX.Y.Z^{}'
``` ```
Если SHA разные — переставьте тег на HEAD и запушьте заново: Если это ошибка инфраструктуры, повторите упавшие jobs на том же коммите.
Если нужна правка исходников, внесите её через PR и выберите новую версию для
```bash нового тега. Опубликованные теги и бинарные артефакты не заменяйте: пользователи
git tag -d vX.Y.Z должны иметь возможность воспроизвести уже выпущенную версию.
git push origin :refs/tags/vX.Y.Z
git tag -a vX.Y.Z <sha ветки main> -m "TGLock X.Y.Z"
git push origin vX.Y.Z
```
Перезапускать упавший workflow бесполезно: он возьмёт тот же отставший тег и
упадёт снова.
## Что защищает автоматически
Первым шагом релиза идёт задача `guard`: она сверяет тег с HEAD ветки по
умолчанию и валится за секунды, не запуская сборки. Она ловит тег, поставленный
на старый коммит.
Она **не** ловит гонку: если запушить в `main` уже после её прохождения, но до
конца сборки, публикация упадёт. Ровно так утонул первый заход v2.0.0-beta.8.
От этого защищает только правило из первого раздела.
+12
View File
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
+20
View File
@@ -0,0 +1,20 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
keystore.properties
/.tauri
/tauri.settings.gradle
+6
View File
@@ -0,0 +1,6 @@
/src/main/**/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties
+76
View File
@@ -0,0 +1,76 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 36
ndkVersion = "28.0.13004108"
namespace = "com.bysonic.tglock"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.bysonic.tglock"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging {
jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
+24
View File
@@ -0,0 +1,24 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# Registered by class name from Rust, so no Java call site keeps it alive.
-keep class com.bysonic.tglock.TunnelPlugin { *; }
-keep class com.bysonic.tglock.TunnelArgs { *; }
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Держит процесс живым, пока приложение свёрнуто: прокси — поток в этом
же процессе, и без сервиса система выгрузит его через минуты. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- Только для показа уведомления сервиса; отказ не ломает туннель. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.tglock"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".TunnelService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps the local Telegram proxy process alive while the app is in use" />
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,34 @@
package com.bysonic.tglock
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
class MainActivity : TauriActivity() {
private val requestNotifications =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
// The tunnel works either way: without the permission the system simply
// hides the notification, while the foreground service still keeps the
// process alive.
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
askForNotificationPermission()
}
private fun askForNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted =
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) {
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
@@ -0,0 +1,28 @@
package com.bysonic.tglock
import android.app.Activity
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.Plugin
@InvokeArg
class TunnelArgs {
var enabled: Boolean = false
}
/** Called by Rust only; no WebView JavaScript interface or exported component. */
@TauriPlugin
class TunnelPlugin(private val activity: Activity) : Plugin(activity) {
@Command
fun setEnabled(invoke: Invoke) {
try {
val args = invoke.parseArgs(TunnelArgs::class.java)
if (args.enabled) TunnelService.start(activity) else TunnelService.stop(activity)
invoke.resolve()
} catch (error: Exception) {
invoke.reject(error.message ?: "Cannot change tunnel foreground service")
}
}
}
@@ -0,0 +1,102 @@
package com.bysonic.tglock
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
/**
* Gives the running proxy Android foreground-service priority.
*
* The proxy itself runs on a Rust thread inside this process, so nothing here
* touches networking. This reduces background eviction while Telegram is in
* use; force-stop and vendor battery management can still end the process.
*/
class TunnelService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) {
stopSelf()
return START_NOT_STICKY
}
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
// The Rust engine belongs to this process. A sticky service restart
// would display a working notification without restarting the engine.
// After process death the user explicitly starts protection again.
return START_NOT_STICKY
}
private fun createChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.tunnel_channel_name),
// Low importance: the notification is required by the platform, not
// something the user needs to be interrupted by.
NotificationManager.IMPORTANCE_LOW,
).apply {
setShowBadge(false)
description = getString(R.string.tunnel_channel_description)
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.tunnel_notification_title))
.setContentText(getString(R.string.tunnel_notification_text))
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(open)
.setOngoing(true)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
companion object {
private const val CHANNEL_ID = "tglock-tunnel"
private const val NOTIFICATION_ID = 1
fun start(context: Context) {
val intent = Intent(context, TunnelService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, TunnelService::class.java))
}
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.tglock" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,8 @@
<resources>
<string name="app_name">"TGLock"</string>
<string name="main_activity_title">"TGLock"</string>
<string name="tunnel_channel_name">Туннель</string>
<string name="tunnel_channel_description">Уведомление, которое не даёт системе выгрузить туннель, пока приложение открыто</string>
<string name="tunnel_notification_title">TGLock активен</string>
<string name="tunnel_notification_text">Пока это уведомление на месте, система не выгрузит туннель</string>
</resources>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.tglock" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+22
View File
@@ -0,0 +1,22 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.21")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}
+23
View File
@@ -0,0 +1,23 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}
@@ -0,0 +1,68 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """npm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("run", "--", "tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}
@@ -0,0 +1,85 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+3
View File
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#070a10" /> <meta name="theme-color" content="#070a10" />
<title>TGLock</title> <title>TGLock</title>
</head> </head>
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "tglock-ui", "name": "tglock-ui",
"version": "2.0.0-beta.1", "version": "2.0.0-beta.15",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tglock-ui", "name": "tglock-ui",
"version": "2.0.0-beta.1", "version": "2.0.0-beta.15",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
}, },
@@ -1118,9 +1118,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
+2 -1
View File
@@ -1,9 +1,10 @@
{ {
"name": "tglock-ui", "name": "tglock-ui",
"private": true, "private": true,
"version": "2.0.0-beta.14", "version": "2.0.0-beta.15",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
"dev": "vite --port 1420", "dev": "vite --port 1420",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Bounded installed-APK smoke; no Telegram account or external network needed."""
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
PACKAGE = "com.bysonic.tglock"
COMPONENT = f"{PACKAGE}/.MainActivity"
EVIDENCE = Path("android-smoke-evidence")
EVIDENCE.mkdir(exist_ok=True)
RESULT = {"launch": "not_run", "lifecycle": "not_run"}
def adb(*args, check=True, timeout=20):
return subprocess.run(
["adb", *args], check=check, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout,
).stdout.strip()
def wait_for(description, predicate, seconds=20):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if predicate():
return
time.sleep(1)
raise AssertionError(f"Timed out: {description}")
def ui_dump(label):
# Dump first, then read the file; never tap coordinates inferred from a
# screenshot, a previous Activity, or assumed phone dimensions.
adb("shell", "rm", "-f", "/sdcard/tglock-ui.xml")
adb("shell", "uiautomator", "dump", "/sdcard/tglock-ui.xml")
text = adb("shell", "cat", "/sdcard/tglock-ui.xml")
(EVIDENCE / f"{label}.xml").write_text(text, encoding="utf-8")
return ET.fromstring(text)
def label_node(tree, label):
for node in tree.iter("node"):
# Android aggregates the decorative arrow into the button's accessible
# text (observed in the API35 CI dump). Accept only that exact suffix.
names = (node.get("text", "").strip(), node.get("content-desc", "").strip())
if any(name in (label, f"{label}") for name in names):
bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
if bounds:
x1, y1, x2, y2 = map(int, bounds.groups())
if x2 > x1 and y2 > y1:
return (x1 + x2) // 2, (y1 + y2) // 2
return None
def tap_label(label, stage):
for attempt in range(3):
point = label_node(ui_dump(f"{stage}-{attempt}"), label)
if point is not None:
adb("shell", "input", "tap", str(point[0]), str(point[1]))
return
time.sleep(1)
raise AssertionError(f"Visible action not found: {label}")
def service_running():
text = adb("shell", "dumpsys", "activity", "services", f"{PACKAGE}/.TunnelService")
(EVIDENCE / "services-last.txt").write_text(text, encoding="utf-8")
return "isForeground=true" in text
def proxy_ready():
# ADB forwards to emulator loopback. A real SOCKS5 greeting proves the
# Rust listener is serving, beyond just a notification being displayed.
try:
with socket.create_connection(("127.0.0.1", 11080), timeout=1) as peer:
peer.sendall(bytes([5, 1, 0]))
return peer.recv(2) == bytes([5, 0])
except (OSError, TimeoutError):
return False
def launch():
adb("shell", "am", "start", "-W", "-n", COMPONENT)
wait_for("Activity resumed", lambda: any(
"ResumedActivity" in line and PACKAGE in line
for line in adb("shell", "dumpsys", "activity", "activities").splitlines()
))
assert adb("shell", "pidof", PACKAGE), "App process is absent"
def main():
apks = sorted(Path(sys.argv[1]).rglob("*.apk"))
assert len(apks) == 1, f"Expected one x86_64 APK, got {len(apks)}"
adb("install", "-r", str(apks[0]), timeout=60)
adb("shell", "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS")
adb("logcat", "-c")
adb("forward", "tcp:11080", "tcp:1080")
launch()
RESULT["launch"] = "passed"
deadline = time.monotonic() + 20
tree = None
start_point = None
attempt = 0
while time.monotonic() < deadline:
try:
tree = ui_dump(f"launched-{attempt}")
start_point = label_node(tree, "Включить защиту")
if start_point is not None:
break
except (subprocess.SubprocessError, ET.ParseError):
pass
attempt += 1
time.sleep(1)
assert not service_running(), "Foreground service started without user action"
assert not proxy_ready(), "Proxy started without user action"
if tree is not None:
assert any(
node.get("class") == "android.webkit.WebView"
for node in tree.iter("node")
), "App Activity is resumed but its WebView is absent"
if start_point is None:
RESULT["lifecycle"] = "skipped: WebView Start not accessible after 20s"
print("::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after 20s")
return
RESULT["lifecycle"] = "failed: lifecycle assertions incomplete"
tap_label("Включить защиту", "before-start")
wait_for("foreground service after Start", service_running)
wait_for("Rust SOCKS listener after Start", proxy_ready)
adb("shell", "input", "keyevent", "KEYCODE_HOME")
time.sleep(5)
assert service_running() and proxy_ready(), "Proxy stopped after backgrounding"
launch()
tap_label("Выключить", "before-stop")
wait_for("foreground service after Stop", lambda: not service_running())
wait_for("Rust listener after Stop", lambda: not proxy_ready())
# Explicit restart and user force-stop, then relaunch. This tests the
# user-stop contract, not Android's automatic low-memory process eviction.
tap_label("Включить защиту", "before-restart")
wait_for("Rust listener after restart", proxy_ready)
adb("shell", "am", "force-stop", PACKAGE)
launch()
assert not service_running() and not proxy_ready(), "Proxy silently restarted after force-stop"
RESULT["lifecycle"] = "passed: Start, background 5s, Stop, restart, force-stop"
try:
main()
except Exception as error:
RESULT["failure"] = str(error)
raise
finally:
try:
logs = adb("logcat", "-d", "-v", "threadtime")
(EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8")
crashes = adb("logcat", "-b", "crash", "-d")
(EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8")
if PACKAGE in crashes or f"ANR in {PACKAGE}" in logs or not adb("shell", "pidof", PACKAGE):
RESULT["launch"] = "failed: application crash, ANR, or missing process"
raise AssertionError("Application did not remain healthy")
finally:
(EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2), encoding="utf-8")
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as report:
report.write("\nAndroid emulator smoke: " + json.dumps(RESULT) + "\n")
print(json.dumps(RESULT))
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env node
// Manual, account-free probe of an ALREADY RUNNING local TGLock instance.
// Protocol sources (this does not import the Rust implementation):
// https://core.telegram.org/mtproto/auth_key
// https://core.telegram.org/mtproto/mtproto-transports#transport-obfuscation
// https://core.telegram.org/mtproto/description#unencrypted-message
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createConnection } from "node:net";
import { setTimeout as sleep } from "node:timers/promises";
import assert from "node:assert/strict";
const MAX_RESPONSE = 2 * 1024 * 1024;
const HELP = `Usage: node scripts/probe_proxy.mjs --secret-file PATH [--port 1080] [--dc 2] [--timeout-ms 15000] [--fragment-size 0]
Or: node scripts/probe_proxy.mjs --direct-cdn --dc 203
DC: 1..5 or 203; negative values request the media route.
Default connects only to 127.0.0.1. --direct-cdn explicitly probes only 91.105.192.100:443 without a secret.
Reads the local proxy secret from the explicit file; never prints it.
Sends one req_pq_multi and checks resPQ/nonce. No account, API credentials, login or auth key is created.
This proves a protocol response, not DC identity, account operation or media downloads.
Use --self-test for offline parser checks; --fragment-size 7 sends small writes with 2ms gaps.`;
function options(args) {
const result = { port: 1080, dc: 2, timeoutMs: 15000, fragmentSize: 0 };
const numeric = { "--port": "port", "--dc": "dc", "--timeout-ms": "timeoutMs", "--fragment-size": "fragmentSize" };
for (let i = 0; i < args.length; i++) {
const name = args[i];
if (name === "--direct-cdn") { result.directCdn = true; continue; }
const value = args[++i];
if (value === undefined) throw new Error(`Missing value for ${name}`);
if (name === "--secret-file") result.secretFile = value;
else if (numeric[name] && /^-?\d+$/.test(value)) result[numeric[name]] = Number(value);
else throw new Error(`Invalid option ${name}`);
}
if (!result.secretFile && !result.directCdn) throw new Error("--secret-file is required");
if (result.directCdn && result.dc !== 203) throw new Error("Direct CDN probe requires --dc 203");
if (!Number.isInteger(result.port) || result.port < 1 || result.port > 65535) throw new Error("Invalid port");
if (![1, 2, 3, 4, 5, 203].includes(Math.abs(result.dc))) throw new Error("Unsupported DC");
if (result.timeoutMs < 100 || result.timeoutMs > 120000) throw new Error("Timeout must be 100..120000ms");
if (result.fragmentSize < 0 || result.fragmentSize > 65536) throw new Error("Fragment size must be 0..65536");
return result;
}
function makeRequest(secret, dc) {
let header;
do {
header = randomBytes(64);
} while (header[0] === 0xef || ["HEAD", "POST", "GET ", "OPTI"].includes(header.toString("ascii", 0, 4))
|| [0xeeeeeeee, 0xdddddddd, 0x02010316].includes(header.readUInt32LE()) || header.readUInt32LE(4) === 0);
header.fill(0xdd, 56, 60); // padded intermediate transport
header.writeInt16LE(dc, 60);
const salted = (key) => secret ? createHash("sha256").update(key).update(secret).digest() : Buffer.from(key);
const encrypt = createCipheriv("aes-256-ctr", salted(header.subarray(8, 40)), header.subarray(40, 56));
const reversed = Buffer.from(header.subarray(8, 56)).reverse();
const decrypt = createDecipheriv("aes-256-ctr", salted(reversed.subarray(0, 32)), reversed.subarray(32));
const wireHeader = Buffer.from(header);
encrypt.update(header).copy(wireHeader, 56, 56); // advances outgoing CTR by all 64 bytes
const nonce = randomBytes(16);
const payload = Buffer.alloc(40);
// auth_key_id = 0, client message ID divisible by four, TL payload length = 20.
const now = BigInt(Date.now());
const messageId = ((now / 1000n << 32n) | ((now % 1000n) * (1n << 32n) / 1000n)) & ~3n;
payload.writeBigUInt64LE(messageId, 8);
payload.writeUInt32LE(20, 16);
payload.writeUInt32LE(0xbe7e8ef1, 20);
nonce.copy(payload, 24);
const padded = Buffer.concat([payload, randomBytes(7)]);
const length = Buffer.alloc(4);
length.writeUInt32LE(padded.length);
return { wire: Buffer.concat([wireHeader, encrypt.update(Buffer.concat([length, padded]))]), decrypt, nonce };
}
function parseResPQ(frame, nonce) {
if (frame.length === 4) throw new Error(`MTProto transport error ${frame.readInt32LE()}`);
if (frame.length < 20 || frame.readBigUInt64LE() !== 0n) throw new Error("Expected an unencrypted MTProto response");
const length = frame.readUInt32LE(16);
if (length < 48 || length % 4 !== 0 || length > frame.length - 20) throw new Error("Invalid MTProto message length");
const body = frame.subarray(20, 20 + length);
if (body.readUInt32LE() !== 0x05162463) throw new Error("Response is not resPQ");
if (!body.subarray(4, 20).equals(nonce)) throw new Error("resPQ nonce does not match request");
// pq is at most eight bytes for this handshake, so its TL string uses the
// one-byte length encoding followed by padding to a four-byte boundary.
const pqLength = body[36];
if (pqLength < 1 || pqLength > 8) throw new Error("Invalid resPQ pq length");
const vectorOffset = 36 + Math.ceil((1 + pqLength) / 4) * 4;
if (vectorOffset + 8 > body.length || body.readUInt32LE(vectorOffset) !== 0x1cb5c415) throw new Error("Invalid RSA fingerprint vector");
const count = body.readUInt32LE(vectorOffset + 4);
// A CDN may include trailing random bytes in its declared message length.
// Validate the complete TL object fits; do not mistake padding for corruption.
if (count < 1 || count > 64 || vectorOffset + 8 + count * 8 > body.length) throw new Error("Invalid RSA fingerprint count");
// Deliberately do not infer DC identity from these public fingerprints.
return { response: "resPQ", nonceMatches: true, rsaFingerprintCount: count };
}
async function probe(config) {
let secret = null;
if (!config.directCdn) {
let hex = (await readFile(config.secretFile, "utf8")).trim();
if (/^dd[0-9a-f]{32}$/i.test(hex)) hex = hex.slice(2);
if (!/^[0-9a-f]{32}$/i.test(hex)) throw new Error("Secret file must contain 32 hex characters or dd followed by 32 hex characters");
secret = Buffer.from(hex, "hex");
}
const { wire, decrypt, nonce } = makeRequest(secret, config.dc);
const started = Date.now();
return new Promise((resolve, reject) => {
const socket = createConnection(config.directCdn
? { host: "91.105.192.100", port: 443 }
: { host: "127.0.0.1", port: config.port });
let pending = Buffer.alloc(0);
let received = 0;
let finished = false;
const finish = (error, result) => {
if (finished) return;
finished = true;
clearTimeout(timer);
socket.destroy();
if (error) reject(error);
else resolve({ transport: config.directCdn ? "direct-cdn-tcp" : "local-proxy", requestedDc: config.dc, ...result, elapsedMs: Date.now() - started });
};
const timer = setTimeout(() => finish(new Error("Timed out waiting for resPQ")), config.timeoutMs);
socket.on("error", (error) => finish(error));
socket.on("end", () => finish(new Error("Proxy closed before a complete resPQ response")));
socket.on("data", (chunk) => {
try {
received += chunk.length;
if (received > MAX_RESPONSE) throw new Error("Response exceeded the 2MiB limit");
pending = Buffer.concat([pending, decrypt.update(chunk)]);
while (pending.length >= 4) {
const length = pending.readUInt32LE();
if (length & 0x80000000) { // optional intermediate quick acknowledgment
pending = pending.subarray(4);
continue;
}
if (length < 4 || length > MAX_RESPONSE - 4) throw new Error("Invalid intermediate frame length");
if (pending.length < 4 + length) return;
if (length >= 8 && length <= 16 && pending.readUInt32LE(4) === 0xffffffff) {
pending = pending.subarray(4 + length); // padded intermediate quick ACK
continue;
}
finish(null, parseResPQ(pending.subarray(4, 4 + length), nonce));
return;
}
} catch (error) { finish(error); }
});
socket.on("connect", async () => {
try {
socket.setNoDelay(true);
const size = config.fragmentSize || wire.length;
for (let offset = 0; offset < wire.length && !finished; offset += size) {
socket.write(wire.subarray(offset, offset + size));
if (config.fragmentSize) await sleep(2);
}
} catch (error) { finish(error); }
});
});
}
function selfTest() {
// Fixed TL fixture: resPQ, request nonce 00..0f, server nonce 10..1f,
// eight-byte pq, a one-element vector of public RSA fingerprints.
const body = Buffer.from("63241605000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f08112233445566778800000015c4b51c010000008877665544332211", "hex");
const envelope = Buffer.alloc(20);
envelope.writeUInt32LE(body.length, 16);
const frame = Buffer.concat([envelope, body, Buffer.from([1, 2, 3])]);
const nonce = Buffer.from("000102030405060708090a0b0c0d0e0f", "hex");
assert.equal(parseResPQ(frame, nonce).rsaFingerprintCount, 1);
const paddedEnvelope = Buffer.from(envelope);
paddedEnvelope.writeUInt32LE(body.length + 128, 16);
assert.equal(parseResPQ(Buffer.concat([paddedEnvelope, body, Buffer.alloc(128, 0x42)]), nonce).rsaFingerprintCount, 1);
assert.throws(() => parseResPQ(frame, Buffer.alloc(16)), /nonce/);
assert.throws(() => parseResPQ(frame.subarray(0, 30), nonce), /length/);
const wrongConstructor = Buffer.from(frame);
wrongConstructor[20] = 0;
assert.throws(() => parseResPQ(wrongConstructor, nonce), /not resPQ/);
const oversizedVector = Buffer.from(frame);
oversizedVector.writeUInt32LE(65, 72);
assert.throws(() => parseResPQ(oversizedVector, nonce), /count/);
assert.throws(() => parseResPQ(Buffer.from("6cfeffff", "hex"), nonce), /-404/);
console.log("Offline parser checks passed; no network connection made.");
}
try {
const args = process.argv.slice(2);
if (args.length === 1 && args[0] === "--self-test") selfTest();
else if (args.length === 1 && ["--help", "-h"].includes(args[0])) console.log(HELP);
else console.log(JSON.stringify(await probe(options(args)), null, 2));
} catch (error) {
console.error(`Probe failed: ${error.message}`);
process.exitCode = 1;
}
+2 -2
View File
@@ -12,8 +12,8 @@ use std::process::ExitCode;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tglock::cli_settings as config; use tglock_lib::cli_settings as config;
use tglock::{proxy, transport}; use tglock_lib::{proxy, transport};
const STATUS_POLL: Duration = Duration::from_secs(1); const STATUS_POLL: Duration = Duration::from_secs(1);
+519
View File
@@ -0,0 +1,519 @@
use crate::config::ListenConfig;
use crate::{proxy, 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 tauri_plugin_opener::OpenerExt;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct Settings {
lan_mode: bool,
port: u16,
worker_domain: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
lan_mode: false,
port: proxy::DEFAULT_PORT,
worker_domain: String::new(),
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LogLine {
timestamp: String,
message: String,
error: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StatusSnapshot {
running: bool,
active_connections: u32,
tunnels: u32,
data_center: Option<u16>,
route: String,
failures: u32,
/// Падения отдельных маршрутов. Растёт даже когда соединение в итоге
/// состоялось через запасной адрес (by-sonic/tglock#32).
route_failures: u32,
/// Запросы, отклонённые политикой «в LAN-режиме только Telegram».
///
/// Ноль означает, что фильтр не отклонял запросы. Доходит ли телефон до
/// машины, показывают соединения и журнал, а не этот счётчик (#42).
blocked: u32,
/// Клиенты, которые дошли, но не сумели договориться. Почти всегда это
/// ссылка `tg://proxy` от прошлого запуска, то есть другой секрет.
unknown_clients: u32,
/// Соединения, которые открылись и ничего не прислали до таймаута.
///
/// Растущее число в такт с переподключениями клиента означает, что он
/// открывает соединения впрок, а мы закрываем их по таймауту
/// (by-sonic/tglock#42).
silent_clients: u32,
uptime_seconds: u64,
port: u16,
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// В LAN-режиме это адрес этого компьютера в локальной сети. Люди искали
/// его в интерфейсе и не находили: вписывали `127.0.0.1`, который на
/// телефоне или в эмуляторе означает само устройство, и подключение не
/// работало (by-sonic/tglock#36).
share_address: Option<String>,
logs: Vec<LogLine>,
}
struct AppState {
stats: Arc<proxy::Stats>,
settings: Mutex<Settings>,
active_port: Mutex<u16>,
/// Слушатель работающего прокси. Нужен, чтобы показать адрес для других
/// устройств именно тот, на котором прокси реально поднят, а не тот, что
/// сейчас выбран в настройках.
active_listen: Mutex<Option<ListenConfig>>,
started_at: Mutex<Option<Instant>>,
logs: Arc<Mutex<Vec<LogLine>>>,
settings_path: PathBuf,
task: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl AppState {
fn new(settings_path: PathBuf, stats: Arc<proxy::Stats>) -> Self {
let settings = std::fs::read(&settings_path)
.ok()
.and_then(|contents| serde_json::from_slice(&contents).ok())
.unwrap_or_default();
Self {
stats,
settings: Mutex::new(settings),
active_port: Mutex::new(proxy::DEFAULT_PORT),
active_listen: Mutex::new(None),
started_at: Mutex::new(None),
logs: Arc::new(Mutex::new(Vec::new())),
settings_path,
task: tokio::sync::Mutex::new(None),
}
}
fn log(&self, message: impl Into<String>, error: bool) {
let mut logs = self.logs.lock().unwrap();
logs.push(LogLine {
timestamp: current_time(),
message: message.into(),
error,
});
if logs.len() > 100 {
logs.remove(0);
}
}
fn snapshot(&self) -> StatusSnapshot {
// События прокси доходят до журнала только здесь: у ядра нет своего
// способа что-то показать, а интерфейс и так опрашивает состояние.
for event in self.stats.drain_events() {
self.log(event, false);
}
let data_center = self.stats.last_dc();
let route = transport::route_label(self.stats.last_route());
StatusSnapshot {
running: self.stats.running.load(Ordering::SeqCst),
active_connections: self.stats.active.load(Ordering::Relaxed),
tunnels: self.stats.ws.load(Ordering::Relaxed),
data_center: (data_center > 0).then_some(data_center),
route: route.to_owned(),
failures: self.stats.ws_failures.load(Ordering::Relaxed),
route_failures: self.stats.route_failures(),
blocked: self.stats.blocked.load(Ordering::Relaxed),
unknown_clients: self.stats.unknown_clients.load(Ordering::Relaxed),
silent_clients: self.stats.silent_clients.load(Ordering::Relaxed),
uptime_seconds: self
.started_at
.lock()
.unwrap()
.map_or(0, |started| started.elapsed().as_secs()),
port: *self.active_port.lock().unwrap(),
share_address: share_address(*self.active_listen.lock().unwrap()),
logs: self.logs.lock().unwrap().clone(),
}
}
fn persist_settings(&self, settings: &Settings) -> Result<(), String> {
if let Some(parent) = self.settings_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("Не удалось создать папку настроек: {error}"))?;
}
let contents = serde_json::to_vec_pretty(settings)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))?;
std::fs::write(&self.settings_path, contents)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))
}
}
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// Только для слушателя на `0.0.0.0`: на loopback делиться нечем, туда никто
/// извне не достучится. Возвращается адрес этой машины в сети, а не `0.0.0.0`
/// и не `127.0.0.1` — последний на телефоне или в эмуляторе означает само
/// устройство, и именно на этом спотыкались (by-sonic/tglock#36).
fn share_address(listen: Option<ListenConfig>) -> Option<String> {
listen
.filter(|listen| listen.addr.ip().is_unspecified())
.map(|listen| format!("{}:{}", listen.advertised_host(), listen.addr.port()))
}
fn current_time() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!(
"{:02}:{:02}:{:02}",
(seconds / 3600) % 24,
(seconds / 60) % 60,
seconds % 60
)
}
#[tauri::command]
fn get_status(state: State<'_, AppState>) -> StatusSnapshot {
state.snapshot()
}
#[tauri::command]
fn get_settings(state: State<'_, AppState>) -> Settings {
state.settings.lock().unwrap().clone()
}
#[tauri::command]
fn save_settings(settings: Settings, state: State<'_, AppState>) -> Result<Settings, String> {
if state.stats.running.load(Ordering::SeqCst) {
return Err("Сначала выключите защиту".into());
}
if settings.port == 0 {
return Err("Порт должен быть от 1 до 65535".into());
}
state.persist_settings(&settings)?;
*state.settings.lock().unwrap() = settings.clone();
state.log("Настройки сохранены", false);
Ok(settings)
}
#[tauri::command]
async fn start_proxy(
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<StatusSnapshot, String> {
// Serialize transitions, including shutdown, so a rapid double click cannot
// create two listeners or stop a newly started Android service.
let mut task = state.task.lock().await;
if state.stats.running.load(Ordering::SeqCst) {
return Ok(state.snapshot());
}
if let Some(previous) = task.take() {
let _ = previous.await;
}
let settings = state.settings.lock().unwrap().clone();
let listen = if settings.lan_mode {
ListenConfig::lan(settings.port)
} else {
ListenConfig::loopback(settings.port)
};
let listener = proxy::bind(listen).await?;
// Called while the user's Activity is visible, before opening Telegram.
// A rejected foreground-service start must not leave a hidden proxy running.
set_foreground_service(&app, true)?;
state.stats.set_worker_domain(&settings.worker_domain);
*state.active_port.lock().unwrap() = settings.port;
*state.active_listen.lock().unwrap() = Some(listen);
*state.started_at.lock().unwrap() = Some(Instant::now());
let stats = state.stats.clone();
let logs = state.logs.clone();
let worker_app = app.clone();
let worker = tokio::spawn(async move {
if let Err(error) = proxy::serve(stats.clone(), listener, listen.allow_direct).await {
push_log(&logs, format!("Ошибка подключения: {error}"), true);
}
stats.running.store(false, Ordering::SeqCst);
if let Err(error) = set_foreground_service(&worker_app, false) {
push_log(&logs, error, true);
}
});
while !state.stats.running.load(Ordering::SeqCst) && !worker.is_finished() {
tokio::task::yield_now().await;
}
*task = Some(worker);
if !state.stats.running.load(Ordering::SeqCst) {
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
return Err("Не удалось запустить прокси; подробности в диагностике".into());
}
state.log(format!("Прокси запущен на {}", listen.addr), false);
if let Err(error) = open_telegram_link(&app, &state) {
// The working proxy remains available through the copy-link control.
state.log(error, true);
}
Ok(state.snapshot())
}
#[tauri::command]
async fn stop_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
let mut task = state.task.lock().await;
state.stats.stop();
if let Some(worker) = task.take() {
let _ = worker.await;
}
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
state.log("Защита выключена", false);
Ok(state.snapshot())
}
fn telegram_link(state: &AppState) -> Result<String, String> {
if !state.stats.running.load(Ordering::SeqCst) {
return Err("Сначала включите защиту".into());
}
state
.active_listen
.lock()
.unwrap()
.map(|listen| listen.telegram_link(&state.stats.telegram_secret()))
.ok_or_else(|| "Сначала включите защиту".into())
}
#[tauri::command]
fn get_telegram_link(state: State<'_, AppState>) -> Result<String, String> {
telegram_link(&state)
}
fn open_telegram_link(app: &tauri::AppHandle, state: &AppState) -> Result<(), String> {
app.opener()
.open_url(telegram_link(state)?, None::<&str>)
.map_err(|error| {
format!("Не удалось открыть Telegram: {error}. Скопируйте ссылку подключения.")
})
}
#[tauri::command]
async fn open_telegram(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> {
open_telegram_link(&app, &state)
}
#[cfg(target_os = "android")]
struct TunnelPlugin(tauri::plugin::PluginHandle<tauri::Wry>);
#[cfg(target_os = "android")]
fn set_foreground_service(app: &tauri::AppHandle, enabled: bool) -> Result<(), String> {
app.state::<TunnelPlugin>()
.0
.run_mobile_plugin::<serde_json::Value>(
"setEnabled",
serde_json::json!({ "enabled": enabled }),
)
.map(|_| ())
.map_err(|error| format!("Фоновый сервис Android: {error}"))
}
#[cfg(not(target_os = "android"))]
fn set_foreground_service(_app: &tauri::AppHandle, _enabled: bool) -> Result<(), String> {
Ok(())
}
fn push_log(logs: &Arc<Mutex<Vec<LogLine>>>, message: String, error: bool) {
logs.lock().unwrap().push(LogLine {
timestamp: current_time(),
message,
error,
});
}
/// Environment variables that make the WebView render without a GPU.
///
/// The window is never created when 3D acceleration is unavailable: no
/// monitor, the default Microsoft display driver, a virtual machine without
/// 3D enabled (by-sonic/tglock#10, by-sonic/tglock#17). For a small status
/// panel software rendering costs nothing noticeable, so preferring it is the
/// safer default.
///
/// Values already present in the environment are never overwritten, and
/// `TGLOCK_FORCE_GPU` disables the whole mechanism.
fn software_rendering_vars(
force_gpu: bool,
is_set: impl Fn(&str) -> bool,
) -> Vec<(&'static str, &'static str)> {
if force_gpu {
return Vec::new();
}
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
&[(
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
"--disable-gpu --disable-gpu-compositing",
)]
} else if cfg!(any(
target_os = "macos",
target_os = "android",
target_os = "ios"
)) {
// WebKit on macOS falls back to software rendering on its own.
&[]
} else {
&[
("WEBKIT_DISABLE_COMPOSITING_MODE", "1"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
]
};
candidates
.iter()
.filter(|(key, _)| !is_set(key))
.copied()
.collect()
}
fn prefer_software_rendering() {
let force_gpu = std::env::var_os("TGLOCK_FORCE_GPU").is_some();
for (key, value) in software_rendering_vars(force_gpu, |key| std::env::var_os(key).is_some()) {
std::env::set_var(key, value);
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
prefer_software_rendering();
let builder = tauri::Builder::default().plugin(tauri_plugin_opener::init());
#[cfg(target_os = "android")]
let builder = builder.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("tunnel")
.setup(|app, api| {
let handle = api.register_android_plugin("com.bysonic.tglock", "TunnelPlugin")?;
app.manage(TunnelPlugin(handle));
Ok(())
})
.build(),
);
builder
.setup(|app| {
let settings_path = app
.path()
.app_config_dir()
.map_err(|error| error.to_string())?
.join("settings.json");
let secret_path = settings_path.with_file_name("secret");
let stored = crate::mtproto::load_or_create_secret_at_with_migration(
&secret_path,
crate::mtproto::legacy_secret_path().as_deref(),
);
let state = AppState::new(settings_path, proxy::Stats::with_stored_secret(stored));
// Если секрет не удалось записать, ссылка tg://proxy изменится после
// перезапуска и Telegram откажется подключаться к сохранённой.
// Раньше это происходило молча (by-sonic/tglock#37).
if let Some(error) = state.stats.secret_write_error() {
state.log(
format!("Секрет не записан ({error}). Проверьте ссылку после перезапуска"),
true,
);
}
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_status,
get_settings,
save_settings,
start_proxy,
stop_proxy,
get_telegram_link,
open_telegram
])
.run(tauri::generate_context!())
.expect("failed to run TGLock");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_to_share_when_the_proxy_is_off_or_local() {
assert_eq!(
share_address(None),
None,
"выключенный прокси нечего делить"
);
assert_eq!(
share_address(Some(ListenConfig::loopback(1080))),
None,
"на loopback снаружи никто не подключится"
);
}
#[test]
fn lan_mode_shares_a_reachable_address() {
let shown = share_address(Some(ListenConfig::lan(1443))).expect("в LAN-режиме адрес нужен");
assert!(shown.ends_with(":1443"), "порт должен быть виден: {shown}");
assert!(
!shown.starts_with("0.0.0.0"),
"0.0.0.0 нельзя вписать в Telegram: {shown}"
);
assert!(
!shown.starts_with("127.0.0.1"),
"127.0.0.1 на другом устройстве означает само устройство: {shown}"
);
}
#[test]
fn software_rendering_is_requested_by_default() {
let vars = software_rendering_vars(false, |_| false);
if cfg!(any(
target_os = "macos",
target_os = "android",
target_os = "ios"
)) {
assert!(vars.is_empty(), "macOS needs no override");
} else {
assert!(
!vars.is_empty(),
"a machine without 3D acceleration must still get a window"
);
}
}
#[test]
fn force_gpu_disables_the_override() {
assert!(software_rendering_vars(true, |_| false).is_empty());
}
#[test]
fn an_operators_own_value_is_never_overwritten() {
assert!(software_rendering_vars(false, |_| true).is_empty());
}
#[test]
fn windows_uses_webview2_arguments_and_linux_uses_webkit_ones() {
let keys: Vec<_> = software_rendering_vars(false, |_| false)
.into_iter()
.map(|(key, _)| key)
.collect();
if cfg!(target_os = "windows") {
assert_eq!(keys, ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"]);
} else if cfg!(target_os = "linux") {
assert_eq!(
keys,
[
"WEBKIT_DISABLE_COMPOSITING_MODE",
"WEBKIT_DISABLE_DMABUF_RENDERER"
]
);
}
}
}
+5 -1
View File
@@ -1,7 +1,7 @@
//! TGLock core: the MTProto/WebSocket transport shared by the desktop GUI and //! TGLock core: the MTProto/WebSocket transport shared by the desktop GUI and
//! the headless CLI. //! the headless CLI.
//! //!
//! Nothing in this crate depends on Tauri or on a windowing system, so the //! With the `gui` feature disabled this crate does not depend on Tauri, so the
//! `tglock-cli` binary can be built with `--no-default-features` on a server //! `tglock-cli` binary can be built with `--no-default-features` on a server
//! that has neither a GPU nor a monitor. //! that has neither a GPU nor a monitor.
@@ -14,3 +14,7 @@ pub mod transport;
/// Настройки headless-версии: файл конфигурации и сведение с флагами. /// Настройки headless-версии: файл конфигурации и сведение с флагами.
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
pub mod cli_settings; pub mod cli_settings;
/// Desktop and Android graphical application; absent from headless builds.
#[cfg(feature = "gui")]
pub mod gui;
+1 -431
View File
@@ -1,435 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
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")]
struct Settings {
lan_mode: bool,
port: u16,
worker_domain: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
lan_mode: false,
port: proxy::DEFAULT_PORT,
worker_domain: String::new(),
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LogLine {
timestamp: String,
message: String,
error: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StatusSnapshot {
running: bool,
active_connections: u32,
tunnels: u32,
data_center: Option<u16>,
route: String,
failures: u32,
/// Падения отдельных маршрутов. Растёт даже когда соединение в итоге
/// состоялось через запасной адрес (by-sonic/tglock#32).
route_failures: u32,
/// Запросы, отклонённые политикой «в LAN-режиме только Telegram».
///
/// Ноль при неработающем телефоне означает, что он вообще не дотянулся до
/// этой машины; не ноль — что дотянулся, и разбираться надо с адресами
/// (by-sonic/tglock#42).
blocked: u32,
/// Клиенты, которые дошли, но не сумели договориться. Почти всегда это
/// ссылка `tg://proxy` от прошлого запуска, то есть другой секрет.
unknown_clients: u32,
/// Соединения, которые открылись и ничего не прислали до таймаута.
///
/// Растущее число в такт с переподключениями клиента означает, что он
/// открывает соединения впрок, а мы закрываем их по таймауту
/// (by-sonic/tglock#42).
silent_clients: u32,
uptime_seconds: u64,
port: u16,
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// В LAN-режиме это адрес этого компьютера в локальной сети. Люди искали
/// его в интерфейсе и не находили: вписывали `127.0.0.1`, который на
/// телефоне или в эмуляторе означает само устройство, и подключение не
/// работало (by-sonic/tglock#36).
share_address: Option<String>,
logs: Vec<LogLine>,
}
struct AppState {
stats: Arc<proxy::Stats>,
settings: Mutex<Settings>,
active_port: Mutex<u16>,
/// Слушатель работающего прокси. Нужен, чтобы показать адрес для других
/// устройств именно тот, на котором прокси реально поднят, а не тот, что
/// сейчас выбран в настройках.
active_listen: Mutex<Option<ListenConfig>>,
started_at: Mutex<Option<Instant>>,
logs: Arc<Mutex<Vec<LogLine>>>,
settings_path: PathBuf,
}
impl AppState {
fn new(settings_path: PathBuf) -> Self {
let settings = std::fs::read(&settings_path)
.ok()
.and_then(|contents| serde_json::from_slice(&contents).ok())
.unwrap_or_default();
Self {
stats: proxy::Stats::new(),
settings: Mutex::new(settings),
active_port: Mutex::new(proxy::DEFAULT_PORT),
active_listen: Mutex::new(None),
started_at: Mutex::new(None),
logs: Arc::new(Mutex::new(Vec::new())),
settings_path,
}
}
fn log(&self, message: impl Into<String>, error: bool) {
let mut logs = self.logs.lock().unwrap();
logs.push(LogLine {
timestamp: current_time(),
message: message.into(),
error,
});
if logs.len() > 100 {
logs.remove(0);
}
}
fn snapshot(&self) -> StatusSnapshot {
// События прокси доходят до журнала только здесь: у ядра нет своего
// способа что-то показать, а интерфейс и так опрашивает состояние.
for event in self.stats.drain_events() {
self.log(event, false);
}
let data_center = self.stats.last_dc();
let route = transport::route_label(self.stats.last_route());
StatusSnapshot {
running: self.stats.running.load(Ordering::SeqCst),
active_connections: self.stats.active.load(Ordering::Relaxed),
tunnels: self.stats.ws.load(Ordering::Relaxed),
data_center: (data_center > 0).then_some(data_center),
route: route.to_owned(),
failures: self.stats.ws_failures.load(Ordering::Relaxed),
route_failures: self.stats.route_failures(),
blocked: self.stats.blocked.load(Ordering::Relaxed),
unknown_clients: self.stats.unknown_clients.load(Ordering::Relaxed),
silent_clients: self.stats.silent_clients.load(Ordering::Relaxed),
uptime_seconds: self
.started_at
.lock()
.unwrap()
.map_or(0, |started| started.elapsed().as_secs()),
port: *self.active_port.lock().unwrap(),
share_address: share_address(*self.active_listen.lock().unwrap()),
logs: self.logs.lock().unwrap().clone(),
}
}
fn persist_settings(&self, settings: &Settings) -> Result<(), String> {
if let Some(parent) = self.settings_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("Не удалось создать папку настроек: {error}"))?;
}
let contents = serde_json::to_vec_pretty(settings)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))?;
std::fs::write(&self.settings_path, contents)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))
}
}
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// Только для слушателя на `0.0.0.0`: на loopback делиться нечем, туда никто
/// извне не достучится. Возвращается адрес этой машины в сети, а не `0.0.0.0`
/// и не `127.0.0.1` — последний на телефоне или в эмуляторе означает само
/// устройство, и именно на этом спотыкались (by-sonic/tglock#36).
fn share_address(listen: Option<ListenConfig>) -> Option<String> {
listen
.filter(|listen| listen.addr.ip().is_unspecified())
.map(|listen| format!("{}:{}", listen.advertised_host(), listen.addr.port()))
}
fn current_time() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!(
"{:02}:{:02}:{:02}",
(seconds / 3600) % 24,
(seconds / 60) % 60,
seconds % 60
)
}
#[tauri::command]
fn get_status(state: State<'_, AppState>) -> StatusSnapshot {
state.snapshot()
}
#[tauri::command]
fn get_settings(state: State<'_, AppState>) -> Settings {
state.settings.lock().unwrap().clone()
}
#[tauri::command]
fn save_settings(settings: Settings, state: State<'_, AppState>) -> Result<Settings, String> {
if state.stats.running.load(Ordering::SeqCst) {
return Err("Сначала выключите защиту".into());
}
if settings.port == 0 {
return Err("Порт должен быть от 1 до 65535".into());
}
state.persist_settings(&settings)?;
*state.settings.lock().unwrap() = settings.clone();
state.log("Настройки сохранены", false);
Ok(settings)
}
#[tauri::command]
fn start_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
if state.stats.running.load(Ordering::SeqCst) {
return Ok(state.snapshot());
}
let settings = state.settings.lock().unwrap().clone();
state.stats.set_worker_domain(&settings.worker_domain);
*state.active_port.lock().unwrap() = settings.port;
*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)
};
*state.active_listen.lock().unwrap() = Some(listen);
let stats = state.stats.clone();
let logs = state.logs.clone();
std::thread::spawn(move || {
let runtime = match tokio::runtime::Runtime::new() {
Ok(runtime) => runtime,
Err(error) => {
push_log(&logs, format!("Не удалось запустить сервис: {error}"), true);
return;
}
};
if let Err(error) = runtime.block_on(proxy::run(stats, listen)) {
push_log(&logs, format!("Ошибка подключения: {error}"), true);
}
});
std::thread::sleep(std::time::Duration::from_millis(220));
if !state.stats.running.load(Ordering::SeqCst) {
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
return Err(state
.logs
.lock()
.unwrap()
.last()
.map(|line| line.message.clone())
.unwrap_or_else(|| "Не удалось запустить прокси".into()));
}
state.log(format!("Прокси запущен на {}", listen.addr), false);
let _ = open::that(listen.telegram_link(&state.stats.telegram_secret()));
state.log("Открываю подключение в Telegram…", false);
Ok(state.snapshot())
}
#[tauri::command]
fn stop_proxy(state: State<'_, AppState>) -> StatusSnapshot {
state.stats.stop();
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
state.log("Защита выключена", false);
state.snapshot()
}
fn push_log(logs: &Arc<Mutex<Vec<LogLine>>>, message: String, error: bool) {
logs.lock().unwrap().push(LogLine {
timestamp: current_time(),
message,
error,
});
}
/// Environment variables that make the WebView render without a GPU.
///
/// The window is never created when 3D acceleration is unavailable: no
/// monitor, the default Microsoft display driver, a virtual machine without
/// 3D enabled (by-sonic/tglock#10, by-sonic/tglock#17). For a small status
/// panel software rendering costs nothing noticeable, so preferring it is the
/// safer default.
///
/// Values already present in the environment are never overwritten, and
/// `TGLOCK_FORCE_GPU` disables the whole mechanism.
fn software_rendering_vars(
force_gpu: bool,
is_set: impl Fn(&str) -> bool,
) -> Vec<(&'static str, &'static str)> {
if force_gpu {
return Vec::new();
}
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
&[(
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
"--disable-gpu --disable-gpu-compositing",
)]
} else if cfg!(target_os = "macos") {
// WebKit on macOS falls back to software rendering on its own.
&[]
} else {
&[
("WEBKIT_DISABLE_COMPOSITING_MODE", "1"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
]
};
candidates
.iter()
.filter(|(key, _)| !is_set(key))
.copied()
.collect()
}
fn prefer_software_rendering() {
let force_gpu = std::env::var_os("TGLOCK_FORCE_GPU").is_some();
for (key, value) in software_rendering_vars(force_gpu, |key| std::env::var_os(key).is_some()) {
std::env::set_var(key, value);
}
}
fn main() { fn main() {
prefer_software_rendering(); tglock_lib::gui::run();
tauri::Builder::default()
.setup(|app| {
let settings_path = app
.path()
.app_config_dir()
.map_err(|error| error.to_string())?
.join("settings.json");
let state = AppState::new(settings_path);
// Если секрет не удалось записать, ссылка tg://proxy изменится после
// перезапуска и Telegram откажется подключаться к сохранённой.
// Раньше это происходило молча (by-sonic/tglock#37).
if let Some(error) = state.stats.secret_write_error() {
state.log(
format!("Секрет не сохранён ({error}). После перезапуска ссылка изменится"),
true,
);
}
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_status,
get_settings,
save_settings,
start_proxy,
stop_proxy
])
.run(tauri::generate_context!())
.expect("failed to run TGLock");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_to_share_when_the_proxy_is_off_or_local() {
assert_eq!(
share_address(None),
None,
"выключенный прокси нечего делить"
);
assert_eq!(
share_address(Some(ListenConfig::loopback(1080))),
None,
"на loopback снаружи никто не подключится"
);
}
#[test]
fn lan_mode_shares_a_reachable_address() {
let shown = share_address(Some(ListenConfig::lan(1443))).expect("в LAN-режиме адрес нужен");
assert!(shown.ends_with(":1443"), "порт должен быть виден: {shown}");
assert!(
!shown.starts_with("0.0.0.0"),
"0.0.0.0 нельзя вписать в Telegram: {shown}"
);
assert!(
!shown.starts_with("127.0.0.1"),
"127.0.0.1 на другом устройстве означает само устройство: {shown}"
);
}
#[test]
fn software_rendering_is_requested_by_default() {
let vars = software_rendering_vars(false, |_| false);
if cfg!(target_os = "macos") {
assert!(vars.is_empty(), "macOS needs no override");
} else {
assert!(
!vars.is_empty(),
"a machine without 3D acceleration must still get a window"
);
}
}
#[test]
fn force_gpu_disables_the_override() {
assert!(software_rendering_vars(true, |_| false).is_empty());
}
#[test]
fn an_operators_own_value_is_never_overwritten() {
assert!(software_rendering_vars(false, |_| true).is_empty());
}
#[test]
fn windows_uses_webview2_arguments_and_linux_uses_webkit_ones() {
let keys: Vec<_> = software_rendering_vars(false, |_| false)
.into_iter()
.map(|(key, _)| key)
.collect();
if cfg!(target_os = "windows") {
assert_eq!(keys, ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"]);
} else if cfg!(target_os = "linux") {
assert_eq!(
keys,
[
"WEBKIT_DISABLE_COMPOSITING_MODE",
"WEBKIT_DISABLE_DMABUF_RENDERER"
]
);
}
}
} }
+120 -8
View File
@@ -2,9 +2,7 @@ use aes::Aes256;
use cipher::{KeyIvInit, StreamCipher}; use cipher::{KeyIvInit, StreamCipher};
use rand::{rngs::OsRng, RngCore}; use rand::{rngs::OsRng, RngCore};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::path::Path; use std::path::{Path, PathBuf};
#[cfg(not(test))]
use std::path::PathBuf;
type AesCtr = ctr::Ctr128BE<Aes256>; type AesCtr = ctr::Ctr128BE<Aes256>;
@@ -119,6 +117,12 @@ impl StoredSecret {
/// Секрет — половина ссылки `tg://proxy`, поэтому сервис, придумывающий новый /// Секрет — половина ссылки `tg://proxy`, поэтому сервис, придумывающий новый
/// при каждом старте, отключает всех уже настроенных клиентов. /// при каждом старте, отключает всех уже настроенных клиентов.
pub fn load_or_create_secret_at(path: &Path) -> StoredSecret { pub fn load_or_create_secret_at(path: &Path) -> StoredSecret {
load_or_create_secret_at_with_migration(path, None)
}
/// Preserve desktop proxy links when the GUI moves to Tauri's app directory.
/// The legacy file remains intact, including when copying it fails.
pub fn load_or_create_secret_at_with_migration(path: &Path, legacy: Option<&Path>) -> StoredSecret {
if let Ok(value) = std::fs::read_to_string(path) { if let Ok(value) = std::fs::read_to_string(path) {
if let Some(value) = parse_secret_hex(value.trim()) { if let Some(value) = parse_secret_hex(value.trim()) {
return StoredSecret { return StoredSecret {
@@ -128,7 +132,10 @@ pub fn load_or_create_secret_at(path: &Path) -> StoredSecret {
} }
} }
let value = generate_secret(); let value = legacy
.and_then(|legacy| std::fs::read_to_string(legacy).ok())
.and_then(|value| parse_secret_hex(value.trim()))
.unwrap_or_else(generate_secret);
let write_error = store_secret(path, &secret_hex(&value)) let write_error = store_secret(path, &secret_hex(&value))
.err() .err()
.map(|error| format!("{}: {error}", path.display())); .map(|error| format!("{}: {error}", path.display()));
@@ -146,7 +153,7 @@ fn store_secret(path: &Path, value: &str) -> std::io::Result<()> {
#[cfg(not(test))] #[cfg(not(test))]
pub fn load_or_create_secret() -> StoredSecret { pub fn load_or_create_secret() -> StoredSecret {
match secret_path() { match legacy_secret_path() {
Some(path) => load_or_create_secret_at(&path), Some(path) => load_or_create_secret_at(&path),
None => StoredSecret { None => StoredSecret {
value: generate_secret(), value: generate_secret(),
@@ -155,8 +162,7 @@ pub fn load_or_create_secret() -> StoredSecret {
} }
} }
#[cfg(not(test))] pub fn legacy_secret_path() -> Option<PathBuf> {
fn secret_path() -> Option<PathBuf> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
std::env::var_os("APPDATA") std::env::var_os("APPDATA")
@@ -221,7 +227,7 @@ pub fn parse_secret(value: &str) -> Option<[u8; 16]> {
} }
fn parse_secret_hex(value: &str) -> Option<[u8; 16]> { fn parse_secret_hex(value: &str) -> Option<[u8; 16]> {
if value.len() != 32 { if value.len() != 32 || !value.is_ascii() {
return None; return None;
} }
let mut secret = [0; 16]; let mut secret = [0; 16];
@@ -300,6 +306,7 @@ fn is_reserved_init(init: &[u8; INIT_LEN]) -> bool {
|| &init[..4] == b"HEAD" || &init[..4] == b"HEAD"
|| &init[..4] == b"POST" || &init[..4] == b"POST"
|| &init[..4] == b"GET " || &init[..4] == b"GET "
|| &init[..4] == b"OPTI"
|| init[..4] == [0xee; 4] || init[..4] == [0xee; 4]
|| init[..4] == [0xdd; 4] || init[..4] == [0xdd; 4]
|| init[..4] == [0x16, 0x03, 0x01, 0x02] || init[..4] == [0x16, 0x03, 0x01, 0x02]
@@ -682,11 +689,50 @@ mod tests {
"00112233445566778899aabbccddee", // 30 символов "00112233445566778899aabbccddee", // 30 символов
"00112233445566778899aabbccddeeffff", // 34 символа "00112233445566778899aabbccddeeffff", // 34 символа
"zz112233445566778899aabbccddeeff", // не hex "zz112233445566778899aabbccddeeff", // не hex
"0я00000000000000000000000000000", // 32 bytes, UTF-8 boundary at byte 2
] { ] {
assert!(parse_secret(bad).is_none(), "{bad:?} должен быть отвергнут"); assert!(parse_secret(bad).is_none(), "{bad:?} должен быть отвергнут");
} }
} }
#[test]
fn android_obfuscated2_vector_survives_fragmented_translation() {
// Independently generated with Node/OpenSSL AES-256-CTR and SHA-256,
// following Telegram Android Connection.cpp sendData/encryptKeyWithSecret.
// Fixed wire bytes avoid a symmetric mistake in the test peer helpers.
fn bytes(hex: &str) -> Vec<u8> {
hex.as_bytes()
.as_chunks::<2>()
.0
.iter()
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect()
}
let init: [u8; 64] = bytes(concat!(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223",
"2425262728292a2b2c2d2e2f303132333435363738043de6c25808afa3"
))
.try_into()
.unwrap();
let parsed = parse_client_init(&init, &[42; 16]).unwrap();
assert_eq!(parsed.dc, 4);
assert!(parsed.media);
let mut relay = test_relay_peer(&parsed.relay_init);
let (mut upstream, mut downstream) = parsed.crypto.split();
let mut request = bytes("31fc48bfc21f9210a57fd63ac344ae50a3a23620");
for chunk in request.chunks_mut(3) {
upstream.apply(chunk);
relay.decrypt(chunk);
}
assert_eq!(request, bytes("10000000112233445566778899aabbccddeeff00"));
let mut reply = bytes("04000000ecfeffff");
relay.encrypt(&mut reply);
for chunk in reply.chunks_mut(1) {
downstream.apply(chunk);
}
assert_eq!(reply, bytes("d5a69e839ec08ebf"));
}
#[test] #[test]
fn parses_persisted_secret() { fn parses_persisted_secret() {
assert_eq!( assert_eq!(
@@ -698,4 +744,70 @@ mod tests {
); );
assert_eq!(parse_secret_hex("not-a-secret"), None); assert_eq!(parse_secret_hex("not-a-secret"), None);
} }
#[test]
fn secret_migration_keeps_legacy_links_and_prefers_existing_destination() {
let root = std::env::temp_dir().join(format!(
"tglock-migration-{}",
secret_hex(&generate_secret())
));
std::fs::create_dir(&root).unwrap();
let legacy = root.join("legacy");
let destination = root.join("secret");
std::fs::write(&legacy, secret_hex(&[17; 16])).unwrap();
let first = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(first.value, [17; 16]);
assert!(first.is_persistent());
assert_eq!(
std::fs::read_to_string(&legacy).unwrap(),
secret_hex(&[17; 16])
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&destination)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
std::fs::write(&legacy, secret_hex(&[23; 16])).unwrap();
let second = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(
second.value, [17; 16],
"an existing destination wins on restart"
);
std::fs::remove_file(destination).unwrap();
std::fs::remove_file(legacy).unwrap();
std::fs::remove_dir(root).unwrap();
}
#[test]
fn failed_migration_reports_error_but_does_not_rotate_the_legacy_secret() {
let root = std::env::temp_dir().join(format!(
"tglock-migration-fail-{}",
secret_hex(&generate_secret())
));
std::fs::create_dir(&root).unwrap();
let legacy = root.join("legacy");
let blocker = root.join("blocker");
std::fs::write(&legacy, secret_hex(&[29; 16])).unwrap();
std::fs::write(&blocker, "not a directory").unwrap();
let destination = blocker.join("secret");
for _ in 0..2 {
let stored = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(stored.value, [29; 16]);
assert!(stored.write_error.is_some());
}
assert_eq!(
std::fs::read_to_string(&legacy).unwrap(),
secret_hex(&[29; 16])
);
std::fs::remove_file(blocker).unwrap();
std::fs::remove_file(legacy).unwrap();
std::fs::remove_dir(root).unwrap();
}
} }
+454 -19
View File
@@ -1,5 +1,5 @@
use crate::config::ListenConfig; use crate::config::ListenConfig;
use std::collections::{HashSet, VecDeque}; use std::collections::VecDeque;
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -71,15 +71,17 @@ pub struct Stats {
shutdown: Mutex<Option<tokio::sync::watch::Sender<bool>>>, shutdown: Mutex<Option<tokio::sync::watch::Sender<bool>>>,
} }
/// Однократные сообщения о том, что происходит с подключениями. /// Сообщения о подключениях со склейкой недавних повторов.
/// ///
/// Однократные намеренно: отклонённый адрес повторяется десятки раз в минуту, /// Склейка намеренна: отклонённый адрес повторяется десятки раз в минуту,
/// и без склейки журнал превратился бы в одну строку, повторённую сто раз. /// и без склейки журнал превратился бы в одну строку, повторённую сто раз.
/// Число повторов при этом не теряется — оно в счётчике `blocked`. /// Число повторов при этом не теряется — оно в счётчике `blocked`.
#[derive(Default)] #[derive(Default)]
struct Events { struct Events {
pending: VecDeque<String>, pending: VecDeque<String>,
seen: HashSet<String>, // FIFO of recent distinct messages, independent from the consumer queue.
// Once full, evict the oldest entry rather than silencing all future logs.
seen: VecDeque<String>,
} }
/// Сколько разных событий помним, чтобы буфер не рос без границы. /// Сколько разных событий помним, чтобы буфер не рос без границы.
@@ -132,13 +134,20 @@ impl Stats {
}) })
} }
/// Запомнить событие, если такого ещё не было. /// Запомнить событие, если его нет среди последних EVENT_LIMIT разных сообщений.
pub fn note(&self, message: impl Into<String>) { pub fn note(&self, message: impl Into<String>) {
let message = message.into(); let message = message.into();
let mut events = self.events.lock().unwrap(); let mut events = self.events.lock().unwrap();
if events.seen.len() >= EVENT_LIMIT || !events.seen.insert(message.clone()) { if events.seen.contains(&message) {
return; return;
} }
if events.seen.len() == EVENT_LIMIT {
events.seen.pop_front();
}
events.seen.push_back(message.clone());
if events.pending.len() == EVENT_LIMIT {
events.pending.pop_front();
}
events.pending.push_back(message); events.pending.push_back(message);
} }
@@ -683,13 +692,9 @@ fn dc_from_init(init: &[u8; 64]) -> Option<(u16, bool)> {
// -- WebSocket tunnel ------------------------------------------------------- // -- WebSocket tunnel -------------------------------------------------------
/// Keeps `Stats::ws` equal to the number of *established* tunnels. /// Counts opened upstream transports, not pending connection attempts.
/// /// WebSocket routes have completed their upgrade; native CDN routes have an
/// Counting attempts instead would let the interface announce «Telegram на /// open TCP socket. Neither alone proves a successful Telegram protocol reply.
/// связи» while the WebSocket handshake is still failing over between routes,
/// which takes seconds per route. Reporting a working tunnel that does not
/// exist yet is the whole reason users saw «прокси подключён, а Telegram не
/// работает».
struct EstablishedTunnel<'a>(&'a Stats); struct EstablishedTunnel<'a>(&'a Stats);
impl<'a> EstablishedTunnel<'a> { impl<'a> EstablishedTunnel<'a> {
@@ -715,7 +720,7 @@ async fn ws_tunnel(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
let (ws, connected) = match stats.transport.connect(dc, media).await { let (connection, connected) = match stats.transport.connect(dc, media).await {
Ok(connected) => connected, Ok(connected) => connected,
Err(error) => { Err(error) => {
// Единственное место, где известно, ПОЧЕМУ туннеля нет. Раньше // Единственное место, где известно, ПОЧЕМУ туннеля нет. Раньше
@@ -729,6 +734,13 @@ async fn ws_tunnel(
let _tunnel = EstablishedTunnel::new(stats); let _tunnel = EstablishedTunnel::new(stats);
stats.note_tunnel(dc, connected.route.kind.ui_code()); stats.note_tunnel(dc, connected.route.kind.ui_code());
let ws = match connection {
crate::transport::TelegramConnection::WebSocket(ws) => *ws,
crate::transport::TelegramConnection::Tcp(remote) => {
return cdn_tcp_tunnel(tcp, remote, init, crypto, stats, &connected, dc).await;
}
};
let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp); let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp);
let (mut ws_w, mut ws_r) = ws.split(); let (mut ws_w, mut ws_r) = ws.split();
let (upstream_crypto, downstream_crypto) = match crypto.map(|crypto| crypto.split()) { let (upstream_crypto, downstream_crypto) = match crypto.map(|crypto| crypto.split()) {
@@ -738,7 +750,8 @@ async fn ws_tunnel(
// Send buffered init as first frame // Send buffered init as first frame
ws_w.send(tungstenite::Message::Binary(init.to_vec())) ws_w.send(tungstenite::Message::Binary(init.to_vec()))
.await?; .await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
// Ping приходит в половину, которая читает, а отвечать на него должна та, // Ping приходит в половину, которая читает, а отвечать на него должна та,
// которая пишет: владелец у отправляющей половины строго один. // которая пишет: владелец у отправляющей половины строго один.
@@ -752,9 +765,11 @@ async fn ws_tunnel(
// сессия встаёт при живом туннеле (by-sonic/tglock#42, #32). // сессия встаёт при живом туннеле (by-sonic/tglock#42, #32).
let downstream = async { let downstream = async {
let mut crypto = downstream_crypto; let mut crypto = downstream_crypto;
let mut received_payload = false;
while let Some(message) = ws_r.next().await { while let Some(message) = ws_r.next().await {
match message { match message {
Ok(tungstenite::Message::Binary(mut data)) => { Ok(tungstenite::Message::Binary(mut data)) => {
received_payload |= !data.is_empty();
if let Some(crypto) = &mut crypto { if let Some(crypto) = &mut crypto {
crypto.apply(data.as_mut()); crypto.apply(data.as_mut());
} }
@@ -766,10 +781,48 @@ async fn ws_tunnel(
break; break;
} }
} }
Ok(tungstenite::Message::Close(_)) | Err(_) => break, Ok(tungstenite::Message::Close(frame)) => {
// Telegram may send code 1000 even for transport errors;
// the actual error is a signed decimal in the reason.
// https://core.telegram.org/mtproto/transports#websocket
let transport_error = frame.as_ref().is_some_and(|frame| {
frame
.reason
.trim()
.parse::<i32>()
.is_ok_and(|code| code != 0)
|| frame.code != tungstenite::protocol::frame::coding::CloseCode::Normal
});
if !received_payload || transport_error {
let reason = match frame {
Some(frame) => {
format!("WebSocket закрыт: {} {}", frame.code, frame.reason)
}
None => "WebSocket закрыт без ответа Telegram".to_owned(),
};
return Err(tunnel_route_error(stats, &connected, dc, &reason));
}
return Ok(());
}
Err(error) => {
return Err(tunnel_route_error(
stats,
&connected,
dc,
&error.to_string(),
));
}
Ok(_) => {} Ok(_) => {}
} }
} }
if !received_payload {
return Err(tunnel_route_error(
stats,
&connected,
dc,
"WebSocket завершён без ответа Telegram",
));
}
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(()) Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}; };
@@ -779,19 +832,22 @@ async fn ws_tunnel(
loop { loop {
tokio::select! { tokio::select! {
read = tcp_r.read(&mut buf) => match read { read = tcp_r.read(&mut buf) => match read {
Ok(0) | Err(_) => break, Ok(0) => break,
Err(error) => return Err(error.into()),
Ok(read) => { Ok(read) => {
if let Some(crypto) = &mut crypto { if let Some(crypto) = &mut crypto {
crypto.apply(&mut buf[..read]); crypto.apply(&mut buf[..read]);
} }
ws_w ws_w
.send(tungstenite::Message::Binary(buf[..read].to_vec())) .send(tungstenite::Message::Binary(buf[..read].to_vec()))
.await?; .await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
} }
}, },
payload = pong_rx.recv() => match payload { payload = pong_rx.recv() => match payload {
Some(payload) => { Some(payload) => {
ws_w.send(tungstenite::Message::Pong(payload)).await?; ws_w.send(tungstenite::Message::Pong(payload)).await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
} }
None => break, None => break,
}, },
@@ -809,6 +865,94 @@ async fn ws_tunnel(
Ok(()) Ok(())
} }
/// DC203's pinned CDN endpoint speaks obfuscated MTProto over TCP. Preserve the
/// same generated init and independent CTR streams used for a WebSocket route.
/// The transport engine restricts this connection to the exact CDN destination.
async fn cdn_tcp_tunnel(
client: TcpStream,
mut remote: TcpStream,
init: &[u8; 64],
crypto: Option<crate::mtproto::CryptoContext>,
stats: &Stats,
connected: &crate::transport::ConnectedRoute,
dc: u16,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
remote
.write_all(init)
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
let (mut client_r, mut client_w) = client.into_split();
let (mut remote_r, mut remote_w) = remote.into_split();
let (upstream_crypto, downstream_crypto) = match crypto.map(|crypto| crypto.split()) {
Some((upstream, downstream)) => (Some(upstream), Some(downstream)),
None => (None, None),
};
let upstream = async {
let mut crypto = upstream_crypto;
let mut buffer = vec![0; 65536];
loop {
let read = client_r.read(&mut buffer).await?;
if read == 0 {
return Ok::<_, Box<dyn std::error::Error + Send + Sync>>(());
}
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buffer[..read]);
}
remote_w
.write_all(&buffer[..read])
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
}
};
let downstream = async {
let mut crypto = downstream_crypto;
let mut buffer = vec![0; 65536];
let mut received_payload = false;
loop {
let read = remote_r
.read(&mut buffer)
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
if read == 0 {
return if received_payload {
Ok(())
} else {
Err(tunnel_route_error(
stats,
connected,
dc,
"CDN TCP закрыт без ответа Telegram",
))
};
}
received_payload = true;
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buffer[..read]);
}
client_w.write_all(&buffer[..read]).await?;
}
};
tokio::select! {
result = upstream => result,
result = downstream => result,
}
}
fn tunnel_route_error(
stats: &Stats,
connected: &crate::transport::ConnectedRoute,
dc: u16,
reason: &str,
) -> Box<dyn std::error::Error + Send + Sync> {
stats.transport.report_route_failure(connected);
let message = format!(
"Сбой туннеля DC{dc} через {}: {reason}",
connected.route.connect_host
);
stats.note(message.clone());
message.into()
}
async fn tcp_relay(a: TcpStream, b: TcpStream) { async fn tcp_relay(a: TcpStream, b: TcpStream) {
let (mut ar, mut aw) = tokio::io::split(a); let (mut ar, mut aw) = tokio::io::split(a);
let (mut br, mut bw) = tokio::io::split(b); let (mut br, mut bw) = tokio::io::split(b);
@@ -953,6 +1097,7 @@ mod tests {
.send(Message::Binary(wire)) .send(Message::Binary(wire))
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
websocket.close(None).await.map_err(|e| e.to_string())?;
Ok((requested, init, request)) Ok((requested, init, request))
} }
@@ -1040,6 +1185,52 @@ mod tests {
); );
} }
#[test]
fn diagnostics_keep_new_events_after_the_first_sixty_four() {
let stats = Stats::with_secret([0; 16]);
for index in 0..=EVENT_LIMIT {
stats.note(format!("route failure {index}"));
}
{
let events = stats.events.lock().unwrap();
assert_eq!(events.seen.len(), EVENT_LIMIT);
assert_eq!(events.pending.len(), EVENT_LIMIT);
}
let events = stats.drain_events();
assert_eq!(events.first().unwrap(), "route failure 1");
assert_eq!(
events.last().unwrap(),
&format!("route failure {EVENT_LIMIT}")
);
// Draining must not discard deduplication history, but must not stop
// the next new error from reaching the operator either.
stats.note(format!("route failure {EVENT_LIMIT}"));
stats.note("a new upstream error");
assert_eq!(stats.drain_events(), vec!["a new upstream error"]);
}
#[test]
fn an_evicted_event_can_be_reported_again_without_repeating_recent_events() {
let stats = Stats::with_secret([0; 16]);
stats.note("original error");
assert_eq!(stats.drain_events(), vec!["original error"]);
stats.note("original error");
assert!(stats.drain_events().is_empty());
// More distinct events than the retained history used to silence
// diagnostics permanently, even when the frontend drained every one.
for index in 0..EVENT_LIMIT * 2 {
let message = format!("error {index}");
stats.note(message.clone());
assert_eq!(stats.drain_events(), vec![message]);
}
stats.note("original error");
stats.note("original error");
assert_eq!(stats.drain_events(), vec!["original error"]);
assert_eq!(stats.events.lock().unwrap().seen.len(), EVENT_LIMIT);
}
/// Диагностика обязана показывать пару из одного соединения. /// Диагностика обязана показывать пару из одного соединения.
/// ///
/// Пока это были два независимых поля, при десятках одновременных /// Пока это были два независимых поля, при десятках одновременных
@@ -1411,6 +1602,250 @@ mod tests {
let _ = server.await.unwrap(); let _ = server.await.unwrap();
} }
#[allow(clippy::result_large_err)]
#[tokio::test]
async fn post_handshake_failures_are_reported_and_penalize_the_route() {
use futures_util::{SinkExt, StreamExt};
use tungstenite::protocol::{frame::coding::CloseCode, CloseFrame};
// No reply, abrupt socket loss, and a Telegram transport error carried
// in a nominally normal WebSocket close all used to disappear silently.
for mode in 0..4 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_port = listener.local_addr().unwrap().port();
let relay = tokio::spawn(async move {
let (tcp, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_hdr_async(
tcp,
|_: &Request, mut response: Response| {
response
.headers_mut()
.insert("Sec-WebSocket-Protocol", "binary".parse().unwrap());
Ok(response)
},
)
.await
.unwrap();
assert!(matches!(ws.next().await, Some(Ok(Message::Binary(_)))));
if mode >= 2 {
ws.send(Message::Binary(vec![1, 2, 3, 4])).await.unwrap();
}
if mode != 1 {
ws.close(Some(CloseFrame {
code: CloseCode::Normal,
reason: if mode == 2 { " -404 " } else { "" }.into(),
}))
.await
.unwrap();
}
});
let stats = Stats::new();
stats.transport.force_local_route(relay_port);
let (port, server) = start_proxy(stats.clone(), false).await;
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
client
.write_all(&unambiguous_client_init(&stats.secret, 2))
.await
.unwrap();
let mut received = Vec::new();
tokio::time::timeout(Duration::from_secs(5), client.read_to_end(&mut received))
.await
.unwrap()
.unwrap();
relay.await.unwrap();
wait_until("connection task finished", || {
stats.active.load(Ordering::Relaxed) == 0
})
.await;
let failed = u32::from(mode != 3);
assert_eq!(
stats.ws_failures.load(Ordering::Relaxed),
failed,
"mode {mode}"
);
assert_eq!(stats.transport.route_failures(), failed, "mode {mode}");
let events = stats.drain_events();
assert_eq!(
events
.iter()
.any(|event| event.contains("Сбой туннеля DC2 через 127.0.0.1")),
mode != 3
);
if mode == 2 {
assert!(events.iter().any(|event| event.contains("-404")));
}
stats.stop();
server.await.unwrap().unwrap();
}
}
#[tokio::test]
async fn fragmented_encrypted_upload_and_download_preserve_both_streams() {
check_fragmented_encrypted_duplex(false).await;
}
#[tokio::test]
async fn cdn_tcp_preserves_fragmented_encrypted_duplex_and_clean_close() {
check_fragmented_encrypted_duplex(true).await;
}
#[tokio::test]
async fn cdn_tcp_closing_before_a_reply_is_reported_as_a_route_failure() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_port = listener.local_addr().unwrap().port();
let relay = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut init = [0; INIT_LEN];
stream.read_exact(&mut init).await.unwrap();
});
let stats = Stats::new();
stats.transport.force_local_route_with(
relay_port,
crate::transport::RouteKind::TelegramTcp,
String::new(),
);
let (port, server) = start_proxy(stats.clone(), false).await;
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
client
.write_all(&unambiguous_client_init(&stats.secret, 203))
.await
.unwrap();
wait_until("CDN failure is counted", || {
stats.ws_failures.load(Ordering::Relaxed) == 1
})
.await;
assert_eq!(stats.transport.route_failures(), 1);
assert!(stats
.drain_events()
.iter()
.any(|event| event.contains("CDN TCP закрыт без ответа")));
relay.await.unwrap();
stats.stop();
server.await.unwrap().unwrap();
}
#[allow(clippy::result_large_err)]
async fn check_fragmented_encrypted_duplex(raw_tcp: bool) {
use futures_util::{SinkExt, StreamExt};
let request: Vec<u8> = (0..131_072).map(|i| (i % 251) as u8).collect();
let response: Vec<u8> = (0..262_144).map(|i| (i % 239) as u8).collect();
let expected_request = request.clone();
let relay_response = response.clone();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_port = listener.local_addr().unwrap().port();
let relay = tokio::spawn(async move {
let (mut tcp, _) = listener.accept().await.unwrap();
if raw_tcp {
let mut init = [0; INIT_LEN];
tcp.read_exact(&mut init).await.unwrap();
let mut peer = crate::mtproto::test_relay_peer(&init);
let mut download = relay_response;
peer.encrypt(&mut download);
let (mut reader, mut writer) = tcp.into_split();
let send = async {
for chunk in download.chunks(8191) {
writer.write_all(chunk).await.unwrap();
tokio::task::yield_now().await;
}
};
let receive = async {
let mut received = vec![0; expected_request.len()];
reader.read_exact(&mut received).await.unwrap();
peer.decrypt(&mut received);
assert_eq!(received, expected_request);
};
tokio::join!(send, receive);
writer.shutdown().await.unwrap();
return;
}
let mut ws =
tokio_tungstenite::accept_hdr_async(tcp, |_: &Request, mut response: Response| {
response
.headers_mut()
.insert("Sec-WebSocket-Protocol", "binary".parse().unwrap());
Ok(response)
})
.await
.unwrap();
let init = match ws.next().await.unwrap().unwrap() {
Message::Binary(init) => init,
other => panic!("expected init: {other:?}"),
};
let mut peer = crate::mtproto::test_relay_peer(&init.try_into().unwrap());
let mut download = relay_response;
peer.encrypt(&mut download);
let (mut sink, mut stream) = ws.split();
let send = async {
for chunk in download.chunks(8191) {
sink.send(Message::Binary(chunk.to_vec())).await.unwrap();
}
};
let receive = async {
let mut received = Vec::new();
while received.len() < expected_request.len() {
if let Message::Binary(mut chunk) = stream.next().await.unwrap().unwrap() {
peer.decrypt(&mut chunk);
received.extend_from_slice(&chunk);
}
}
assert_eq!(received, expected_request);
};
tokio::join!(send, receive);
sink.close().await.unwrap();
});
let stats = Stats::new();
if raw_tcp {
stats.transport.force_local_route_with(
relay_port,
crate::transport::RouteKind::TelegramTcp,
String::new(),
);
} else {
stats.transport.force_local_route(relay_port);
}
let (port, server) = start_proxy(stats.clone(), false).await;
let init = unambiguous_client_init(&stats.secret, if raw_tcp { 203 } else { -4 });
let mut peer = crate::mtproto::test_client_peer(&init, &stats.secret);
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
// A mobile TCP stream can split the 64-byte init and subsequent data
// at arbitrary boundaries. WebSocket boundaries need not match these.
for chunk in init.chunks(7) {
client.write_all(chunk).await.unwrap();
tokio::task::yield_now().await;
}
let mut upload = request;
peer.encrypt(&mut upload);
let (mut reader, mut writer) = client.into_split();
let send = async {
for chunk in upload.chunks(137) {
writer.write_all(chunk).await.unwrap();
tokio::task::yield_now().await;
}
};
let receive = async {
let mut received = vec![0; response.len()];
reader.read_exact(&mut received).await.unwrap();
peer.decrypt(&mut received);
assert_eq!(received, response);
};
tokio::time::timeout(Duration::from_secs(10), async {
tokio::join!(send, receive);
relay.await.unwrap();
})
.await
.expect("both encrypted directions must make progress");
assert_eq!(stats.ws_failures.load(Ordering::Relaxed), 0);
if raw_tcp {
assert_eq!(
stats.last_route(),
crate::transport::RouteKind::TelegramTcp.ui_code()
);
assert_eq!(stats.last_dc(), 203);
}
stats.stop();
server.await.unwrap().unwrap();
}
#[tokio::test] #[tokio::test]
async fn connects_through_the_documented_worker_contract() { async fn connects_through_the_documented_worker_contract() {
// Locks the contract in docs/CLOUDFLARE_WORKER.md: a server that // Locks the contract in docs/CLOUDFLARE_WORKER.md: a server that
+558 -73
View File
@@ -1,25 +1,38 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex; use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant}; use std::time::Duration;
use futures_util::stream::{FuturesUnordered, StreamExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::time::Instant;
use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(4); const CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
const FALLBACK_DELAY: Duration = Duration::from_millis(250);
const MAX_CONNECTING: usize = 3;
const FAILURE_BACKOFF_INITIAL: Duration = Duration::from_secs(30); const FAILURE_BACKOFF_INITIAL: Duration = Duration::from_secs(30);
const FAILURE_BACKOFF_MAX: Duration = Duration::from_secs(30 * 60); const FAILURE_BACKOFF_MAX: Duration = Duration::from_secs(30 * 60);
const HTTPS_PORT: u16 = 443; const HTTPS_PORT: u16 = 443;
const CDN203_IP: &str = "91.105.192.100";
pub type TelegramWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>; pub type TelegramWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
#[derive(Debug)]
pub enum TelegramConnection {
WebSocket(Box<TelegramWebSocket>),
/// Native obfuscated2 transport, currently restricted to the CDN203 IP.
Tcp(TcpStream),
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RouteKind { pub enum RouteKind {
TelegramIp, TelegramIp,
AlternateTelegramIp, AlternateTelegramIp,
SystemDns, SystemDns,
CloudflareWorker, CloudflareWorker,
TelegramTcp,
} }
impl RouteKind { impl RouteKind {
@@ -29,6 +42,7 @@ impl RouteKind {
Self::AlternateTelegramIp => 2, Self::AlternateTelegramIp => 2,
Self::SystemDns => 3, Self::SystemDns => 3,
Self::CloudflareWorker => 4, Self::CloudflareWorker => 4,
Self::TelegramTcp => 5,
} }
} }
@@ -38,6 +52,7 @@ impl RouteKind {
2 => Some(Self::AlternateTelegramIp), 2 => Some(Self::AlternateTelegramIp),
3 => Some(Self::SystemDns), 3 => Some(Self::SystemDns),
4 => Some(Self::CloudflareWorker), 4 => Some(Self::CloudflareWorker),
5 => Some(Self::TelegramTcp),
_ => None, _ => None,
} }
} }
@@ -49,6 +64,7 @@ impl RouteKind {
Self::AlternateTelegramIp => "Запасной Telegram IP", Self::AlternateTelegramIp => "Запасной Telegram IP",
Self::SystemDns => "Системный DNS", Self::SystemDns => "Системный DNS",
Self::CloudflareWorker => "Cloudflare Worker", Self::CloudflareWorker => "Cloudflare Worker",
Self::TelegramTcp => "Telegram CDN TCP",
} }
} }
} }
@@ -69,11 +85,22 @@ pub struct Route {
pub kind: RouteKind, pub kind: RouteKind,
/// TCP port to dial. Always 443 for Telegram and for Cloudflare Workers. /// TCP port to dial. Always 443 for Telegram and for Cloudflare Workers.
pub port: u16, pub port: u16,
/// Wrap the connection in TLS. Always true outside tests. /// TLS for WebSocket routes; false for native obfuscated2 CDN TCP.
pub secure: bool, pub secure: bool,
} }
impl Route { impl Route {
fn cdn_tcp() -> Self {
Self {
connect_host: CDN203_IP.to_owned(),
websocket_host: String::new(),
path: String::new(),
kind: RouteKind::TelegramTcp,
port: HTTPS_PORT,
secure: false,
}
}
/// A production route: TLS on 443. /// A production route: TLS on 443.
fn https(connect_host: String, websocket_host: String, path: String, kind: RouteKind) -> Self { fn https(connect_host: String, websocket_host: String, path: String, kind: RouteKind) -> Self {
Self { Self {
@@ -189,39 +216,104 @@ impl TransportEngine {
&self, &self,
dc: u16, dc: u16,
media: bool, media: bool,
) -> Result<(TelegramWebSocket, ConnectedRoute), String> { ) -> Result<(TelegramConnection, ConnectedRoute), String> {
let key = DcKey { dc, media }; let key = DcKey { dc, media };
let candidates = self.ordered_candidates(key); self.race_connections(key, |route| async move { connect_route(&route).await })
.await
}
// A slow first IP must not hold every other route hostage. At most three
// handshakes run per client, staggered so a healthy preferred route wins
// without opening needless fallback sockets. Dropping this future or
// returning a winner cancels every losing connection attempt.
async fn race_connections<T, F, Fut>(
&self,
key: DcKey,
mut connect: F,
) -> Result<(T, ConnectedRoute), String>
where
F: FnMut(Route) -> Fut,
Fut: std::future::Future<Output = Result<T, String>>,
{
let mut candidates = self.ordered_candidates(key).into_iter().peekable();
let mut pending = FuturesUnordered::new();
let mut next_start = Instant::now();
let mut errors = Vec::new(); let mut errors = Vec::new();
for route in candidates { while candidates.peek().is_some() || !pending.is_empty() {
match connect_route(&route).await { if candidates.peek().is_some()
Ok(websocket) => { && pending.len() < MAX_CONNECTING
self.record_success(key, &route); && (pending.is_empty() || Instant::now() >= next_start)
return Ok((websocket, ConnectedRoute { route })); {
let route = candidates.next().unwrap();
// Another client may have failed this route since we took our
// snapshot. Never knowingly bypass its current cooldown.
if !self.route_available(&route) {
continue;
} }
Err(error) => { let connection = connect(route.clone());
self.record_failure(&route); pending.push(async move { (route, connection.await) });
let attempt = format!("{}{}", route.connect_host, error); next_start = Instant::now() + FALLBACK_DELAY;
if !errors.contains(&attempt) { continue;
errors.push(attempt); }
tokio::select! {
Some((route, result)) = pending.next(), if !pending.is_empty() => {
match result {
Ok(connection) => {
self.record_success(key, &route);
return Ok((connection, ConnectedRoute { route }));
}
Err(error) => {
self.record_failure(&route);
let endpoint = if route.kind == RouteKind::TelegramTcp {
format!("{}:{} (MTProto TCP)", route.connect_host, route.port)
} else {
format!("{} (TLS {})", route.connect_host, route.websocket_host)
};
errors.push(format!("{endpoint}{error}"));
}
} }
} }
_ = tokio::time::sleep_until(next_start),
if candidates.peek().is_some() && pending.len() < MAX_CONNECTING => {}
} }
} }
// Текст читает человек: он попадает в журнал событий, и по нему if errors.is_empty() {
// отличают «провайдер режет закреплённые адреса» от «воркер отвечает let routes = self.routes_for_key(key);
// отказом». Раньше причина отказа не доходила никуда, и при let health = self.health.lock().unwrap();
// `туннелей 0` узнать, почему их ноль, было нечем (by-sonic/tglock#50). let retry = routes
.iter()
.filter_map(|route| health.routes.get(route))
.map(|entry| entry.retry_at.saturating_duration_since(Instant::now()))
.min();
errors.push(match retry {
Some(delay) => format!(
"маршруты на паузе после ошибок; повтор через {} с",
delay
.as_secs()
.saturating_add(u64::from(delay.subsec_nanos() != 0))
),
None => "для этого дата-центра нет маршрутов".to_owned(),
});
}
Err(format!( Err(format!(
"Не поднялся туннель до DC{}{}: {}", "Не поднялся туннель до DC{}{}: {}",
dc, key.dc,
if media { " (медиа)" } else { "" }, if key.media { " (медиа)" } else { "" },
errors.join("; ") errors.join("; ")
)) ))
} }
fn route_available(&self, route: &Route) -> bool {
self.health
.lock()
.unwrap()
.routes
.get(route)
.is_none_or(|health| health.retry_at <= Instant::now())
}
fn ordered_candidates(&self, key: DcKey) -> Vec<Route> { fn ordered_candidates(&self, key: DcKey) -> Vec<Route> {
let now = Instant::now(); let now = Instant::now();
let all_routes = self.routes_for_key(key); let all_routes = self.routes_for_key(key);
@@ -241,25 +333,18 @@ impl TransportEngine {
candidates.sort_by_key(|route| { candidates.sort_by_key(|route| {
let preferred_rank = u8::from(preferred != Some(route)); let preferred_rank = u8::from(preferred != Some(route));
let kind_rank = match route.kind { let kind_rank = match route.kind {
RouteKind::TelegramIp => 0, RouteKind::TelegramTcp => 0,
RouteKind::AlternateTelegramIp => 1, RouteKind::TelegramIp if Some(route) == all_routes.first() => 0,
RouteKind::SystemDns => 2, // The operator explicitly configured this independent path.
RouteKind::CloudflareWorker => 3, // Start it after the primary IP, before more potentially
// blocked Telegram addresses consume the concurrency budget.
RouteKind::CloudflareWorker => 1,
RouteKind::TelegramIp | RouteKind::AlternateTelegramIp => 2,
RouteKind::SystemDns => 3,
}; };
(preferred_rank, kind_rank) (preferred_rank, kind_rank)
}); });
// If every route is cooling down, retry the one that becomes available first.
if candidates.is_empty() {
if let Some((route, _)) = health
.routes
.iter()
.filter(|(route, _)| all_routes.contains(route))
.min_by_key(|(_, route_health)| route_health.retry_at)
{
candidates.push(route.clone());
}
}
candidates candidates
} }
@@ -299,9 +384,17 @@ impl TransportEngine {
self.route_failures.load(Ordering::Relaxed) self.route_failures.load(Ordering::Relaxed)
} }
/// A successful WebSocket upgrade is not proof that its upstream works.
/// Call this for upstream failures after the handshake, never merely for
/// a client disconnect or a canceled losing connection attempt.
pub fn report_route_failure(&self, connected: &ConnectedRoute) {
self.record_failure(&connected.route);
}
fn record_failure(&self, route: &Route) { fn record_failure(&self, route: &Route) {
self.route_failures.fetch_add(1, Ordering::Relaxed); self.route_failures.fetch_add(1, Ordering::Relaxed);
let mut health = self.health.lock().unwrap(); let mut health = self.health.lock().unwrap();
health.preferred.retain(|_, preferred| preferred != route);
let failures = health let failures = health
.routes .routes
.get(route) .get(route)
@@ -357,12 +450,15 @@ fn telegram_ips(dc: u16) -> &'static [&'static str] {
3 => &["149.154.175.100"], 3 => &["149.154.175.100"],
4 => &["149.154.167.91", "149.154.167.220"], 4 => &["149.154.167.91", "149.154.167.220"],
5 => &["149.154.171.5"], 5 => &["149.154.171.5"],
203 => &["91.105.192.100"], 203 => &[CDN203_IP],
_ => &[], _ => &[],
} }
} }
pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> { pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
if telegram_ips(dc).is_empty() {
return Vec::new();
}
let websocket_dc = canonical_dc(dc); let websocket_dc = canonical_dc(dc);
let primary = format!("kws{}.web.telegram.org", websocket_dc); let primary = format!("kws{}.web.telegram.org", websocket_dc);
let secondary = format!("kws{}-1.web.telegram.org", websocket_dc); let secondary = format!("kws{}-1.web.telegram.org", websocket_dc);
@@ -373,6 +469,13 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
}; };
let ips = telegram_ips(dc); let ips = telegram_ips(dc);
let mut routes = Vec::new(); let mut routes = Vec::new();
if dc == 203 {
// CDN203 speaks native obfuscated2 on this exact IP:443. A WebSocket
// handshake on the same address can time out even while MTProto is
// healthy. Keep the destination unchanged and use its native wire
// transport; never redirect CDN authorization to ordinary DC2.
routes.push(Route::cdn_tcp());
}
for websocket_host in &websocket_hosts { for websocket_host in &websocket_hosts {
for (index, ip) in ips.iter().enumerate() { for (index, ip) in ips.iter().enumerate() {
@@ -387,17 +490,57 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
}, },
)); ));
} }
routes.push(Route::https( // DC203 uses the DC2 hostname only for TLS/HTTP virtual hosting.
websocket_host.clone(), // Resolving it would connect to DC2 itself and send CDN sessions to
websocket_host.clone(), // the wrong data centre. Its fallback must retain the CDN address.
"/apiws".to_owned(), if dc != 203 {
RouteKind::SystemDns, routes.push(Route::https(
)); websocket_host.clone(),
websocket_host.clone(),
"/apiws".to_owned(),
RouteKind::SystemDns,
));
}
} }
routes routes
} }
async fn connect_route(route: &Route) -> Result<TelegramWebSocket, String> { async fn connect_route(route: &Route) -> Result<TelegramConnection, String> {
connect_route_with_config(route, tls_config()).await
}
fn client_config(roots: rustls::RootCertStore) -> Arc<rustls::ClientConfig> {
Arc::new(
rustls::ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.expect("ring supports the default TLS protocol versions")
.with_root_certificates(roots)
.with_no_client_auth(),
)
}
fn tls_config() -> Arc<rustls::ClientConfig> {
static CONFIG: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
CONFIG
.get_or_init(|| {
client_config(rustls::RootCertStore::from_iter(
webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
))
})
.clone()
}
async fn connect_route_with_config(
route: &Route,
config: Arc<rustls::ClientConfig>,
) -> Result<TelegramConnection, String> {
// Native TCP is a narrowly scoped CDN route, not a general proxy escape
// hatch. Reject malformed raw routes before dialing anything.
if route.kind == RouteKind::TelegramTcp && !allowed_tcp_route(route) {
return Err("native MTProto TCP разрешён только для закреплённого CDN203".to_owned());
}
let tcp = tokio::time::timeout( let tcp = tokio::time::timeout(
CONNECT_TIMEOUT, CONNECT_TIMEOUT,
TcpStream::connect((route.connect_host.as_str(), route.port)), TcpStream::connect((route.connect_host.as_str(), route.port)),
@@ -408,6 +551,10 @@ async fn connect_route(route: &Route) -> Result<TelegramWebSocket, String> {
tcp.set_nodelay(true) tcp.set_nodelay(true)
.map_err(|error| format!("TCP_NODELAY: {}", error))?; .map_err(|error| format!("TCP_NODELAY: {}", error))?;
if route.kind == RouteKind::TelegramTcp {
return Ok(TelegramConnection::Tcp(tcp));
}
let scheme = if route.secure { "wss" } else { "ws" }; let scheme = if route.secure { "wss" } else { "ws" };
let url = format!("{}://{}{}", scheme, route.websocket_host, route.path); let url = format!("{}://{}{}", scheme, route.websocket_host, route.path);
let mut request = url let mut request = url
@@ -422,33 +569,45 @@ async fn connect_route(route: &Route) -> Result<TelegramWebSocket, String> {
); );
if !route.secure { if !route.secure {
// Only reachable from tests, which run a local WebSocket server without // Raw TCP has already returned above. Plain WebSocket is only used by
// a certificate. Production routes are always built by `Route::https`. // local fixtures; production WebSocket routes are always HTTPS.
return tokio::time::timeout( return tokio::time::timeout(
CONNECT_TIMEOUT, CONNECT_TIMEOUT,
tokio_tungstenite::client_async(request, MaybeTlsStream::Plain(tcp)), tokio_tungstenite::client_async(request, MaybeTlsStream::Plain(tcp)),
) )
.await .await
.map_err(|_| "таймаут WebSocket".to_owned())? .map_err(|_| "таймаут WebSocket".to_owned())?
.map(|(websocket, _)| websocket) .map(|(websocket, _)| TelegramConnection::WebSocket(Box::new(websocket)))
.map_err(|error| format!("рукопожатие WebSocket: {}", error)); .map_err(|error| format!("рукопожатие WebSocket: {}", error));
} }
// The URI host remains the real Telegram hostname even when the TCP socket // 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 // is opened to a pinned IP. rustls validates that hostname and certificate
// certificate and sends the correct SNI. // against the bundled roots, and sends that hostname in SNI. The explicit
let tls = native_tls::TlsConnector::new().map_err(|error| format!("TLS setup: {}", error))?; // ring provider avoids process-global provider selection and its panics.
let connector = tokio_tungstenite::Connector::NativeTls(tls); let connector = tokio_tungstenite::Connector::Rustls(config);
tokio::time::timeout( tokio::time::timeout(
CONNECT_TIMEOUT, CONNECT_TIMEOUT,
tokio_tungstenite::client_async_tls_with_config(request, tcp, None, Some(connector)), tokio_tungstenite::client_async_tls_with_config(request, tcp, None, Some(connector)),
) )
.await .await
.map_err(|_| "таймаут TLS/WebSocket".to_owned())? .map_err(|_| "таймаут TLS/WebSocket".to_owned())?
.map(|(websocket, _)| websocket) .map(|(websocket, _)| TelegramConnection::WebSocket(Box::new(websocket)))
.map_err(|error| format!("TLS/WebSocket handshake: {}", error)) .map_err(|error| format!("TLS/WebSocket handshake: {}", error))
} }
fn allowed_tcp_route(route: &Route) -> bool {
#[cfg(test)]
if route.connect_host == "127.0.0.1" && !route.secure {
return true;
}
route.connect_host == CDN203_IP
&& route.port == HTTPS_PORT
&& route.websocket_host.is_empty()
&& route.path.is_empty()
&& !route.secure
}
fn valid_domain(domain: &str) -> bool { fn valid_domain(domain: &str) -> bool {
if domain.is_empty() || domain.len() > 253 || !domain.contains('.') { if domain.is_empty() || domain.len() > 253 || !domain.contains('.') {
return false; return false;
@@ -476,10 +635,35 @@ mod tests {
} }
#[test] #[test]
fn dc203_uses_dc2_websocket_and_its_own_ip() { fn dc203_prefers_native_tcp_and_never_changes_the_cdn_destination() {
let routes = routes_for_dc(203, false); let routes = routes_for_dc(203, false);
assert_eq!(routes[0].websocket_host, "kws2.web.telegram.org"); assert_eq!(routes[0].kind, RouteKind::TelegramTcp);
assert!(routes[0].websocket_host.is_empty());
assert!(!routes[0].secure);
assert_eq!(routes[0].connect_host, "91.105.192.100"); assert_eq!(routes[0].connect_host, "91.105.192.100");
assert_eq!(routes[1].websocket_host, "kws2.web.telegram.org");
assert!(routes
.iter()
.all(|route| route.connect_host == "91.105.192.100"));
assert!(!routes
.iter()
.any(|route| route.kind == RouteKind::SystemDns));
let engine = TransportEngine::new();
engine.set_worker_domains(&["cdn.workers.dev".to_owned()]);
let routes = engine.routes_for_key(DcKey {
dc: 203,
media: true,
});
let ordered = engine.ordered_candidates(DcKey {
dc: 203,
media: true,
});
assert_eq!(ordered[0].kind, RouteKind::TelegramTcp);
assert_eq!(ordered[1].kind, RouteKind::CloudflareWorker);
assert!(routes
.iter()
.filter(|route| route.kind == RouteKind::CloudflareWorker)
.all(|route| route.path == "/apiws?dst=91.105.192.100&dc=203"));
} }
#[test] #[test]
@@ -530,7 +714,7 @@ mod tests {
} }
#[test] #[test]
fn every_production_route_is_tls_on_443() { fn production_websockets_use_tls_and_native_tcp_is_restricted_to_cdn203() {
let engine = TransportEngine::new(); let engine = TransportEngine::new();
engine.set_worker_domains(&["fallback.workers.dev".to_owned()]); engine.set_worker_domains(&["fallback.workers.dev".to_owned()]);
for dc in [1, 2, 3, 4, 5, 203] { for dc in [1, 2, 3, 4, 5, 203] {
@@ -539,7 +723,13 @@ mod tests {
assert!(!routes.is_empty(), "DC{dc} must have at least one route"); assert!(!routes.is_empty(), "DC{dc} must have at least one route");
for route in routes { for route in routes {
assert_eq!(route.port, 443, "{route:?}"); assert_eq!(route.port, 443, "{route:?}");
assert!(route.secure, "{route:?}"); if route.kind == RouteKind::TelegramTcp {
assert_eq!(dc, 203);
assert_eq!(route.connect_host, CDN203_IP);
assert!(allowed_tcp_route(&route));
} else {
assert!(route.secure, "{route:?}");
}
} }
} }
} }
@@ -547,7 +737,7 @@ mod tests {
#[test] #[test]
fn every_data_center_offers_a_pinned_ip_and_a_dns_route() { fn every_data_center_offers_a_pinned_ip_and_a_dns_route() {
for dc in [1, 2, 3, 4, 5, 203] { for dc in [1, 2, 3, 4, 5] {
let routes = routes_for_dc(dc, false); let routes = routes_for_dc(dc, false);
assert!( assert!(
routes routes
@@ -611,7 +801,7 @@ mod tests {
} }
#[test] #[test]
fn all_routes_cooling_down_still_yields_the_soonest_retry() { fn all_routes_cooling_down_do_not_bypass_the_backoff() {
let engine = TransportEngine::new(); let engine = TransportEngine::new();
let key = DcKey { let key = DcKey {
dc: 5, dc: 5,
@@ -627,13 +817,7 @@ mod tests {
engine.record_failure(route); engine.record_failure(route);
} }
let candidates = engine.ordered_candidates(key); assert!(engine.ordered_candidates(key).is_empty());
assert_eq!(
candidates.len(),
1,
"a fully cooling table must offer exactly one retry, not give up"
);
assert_eq!(candidates[0], routes[0]);
} }
#[test] #[test]
@@ -694,7 +878,7 @@ mod tests {
} }
#[test] #[test]
fn worker_is_the_last_resort() { fn explicit_worker_is_tried_before_redundant_telegram_fallbacks() {
let engine = TransportEngine::new(); let engine = TransportEngine::new();
engine.set_worker_domains(&["fallback.workers.dev".to_owned()]); engine.set_worker_domains(&["fallback.workers.dev".to_owned()]);
let key = DcKey { let key = DcKey {
@@ -702,10 +886,19 @@ mod tests {
media: false, media: false,
}; };
let candidates = engine.ordered_candidates(key); let candidates = engine.ordered_candidates(key);
assert_eq!( assert_eq!(candidates[0].kind, RouteKind::TelegramIp);
candidates.last().unwrap().kind, assert_eq!(candidates[1].kind, RouteKind::CloudflareWorker);
RouteKind::CloudflareWorker, let worker = candidates
"third-party infrastructure must never be tried before Telegram itself" .iter()
.position(|route| route.kind == RouteKind::CloudflareWorker)
.unwrap();
let dns = candidates
.iter()
.position(|route| route.kind == RouteKind::SystemDns)
.unwrap();
assert!(
worker < dns,
"an explicitly configured independent path must not wait for every blocked IP"
); );
} }
@@ -781,6 +974,7 @@ mod tests {
RouteKind::AlternateTelegramIp, RouteKind::AlternateTelegramIp,
RouteKind::SystemDns, RouteKind::SystemDns,
RouteKind::CloudflareWorker, RouteKind::CloudflareWorker,
RouteKind::TelegramTcp,
] { ] {
assert_eq!(RouteKind::from_ui_code(kind.ui_code()), Some(kind)); assert_eq!(RouteKind::from_ui_code(kind.ui_code()), Some(kind));
assert_eq!(route_label(kind.ui_code()), kind.label()); assert_eq!(route_label(kind.ui_code()), kind.label());
@@ -796,19 +990,310 @@ mod tests {
RouteKind::AlternateTelegramIp, RouteKind::AlternateTelegramIp,
RouteKind::SystemDns, RouteKind::SystemDns,
RouteKind::CloudflareWorker, RouteKind::CloudflareWorker,
RouteKind::TelegramTcp,
] { ] {
assert_ne!(route_label(0), kind.label()); assert_ne!(route_label(0), kind.label());
} }
} }
fn local_routes(count: usize) -> Vec<Route> {
(0..count)
.map(|index| Route {
connect_host: "127.0.0.1".to_owned(),
websocket_host: "localhost".to_owned(),
path: format!("/{index}"),
kind: RouteKind::TelegramIp,
port: 443,
secure: false,
})
.collect()
}
struct ActiveAttempt(Arc<std::sync::atomic::AtomicUsize>);
impl Drop for ActiveAttempt {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[tokio::test(start_paused = true)]
async fn a_hanging_first_route_does_not_delay_a_working_fallback() {
let engine = TransportEngine::new();
*engine.forced_routes.lock().unwrap() = local_routes(3);
let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let started = Instant::now();
let (_, winner) = engine
.race_connections(
DcKey {
dc: 2,
media: false,
},
|route| {
let active = active.clone();
async move {
active.fetch_add(1, Ordering::SeqCst);
let _active = ActiveAttempt(active);
if route.path == "/1" {
tokio::time::sleep(Duration::from_millis(1)).await;
Ok(())
} else {
std::future::pending::<Result<(), String>>().await
}
}
},
)
.await
.unwrap();
assert_eq!(winner.route.path, "/1");
assert_eq!(started.elapsed(), FALLBACK_DELAY + Duration::from_millis(1));
assert_eq!(
active.load(Ordering::SeqCst),
0,
"losing attempts must be canceled"
);
assert_eq!(
engine.route_failures(),
0,
"cancellation is not a route failure"
);
}
#[tokio::test(start_paused = true)]
async fn connection_races_are_bounded_and_cooldown_prevents_redialing() {
let engine = TransportEngine::new();
*engine.forced_routes.lock().unwrap() = local_routes(8);
let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let peak = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let key = DcKey {
dc: 2,
media: false,
};
let error = engine
.race_connections(key, |_| {
let active = active.clone();
let peak = peak.clone();
async move {
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(current, Ordering::SeqCst);
let _active = ActiveAttempt(active);
tokio::time::sleep(Duration::from_secs(1)).await;
Err::<(), _>("test handshake failure".to_owned())
}
})
.await
.unwrap_err();
assert!(error.contains("test handshake failure"));
assert_eq!(peak.load(Ordering::SeqCst), MAX_CONNECTING);
assert_eq!(engine.route_failures(), 8);
let error = engine
.race_connections(key, |_| {
panic!("a route in cooldown must never be dialed");
#[allow(unreachable_code)]
std::future::ready(Ok::<(), String>(()))
})
.await
.unwrap_err();
assert!(error.contains("повтор через"));
assert_eq!(engine.route_failures(), 8);
tokio::time::advance(FAILURE_BACKOFF_INITIAL).await;
assert_eq!(engine.ordered_candidates(key).len(), 8);
}
#[test]
fn an_upstream_failure_removes_preference_and_starts_cooldown() {
let engine = TransportEngine::new();
let key = DcKey {
dc: 2,
media: false,
};
let route = routes_for_dc(2, false)[0].clone();
engine.record_success(key, &route);
engine.report_route_failure(&ConnectedRoute {
route: route.clone(),
});
assert!(!engine.ordered_candidates(key).contains(&route));
assert!(!engine.health.lock().unwrap().preferred.contains_key(&key));
assert_eq!(engine.route_failures(), 1);
}
#[tokio::test]
async fn unsupported_dc_reports_missing_routes_without_dialing() {
let error = TransportEngine::new()
.connect(999, false)
.await
.unwrap_err();
assert!(error.contains("нет маршрутов"));
}
async fn local_tls_route(
hostname: &str,
) -> (
Route,
Arc<rustls::ClientConfig>,
tokio::task::JoinHandle<Option<String>>,
) {
let certificate = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap();
let key = rustls::pki_types::PrivatePkcs8KeyDer::from(certificate.key_pair.serialize_der());
let cert = certificate.cert.der().clone();
let mut roots = rustls::RootCertStore::empty();
roots.add(cert.clone()).unwrap();
let server = rustls::ServerConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![cert], key.into())
.unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server));
let Ok(tls) = acceptor.accept(socket).await else {
return None;
};
let name = tls.get_ref().1.server_name().map(str::to_owned);
#[allow(clippy::result_large_err)]
let negotiate = |_: &tokio_tungstenite::tungstenite::handshake::server::Request,
mut response: tokio_tungstenite::tungstenite::handshake::server::Response| {
response.headers_mut().insert("Sec-WebSocket-Protocol", "binary".parse().unwrap());
Ok(response)
};
let _ = tokio_tungstenite::accept_hdr_async(tls, negotiate).await;
name
});
(
Route {
connect_host: "127.0.0.1".to_owned(),
websocket_host: hostname.to_owned(),
path: "/apiws".to_owned(),
kind: RouteKind::TelegramIp,
port,
secure: true,
},
client_config(roots),
task,
)
}
#[tokio::test]
async fn pinned_ip_tls_uses_uri_hostname_for_sni_and_certificate_validation() {
let (route, config, server) = local_tls_route("localhost").await;
connect_route_with_config(&route, config).await.unwrap();
assert_eq!(server.await.unwrap().as_deref(), Some("localhost"));
}
#[tokio::test]
async fn tls_rejects_a_trusted_certificate_for_a_different_hostname() {
let (route, config, server) = local_tls_route("wrong.example").await;
let error = connect_route_with_config(&route, config).await.unwrap_err();
assert!(error.contains("TLS/WebSocket handshake"), "{error}");
assert!(server.await.unwrap().is_none());
}
#[tokio::test]
async fn production_roots_reject_an_untrusted_certificate() {
let (route, _, server) = local_tls_route("localhost").await;
let error = connect_route(&route).await.unwrap_err();
assert!(error.contains("TLS/WebSocket handshake"), "{error}");
assert!(server.await.unwrap().is_none());
assert!(
Arc::ptr_eq(&tls_config(), &tls_config()),
"reuse the TLS configuration"
);
}
#[tokio::test]
async fn native_tcp_connects_without_sending_a_tls_or_websocket_handshake() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let engine = TransportEngine::new();
engine.force_local_route_with(
listener.local_addr().unwrap().port(),
RouteKind::TelegramTcp,
String::new(),
);
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut bytes = [0; 4];
socket.read_exact(&mut bytes).await.unwrap();
assert_eq!(
&bytes, b"init",
"first bytes must be native transport bytes"
);
socket.write_all(b"pong").await.unwrap();
});
let (connection, connected) =
tokio::time::timeout(Duration::from_secs(1), engine.connect(203, true))
.await
.unwrap()
.unwrap();
assert_eq!(connected.route.kind, RouteKind::TelegramTcp);
let TelegramConnection::Tcp(mut socket) = connection else {
panic!("native CDN route must return a TCP stream");
};
assert!(socket.nodelay().unwrap());
socket.write_all(b"init").await.unwrap();
let mut reply = [0; 4];
tokio::time::timeout(Duration::from_secs(1), socket.read_exact(&mut reply))
.await
.unwrap()
.unwrap();
assert_eq!(&reply, b"pong");
server.await.unwrap();
}
#[tokio::test]
async fn raw_tcp_failure_is_named_and_enters_cooldown() {
let engine = TransportEngine::new();
// Port zero cannot accept a TCP connection; no external traffic.
engine.force_local_route_with(0, RouteKind::TelegramTcp, String::new());
let error = engine.connect(203, false).await.unwrap_err();
assert!(error.contains("MTProto TCP"), "{error}");
assert!(
!error.contains("TLS"),
"native transport must not be called TLS"
);
assert_eq!(engine.route_failures(), 1);
assert!(engine
.ordered_candidates(DcKey {
dc: 203,
media: false
})
.is_empty());
}
#[tokio::test]
async fn raw_tcp_guard_rejects_a_different_destination_before_dialing() {
let mut route = Route::cdn_tcp();
route.connect_host = "127.0.0.2".to_owned();
let error = connect_route(&route).await.unwrap_err();
assert!(error.contains("только для закреплённого CDN203"), "{error}");
for dc in [1, 2, 3, 4, 5, 999] {
assert!(routes_for_dc(dc, false)
.iter()
.all(|route| route.kind != RouteKind::TelegramTcp));
}
}
#[tokio::test] #[tokio::test]
#[ignore = "requires live Telegram network access"] #[ignore = "requires live Telegram network access"]
async fn connects_to_all_production_data_centers() { async fn connects_to_all_production_data_centers() {
let engine = TransportEngine::new(); let engine = TransportEngine::new();
for dc in [1, 2, 3, 4, 5, 203] { for dc in [1, 2, 3, 4, 5, 203] {
let (mut websocket, connected) = engine.connect(dc, false).await.unwrap(); let (connection, connected) = engine.connect(dc, false).await.unwrap();
assert!(!connected.route.websocket_host.is_empty()); assert!(!connected.route.connect_host.is_empty());
websocket.close(None).await.unwrap(); match connection {
TelegramConnection::WebSocket(mut websocket) => {
websocket.close(None).await.unwrap()
}
TelegramConnection::Tcp(mut socket) => {
use tokio::io::AsyncWriteExt;
socket.shutdown().await.unwrap();
}
}
} }
} }
} }
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [{ "label": "main", "title": "TGLock", "backgroundColor": "#070A10" }]
},
"bundle": { "android": { "minSdkVersion": 24 } }
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "TGLock", "productName": "TGLock",
"version": "2.0.0-beta.14", "version": "2.0.0-beta.15",
"identifier": "com.bysonic.tglock", "identifier": "com.bysonic.tglock",
"mainBinaryName": "tglock", "mainBinaryName": "tglock",
"build": { "build": {
+59 -2
View File
@@ -58,6 +58,8 @@ let status: Status = {
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "" }; let settings: Settings = { lanMode: false, port: 1080, workerDomain: "" };
let busy = false; let busy = false;
let toastTimer: number | undefined; let toastTimer: number | undefined;
let toastMessage = "";
let toastError = false;
const icons = { const icons = {
arrowLeft: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>`, arrowLeft: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>`,
@@ -115,7 +117,7 @@ function shell(content: string, pageClass = ""): string {
<main class="app-shell ${pageClass}"> <main class="app-shell ${pageClass}">
<div class="drag-region" data-tauri-drag-region></div> <div class="drag-region" data-tauri-drag-region></div>
${content} ${content}
<div id="toast" class="toast" role="status"></div> <div id="toast" class="toast${toastMessage ? " visible" : ""}${toastError ? " error" : ""}" role="status">${escapeHtml(toastMessage)}</div>
</main> </main>
`; `;
} }
@@ -152,6 +154,11 @@ function renderHome(): void {
<span class="button-arrow"></span> <span class="button-arrow"></span>
</button> </button>
${status.running ? `<div class="connection-actions">
<button id="open-telegram" class="nav-button">Открыть Telegram</button>
<button id="copy-link" class="nav-button">Скопировать ссылку</button>
</div>` : ""}
<div class="route-pill"> <div class="route-pill">
<span class="route-pulse"></span> <span class="route-pulse"></span>
<span>${escapeHtml(status.route)}</span> <span>${escapeHtml(status.route)}</span>
@@ -186,6 +193,8 @@ function renderHome(): void {
`, "home-page"); `, "home-page");
document.querySelector("#power")?.addEventListener("click", toggleProtection); document.querySelector("#power")?.addEventListener("click", toggleProtection);
document.querySelector("#open-telegram")?.addEventListener("click", openTelegram);
document.querySelector("#copy-link")?.addEventListener("click", copyTelegramLink);
document.querySelector("#copy-address")?.addEventListener("click", copyShareAddress); document.querySelector("#copy-address")?.addEventListener("click", copyShareAddress);
document.querySelector("#settings-nav")?.addEventListener("click", () => navigate("settings")); document.querySelector("#settings-nav")?.addEventListener("click", () => navigate("settings"));
document.querySelector("#diagnostics-nav")?.addEventListener("click", () => navigate("diagnostics")); document.querySelector("#diagnostics-nav")?.addEventListener("click", () => navigate("diagnostics"));
@@ -326,6 +335,8 @@ function renderDiagnostics(): void {
настроен неверно», а адрес такого клиента появится в журнале ниже. настроен неверно», а адрес такого клиента появится в журнале ниже.
</p> </p>
<button id="copy-diagnostics" class="save-button">Скопировать диагностику</button>
<div class="log-panel"> <div class="log-panel">
<div class="log-heading"> <div class="log-heading">
<span>Последние события</span> <span>Последние события</span>
@@ -348,6 +359,7 @@ function renderDiagnostics(): void {
</section> </section>
`, "subpage-shell"); `, "subpage-shell");
document.querySelector("#copy-diagnostics")?.addEventListener("click", copyDiagnostics);
document.querySelector("#back")?.addEventListener("click", () => navigate("home")); document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
} }
@@ -419,6 +431,46 @@ async function saveSettings(event: Event): Promise<void> {
} }
} }
async function openTelegram(): Promise<void> {
try { await invoke("open_telegram"); }
catch (error) { showToast(String(error), true); }
}
async function copyTelegramLink(): Promise<void> {
try {
const link = await invoke<string>("get_telegram_link");
await navigator.clipboard.writeText(link);
showToast("Ссылка скопирована; она содержит секрет прокси");
} catch (error) {
showToast(`Не удалось скопировать ссылку: ${String(error)}`, true);
}
}
async function copyDiagnostics(): Promise<void> {
try {
// Public reports exclude proxy links, worker domains and free-form logs.
const report = {
application: "TGLock",
version: "2.0.0-beta.15",
running: status.running,
activeConnections: status.activeConnections,
tunnels: status.tunnels,
dataCenter: status.dataCenter,
route: status.route,
failures: status.failures,
routeFailures: status.routeFailures,
blocked: status.blocked,
unknownClients: status.unknownClients,
silentClients: status.silentClients,
uptimeSeconds: status.uptimeSeconds,
port: status.port,
lanMode: settings.lanMode,
};
await navigator.clipboard.writeText(JSON.stringify(report, null, 2));
showToast("Диагностика скопирована без адресов и секретов");
} catch { showToast("Буфер обмена недоступен", true); }
}
async function copyShareAddress(): Promise<void> { async function copyShareAddress(): Promise<void> {
const address = status.shareAddress; const address = status.shareAddress;
if (!address) return; if (!address) return;
@@ -433,12 +485,17 @@ async function copyShareAddress(): Promise<void> {
function showToast(message: string, error = false): void { function showToast(message: string, error = false): void {
window.clearTimeout(toastTimer); window.clearTimeout(toastTimer);
toastMessage = message.replace(/^["']|["']$/g, "");
toastError = error;
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
const toast = document.querySelector<HTMLDivElement>("#toast"); const toast = document.querySelector<HTMLDivElement>("#toast");
if (!toast) return; if (!toast) return;
toast.textContent = message.replace(/^["']|["']$/g, ""); toast.textContent = message.replace(/^["']|["']$/g, "");
toast.className = `toast visible${error ? " error" : ""}`; toast.className = `toast visible${error ? " error" : ""}`;
toastTimer = window.setTimeout(() => toast.classList.remove("visible"), 2800); toastTimer = window.setTimeout(() => {
toastMessage = "";
document.querySelector("#toast")?.classList.remove("visible");
}, 4500);
}); });
} }
+18
View File
@@ -897,3 +897,21 @@ input:disabled {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px; font-size: 11px;
} }
/* Keep actions reachable with mobile safe areas and accessibility text scaling. */
.app-shell { overflow-y: auto; }
.home, .subpage { height: auto; min-height: 100%; }
.connection-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 12px;
}
.connection-actions .nav-button { min-height: 44px; padding: 8px 12px; }
@media (max-width: 500px) {
.home { padding: max(32px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); }
.subpage { padding-left: 20px; padding-right: 20px; padding-top: max(28px, env(safe-area-inset-top)); }
.drag-region { display: none; }
.share-address { overflow-wrap: anywhere; }
}
+71 -15
View File
@@ -28,9 +28,14 @@ const ALLOWED_DESTINATIONS = new Set([
]); ]);
const TELEGRAM_PORT = 443; const TELEGRAM_PORT = 443;
const CONNECT_TIMEOUT_MS = 3000;
// WebSocket events cannot be paused. Bound the outstanding writes instead of
// retaining an unlimited chain of promises when Telegram stops reading.
const MAX_PENDING_BYTES = 1024 * 1024;
const MAX_PENDING_MESSAGES = 256;
export default { export default {
async fetch(request, env) { async fetch(request, env = {}) {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname !== "/apiws") { if (url.pathname !== "/apiws") {
@@ -50,19 +55,48 @@ export default {
return new Response("destination not allowed", { status: 403 }); return new Response("destination not allowed", { status: 403 });
} }
if (!request.headers.get("Sec-WebSocket-Protocol")?.split(",").some((p) => p.trim() === "binary")) {
return new Response("binary websocket subprotocol required", { status: 400 });
}
let upstream;
let timer;
let timedOut = false;
try {
upstream = connect({ hostname: destination, port: TELEGRAM_PORT });
// Both promises can reject on connect failure; observe closed immediately.
upstream.closed.catch(() => {});
await Promise.race([
upstream.opened,
new Promise((_, reject) => {
timer = setTimeout(() => {
timedOut = true;
reject(new Error("connect timeout"));
}, CONNECT_TIMEOUT_MS);
}),
]);
} catch {
if (upstream) await upstream.close().catch(() => {});
return new Response(timedOut ? "Telegram TCP connect timeout" : "Telegram TCP connect failed", {
status: timedOut ? 504 : 502,
});
} finally {
clearTimeout(timer);
}
const [client, server] = Object.values(new WebSocketPair()); const [client, server] = Object.values(new WebSocketPair());
server.accept(); server.accept();
const upstream = connect({ hostname: destination, port: TELEGRAM_PORT });
const writer = upstream.writable.getWriter(); const writer = upstream.writable.getWriter();
let closed = false; let closed = false;
const shutdown = () => { const shutdown = (code = 1000, reason = "") => {
if (closed) return; if (closed) return;
closed = true; closed = true;
writer.close().catch(() => {}); // close() cancels both directions, including a blocked write/read.
upstream.close().catch(() => {});
try { try {
server.close(); server.close(code, reason);
} catch { } catch {
// соединение уже закрыто // соединение уже закрыто
} }
@@ -77,19 +111,39 @@ export default {
// проявлялось. Как только голодание починили, в воркер пошёл настоящий // проявлялось. Как только голодание починили, в воркер пошёл настоящий
// поток (by-sonic/tglock#42). // поток (by-sonic/tglock#42).
let pending = Promise.resolve(); let pending = Promise.resolve();
let pendingBytes = 0;
let pendingMessages = 0;
server.addEventListener("message", (event) => { server.addEventListener("message", (event) => {
const chunk = if (closed) return;
event.data instanceof ArrayBuffer if (!(event.data instanceof ArrayBuffer) && !ArrayBuffer.isView(event.data)) {
? new Uint8Array(event.data) shutdown(1003, "binary messages required");
: event.data; return;
}
const chunk = event.data instanceof ArrayBuffer
? new Uint8Array(event.data)
: new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength);
if (pendingBytes + chunk.byteLength > MAX_PENDING_BYTES || pendingMessages >= MAX_PENDING_MESSAGES) {
shutdown(1009, "Telegram write queue full");
return;
}
pendingBytes += chunk.byteLength;
pendingMessages += 1;
pending = pending pending = pending
.then(() => writer.ready) .then(async () => {
.then(() => writer.write(chunk)) if (closed) return;
.catch(shutdown); await writer.ready;
if (!closed) await writer.write(chunk);
})
.catch(() => shutdown(1011, "Telegram write failed"))
.finally(() => {
pendingBytes -= chunk.byteLength;
pendingMessages -= 1;
});
}); });
server.addEventListener("close", shutdown); server.addEventListener("close", () => shutdown());
server.addEventListener("error", shutdown); server.addEventListener("error", () => shutdown(1011, "WebSocket failed"));
upstream.closed.catch(() => shutdown(1011, "Telegram socket failed"));
// Обратное направление: всё, что приходит от Telegram, уходит клиенту. // Обратное направление: всё, что приходит от Telegram, уходит клиенту.
(async () => { (async () => {
@@ -101,7 +155,9 @@ export default {
server.send(value); server.send(value);
} }
} catch { } catch {
// разрыв соединения — обычная ситуация, не ошибка shutdown(1011, "Telegram read failed");
} finally {
reader.releaseLock();
} }
shutdown(); shutdown();
})(); })();
+196
View File
@@ -0,0 +1,196 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
const source = await readFile(new URL("./tglock-worker.js", import.meta.url), "utf8");
const tick = () => new Promise((resolve) => setImmediate(resolve));
function deferred() {
let resolve, reject;
const promise = new Promise((a, b) => { resolve = a; reject = b; });
return { promise, resolve, reject };
}
// Execute the actual standalone deployment file, replacing only Cloudflare's
// platform primitives. No test-only implementation of the relay is used.
async function fixture({ opened = Promise.resolve(), write, timeout = false } = {}) {
let readController;
const readable = new ReadableStream({ start(controller) { readController = controller; } });
const reads = { reject(error) { readController.error(error); } };
const closed = deferred();
const writes = [];
const events = new Map();
const state = { connects: 0, accepted: false, socketCloses: 0, sent: [], closes: [] };
const server = {
accept() { state.accepted = true; },
addEventListener(name, callback) { events.set(name, callback); },
close(code, reason) { state.closes.push({ code, reason }); },
send(chunk) { state.sent.push(Array.from(chunk)); },
};
const socket = {
opened, closed: closed.promise,
writable: { getWriter: () => ({
ready: Promise.resolve(),
async write(chunk) { writes.push(Array.from(chunk)); if (write) await write(chunk); },
}) },
readable,
async close() {
state.socketCloses++;
try { readController.close(); } catch { /* already closed or errored */ }
closed.resolve();
},
};
const context = vm.createContext({
URL, Uint8Array, ArrayBuffer, setTimeout: timeout ? (fn) => setTimeout(fn, 0) : setTimeout, clearTimeout,
Response: class {
constructor(body, options) { this.body = body; Object.assign(this, options); }
},
WebSocketPair: class { constructor() { this[0] = {}; this[1] = server; } },
});
const sockets = new vm.SyntheticModule(["connect"], function () {
this.setExport("connect", (address) => {
state.connects++;
state.address = address;
return socket;
});
}, { context });
const module = new vm.SourceTextModule(source, { context });
await module.link((name) => {
assert.equal(name, "cloudflare:sockets");
return sockets;
});
await module.evaluate();
return {
...state,
state, writes, reads, closed, socket,
download(chunk) { readController.enqueue(chunk); },
eof() { readController.close(); },
message(data) { events.get("message")({ data }); },
event(name) { events.get(name)({}); },
fetch(path = "/apiws?dst=149.154.167.51&dc=2", headers = {}, env = {}) {
return module.namespace.default.fetch(new Request(`https://example.workers.dev${path}`, {
headers: { Upgrade: "websocket", "Sec-WebSocket-Protocol": "binary", ...headers },
}), env);
},
};
}
test("HTTP deployment check does not claim Telegram connectivity", async () => {
const f = await fixture();
assert.equal((await f.fetch("/apiws", { Upgrade: "" })).status, 426);
assert.equal(f.state.connects, 0);
});
test("reject unauthorized destinations, token and subprotocol before opening TCP", async () => {
const f = await fixture();
assert.equal((await f.fetch("/wrong")).status, 404);
assert.equal((await f.fetch("/apiws?dst=127.0.0.1")).status, 403);
assert.equal((await f.fetch(undefined, {}, { TGLOCK_TOKEN: "test-token" })).status, 403);
assert.equal((await f.fetch(undefined, { "Sec-WebSocket-Protocol": "chat" })).status, 400);
assert.equal(f.state.connects, 0);
});
test("wait for upstream TCP before accepting WebSocket", async () => {
const opened = deferred();
const f = await fixture({ opened: opened.promise });
const response = f.fetch();
await tick();
assert.equal(f.state.accepted, false);
opened.resolve();
const result = await response;
assert.equal(result.status, 101);
assert.equal(result.headers["Sec-WebSocket-Protocol"], "binary");
assert.equal(f.state.accepted, true);
f.event("close");
});
test("failed upstream returns HTTP 502, never a successful tunnel", async () => {
const f = await fixture({ opened: Promise.reject(new Error("unreachable")) });
assert.equal((await f.fetch()).status, 502);
assert.equal(f.state.accepted, false);
assert.equal(f.state.socketCloses, 1);
});
test("upstream timeout returns 504 and cancels socket", async () => {
const f = await fixture({ opened: new Promise(() => {}), timeout: true });
assert.equal((await f.fetch()).status, 504);
assert.equal(f.state.accepted, false);
assert.equal(f.state.socketCloses, 1);
});
test("serialize binary writes, preserve bytes and view offsets", async () => {
const firstWrite = deferred();
let calls = 0;
const f = await fixture({ write: async () => { if (++calls === 1) await firstWrite.promise; } });
await f.fetch();
f.message(new Uint8Array([1, 2]).buffer);
f.message(new Uint8Array([99, 3, 4, 88]).subarray(1, 3));
await tick();
assert.deepEqual(f.writes, [[1, 2]]);
firstWrite.resolve();
await tick();
assert.deepEqual(f.writes, [[1, 2], [3, 4]]);
f.event("close");
});
test("overflow closes socket and discards queued writes", async () => {
const f = await fixture();
await f.fetch();
f.message(new Uint8Array(1024 * 1024).buffer);
f.message(new Uint8Array([1]).buffer);
await tick();
assert.equal(f.state.closes[0].code, 1009);
assert.equal(f.state.socketCloses, 1);
assert.equal(f.writes.length, 0);
});
test("empty-message floods have a bounded queue too", async () => {
const f = await fixture();
await f.fetch();
for (let i = 0; i < 257; i++) f.message(new ArrayBuffer(0));
await tick();
assert.equal(f.state.closes[0].code, 1009);
assert.equal(f.state.socketCloses, 1);
});
test("text and write failures get diagnostic close codes", async () => {
const f = await fixture();
await f.fetch();
f.message("not binary");
assert.equal(f.state.closes[0].code, 1003);
const failing = await fixture({ write: async () => { throw new Error("reset"); } });
await failing.fetch();
failing.message(new Uint8Array([1]).buffer);
await tick();
assert.equal(failing.state.closes[0].code, 1011);
assert.equal(failing.state.socketCloses, 1);
});
test("read errors propagate and close both socket directions", async () => {
const f = await fixture();
await f.fetch();
f.reads.reject(new Error("reset"));
await tick();
assert.equal(f.state.closes[0].code, 1011);
assert.equal(f.state.socketCloses, 1);
});
test("Telegram bytes reach WebSocket unchanged and EOF closes both directions", async () => {
const f = await fixture();
await f.fetch();
f.download(new Uint8Array([0, 255, 128]));
f.download(new Uint8Array([1, 2]));
f.eof();
await tick();
assert.deepEqual(f.state.sent, [[0, 255, 128], [1, 2]]);
assert.equal(f.state.closes[0].code, 1000);
assert.equal(f.state.socketCloses, 1);
});
test("CDN worker keeps the requested DC203 destination", async () => {
const f = await fixture();
await f.fetch("/apiws?dst=91.105.192.100&dc=203");
assert.equal(f.state.address.hostname, "91.105.192.100");
assert.equal(f.state.address.port, 443);
f.event("close");
});
+3
View File
@@ -0,0 +1,3 @@
name = "tglock"
main = "tglock-worker.js"
compatibility_date = "2026-09-19"