fix: address routing, media, worker and platform issues

This commit is contained in:
babin
2026-09-19 15:16:01 +03:00
parent 8617d25f3a
commit 84a05a4698
73 changed files with 3749 additions and 681 deletions
+54
View File
@@ -0,0 +1,54 @@
name: Android APK
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
apk:
name: Android ARM64 debug APK
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
- 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: aarch64-linux-android
- 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 aarch64 --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/arm64-v8a/libtglock_lib.so'
- uses: actions/upload-artifact@v4
with:
name: tglock-android-arm64-debug
path: gen/android/app/build/outputs/apk/**/*.apk
if-no-files-found: error
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
+16
View File
@@ -9,6 +9,19 @@ permissions:
contents: read
jobs:
windows-core:
name: Windows headless core
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.88.0
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
rust:
name: Rust checks
runs-on: macos-latest
@@ -210,3 +223,6 @@ jobs:
- name: Type-check and build
run: npm ci && npm run build
- name: Worker contract and failure-path tests
run: npm run test:worker
+20
View File
@@ -144,6 +144,26 @@ jobs:
test -f "$exe" || { echo "нет /usr/bin/tglock:"; dpkg-deb -c "$deb" | grep '/bin/'; exit 1; }
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 }}
files: arm64-cli/*
cli:
name: Headless CLI ${{ matrix.platform }}
needs: publish
+2 -1
View File
@@ -2,7 +2,8 @@
/dist
/dist-ui
/node_modules
/gen
/gen/*
!/gen/android/
/tools
*.zip
.claude/
Generated
+575 -124
View File
@@ -108,6 +108,137 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "atk"
version = "0.18.2"
@@ -203,6 +334,19 @@ dependencies = [
"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]]
name = "brotli"
version = "8.0.4"
@@ -448,6 +592,15 @@ dependencies = [
"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]]
name = "cookie"
version = "0.18.1"
@@ -483,7 +636,7 @@ dependencies = [
"bitflags 2.13.1",
"core-foundation",
"core-graphics-types",
"foreign-types 0.5.0",
"foreign-types",
"libc",
]
@@ -833,6 +986,33 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "equivalent"
version = "1.0.2"
@@ -860,6 +1040,26 @@ dependencies = [
"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]]
name = "fastrand"
version = "2.5.0"
@@ -913,15 +1113,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "foreign-types"
version = "0.5.0"
@@ -929,7 +1120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared 0.3.1",
"foreign-types-shared",
]
[[package]]
@@ -943,12 +1134,6 @@ dependencies = [
"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]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -996,6 +1181,19 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "futures-macro"
version = "0.3.33"
@@ -1350,6 +1548,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1939,23 +2143,6 @@ dependencies = [
"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]]
name = "ndk"
version = "0.9.0"
@@ -2236,59 +2423,27 @@ version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
dependencies = [
"dunce",
"is-wsl",
"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]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "pango"
version = "0.18.3"
@@ -2314,6 +2469,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2337,6 +2498,16 @@ dependencies = [
"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]]
name = "percent-encoding"
version = "2.3.2"
@@ -2402,6 +2573,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "pkg-config"
version = "0.3.33"
@@ -2447,6 +2629,20 @@ dependencies = [
"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]]
name = "potential_utf"
version = "0.1.5"
@@ -2605,6 +2801,19 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "redox_syscall"
version = "0.5.18"
@@ -2708,6 +2917,20 @@ dependencies = [
"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]]
name = "rustc-hash"
version = "2.1.3"
@@ -2736,6 +2959,40 @@ dependencies = [
"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]]
name = "rustversion"
version = "1.0.23"
@@ -2751,15 +3008,6 @@ dependencies = [
"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]]
name = "schemars"
version = "0.8.22"
@@ -2817,29 +3065,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "selectors"
version = "0.36.1"
@@ -3183,6 +3408,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3429,6 +3660,44 @@ dependencies = [
"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]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3560,17 +3829,20 @@ dependencies = [
"clap",
"ctr",
"futures-util",
"native-tls",
"open",
"rand",
"rcgen",
"rustls",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"toml 0.8.2",
"webpki-roots 0.26.11",
]
[[package]]
@@ -3696,12 +3968,12 @@ dependencies = [
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
name = "tokio-rustls"
version = "0.26.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
dependencies = [
"native-tls",
"rustls",
"tokio",
]
@@ -3713,10 +3985,12 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"native-tls",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]]
@@ -3904,9 +4178,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"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]]
name = "tracing-core"
version = "0.1.36"
@@ -3956,8 +4242,9 @@ dependencies = [
"http",
"httparse",
"log",
"native-tls",
"rand",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 1.0.69",
"utf-8",
@@ -3975,6 +4262,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "unic-char-property"
version = "0.9.0"
@@ -4028,6 +4326,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4083,12 +4387,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4289,6 +4587,24 @@ dependencies = [
"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]]
name = "webview2-com"
version = "0.38.2"
@@ -4488,6 +4804,15 @@ dependencies = [
"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]]
name = "windows-sys"
version = "0.59.0"
@@ -4756,6 +5081,15 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -4779,6 +5113,76 @@ dependencies = [
"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]]
name = "zerocopy"
version = "0.8.55"
@@ -4820,6 +5224,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.4"
@@ -4858,3 +5268,44 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
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",
]
+10 -5
View File
@@ -12,7 +12,7 @@ default = ["gui"]
# The desktop GUI. Turning it off drops Tauri, the system WebView and the
# frontend bundle from the build, which is what makes headless servers and
# machines without a GPU or monitor able to build and run TGLock at all.
gui = ["dep:tauri", "dep:tauri-build", "dep:open"]
gui = ["dep:tauri", "dep:tauri-build", "dep:tauri-plugin-opener"]
# The headless binary. Deliberately NOT in `default`.
#
# 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"]
[lib]
name = "tglock"
name = "tglock_lib"
path = "src/lib.rs"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "tglock"
@@ -38,7 +39,7 @@ required-features = ["cli"]
[dependencies]
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 }
# Файл конфигурации CLI. TOML, а не JSON, потому что файл правят руками и в нём
# нужны комментарии.
@@ -54,8 +55,10 @@ tokio = { version = "1", features = [
"sync",
"signal",
] }
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
native-tls = "0.2"
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
# 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"
aes = "0.8"
ctr = "0.9"
@@ -67,6 +70,8 @@ rand = "0.8"
tauri-build = { version = "2", features = [], optional = true }
[dev-dependencies]
rcgen = "0.13"
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
# `start_paused` в тестах: таймаут ожидания запроса от клиента — десять секунд,
# и ждать их по-настоящему в тесте нельзя. В сборку не попадает: dev-зависимости
# участвуют только в тестах.
+17 -7
View File
@@ -70,7 +70,7 @@ TGLock — это **локальный прокси** на твоём компь
-**Голосовые и видеозвонки.** Они идут по UDP, а TGLock проксирует только TCP. Со звонками ничего не изменится
-**Всё, кроме 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**. Остальной трафик идёт напрямую — ничего не замедляется, мобильный трафик не расходуется впустую.
@@ -90,6 +90,10 @@ TGLock — это **локальный прокси** на твоём компь
Все сборки весят единицы мегабайт. Исключение — `.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).
### 🛡 Антивирус ругается, SmartScreen предупреждает, VirusTotal показывает детекты
@@ -297,7 +301,7 @@ Telegram Desktop / mobile (через LAN)
1. **Локальный прокси** принимает соединения Telegram: и MTProto (по ссылке `tg://proxy`), и SOCKS5.
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 в браузере.
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 в нём не виден.
6. Весь остальной трафик (не-Telegram) проходит **напрямую** — без замедления.
@@ -356,12 +360,16 @@ TGLock же отправляет трафик на **`web.telegram.org`** — о
<details>
<summary><b>Работает ли на iPhone или Android?</b></summary>
Своего приложения под Android и iOS нет — TGLock только для desktop. Есть два обходных пути:
Для Android есть [экспериментальный APK](docs/ANDROID.md): локальный MTProto-прокси
на телефоне, без VpnService. Для iOS отдельного приложения нет. Для обеих платформ
также доступны два варианта:
1. **LAN-режим на компьютере.** Включи галочку LAN, и в настройках Telegram на телефоне укажи прокси с IP компьютера. Работает, пока компьютер включён и телефон в той же сети.
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>
@@ -449,7 +457,7 @@ TGLock определяет Telegram по IP получателя и завор
| **Tauri 2** | Нативная оболочка для GUI. Опциональна: за фичей `gui`, в CLI не входит |
| **TypeScript + Vite** | Интерфейс, внутренняя навигация и строгая типизация |
| **tokio** | Async I/O, обработка сигналов для корректной остановки сервиса |
| **tokio-tungstenite** | WebSocket-клиент с TLS поверх `native-tls` |
| **tokio-tungstenite** | WebSocket-клиент с rustls, ring и встроенными WebPKI roots |
| **aes** + **ctr** | Расшифровка MTProto `obfuscated2` init-пакета |
| **clap** | Разбор аргументов `tglock-cli` |
@@ -488,7 +496,7 @@ cargo test --all-targets
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
@@ -505,7 +513,9 @@ cargo test --no-default-features --features cli --lib --bins
- **Присылаешь 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",
"identifier": "default",
"description": "TGLock desktop window",
"description": "TGLock application window",
"windows": ["main"],
"permissions": ["core:default"]
}
+66
View File
@@ -0,0 +1,66 @@
# 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 the ARM64 Rust library.
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.
+21 -9
View File
@@ -201,22 +201,32 @@ Ping приходит в читающую половину, а отвечает
1. сохранённый успешный маршрут;
2. точный Telegram IP с `kwsN` или `kwsN-1` в TLS SNI и WebSocket Host;
3. дополнительный Telegram IP, если он определён;
4. системный DNS;
5. явно настроенный пользователем Cloudflare Worker.
3. явно настроенный пользователем Cloudflare Worker;
4. дополнительные Telegram IP и варианты хоста;
5. системный DNS (кроме CDN DC203).
Поддерживаются DC15 и media/CDN DC203. DC203 использует WebSocket-host DC2,
но подключается к собственному IP.
но подключается к собственному IP. DNS fallback на DC2 для DC203 запрещён:
успешный WebSocket handshake с другим DC не доставляет CDN-запрос в нужный
датацентр. Worker также получает именно IP DC203.
После ошибки маршрут получает exponential cooldown от 30 секунд до 30 минут.
Успешный маршрут становится первым для следующего соединения того же DC и
типа трафика.
типа трафика. Попытки запускаются с интервалом 250 мс, не больше трёх
одновременно на соединение; остальные отменяются после первого успеха.
Если все маршруты на паузе, возвращается причина и время до повторной
попытки. Новое подключение клиента не обходит cooldown. Ошибка upstream
после handshake также снимает предпочтение маршрута и добавляет cooldown.
## TLS policy
Проверка сертификатов и hostname никогда не отключается. При подключении к
заданному Telegram IP TCP destination отделён от URI host: TLS продолжает
проверять сертификат настоящего `kws*.web.telegram.org`.
проверять сертификат настоящего `kws*.web.telegram.org`. Используется rustls
с провайдером ring и встроенными WebPKI roots; системный OpenSSL для ядра
и CLI не нужен. Собственные корневые сертификаты ОС автоматически не
подхватываются. Тесты проверяют доверенный сертификат, SNI, неверное имя
хоста и недоверенного издателя.
Cloudflare Worker принимается только как пользовательская настройка. TGLock
не загружает и не скрывает публичные списки чужих доменов.
@@ -229,9 +239,11 @@ Worker должен принимать WebSocket на:
/apiws?dst=<telegram-ip>&dc=<dc-id>
```
и проксировать бинарные frames в TCP `<telegram-ip>:443`. Рекомендуется
добавить собственную авторизацию до стабильного релиза; поэтому Worker
остаётся расширенной опцией alpha-версии.
и проксировать бинарные frames в TCP `<telegram-ip>:443`. Ответ 101 отправляется
после открытия upstream TCP; таймаут/отказ — HTTP 504/502. Список назначений
фиксирован, текстовые сообщения отклоняются. Очередь входящих записей ограничена
1 МиБ и 256 сообщениями. Авторизация токеном требует собственного клиента:
штатный TGLock токен Worker пока не передаёт.
Запись в сокет 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** сегодняшнюю.
> **Разворачивал воркер до 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
@@ -54,13 +56,13 @@ tglock-cli --worker tglock.имя.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-прокси он не станет.
@@ -76,6 +78,7 @@ Sec-WebSocket-Protocol: binary
- `dst` — адрес Telegram, к которому нужно подключиться по TCP на порт 443;
- `dc` — номер датацентра, для логов;
- **подпротокол `binary` обязательно нужно подтвердить в ответе** — без этого клиент разорвёт рукопожатие;
- ответ 101 означает, что TCP к разрешённому Telegram IP уже открыт; HTTP 502/504 означает отказ/таймаут этого подключения;
- дальше бинарные frames пересылаются в обе стороны без изменений;
- TLS до самого воркера обеспечивает Cloudflare.
@@ -85,4 +88,6 @@ Sec-WebSocket-Protocol: binary
Скрипт написан по контракту, вычитанному из исходников клиента, и путь с параметрами закреплён тестом `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 перед публикацией убери.
+68
View File
@@ -0,0 +1,68 @@
# Открытые 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 и ключи | DC203 сохраняет IP CDN во всех маршрутах; тест Worker dst, двунаправленная передача. Реальные фотографии аккаунта репортёра не проверены |
| #53 — статическая aarch64 | Системный OpenSSL мешал самостоятельной musl-сборке | rustls + ring, ARM64 CI с проверкой ELF и запуском CLI, артефакт и OpenWrt-инструкция. Физический NanoPi R4S не проверен |
| #9 — Android | Старый PR отстал от main, сборка требовала ручной настройки; жизненный цикл сервиса мог останавливать прокси или оставлять ложное уведомление | Перенос актуального ядра, foreground service по состоянию прокси, APK CI, постоянный секрет в каталоге приложения. HyperOS и длительная фоновая работа требуют устройства |
Дополнительно: Unicode-секрет больше не вызывает panic; GUI сохраняет секрет
в каталоге приложения с миграцией прежнего файла; npm lock обновляет уязвимый
транзитивный `nanoid` без смены версии приложения.
## Протокол и доверие
- TLS проверяет имя из WebSocket URI даже при подключении к закреплённому IP.
Переход на rustls использует встроенные WebPKI roots; системные пользовательские
корневые сертификаты автоматически не импортируются.
- [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. Конкретные результаты
и ссылки на прогоны фиксируются в описании PR после завершения CI.
Worker: `npm run test:worker` исполняет настоящий deployment-файл с подменой
только Cloudflare API; `wrangler deploy --dry-run` проверяет сборку без публикации.
Это не тест живого Cloudflare-аккаунта. Отдельный тест Rust поднимает локальный
WebSocket и проверяет совместимость клиента с контрактом Worker.
## Что нужно проверить на устройствах
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
Актуальный разбор семи открытых issues и границ проверки:
[19 сентября 2026](ISSUES_2026-09-19.md). Ниже сохранены исторические записи.
Проверено 29 июля 2026 года: все 15 issues и 5 pull requests, существовавшие
в репозитории на момент аудита.
+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). Статическая сборка сама по себе не
устраняет блокировку всех внешних маршрутов.
+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">
<head>
<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" />
<title>TGLock</title>
</head>
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "tglock-ui",
"version": "2.0.0-beta.1",
"version": "2.0.0-beta.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tglock-ui",
"version": "2.0.0-beta.1",
"version": "2.0.0-beta.14",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
},
@@ -1118,9 +1118,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"dev": true,
"funding": [
{
+1
View File
@@ -4,6 +4,7 @@
"version": "2.0.0-beta.14",
"type": "module",
"scripts": {
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
"dev": "vite --port 1420",
"build": "tsc && vite build",
"preview": "vite preview",
+2 -2
View File
@@ -12,8 +12,8 @@ use std::process::ExitCode;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use tglock::cli_settings as config;
use tglock::{proxy, transport};
use tglock_lib::cli_settings as config;
use tglock_lib::{proxy, transport};
const STATUS_POLL: Duration = Duration::from_secs(1);
+520
View File
@@ -0,0 +1,520 @@
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».
///
/// Ноль при неработающем телефоне означает, что он вообще не дотянулся до
/// этой машины; не ноль — что дотянулся, и разбираться надо с адресами
/// (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,
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>) -> StatusSnapshot {
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);
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
//! 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
//! that has neither a GPU nor a monitor.
@@ -14,3 +14,7 @@ pub mod transport;
/// Настройки headless-версии: файл конфигурации и сведение с флагами.
#[cfg(feature = "cli")]
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")]
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() {
prefer_software_rendering();
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"
]
);
}
}
tglock_lib::gui::run();
}
+118 -8
View File
@@ -2,9 +2,7 @@ use aes::Aes256;
use cipher::{KeyIvInit, StreamCipher};
use rand::{rngs::OsRng, RngCore};
use sha2::{Digest, Sha256};
use std::path::Path;
#[cfg(not(test))]
use std::path::PathBuf;
use std::path::{Path, PathBuf};
type AesCtr = ctr::Ctr128BE<Aes256>;
@@ -119,6 +117,12 @@ impl StoredSecret {
/// Секрет — половина ссылки `tg://proxy`, поэтому сервис, придумывающий новый
/// при каждом старте, отключает всех уже настроенных клиентов.
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 Some(value) = parse_secret_hex(value.trim()) {
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))
.err()
.map(|error| format!("{}: {error}", path.display()));
@@ -146,7 +153,7 @@ fn store_secret(path: &Path, value: &str) -> std::io::Result<()> {
#[cfg(not(test))]
pub fn load_or_create_secret() -> StoredSecret {
match secret_path() {
match legacy_secret_path() {
Some(path) => load_or_create_secret_at(&path),
None => StoredSecret {
value: generate_secret(),
@@ -155,8 +162,7 @@ pub fn load_or_create_secret() -> StoredSecret {
}
}
#[cfg(not(test))]
fn secret_path() -> Option<PathBuf> {
pub fn legacy_secret_path() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
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]> {
if value.len() != 32 {
if value.len() != 32 || !value.is_ascii() {
return None;
}
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"POST"
|| &init[..4] == b"GET "
|| &init[..4] == b"OPTI"
|| init[..4] == [0xee; 4]
|| init[..4] == [0xdd; 4]
|| init[..4] == [0x16, 0x03, 0x01, 0x02]
@@ -682,11 +689,48 @@ mod tests {
"00112233445566778899aabbccddee", // 30 символов
"00112233445566778899aabbccddeeffff", // 34 символа
"zz112233445566778899aabbccddeeff", // не hex
"0я00000000000000000000000000000", // 32 bytes, UTF-8 boundary at byte 2
] {
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()
.chunks_exact(2)
.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]
fn parses_persisted_secret() {
assert_eq!(
@@ -698,4 +742,70 @@ mod tests {
);
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();
}
}
+226 -5
View File
@@ -738,7 +738,8 @@ async fn ws_tunnel(
// Send buffered init as first frame
ws_w.send(tungstenite::Message::Binary(init.to_vec()))
.await?;
.await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
// Ping приходит в половину, которая читает, а отвечать на него должна та,
// которая пишет: владелец у отправляющей половины строго один.
@@ -752,9 +753,11 @@ async fn ws_tunnel(
// сессия встаёт при живом туннеле (by-sonic/tglock#42, #32).
let downstream = async {
let mut crypto = downstream_crypto;
let mut received_payload = false;
while let Some(message) = ws_r.next().await {
match message {
Ok(tungstenite::Message::Binary(mut data)) => {
received_payload |= !data.is_empty();
if let Some(crypto) = &mut crypto {
crypto.apply(data.as_mut());
}
@@ -766,10 +769,48 @@ async fn ws_tunnel(
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(_) => {}
}
}
if !received_payload {
return Err(tunnel_route_error(
stats,
&connected,
dc,
"WebSocket завершён без ответа Telegram",
));
}
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
};
@@ -779,19 +820,22 @@ async fn ws_tunnel(
loop {
tokio::select! {
read = tcp_r.read(&mut buf) => match read {
Ok(0) | Err(_) => break,
Ok(0) => break,
Err(error) => return Err(error.into()),
Ok(read) => {
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buf[..read]);
}
ws_w
.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 {
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,
},
@@ -809,6 +853,21 @@ async fn ws_tunnel(
Ok(())
}
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) {
let (mut ar, mut aw) = tokio::io::split(a);
let (mut br, mut bw) = tokio::io::split(b);
@@ -953,6 +1012,7 @@ mod tests {
.send(Message::Binary(wire))
.await
.map_err(|e| e.to_string())?;
websocket.close(None).await.map_err(|e| e.to_string())?;
Ok((requested, init, request))
}
@@ -1411,6 +1471,167 @@ mod tests {
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();
}
}
#[allow(clippy::result_large_err)]
#[tokio::test]
async fn fragmented_encrypted_upload_and_download_preserve_both_streams() {
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 (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();
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();
stats.transport.force_local_route(relay_port);
let (port, server) = start_proxy(stats.clone(), false).await;
let init = unambiguous_client_init(&stats.secret, -4);
let mut peer = crate::mtproto::test_client_peer(&init, &stats.secret);
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
// 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");
stats.stop();
server.await.unwrap().unwrap();
}
#[tokio::test]
async fn connects_through_the_documented_worker_contract() {
// Locks the contract in docs/CLOUDFLARE_WORKER.md: a server that
+388 -58
View File
@@ -1,13 +1,17 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use futures_util::stream::{FuturesUnordered, StreamExt};
use tokio::net::TcpStream;
use tokio::time::Instant;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
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_MAX: Duration = Duration::from_secs(30 * 60);
const HTTPS_PORT: u16 = 443;
@@ -191,37 +195,97 @@ impl TransportEngine {
media: bool,
) -> Result<(TelegramWebSocket, ConnectedRoute), String> {
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();
for route in candidates {
match connect_route(&route).await {
Ok(websocket) => {
self.record_success(key, &route);
return Ok((websocket, ConnectedRoute { route }));
while candidates.peek().is_some() || !pending.is_empty() {
if candidates.peek().is_some()
&& pending.len() < MAX_CONNECTING
&& (pending.is_empty() || Instant::now() >= next_start)
{
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) => {
self.record_failure(&route);
let attempt = format!("{}{}", route.connect_host, error);
if !errors.contains(&attempt) {
errors.push(attempt);
let connection = connect(route.clone());
pending.push(async move { (route, connection.await) });
next_start = Instant::now() + FALLBACK_DELAY;
continue;
}
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);
errors.push(format!("{} (TLS {}) — {}", route.connect_host, route.websocket_host, error));
}
}
}
_ = tokio::time::sleep_until(next_start),
if candidates.peek().is_some() && pending.len() < MAX_CONNECTING => {}
}
}
// Текст читает человек: он попадает в журнал событий, и по нему
// отличают «провайдер режет закреплённые адреса» от «воркер отвечает
// отказом». Раньше причина отказа не доходила никуда, и при
// `туннелей 0` узнать, почему их ноль, было нечем (by-sonic/tglock#50).
if errors.is_empty() {
let routes = self.routes_for_key(key);
let health = self.health.lock().unwrap();
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!(
"Не поднялся туннель до DC{}{}: {}",
dc,
if media { " (медиа)" } else { "" },
key.dc,
if key.media { " (медиа)" } else { "" },
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> {
let now = Instant::now();
let all_routes = self.routes_for_key(key);
@@ -241,25 +305,17 @@ impl TransportEngine {
candidates.sort_by_key(|route| {
let preferred_rank = u8::from(preferred != Some(route));
let kind_rank = match route.kind {
RouteKind::TelegramIp => 0,
RouteKind::AlternateTelegramIp => 1,
RouteKind::SystemDns => 2,
RouteKind::CloudflareWorker => 3,
RouteKind::TelegramIp if Some(route) == all_routes.first() => 0,
// The operator explicitly configured this independent path.
// 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)
});
// 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
}
@@ -299,9 +355,17 @@ impl TransportEngine {
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) {
self.route_failures.fetch_add(1, Ordering::Relaxed);
let mut health = self.health.lock().unwrap();
health.preferred.retain(|_, preferred| preferred != route);
let failures = health
.routes
.get(route)
@@ -363,6 +427,9 @@ fn telegram_ips(dc: u16) -> &'static [&'static str] {
}
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 primary = format!("kws{}.web.telegram.org", websocket_dc);
let secondary = format!("kws{}-1.web.telegram.org", websocket_dc);
@@ -387,17 +454,52 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
},
));
}
routes.push(Route::https(
websocket_host.clone(),
websocket_host.clone(),
"/apiws".to_owned(),
RouteKind::SystemDns,
));
// DC203 uses the DC2 hostname only for TLS/HTTP virtual hosting.
// Resolving it would connect to DC2 itself and send CDN sessions to
// the wrong data centre. Its fallback must retain the CDN address.
if dc != 203 {
routes.push(Route::https(
websocket_host.clone(),
websocket_host.clone(),
"/apiws".to_owned(),
RouteKind::SystemDns,
));
}
}
routes
}
async fn connect_route(route: &Route) -> Result<TelegramWebSocket, 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<TelegramWebSocket, String> {
let tcp = tokio::time::timeout(
CONNECT_TIMEOUT,
TcpStream::connect((route.connect_host.as_str(), route.port)),
@@ -435,10 +537,10 @@ async fn connect_route(route: &Route) -> Result<TelegramWebSocket, String> {
}
// The URI host remains the real Telegram hostname even when the TCP socket
// is opened to a pinned IP. Native TLS therefore validates Telegram's
// certificate and sends the correct SNI.
let tls = native_tls::TlsConnector::new().map_err(|error| format!("TLS setup: {}", error))?;
let connector = tokio_tungstenite::Connector::NativeTls(tls);
// is opened to a pinned IP. rustls validates that hostname and certificate
// against the bundled roots, and sends that hostname in SNI. The explicit
// ring provider avoids process-global provider selection and its panics.
let connector = tokio_tungstenite::Connector::Rustls(config);
tokio::time::timeout(
CONNECT_TIMEOUT,
tokio_tungstenite::client_async_tls_with_config(request, tcp, None, Some(connector)),
@@ -480,6 +582,22 @@ mod tests {
let routes = routes_for_dc(203, false);
assert_eq!(routes[0].websocket_host, "kws2.web.telegram.org");
assert_eq!(routes[0].connect_host, "91.105.192.100");
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,
});
assert!(routes
.iter()
.filter(|route| route.kind == RouteKind::CloudflareWorker)
.all(|route| route.path == "/apiws?dst=91.105.192.100&dc=203"));
}
#[test]
@@ -547,7 +665,7 @@ mod tests {
#[test]
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);
assert!(
routes
@@ -611,7 +729,7 @@ mod tests {
}
#[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 key = DcKey {
dc: 5,
@@ -627,13 +745,7 @@ mod tests {
engine.record_failure(route);
}
let candidates = engine.ordered_candidates(key);
assert_eq!(
candidates.len(),
1,
"a fully cooling table must offer exactly one retry, not give up"
);
assert_eq!(candidates[0], routes[0]);
assert!(engine.ordered_candidates(key).is_empty());
}
#[test]
@@ -694,7 +806,7 @@ mod tests {
}
#[test]
fn worker_is_the_last_resort() {
fn explicit_worker_is_tried_before_redundant_telegram_fallbacks() {
let engine = TransportEngine::new();
engine.set_worker_domains(&["fallback.workers.dev".to_owned()]);
let key = DcKey {
@@ -702,10 +814,19 @@ mod tests {
media: false,
};
let candidates = engine.ordered_candidates(key);
assert_eq!(
candidates.last().unwrap().kind,
RouteKind::CloudflareWorker,
"third-party infrastructure must never be tried before Telegram itself"
assert_eq!(candidates[0].kind, RouteKind::TelegramIp);
assert_eq!(candidates[1].kind, RouteKind::CloudflareWorker);
let worker = candidates
.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"
);
}
@@ -801,6 +922,215 @@ mod tests {
}
}
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]
#[ignore = "requires live Telegram network access"]
async fn connects_to_all_production_data_centers() {
+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 } }
}
+59 -2
View File
@@ -58,6 +58,8 @@ let status: Status = {
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "" };
let busy = false;
let toastTimer: number | undefined;
let toastMessage = "";
let toastError = false;
const icons = {
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}">
<div class="drag-region" data-tauri-drag-region></div>
${content}
<div id="toast" class="toast" role="status"></div>
<div id="toast" class="toast${toastMessage ? " visible" : ""}${toastError ? " error" : ""}" role="status">${escapeHtml(toastMessage)}</div>
</main>
`;
}
@@ -152,6 +154,11 @@ function renderHome(): void {
<span class="button-arrow"></span>
</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">
<span class="route-pulse"></span>
<span>${escapeHtml(status.route)}</span>
@@ -186,6 +193,8 @@ function renderHome(): void {
`, "home-page");
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("#settings-nav")?.addEventListener("click", () => navigate("settings"));
document.querySelector("#diagnostics-nav")?.addEventListener("click", () => navigate("diagnostics"));
@@ -326,6 +335,8 @@ function renderDiagnostics(): void {
настроен неверно», а адрес такого клиента появится в журнале ниже.
</p>
<button id="copy-diagnostics" class="save-button">Скопировать диагностику</button>
<div class="log-panel">
<div class="log-heading">
<span>Последние события</span>
@@ -348,6 +359,7 @@ function renderDiagnostics(): void {
</section>
`, "subpage-shell");
document.querySelector("#copy-diagnostics")?.addEventListener("click", copyDiagnostics);
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.14",
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> {
const address = status.shareAddress;
if (!address) return;
@@ -433,12 +485,17 @@ async function copyShareAddress(): Promise<void> {
function showToast(message: string, error = false): void {
window.clearTimeout(toastTimer);
toastMessage = message.replace(/^["']|["']$/g, "");
toastError = error;
window.requestAnimationFrame(() => {
const toast = document.querySelector<HTMLDivElement>("#toast");
if (!toast) return;
toast.textContent = message.replace(/^["']|["']$/g, "");
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-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 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 {
async fetch(request, env) {
async fetch(request, env = {}) {
const url = new URL(request.url);
if (url.pathname !== "/apiws") {
@@ -50,19 +55,48 @@ export default {
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());
server.accept();
const upstream = connect({ hostname: destination, port: TELEGRAM_PORT });
const writer = upstream.writable.getWriter();
let closed = false;
const shutdown = () => {
const shutdown = (code = 1000, reason = "") => {
if (closed) return;
closed = true;
writer.close().catch(() => {});
// close() cancels both directions, including a blocked write/read.
upstream.close().catch(() => {});
try {
server.close();
server.close(code, reason);
} catch {
// соединение уже закрыто
}
@@ -77,19 +111,39 @@ export default {
// проявлялось. Как только голодание починили, в воркер пошёл настоящий
// поток (by-sonic/tglock#42).
let pending = Promise.resolve();
let pendingBytes = 0;
let pendingMessages = 0;
server.addEventListener("message", (event) => {
const chunk =
event.data instanceof ArrayBuffer
? new Uint8Array(event.data)
: event.data;
if (closed) return;
if (!(event.data instanceof ArrayBuffer) && !ArrayBuffer.isView(event.data)) {
shutdown(1003, "binary messages required");
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
.then(() => writer.ready)
.then(() => writer.write(chunk))
.catch(shutdown);
.then(async () => {
if (closed) return;
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("error", shutdown);
server.addEventListener("close", () => shutdown());
server.addEventListener("error", () => shutdown(1011, "WebSocket failed"));
upstream.closed.catch(() => shutdown(1011, "Telegram socket failed"));
// Обратное направление: всё, что приходит от Telegram, уходит клиенту.
(async () => {
@@ -101,7 +155,9 @@ export default {
server.send(value);
}
} catch {
// разрыв соединения — обычная ситуация, не ошибка
shutdown(1011, "Telegram read failed");
} finally {
reader.releaseLock();
}
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"