diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..03e3503 --- /dev/null +++ b/.github/workflows/android.yml @@ -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 diff --git a/.github/workflows/arm64.yml b/.github/workflows/arm64.yml new file mode 100644 index 0000000..d6750f2 --- /dev/null +++ b/.github/workflows/arm64.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba5aa66..e17a4d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 005c320..8b9d6b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 1eba73c..1d74a4a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ /dist /dist-ui /node_modules -/gen +/gen/* +!/gen/android/ /tools *.zip .claude/ diff --git a/Cargo.lock b/Cargo.lock index 48574e7..e1e183f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index 0bfb59d..aeb3377 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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-зависимости # участвуют только в тестах. diff --git a/README.md b/README.md index cb57ef8..837c0a5 100644 --- a/README.md +++ b/README.md @@ -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`** — о
Работает ли на iPhone или Android? -Своего приложения под 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 сама по себе этого не проверяет.
@@ -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). --- diff --git a/capabilities/default.json b/capabilities/default.json index 537e36a..fe154de 100644 --- a/capabilities/default.json +++ b/capabilities/default.json @@ -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"] } diff --git a/docs/ANDROID.md b/docs/ANDROID.md new file mode 100644 index 0000000..31c582c --- /dev/null +++ b/docs/ANDROID.md @@ -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. diff --git a/docs/ARCHITECTURE_V2.md b/docs/ARCHITECTURE_V2.md index 536db80..dd6496a 100644 --- a/docs/ARCHITECTURE_V2.md +++ b/docs/ARCHITECTURE_V2.md @@ -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). Поддерживаются DC1–5 и 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=&dc= ``` -и проксировать бинарные frames в TCP `:443`. Рекомендуется -добавить собственную авторизацию до стабильного релиза; поэтому Worker -остаётся расширенной опцией alpha-версии. +и проксировать бинарные frames в TCP `:443`. Ответ 101 отправляется +после открытия upstream TCP; таймаут/отказ — HTTP 504/502. Список назначений +фиксирован, текстовые сообщения отклоняются. Очередь входящих записей ограничена +1 МиБ и 256 сообщениями. Авторизация токеном требует собственного клиента: +штатный TGLock токен Worker пока не передаёт. Запись в сокет Telegram обязана быть последовательной: следующий чанк уходит после того, как записан предыдущий, и только когда писатель к этому готов diff --git a/docs/CLOUDFLARE_WORKER.md b/docs/CLOUDFLARE_WORKER.md index 127d184..82b85b0 100644 --- a/docs/CLOUDFLARE_WORKER.md +++ b/docs/CLOUDFLARE_WORKER.md @@ -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 перед публикацией убери. diff --git a/docs/ISSUES_2026-09-19.md b/docs/ISSUES_2026-09-19.md new file mode 100644 index 0000000..eaf6407 --- /dev/null +++ b/docs/ISSUES_2026-09-19.md @@ -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 о пользовательских симптомах остаются открыты до этой проверки. +Наличие исправленного дефекта и зелёных регрессий не доказывает, что в конкретной +сети отсутствует дополнительная блокировка. diff --git a/docs/ISSUE_AUDIT.md b/docs/ISSUE_AUDIT.md index 0c55961..1f92614 100644 --- a/docs/ISSUE_AUDIT.md +++ b/docs/ISSUE_AUDIT.md @@ -1,5 +1,8 @@ # TGLock 2.0 issue audit +Актуальный разбор семи открытых issues и границ проверки: +[19 сентября 2026](ISSUES_2026-09-19.md). Ниже сохранены исторические записи. + Проверено 29 июля 2026 года: все 15 issues и 5 pull requests, существовавшие в репозитории на момент аудита. diff --git a/docs/OPENWRT.md b/docs/OPENWRT.md new file mode 100644 index 0000000..b716f5c --- /dev/null +++ b/docs/OPENWRT.md @@ -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). Статическая сборка сама по себе не +устраняет блокировку всех внешних маршрутов. diff --git a/gen/android/.editorconfig b/gen/android/.editorconfig new file mode 100644 index 0000000..ebe51d3 --- /dev/null +++ b/gen/android/.editorconfig @@ -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 \ No newline at end of file diff --git a/gen/android/.gitignore b/gen/android/.gitignore new file mode 100644 index 0000000..1c636c3 --- /dev/null +++ b/gen/android/.gitignore @@ -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 \ No newline at end of file diff --git a/gen/android/app/.gitignore b/gen/android/app/.gitignore new file mode 100644 index 0000000..6c4d56b --- /dev/null +++ b/gen/android/app/.gitignore @@ -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 \ No newline at end of file diff --git a/gen/android/app/build.gradle.kts b/gen/android/app/build.gradle.kts new file mode 100644 index 0000000..8fd7cda --- /dev/null +++ b/gen/android/app/build.gradle.kts @@ -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") \ No newline at end of file diff --git a/gen/android/app/proguard-rules.pro b/gen/android/app/proguard-rules.pro new file mode 100644 index 0000000..dbeccec --- /dev/null +++ b/gen/android/app/proguard-rules.pro @@ -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 { *; } diff --git a/gen/android/app/src/main/AndroidManifest.xml b/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5125f2e --- /dev/null +++ b/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gen/android/app/src/main/java/com/bysonic/tglock/MainActivity.kt b/gen/android/app/src/main/java/com/bysonic/tglock/MainActivity.kt new file mode 100644 index 0000000..29d63c1 --- /dev/null +++ b/gen/android/app/src/main/java/com/bysonic/tglock/MainActivity.kt @@ -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) + } + } +} diff --git a/gen/android/app/src/main/java/com/bysonic/tglock/TunnelPlugin.kt b/gen/android/app/src/main/java/com/bysonic/tglock/TunnelPlugin.kt new file mode 100644 index 0000000..d684c07 --- /dev/null +++ b/gen/android/app/src/main/java/com/bysonic/tglock/TunnelPlugin.kt @@ -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") + } + } +} diff --git a/gen/android/app/src/main/java/com/bysonic/tglock/TunnelService.kt b/gen/android/app/src/main/java/com/bysonic/tglock/TunnelService.kt new file mode 100644 index 0000000..d5ea242 --- /dev/null +++ b/gen/android/app/src/main/java/com/bysonic/tglock/TunnelService.kt @@ -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)) + } + } +} diff --git a/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gen/android/app/src/main/res/layout/activity_main.xml b/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..4fc2444 --- /dev/null +++ b/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..85d0c88 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..13dd214 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a888b33 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a2a838e Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3f8a57f Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/gen/android/app/src/main/res/values-night/themes.xml b/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..e82ff72 --- /dev/null +++ b/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/gen/android/app/src/main/res/values/colors.xml b/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/gen/android/app/src/main/res/values/strings.xml b/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a5a45a2 --- /dev/null +++ b/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + "TGLock" + "TGLock" + Туннель + Уведомление, которое не даёт системе выгрузить туннель, пока приложение открыто + TGLock активен + Пока это уведомление на месте, система не выгрузит туннель + \ No newline at end of file diff --git a/gen/android/app/src/main/res/values/themes.xml b/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..e82ff72 --- /dev/null +++ b/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/gen/android/app/src/main/res/xml/file_paths.xml b/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..782d63b --- /dev/null +++ b/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/gen/android/build.gradle.kts b/gen/android/build.gradle.kts new file mode 100644 index 0000000..2427871 --- /dev/null +++ b/gen/android/build.gradle.kts @@ -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") +} + diff --git a/gen/android/buildSrc/build.gradle.kts b/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 0000000..5c55bba --- /dev/null +++ b/gen/android/buildSrc/build.gradle.kts @@ -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") +} + diff --git a/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/BuildTask.kt b/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/BuildTask.kt new file mode 100644 index 0000000..a3de125 --- /dev/null +++ b/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/BuildTask.kt @@ -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() + } +} \ No newline at end of file diff --git a/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/RustPlugin.kt b/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/RustPlugin.kt new file mode 100644 index 0000000..4aa7fca --- /dev/null +++ b/gen/android/buildSrc/src/main/java/com/bysonic/tglock/kotlin/RustPlugin.kt @@ -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 { + 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 { + @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 + ) + } + } + } + } +} \ No newline at end of file diff --git a/gen/android/gradle.properties b/gen/android/gradle.properties new file mode 100644 index 0000000..2a7ec69 --- /dev/null +++ b/gen/android/gradle.properties @@ -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 \ No newline at end of file diff --git a/gen/android/gradle/wrapper/gradle-wrapper.jar b/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gen/android/gradle/wrapper/gradle-wrapper.properties b/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c5f9a53 --- /dev/null +++ b/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gen/android/gradlew b/gen/android/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gen/android/gradlew @@ -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" "$@" diff --git a/gen/android/gradlew.bat b/gen/android/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gen/android/gradlew.bat @@ -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 diff --git a/gen/android/settings.gradle b/gen/android/settings.gradle new file mode 100644 index 0000000..3939116 --- /dev/null +++ b/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' diff --git a/index.html b/index.html index c39f223..db7fd73 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + TGLock diff --git a/package-lock.json b/package-lock.json index a531ce0..ffe01b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ { diff --git a/package.json b/package.json index ab92b61..c7b9c49 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/bin/cli.rs b/src/bin/cli.rs index 44be4a2..23335bf 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -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); diff --git a/src/gui.rs b/src/gui.rs new file mode 100644 index 0000000..3ca5592 --- /dev/null +++ b/src/gui.rs @@ -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, + 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, + logs: Vec, +} + +struct AppState { + stats: Arc, + settings: Mutex, + active_port: Mutex, + /// Слушатель работающего прокси. Нужен, чтобы показать адрес для других + /// устройств именно тот, на котором прокси реально поднят, а не тот, что + /// сейчас выбран в настройках. + active_listen: Mutex>, + started_at: Mutex>, + logs: Arc>>, + settings_path: PathBuf, + task: tokio::sync::Mutex>>, +} + +impl AppState { + fn new(settings_path: PathBuf, stats: Arc) -> 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, 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) -> Option { + 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 { + 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 { + // 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 { + 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 { + 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); + +#[cfg(target_os = "android")] +fn set_foreground_service(app: &tauri::AppHandle, enabled: bool) -> Result<(), String> { + app.state::() + .0 + .run_mobile_plugin::( + "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>>, 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::::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" + ] + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 20373d4..f50dd0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 1cc7232..6a4534f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, - 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, - logs: Vec, -} - -struct AppState { - stats: Arc, - settings: Mutex, - active_port: Mutex, - /// Слушатель работающего прокси. Нужен, чтобы показать адрес для других - /// устройств именно тот, на котором прокси реально поднят, а не тот, что - /// сейчас выбран в настройках. - active_listen: Mutex>, - started_at: Mutex>, - logs: Arc>>, - 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, 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) -> Option { - 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 { - 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 { - 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>>, 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(); } diff --git a/src/mtproto.rs b/src/mtproto.rs index 26e9a99..c2857e4 100644 --- a/src/mtproto.rs +++ b/src/mtproto.rs @@ -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; @@ -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 { +pub fn legacy_secret_path() -> Option { #[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 { + 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(); + } } diff --git a/src/proxy.rs b/src/proxy.rs index 6b6c3b1..edbbe66 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -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::() + .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>(()) }; @@ -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 { + 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 = (0..131_072).map(|i| (i % 251) as u8).collect(); + let response: Vec = (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 diff --git a/src/transport.rs b/src/transport.rs index 18e2cf2..61f7b57 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -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( + &self, + key: DcKey, + mut connect: F, + ) -> Result<(T, ConnectedRoute), String> + where + F: FnMut(Route) -> Fut, + Fut: std::future::Future>, + { + 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 { 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 { + 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 { }, )); } - 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 { + connect_route_with_config(route, tls_config()).await +} + +fn client_config(roots: rustls::RootCertStore) -> Arc { + 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 { + static CONFIG: OnceLock> = 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, +) -> Result { 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 { } // 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 { + (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); + + 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::>().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, + tokio::task::JoinHandle>, + ) { + 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() { diff --git a/tauri.android.conf.json b/tauri.android.conf.json new file mode 100644 index 0000000..e47adb5 --- /dev/null +++ b/tauri.android.conf.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [{ "label": "main", "title": "TGLock", "backgroundColor": "#070A10" }] + }, + "bundle": { "android": { "minSdkVersion": 24 } } +} diff --git a/ui/main.ts b/ui/main.ts index a182025..360e999 100644 --- a/ui/main.ts +++ b/ui/main.ts @@ -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: ``, @@ -115,7 +117,7 @@ function shell(content: string, pageClass = ""): string {
${content} -
+
${escapeHtml(toastMessage)}
`; } @@ -152,6 +154,11 @@ function renderHome(): void { + ${status.running ? `
+ + +
` : ""} +
${escapeHtml(status.route)} @@ -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 { настроен неверно», а адрес такого клиента появится в журнале ниже.

+ +
Последние события @@ -348,6 +359,7 @@ function renderDiagnostics(): void { `, "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 { } } +async function openTelegram(): Promise { + try { await invoke("open_telegram"); } + catch (error) { showToast(String(error), true); } +} + +async function copyTelegramLink(): Promise { + try { + const link = await invoke("get_telegram_link"); + await navigator.clipboard.writeText(link); + showToast("Ссылка скопирована; она содержит секрет прокси"); + } catch (error) { + showToast(`Не удалось скопировать ссылку: ${String(error)}`, true); + } +} + +async function copyDiagnostics(): Promise { + 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 { const address = status.shareAddress; if (!address) return; @@ -433,12 +485,17 @@ async function copyShareAddress(): Promise { function showToast(message: string, error = false): void { window.clearTimeout(toastTimer); + toastMessage = message.replace(/^["']|["']$/g, ""); + toastError = error; window.requestAnimationFrame(() => { const toast = document.querySelector("#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); }); } diff --git a/ui/styles.css b/ui/styles.css index d2afd58..5291344 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -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; } +} diff --git a/worker/tglock-worker.js b/worker/tglock-worker.js index 43a24fb..732e41e 100644 --- a/worker/tglock-worker.js +++ b/worker/tglock-worker.js @@ -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(); })(); diff --git a/worker/tglock-worker.test.js b/worker/tglock-worker.test.js new file mode 100644 index 0000000..26ba404 --- /dev/null +++ b/worker/tglock-worker.test.js @@ -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"); +}); diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..321995c --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,3 @@ +name = "tglock" +main = "tglock-worker.js" +compatibility_date = "2026-09-19"