Compare commits

...

10 Commits

Author SHA1 Message Date
by-sonic bc18509b9c README: поддержать проект — USDT TRC20
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:54:55 +03:00
Никита Sonic 098178ead4 feat(gui): закрытие окна при включённой защите сворачивает TGLock в трей; 2.0.0-beta.18 (#74)
Раньше закрытие окна завершало приложение вместе с прокси, и Telegram
терял соединение. Теперь, пока защита включена, окно прячется в трей,
а прокси продолжает работать, как в tg-ws-proxy. Меню трея: открыть
TGLock, открыть Telegram, выход. Левый клик по значку открывает окно
(в macOS меню по клику, окно возвращает и клик по значку в Dock).

Если защита выключена или значок в трее создать не удалось (в Linux
нет libayatana-appindicator3), окно закрывается как раньше, чтобы не
оставлять приложение без способа до него добраться.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:48:04 +00:00
Никита Sonic 7b48f0046b fix(ui): status refresh no longer recreates the power button (#71)
refreshStatus() runs every second and render() used to rewrite
root.innerHTML, so #power became a new DOM node each tick. A tap whose
press and release landed on different nodes produced no click in
Chromium/WebView; on a slow WebView the "Выключить" tap was lost.

render now patches the existing DOM in place (text and attributes only;
a node is replaced only where the tag or id at that position differs),
and click/submit handlers are delegated from #app instead of being
reattached to fresh nodes on every render. Texts, classes, ids and
accessible labels are unchanged.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:24:09 +00:00
Никита Sonic 1d96609563 feat: запасной MTProto TCP и CF-прокси через свой домен (по образцу Flowseal/tg-ws-proxy); 2.0.0-beta.17 (#73)
* feat: native MTProto TCP fallback and CF-proxy domains, after Flowseal/tg-ws-proxy; 2.0.0-beta.17

Two routes Flowseal/tg-ws-proxy has and TGLock lacked.

Native TCP fallback. When every WebSocket route for a DC fails, TGLock now
tries plain obfuscated2 MTProto to the DC's primary address, which the
CDN203 route already used. Measured from a Russian ISP: TLS to .51, .91,
.50, .100 and .5 was cut, while native TCP to each answered req_pq_multi,
for main and media DCs alike. 149.154.167.220 is a WebSocket frontend only
and gets no TCP route. TCP opens before Telegram answers, so it is raced
only after all other routes failed; it can never displace a slower working
WebSocket. Raw TCP stays restricted to the pinned Telegram addresses.

CF proxy. A user's own Cloudflare domain with kws1..kws5/kws203 records
pointing at Telegram proxies the WebSocket without a Worker or its limits
(--cf-proxy, cf_proxy in tglock.toml, a GUI field; docs/CF_PROXY.md).

GUI settings now deserialize with serde(default): a file written before a
field existed used to fail to parse and silently reset to defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:24:38 +00:00
Никита Sonic 31bb442b0c fix(worker): name the published script version in the browser check (#72)
The dashboard editor shows a saved draft while requests are served by the
last deployed version. A user with the new text in the editor was running
the pre-beta.15 script, which writes Blob frames into the socket on current
compatibility dates (Cloudflare warns "This TransformStream is being used as
a byte stream ...") and drops every tunnel (#53). Reproduced on local
workerd: the old script logs exactly that warning, the current one does not.

GET /apiws now answers "TGLock Worker <version>" and X-TGLock-Worker, and the
docs say a missing line means an old script is still deployed.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:17:04 +00:00
Никита Sonic 33322ea3a3 ci(android): make the emulator smoke survive a slow emulator, not app failures (#68)
Evidence from runs 39866280662, 39870244857 and 40091145294 shows the red
smoke runs were the emulator, not TGLock: on the software GPU the WebView
paints about one frame per second while the page re-renders every second,
so a tap's down/up can straddle a re-render and Chromium drops the click
(no setEnabled call at all in the failed Stop run). Play services and Pixel
Launcher post-boot work also starved system_server into foreign ANR dialogs.

- AOSP image, 4 cores, 4 GB RAM, no snapshot save; wait for CPU to settle
  after boot before launching.
- Answer foreign "isn't responding" dialogs with Wait and record them; a
  TGLock ANR/error dialog, crash or missing process still fails.
- Re-tap only when the button is unchanged 10s after a tap (at most 3 taps);
  foreground-service and SOCKS listener effects stay strictly asserted with
  a 45s budget sized for this emulator.
- Timed step markers in stdout and logcat, failure screenshot and window
  dump, one evidence artifact per run attempt.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:37:41 +00:00
Никита Sonic 3aa6f413a3 fix(worker): type-clean frame handling; name a Telegram close before any reply (#67)
Cloudflare's dashboard editor type-checks the script and underlined four
lines of the Blob handling, because a separate `blob` flag did not narrow
`event.data` (#66). binaryFrame() narrows each branch with instanceof; the
file now type-checks cleanly against current @cloudflare/workers-types.

When Telegram closes TCP without sending a byte, the Worker used to end the
WebSocket with a plain 1000. It now closes with 1011 "Telegram closed before
replying" and logs the destination and the bytes the client sent, so the
client log and the Cloudflare log both say where the tunnel broke (#53).

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:03:26 +00:00
Никита Sonic f82543e44e ci(android): retry UIAutomator dumps that write nothing (#64)
On the loaded API 35 emulator `uiautomator dump` intermittently writes no
file ("could not get idle state"). tap_label passed that straight to
`cat` and failed the whole smoke on the first miss, while the launch loop
already retried. The beta.16 commit failed four runs in a row this way;
the identical tree passed on its PR. Only the dump is retried — crash,
ANR, service and listener assertions are unchanged.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:12:23 +00:00
Никита Sonic 9529df3fdf fix(worker): туннели через Worker рвались с 1003 на новых compatibility date; 2.0.0-beta.16 (#63)
* worker: set binaryType = "arraybuffer" to fix 1003 "binary messages required"

Since compatibility date 2026-03-17 Cloudflare delivers binary WebSocket messages as Blob (websocket_standard_binary_type). The message handler accepts only ArrayBuffer and closes every tunnel with 1003. Setting binaryType before accept() restores ArrayBuffer delivery regardless of the compatibility date.

* fix(worker): accept Blob frames and name an outdated Worker in the log; 2.0.0-beta.16

Since compatibility date 2026-03-17 Cloudflare delivers binary WebSocket
frames as Blob unless binaryType is "arraybuffer", and the Worker closed
every tunnel with 1003 right after the handshake. wrangler.toml itself pins
2026-09-19, so every fresh deployment was broken (#58, #50, #53).

On top of #61 (binaryType before accept) the Worker now also relays Blob
frames in order, and the client log tells the user to redeploy the script
when a Worker closes with "1003 binary messages required".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitry Sankevich <16187308+avost@users.noreply.github.com>
Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:37:15 +00:00
Никита Sonic 05ce9566a8 fix: маршруты, медиа и Worker; Android APK и статический ARM64 CLI (#60)
* fix: address routing, media, worker and platform issues

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

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

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

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

* Fix owned WebSocket split after transport boxing

* Clarify CDN transport and connection status guarantees

* Recognize accessible Android power button labels

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

---------

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

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.tglock" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,8 @@
<resources>
<string name="app_name">"TGLock"</string>
<string name="main_activity_title">"TGLock"</string>
<string name="tunnel_channel_name">Туннель</string>
<string name="tunnel_channel_description">Уведомление, которое не даёт системе выгрузить туннель, пока приложение открыто</string>
<string name="tunnel_notification_title">TGLock активен</string>
<string name="tunnel_notification_text">Пока это уведомление на месте, система не выгрузит туннель</string>
</resources>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.tglock" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+22
View File
@@ -0,0 +1,22 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.21")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}
+23
View File
@@ -0,0 +1,23 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}
@@ -0,0 +1,68 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """npm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("run", "--", "tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}
@@ -0,0 +1,85 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+3
View File
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#070a10" /> <meta name="theme-color" content="#070a10" />
<title>TGLock</title> <title>TGLock</title>
</head> </head>
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "tglock-ui", "name": "tglock-ui",
"version": "2.0.0-beta.1", "version": "2.0.0-beta.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tglock-ui", "name": "tglock-ui",
"version": "2.0.0-beta.1", "version": "2.0.0-beta.18",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
}, },
@@ -1118,9 +1118,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
+2 -1
View File
@@ -1,9 +1,10 @@
{ {
"name": "tglock-ui", "name": "tglock-ui",
"private": true, "private": true,
"version": "2.0.0-beta.14", "version": "2.0.0-beta.18",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
"dev": "vite --port 1420", "dev": "vite --port 1420",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Bounded installed-APK smoke; no Telegram account or external network needed."""
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
PACKAGE = "com.bysonic.tglock"
COMPONENT = f"{PACKAGE}/.MainActivity"
EVIDENCE = Path("android-smoke-evidence")
EVIDENCE.mkdir(exist_ok=True)
RESULT = {"launch": "not_run", "lifecycle": "not_run", "foreign_dialogs": [], "taps": {}}
STARTED = time.monotonic()
# Budgets measured on the GitHub-hosted emulator, not on a phone. With the
# software GPU the WebView paints about one frame per second, and dumpsys
# itself can take seconds while system_server catches up after boot.
SETTLE_SECONDS = 120
VISIBLE_SECONDS = 60
EFFECT_SECONDS = 45
# A click that reaches the handler re-renders the button as "Подождите…" or
# the opposite action within a frame. Only a button left unchanged this long
# was never clicked.
RETAP_AFTER_SECONDS = 10
MAX_TAPS = 3
SYSTEM_DIALOG = re.compile(r"(Application Not Responding|Application Error): ([\w.:]+)")
def adb(*args, check=True, timeout=30):
return subprocess.run(
["adb", *args], check=check, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout,
).stdout.strip()
def step(message):
# The same marker in stdout and logcat lines up the job log with evidence.
line = f"[{time.monotonic() - STARTED:6.1f}s] {message}"
print(line, flush=True)
with open(EVIDENCE / "steps.txt", "a", encoding="utf-8") as steps:
steps.write(line + "\n")
adb("shell", "log", "-t", "tglock-smoke", json.dumps(message), check=False)
def system_dialog():
try:
windows = adb("shell", "dumpsys", "window", "windows", check=False)
except subprocess.SubprocessError:
return None
match = SYSTEM_DIALOG.search(windows)
return (match.group(1), match.group(2)) if match else None
def clear_foreign_dialogs():
# A loaded emulator regularly declares its own launcher or system apps
# unresponsive, and that dialog covers the WebView. Ours is a failure.
dialog = system_dialog()
if dialog is None:
return
kind, process = dialog
if process.split(":")[0] == PACKAGE:
raise AssertionError(f"{kind} dialog for {process}")
button = "android:id/aerr_wait" if kind == "Application Not Responding" else "android:id/aerr_close"
try:
point = node_center(ui_dump(f"dialog-{len(RESULT['foreign_dialogs'])}"),
lambda node: node.get("resource-id") == button)
except (subprocess.SubprocessError, ET.ParseError):
point = None
if point is not None:
adb("shell", "input", "tap", str(point[0]), str(point[1]))
outcome = f"tapped {button}" if point else "button not dumped yet"
RESULT["foreign_dialogs"].append(f"{kind}: {process}, {outcome}")
step(f"foreign dialog {kind}: {process}, {outcome}")
def wait_for(description, predicate, seconds=EFFECT_SECONDS):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
clear_foreign_dialogs()
if predicate():
step(f"ok: {description}")
return
time.sleep(1)
raise AssertionError(f"Timed out after {seconds}s: {description}")
def cpu_idle_share(interval=3):
def sample():
values = list(map(int, adb("shell", "head", "-1", "/proc/stat").split()[1:]))
return values[3] + values[4], sum(values)
idle1, total1 = sample()
time.sleep(interval)
idle2, total2 = sample()
return (idle2 - idle1) / max(1, total2 - total1)
def settle():
# boot_completed fires long before package, launcher and dexopt work
# drains. Start the app on a quiet device instead of racing that work.
deadline = time.monotonic() + SETTLE_SECONDS
quiet = 0
idle = 0.0
while time.monotonic() < deadline and quiet < 2:
clear_foreign_dialogs()
idle = cpu_idle_share()
quiet = quiet + 1 if idle >= 0.6 else 0
RESULT["settle"] = f"{'quiet' if quiet >= 2 else 'still busy'} at {time.monotonic() - STARTED:.0f}s, {idle:.0%} idle"
step(f"settle: {RESULT['settle']}")
def ui_dump(label):
# Dump first, then read the file; never tap coordinates inferred from a
# screenshot, a previous Activity, or assumed phone dimensions.
adb("shell", "rm", "-f", "/sdcard/tglock-ui.xml")
status = adb("shell", "uiautomator", "dump", "/sdcard/tglock-ui.xml", check=False)
if not adb("shell", "ls", "/sdcard/tglock-ui.xml", check=False).endswith("tglock-ui.xml"):
# On a loaded emulator UIAutomator intermittently reports "could not get
# idle state" and writes nothing. That is the tool, not the app.
raise subprocess.SubprocessError(f"uiautomator dump failed: {status}")
text = adb("shell", "cat", "/sdcard/tglock-ui.xml")
(EVIDENCE / f"{label}.xml").write_text(text, encoding="utf-8")
return ET.fromstring(text)
def node_center(tree, matches):
for node in tree.iter("node"):
if matches(node):
bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
if bounds:
x1, y1, x2, y2 = map(int, bounds.groups())
if x2 > x1 and y2 > y1:
return (x1 + x2) // 2, (y1 + y2) // 2
return None
def label_node(tree, label):
# Android aggregates the decorative arrow into the button's accessible
# text (observed in the API35 CI dump). Accept only that exact suffix.
return node_center(tree, lambda node: any(
name in (label, f"{label} →")
for name in (node.get("text", "").strip(), node.get("content-desc", "").strip())
))
def find_label(label, stage):
clear_foreign_dialogs()
try:
return label_node(ui_dump(stage), label)
except (subprocess.SubprocessError, ET.ParseError) as error:
(EVIDENCE / f"{stage}.error.txt").write_text(str(error), encoding="utf-8")
return None
def press(label, stage):
"""Tap a visible WebView action until the page shows it was clicked.
The page re-renders every second. At one frame per second the touch down
and up can straddle that re-render, and Chromium then drops the click: the
button stays exactly as it was and the handler never runs. Callers still
assert the service and listener effect strictly afterwards.
"""
deadline = time.monotonic() + VISIBLE_SECONDS
taps = 0
last_tap = 0.0
attempt = 0
while time.monotonic() < deadline:
point = find_label(label, f"{stage}-{attempt}")
attempt += 1
if point is None and taps:
RESULT["taps"][stage] = taps
step(f"{label}: clicked after {taps} tap(s)")
return
if point is not None and (not taps or time.monotonic() - last_tap >= RETAP_AFTER_SECONDS):
if taps == MAX_TAPS:
raise AssertionError(f"{label} stayed unchanged after {taps} taps")
adb("shell", "input", "tap", str(point[0]), str(point[1]))
taps += 1
last_tap = time.monotonic()
step(f"{label}: tap {taps} at {point}")
time.sleep(1)
if taps:
raise AssertionError(f"{label} stayed unchanged {VISIBLE_SECONDS}s after {taps} tap(s)")
raise AssertionError(f"Visible action not found: {label}")
def service_running():
text = adb("shell", "dumpsys", "activity", "services", f"{PACKAGE}/.TunnelService")
(EVIDENCE / "services-last.txt").write_text(text, encoding="utf-8")
return "isForeground=true" in text
def proxy_ready():
# ADB forwards to emulator loopback. A real SOCKS5 greeting proves the
# Rust listener is serving, beyond just a notification being displayed.
try:
with socket.create_connection(("127.0.0.1", 11080), timeout=3) as peer:
peer.sendall(bytes([5, 1, 0]))
return peer.recv(2) == bytes([5, 0])
except (OSError, TimeoutError):
return False
def launch():
adb("shell", "am", "start", "-W", "-n", COMPONENT, timeout=60)
wait_for("Activity resumed", lambda: any(
"ResumedActivity" in line and PACKAGE in line
for line in adb("shell", "dumpsys", "activity", "activities").splitlines()
))
assert adb("shell", "pidof", PACKAGE), "App process is absent"
def main():
apks = sorted(Path(sys.argv[1]).rglob("*.apk"))
assert len(apks) == 1, f"Expected one x86_64 APK, got {len(apks)}"
adb("install", "-r", str(apks[0]), timeout=120)
adb("shell", "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS")
adb("forward", "tcp:11080", "tcp:1080")
settle()
adb("logcat", "-c")
step("launch")
launch()
RESULT["launch"] = "passed"
deadline = time.monotonic() + VISIBLE_SECONDS
tree = None
start_point = None
attempt = 0
while time.monotonic() < deadline:
clear_foreign_dialogs()
try:
tree = ui_dump(f"launched-{attempt}")
start_point = label_node(tree, "Включить защиту")
if start_point is not None:
break
except (subprocess.SubprocessError, ET.ParseError):
pass
attempt += 1
time.sleep(1)
assert not service_running(), "Foreground service started without user action"
assert not proxy_ready(), "Proxy started without user action"
if tree is not None:
assert any(
node.get("class") == "android.webkit.WebView"
for node in tree.iter("node")
), "App Activity is resumed but its WebView is absent"
if start_point is None:
RESULT["lifecycle"] = f"skipped: WebView Start not accessible after {VISIBLE_SECONDS}s"
print(f"::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after {VISIBLE_SECONDS}s")
return
RESULT["lifecycle"] = "failed: lifecycle assertions incomplete"
press("Включить защиту", "before-start")
wait_for("foreground service after Start", service_running)
wait_for("Rust SOCKS listener after Start", proxy_ready)
adb("shell", "input", "keyevent", "KEYCODE_HOME")
time.sleep(5)
clear_foreign_dialogs()
assert service_running() and proxy_ready(), "Proxy stopped after backgrounding"
step("ok: still serving after 5s in background")
launch()
press("Выключить", "before-stop")
wait_for("foreground service after Stop", lambda: not service_running())
wait_for("Rust listener after Stop", lambda: not proxy_ready())
# Explicit restart and user force-stop, then relaunch. This tests the
# user-stop contract, not Android's automatic low-memory process eviction.
press("Включить защиту", "before-restart")
wait_for("Rust listener after restart", proxy_ready)
adb("shell", "am", "force-stop", PACKAGE)
launch()
assert not service_running() and not proxy_ready(), "Proxy silently restarted after force-stop"
RESULT["lifecycle"] = "passed: Start, background 5s, Stop, restart, force-stop"
try:
main()
except Exception as error:
RESULT["failure"] = str(error)
for name, args in (("failure-windows.txt", ("dumpsys", "window", "windows")),
("failure-activities.txt", ("dumpsys", "activity", "activities"))):
try:
(EVIDENCE / name).write_text(adb("shell", *args, check=False), encoding="utf-8")
except subprocess.SubprocessError:
pass
try:
(EVIDENCE / "failure.png").write_bytes(subprocess.run(
["adb", "exec-out", "screencap", "-p"], capture_output=True, timeout=30).stdout)
except subprocess.SubprocessError:
pass
raise
finally:
try:
logs = adb("logcat", "-d", "-v", "threadtime", timeout=60)
(EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8")
crashes = adb("logcat", "-b", "crash", "-d")
(EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8")
if PACKAGE in crashes or f"ANR in {PACKAGE}" in logs or not adb("shell", "pidof", PACKAGE):
RESULT["launch"] = "failed: application crash, ANR, or missing process"
raise AssertionError("Application did not remain healthy")
finally:
(EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2, ensure_ascii=False), encoding="utf-8")
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as report:
report.write("\nAndroid emulator smoke: " + json.dumps(RESULT, ensure_ascii=False) + "\n")
print(json.dumps(RESULT, ensure_ascii=False))
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env node
// Manual, account-free probe of an ALREADY RUNNING local TGLock instance.
// Protocol sources (this does not import the Rust implementation):
// https://core.telegram.org/mtproto/auth_key
// https://core.telegram.org/mtproto/mtproto-transports#transport-obfuscation
// https://core.telegram.org/mtproto/description#unencrypted-message
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createConnection } from "node:net";
import { setTimeout as sleep } from "node:timers/promises";
import assert from "node:assert/strict";
const MAX_RESPONSE = 2 * 1024 * 1024;
const HELP = `Usage: node scripts/probe_proxy.mjs --secret-file PATH [--port 1080] [--dc 2] [--timeout-ms 15000] [--fragment-size 0]
Or: node scripts/probe_proxy.mjs --direct-cdn --dc 203
DC: 1..5 or 203; negative values request the media route.
Default connects only to 127.0.0.1. --direct-cdn explicitly probes only 91.105.192.100:443 without a secret.
Reads the local proxy secret from the explicit file; never prints it.
Sends one req_pq_multi and checks resPQ/nonce. No account, API credentials, login or auth key is created.
This proves a protocol response, not DC identity, account operation or media downloads.
Use --self-test for offline parser checks; --fragment-size 7 sends small writes with 2ms gaps.`;
function options(args) {
const result = { port: 1080, dc: 2, timeoutMs: 15000, fragmentSize: 0 };
const numeric = { "--port": "port", "--dc": "dc", "--timeout-ms": "timeoutMs", "--fragment-size": "fragmentSize" };
for (let i = 0; i < args.length; i++) {
const name = args[i];
if (name === "--direct-cdn") { result.directCdn = true; continue; }
const value = args[++i];
if (value === undefined) throw new Error(`Missing value for ${name}`);
if (name === "--secret-file") result.secretFile = value;
else if (numeric[name] && /^-?\d+$/.test(value)) result[numeric[name]] = Number(value);
else throw new Error(`Invalid option ${name}`);
}
if (!result.secretFile && !result.directCdn) throw new Error("--secret-file is required");
if (result.directCdn && result.dc !== 203) throw new Error("Direct CDN probe requires --dc 203");
if (!Number.isInteger(result.port) || result.port < 1 || result.port > 65535) throw new Error("Invalid port");
if (![1, 2, 3, 4, 5, 203].includes(Math.abs(result.dc))) throw new Error("Unsupported DC");
if (result.timeoutMs < 100 || result.timeoutMs > 120000) throw new Error("Timeout must be 100..120000ms");
if (result.fragmentSize < 0 || result.fragmentSize > 65536) throw new Error("Fragment size must be 0..65536");
return result;
}
function makeRequest(secret, dc) {
let header;
do {
header = randomBytes(64);
} while (header[0] === 0xef || ["HEAD", "POST", "GET ", "OPTI"].includes(header.toString("ascii", 0, 4))
|| [0xeeeeeeee, 0xdddddddd, 0x02010316].includes(header.readUInt32LE()) || header.readUInt32LE(4) === 0);
header.fill(0xdd, 56, 60); // padded intermediate transport
header.writeInt16LE(dc, 60);
const salted = (key) => secret ? createHash("sha256").update(key).update(secret).digest() : Buffer.from(key);
const encrypt = createCipheriv("aes-256-ctr", salted(header.subarray(8, 40)), header.subarray(40, 56));
const reversed = Buffer.from(header.subarray(8, 56)).reverse();
const decrypt = createDecipheriv("aes-256-ctr", salted(reversed.subarray(0, 32)), reversed.subarray(32));
const wireHeader = Buffer.from(header);
encrypt.update(header).copy(wireHeader, 56, 56); // advances outgoing CTR by all 64 bytes
const nonce = randomBytes(16);
const payload = Buffer.alloc(40);
// auth_key_id = 0, client message ID divisible by four, TL payload length = 20.
const now = BigInt(Date.now());
const messageId = ((now / 1000n << 32n) | ((now % 1000n) * (1n << 32n) / 1000n)) & ~3n;
payload.writeBigUInt64LE(messageId, 8);
payload.writeUInt32LE(20, 16);
payload.writeUInt32LE(0xbe7e8ef1, 20);
nonce.copy(payload, 24);
const padded = Buffer.concat([payload, randomBytes(7)]);
const length = Buffer.alloc(4);
length.writeUInt32LE(padded.length);
return { wire: Buffer.concat([wireHeader, encrypt.update(Buffer.concat([length, padded]))]), decrypt, nonce };
}
function parseResPQ(frame, nonce) {
if (frame.length === 4) throw new Error(`MTProto transport error ${frame.readInt32LE()}`);
if (frame.length < 20 || frame.readBigUInt64LE() !== 0n) throw new Error("Expected an unencrypted MTProto response");
const length = frame.readUInt32LE(16);
if (length < 48 || length % 4 !== 0 || length > frame.length - 20) throw new Error("Invalid MTProto message length");
const body = frame.subarray(20, 20 + length);
if (body.readUInt32LE() !== 0x05162463) throw new Error("Response is not resPQ");
if (!body.subarray(4, 20).equals(nonce)) throw new Error("resPQ nonce does not match request");
// pq is at most eight bytes for this handshake, so its TL string uses the
// one-byte length encoding followed by padding to a four-byte boundary.
const pqLength = body[36];
if (pqLength < 1 || pqLength > 8) throw new Error("Invalid resPQ pq length");
const vectorOffset = 36 + Math.ceil((1 + pqLength) / 4) * 4;
if (vectorOffset + 8 > body.length || body.readUInt32LE(vectorOffset) !== 0x1cb5c415) throw new Error("Invalid RSA fingerprint vector");
const count = body.readUInt32LE(vectorOffset + 4);
// A CDN may include trailing random bytes in its declared message length.
// Validate the complete TL object fits; do not mistake padding for corruption.
if (count < 1 || count > 64 || vectorOffset + 8 + count * 8 > body.length) throw new Error("Invalid RSA fingerprint count");
// Deliberately do not infer DC identity from these public fingerprints.
return { response: "resPQ", nonceMatches: true, rsaFingerprintCount: count };
}
async function probe(config) {
let secret = null;
if (!config.directCdn) {
let hex = (await readFile(config.secretFile, "utf8")).trim();
if (/^dd[0-9a-f]{32}$/i.test(hex)) hex = hex.slice(2);
if (!/^[0-9a-f]{32}$/i.test(hex)) throw new Error("Secret file must contain 32 hex characters or dd followed by 32 hex characters");
secret = Buffer.from(hex, "hex");
}
const { wire, decrypt, nonce } = makeRequest(secret, config.dc);
const started = Date.now();
return new Promise((resolve, reject) => {
const socket = createConnection(config.directCdn
? { host: "91.105.192.100", port: 443 }
: { host: "127.0.0.1", port: config.port });
let pending = Buffer.alloc(0);
let received = 0;
let finished = false;
const finish = (error, result) => {
if (finished) return;
finished = true;
clearTimeout(timer);
socket.destroy();
if (error) reject(error);
else resolve({ transport: config.directCdn ? "direct-cdn-tcp" : "local-proxy", requestedDc: config.dc, ...result, elapsedMs: Date.now() - started });
};
const timer = setTimeout(() => finish(new Error("Timed out waiting for resPQ")), config.timeoutMs);
socket.on("error", (error) => finish(error));
socket.on("end", () => finish(new Error("Proxy closed before a complete resPQ response")));
socket.on("data", (chunk) => {
try {
received += chunk.length;
if (received > MAX_RESPONSE) throw new Error("Response exceeded the 2MiB limit");
pending = Buffer.concat([pending, decrypt.update(chunk)]);
while (pending.length >= 4) {
const length = pending.readUInt32LE();
if (length & 0x80000000) { // optional intermediate quick acknowledgment
pending = pending.subarray(4);
continue;
}
if (length < 4 || length > MAX_RESPONSE - 4) throw new Error("Invalid intermediate frame length");
if (pending.length < 4 + length) return;
if (length >= 8 && length <= 16 && pending.readUInt32LE(4) === 0xffffffff) {
pending = pending.subarray(4 + length); // padded intermediate quick ACK
continue;
}
finish(null, parseResPQ(pending.subarray(4, 4 + length), nonce));
return;
}
} catch (error) { finish(error); }
});
socket.on("connect", async () => {
try {
socket.setNoDelay(true);
const size = config.fragmentSize || wire.length;
for (let offset = 0; offset < wire.length && !finished; offset += size) {
socket.write(wire.subarray(offset, offset + size));
if (config.fragmentSize) await sleep(2);
}
} catch (error) { finish(error); }
});
});
}
function selfTest() {
// Fixed TL fixture: resPQ, request nonce 00..0f, server nonce 10..1f,
// eight-byte pq, a one-element vector of public RSA fingerprints.
const body = Buffer.from("63241605000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f08112233445566778800000015c4b51c010000008877665544332211", "hex");
const envelope = Buffer.alloc(20);
envelope.writeUInt32LE(body.length, 16);
const frame = Buffer.concat([envelope, body, Buffer.from([1, 2, 3])]);
const nonce = Buffer.from("000102030405060708090a0b0c0d0e0f", "hex");
assert.equal(parseResPQ(frame, nonce).rsaFingerprintCount, 1);
const paddedEnvelope = Buffer.from(envelope);
paddedEnvelope.writeUInt32LE(body.length + 128, 16);
assert.equal(parseResPQ(Buffer.concat([paddedEnvelope, body, Buffer.alloc(128, 0x42)]), nonce).rsaFingerprintCount, 1);
assert.throws(() => parseResPQ(frame, Buffer.alloc(16)), /nonce/);
assert.throws(() => parseResPQ(frame.subarray(0, 30), nonce), /length/);
const wrongConstructor = Buffer.from(frame);
wrongConstructor[20] = 0;
assert.throws(() => parseResPQ(wrongConstructor, nonce), /not resPQ/);
const oversizedVector = Buffer.from(frame);
oversizedVector.writeUInt32LE(65, 72);
assert.throws(() => parseResPQ(oversizedVector, nonce), /count/);
assert.throws(() => parseResPQ(Buffer.from("6cfeffff", "hex"), nonce), /-404/);
console.log("Offline parser checks passed; no network connection made.");
}
try {
const args = process.argv.slice(2);
if (args.length === 1 && args[0] === "--self-test") selfTest();
else if (args.length === 1 && ["--help", "-h"].includes(args[0])) console.log(HELP);
else console.log(JSON.stringify(await probe(options(args)), null, 2));
} catch (error) {
console.error(`Probe failed: ${error.message}`);
process.exitCode = 1;
}
+18 -2
View File
@@ -12,8 +12,8 @@ use std::process::ExitCode;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tglock::cli_settings as config; use tglock_lib::cli_settings as config;
use tglock::{proxy, transport}; use tglock_lib::{proxy, transport};
const STATUS_POLL: Duration = Duration::from_secs(1); const STATUS_POLL: Duration = Duration::from_secs(1);
@@ -55,6 +55,11 @@ struct Args {
#[arg(long, value_name = "DOMAIN")] #[arg(long, value_name = "DOMAIN")]
worker: Vec<String>, worker: Vec<String>,
/// Свой домен на Cloudflare для CF-прокси (записи kws1…kws5, kws203 на
/// адреса Telegram, docs/CF_PROXY.md). Можно повторять
#[arg(long, value_name = "DOMAIN")]
cf_proxy: Vec<String>,
/// Проксировать и не-Telegram адреса. На сетевом адресе это открытый SOCKS5 /// Проксировать и не-Telegram адреса. На сетевом адресе это открытый SOCKS5
#[arg(long)] #[arg(long)]
allow_direct: bool, allow_direct: bool,
@@ -77,6 +82,7 @@ impl Args {
lan: self.lan, lan: self.lan,
allow_direct: self.allow_direct, allow_direct: self.allow_direct,
worker: self.worker.clone(), worker: self.worker.clone(),
cf_proxy: self.cf_proxy.clone(),
secret_file: self.secret_file.clone(), secret_file: self.secret_file.clone(),
quiet: self.quiet, quiet: self.quiet,
} }
@@ -168,6 +174,9 @@ async fn serve(args: Args) -> Result<(), String> {
if !settings.workers.is_empty() { if !settings.workers.is_empty() {
say(&format!("Резервные Worker-домены: {}", settings.workers)); say(&format!("Резервные Worker-домены: {}", settings.workers));
} }
if !settings.cf_proxies.is_empty() {
say(&format!("CF-прокси домены: {}", settings.cf_proxies));
}
} }
let server_stats = stats.clone(); let server_stats = stats.clone();
@@ -338,6 +347,13 @@ mod tests {
assert_eq!(settings.workers, "a.workers.dev,b.workers.dev"); assert_eq!(settings.workers, "a.workers.dev,b.workers.dev");
} }
#[test]
fn repeated_cf_proxy_flags_collapse_into_one_list() {
let settings = from_flags(&["--cf-proxy", "a.example", "--cf-proxy", "b.example"]);
assert_eq!(settings.cf_proxies, "a.example,b.example");
assert!(from_flags(&[]).cf_proxies.is_empty());
}
#[test] #[test]
fn no_worker_flag_means_no_domains() { fn no_worker_flag_means_no_domains() {
assert!(from_flags(&[]).workers.is_empty()); assert!(from_flags(&[]).workers.is_empty());
+14
View File
@@ -33,6 +33,8 @@ pub struct FileConfig {
pub lan: Option<bool>, pub lan: Option<bool>,
pub allow_direct: Option<bool>, pub allow_direct: Option<bool>,
pub worker: Option<Vec<String>>, pub worker: Option<Vec<String>>,
/// Свои домены на Cloudflare для CF-прокси (docs/CF_PROXY.md).
pub cf_proxy: Option<Vec<String>>,
pub secret: Option<String>, pub secret: Option<String>,
pub secret_file: Option<PathBuf>, pub secret_file: Option<PathBuf>,
pub quiet: Option<bool>, pub quiet: Option<bool>,
@@ -58,6 +60,7 @@ pub struct Overrides {
pub lan: bool, pub lan: bool,
pub allow_direct: bool, pub allow_direct: bool,
pub worker: Vec<String>, pub worker: Vec<String>,
pub cf_proxy: Vec<String>,
pub secret_file: Option<PathBuf>, pub secret_file: Option<PathBuf>,
pub quiet: bool, pub quiet: bool,
} }
@@ -79,6 +82,8 @@ pub struct Resolved {
pub listen: ListenConfig, pub listen: ListenConfig,
/// Домены Worker'ов в том виде, в каком их ждёт `Stats::set_worker_domain`. /// Домены Worker'ов в том виде, в каком их ждёт `Stats::set_worker_domain`.
pub workers: String, pub workers: String,
/// Домены CF-прокси в том виде, в каком их ждёт `Stats::set_cf_proxy_domain`.
pub cf_proxies: String,
pub secret: SecretSource, pub secret: SecretSource,
pub quiet: bool, pub quiet: bool,
} }
@@ -93,6 +98,7 @@ impl Resolved {
SecretSource::Ephemeral => proxy::Stats::new(), SecretSource::Ephemeral => proxy::Stats::new(),
}; };
stats.set_worker_domain(&self.workers); stats.set_worker_domain(&self.workers);
stats.set_cf_proxy_domain(&self.cf_proxies);
stats stats
} }
} }
@@ -127,6 +133,11 @@ pub fn resolve(file: FileConfig, cli: Overrides) -> Result<Resolved, String> {
} else { } else {
cli.worker cli.worker
}; };
let cf_proxies = if cli.cf_proxy.is_empty() {
file.cf_proxy.unwrap_or_default()
} else {
cli.cf_proxy
};
// Секрет из файла конфигурации важнее отдельного файла: если человек вписал // Секрет из файла конфигурации важнее отдельного файла: если человек вписал
// его сюда, значит хотел держать всё в одном месте. // его сюда, значит хотел держать всё в одном месте.
@@ -142,6 +153,7 @@ pub fn resolve(file: FileConfig, cli: Overrides) -> Result<Resolved, String> {
Ok(Resolved { Ok(Resolved {
listen, listen,
workers: workers.join(","), workers: workers.join(","),
cf_proxies: cf_proxies.join(","),
secret, secret,
quiet: cli.quiet || file.quiet.unwrap_or(false), quiet: cli.quiet || file.quiet.unwrap_or(false),
}) })
@@ -185,6 +197,7 @@ mod tests {
port = 1443 port = 1443
allow_direct = true allow_direct = true
worker = ["a.workers.dev", "b.workers.dev"] worker = ["a.workers.dev", "b.workers.dev"]
cf_proxy = ["example.com"]
quiet = true quiet = true
"#, "#,
) )
@@ -193,6 +206,7 @@ mod tests {
assert_eq!(resolved.listen.addr.to_string(), "10.0.0.5:1443"); assert_eq!(resolved.listen.addr.to_string(), "10.0.0.5:1443");
assert!(resolved.listen.allow_direct); assert!(resolved.listen.allow_direct);
assert_eq!(resolved.workers, "a.workers.dev,b.workers.dev"); assert_eq!(resolved.workers, "a.workers.dev,b.workers.dev");
assert_eq!(resolved.cf_proxies, "example.com");
assert!(resolved.quiet); assert!(resolved.quiet);
} }
+641
View File
@@ -0,0 +1,641 @@
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;
// `default`: a settings file written before a field existed must keep the
// values it has. Without it the whole file failed to parse and silently fell
// back to defaults, dropping the user's Worker domain with it.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", default)]
struct Settings {
lan_mode: bool,
port: u16,
worker_domain: String,
/// Свой домен на Cloudflare для CF-прокси (docs/CF_PROXY.md).
cf_proxy_domain: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
lan_mode: false,
port: proxy::DEFAULT_PORT,
worker_domain: String::new(),
cf_proxy_domain: String::new(),
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LogLine {
timestamp: String,
message: String,
error: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StatusSnapshot {
running: bool,
active_connections: u32,
tunnels: u32,
data_center: Option<u16>,
route: String,
failures: u32,
/// Падения отдельных маршрутов. Растёт даже когда соединение в итоге
/// состоялось через запасной адрес (by-sonic/tglock#32).
route_failures: u32,
/// Запросы, отклонённые политикой «в LAN-режиме только Telegram».
///
/// Ноль означает, что фильтр не отклонял запросы. Доходит ли телефон до
/// машины, показывают соединения и журнал, а не этот счётчик (#42).
blocked: u32,
/// Клиенты, которые дошли, но не сумели договориться. Почти всегда это
/// ссылка `tg://proxy` от прошлого запуска, то есть другой секрет.
unknown_clients: u32,
/// Соединения, которые открылись и ничего не прислали до таймаута.
///
/// Растущее число в такт с переподключениями клиента означает, что он
/// открывает соединения впрок, а мы закрываем их по таймауту
/// (by-sonic/tglock#42).
silent_clients: u32,
uptime_seconds: u64,
port: u16,
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// В LAN-режиме это адрес этого компьютера в локальной сети. Люди искали
/// его в интерфейсе и не находили: вписывали `127.0.0.1`, который на
/// телефоне или в эмуляторе означает само устройство, и подключение не
/// работало (by-sonic/tglock#36).
share_address: Option<String>,
logs: Vec<LogLine>,
}
struct AppState {
stats: Arc<proxy::Stats>,
settings: Mutex<Settings>,
active_port: Mutex<u16>,
/// Слушатель работающего прокси. Нужен, чтобы показать адрес для других
/// устройств именно тот, на котором прокси реально поднят, а не тот, что
/// сейчас выбран в настройках.
active_listen: Mutex<Option<ListenConfig>>,
started_at: Mutex<Option<Instant>>,
logs: Arc<Mutex<Vec<LogLine>>>,
settings_path: PathBuf,
task: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl AppState {
fn new(settings_path: PathBuf, stats: Arc<proxy::Stats>) -> Self {
let settings = std::fs::read(&settings_path)
.ok()
.and_then(|contents| serde_json::from_slice(&contents).ok())
.unwrap_or_default();
Self {
stats,
settings: Mutex::new(settings),
active_port: Mutex::new(proxy::DEFAULT_PORT),
active_listen: Mutex::new(None),
started_at: Mutex::new(None),
logs: Arc::new(Mutex::new(Vec::new())),
settings_path,
task: tokio::sync::Mutex::new(None),
}
}
fn log(&self, message: impl Into<String>, error: bool) {
let mut logs = self.logs.lock().unwrap();
logs.push(LogLine {
timestamp: current_time(),
message: message.into(),
error,
});
if logs.len() > 100 {
logs.remove(0);
}
}
fn snapshot(&self) -> StatusSnapshot {
// События прокси доходят до журнала только здесь: у ядра нет своего
// способа что-то показать, а интерфейс и так опрашивает состояние.
for event in self.stats.drain_events() {
self.log(event, false);
}
let data_center = self.stats.last_dc();
let route = transport::route_label(self.stats.last_route());
StatusSnapshot {
running: self.stats.running.load(Ordering::SeqCst),
active_connections: self.stats.active.load(Ordering::Relaxed),
tunnels: self.stats.ws.load(Ordering::Relaxed),
data_center: (data_center > 0).then_some(data_center),
route: route.to_owned(),
failures: self.stats.ws_failures.load(Ordering::Relaxed),
route_failures: self.stats.route_failures(),
blocked: self.stats.blocked.load(Ordering::Relaxed),
unknown_clients: self.stats.unknown_clients.load(Ordering::Relaxed),
silent_clients: self.stats.silent_clients.load(Ordering::Relaxed),
uptime_seconds: self
.started_at
.lock()
.unwrap()
.map_or(0, |started| started.elapsed().as_secs()),
port: *self.active_port.lock().unwrap(),
share_address: share_address(*self.active_listen.lock().unwrap()),
logs: self.logs.lock().unwrap().clone(),
}
}
fn persist_settings(&self, settings: &Settings) -> Result<(), String> {
if let Some(parent) = self.settings_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("Не удалось создать папку настроек: {error}"))?;
}
let contents = serde_json::to_vec_pretty(settings)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))?;
std::fs::write(&self.settings_path, contents)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))
}
}
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// Только для слушателя на `0.0.0.0`: на loopback делиться нечем, туда никто
/// извне не достучится. Возвращается адрес этой машины в сети, а не `0.0.0.0`
/// и не `127.0.0.1` — последний на телефоне или в эмуляторе означает само
/// устройство, и именно на этом спотыкались (by-sonic/tglock#36).
fn share_address(listen: Option<ListenConfig>) -> Option<String> {
listen
.filter(|listen| listen.addr.ip().is_unspecified())
.map(|listen| format!("{}:{}", listen.advertised_host(), listen.addr.port()))
}
fn current_time() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!(
"{:02}:{:02}:{:02}",
(seconds / 3600) % 24,
(seconds / 60) % 60,
seconds % 60
)
}
#[tauri::command]
fn get_status(state: State<'_, AppState>) -> StatusSnapshot {
state.snapshot()
}
#[tauri::command]
fn get_settings(state: State<'_, AppState>) -> Settings {
state.settings.lock().unwrap().clone()
}
#[tauri::command]
fn save_settings(settings: Settings, state: State<'_, AppState>) -> Result<Settings, String> {
if state.stats.running.load(Ordering::SeqCst) {
return Err("Сначала выключите защиту".into());
}
if settings.port == 0 {
return Err("Порт должен быть от 1 до 65535".into());
}
state.persist_settings(&settings)?;
*state.settings.lock().unwrap() = settings.clone();
state.log("Настройки сохранены", false);
Ok(settings)
}
#[tauri::command]
async fn start_proxy(
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<StatusSnapshot, String> {
// Serialize transitions, including shutdown, so a rapid double click cannot
// create two listeners or stop a newly started Android service.
let mut task = state.task.lock().await;
if state.stats.running.load(Ordering::SeqCst) {
return Ok(state.snapshot());
}
if let Some(previous) = task.take() {
let _ = previous.await;
}
let settings = state.settings.lock().unwrap().clone();
let listen = if settings.lan_mode {
ListenConfig::lan(settings.port)
} else {
ListenConfig::loopback(settings.port)
};
let listener = proxy::bind(listen).await?;
// Called while the user's Activity is visible, before opening Telegram.
// A rejected foreground-service start must not leave a hidden proxy running.
set_foreground_service(&app, true)?;
state.stats.set_worker_domain(&settings.worker_domain);
state.stats.set_cf_proxy_domain(&settings.cf_proxy_domain);
*state.active_port.lock().unwrap() = settings.port;
*state.active_listen.lock().unwrap() = Some(listen);
*state.started_at.lock().unwrap() = Some(Instant::now());
let stats = state.stats.clone();
let logs = state.logs.clone();
let worker_app = app.clone();
let worker = tokio::spawn(async move {
if let Err(error) = proxy::serve(stats.clone(), listener, listen.allow_direct).await {
push_log(&logs, format!("Ошибка подключения: {error}"), true);
}
stats.running.store(false, Ordering::SeqCst);
if let Err(error) = set_foreground_service(&worker_app, false) {
push_log(&logs, error, true);
}
});
while !state.stats.running.load(Ordering::SeqCst) && !worker.is_finished() {
tokio::task::yield_now().await;
}
*task = Some(worker);
if !state.stats.running.load(Ordering::SeqCst) {
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
return Err("Не удалось запустить прокси; подробности в диагностике".into());
}
state.log(format!("Прокси запущен на {}", listen.addr), false);
if let Err(error) = open_telegram_link(&app, &state) {
// The working proxy remains available through the copy-link control.
state.log(error, true);
}
Ok(state.snapshot())
}
#[tauri::command]
async fn stop_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
let mut task = state.task.lock().await;
state.stats.stop();
if let Some(worker) = task.take() {
let _ = worker.await;
}
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
state.log("Защита выключена", false);
Ok(state.snapshot())
}
fn telegram_link(state: &AppState) -> Result<String, String> {
if !state.stats.running.load(Ordering::SeqCst) {
return Err("Сначала включите защиту".into());
}
state
.active_listen
.lock()
.unwrap()
.map(|listen| listen.telegram_link(&state.stats.telegram_secret()))
.ok_or_else(|| "Сначала включите защиту".into())
}
#[tauri::command]
fn get_telegram_link(state: State<'_, AppState>) -> Result<String, String> {
telegram_link(&state)
}
fn open_telegram_link(app: &tauri::AppHandle, state: &AppState) -> Result<(), String> {
app.opener()
.open_url(telegram_link(state)?, None::<&str>)
.map_err(|error| {
format!("Не удалось открыть Telegram: {error}. Скопируйте ссылку подключения.")
})
}
#[tauri::command]
async fn open_telegram(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> {
open_telegram_link(&app, &state)
}
#[cfg(target_os = "android")]
struct TunnelPlugin(tauri::plugin::PluginHandle<tauri::Wry>);
#[cfg(target_os = "android")]
fn set_foreground_service(app: &tauri::AppHandle, enabled: bool) -> Result<(), String> {
app.state::<TunnelPlugin>()
.0
.run_mobile_plugin::<serde_json::Value>(
"setEnabled",
serde_json::json!({ "enabled": enabled }),
)
.map(|_| ())
.map_err(|error| format!("Фоновый сервис Android: {error}"))
}
#[cfg(not(target_os = "android"))]
fn set_foreground_service(_app: &tauri::AppHandle, _enabled: bool) -> Result<(), String> {
Ok(())
}
fn push_log(logs: &Arc<Mutex<Vec<LogLine>>>, message: String, error: bool) {
logs.lock().unwrap().push(LogLine {
timestamp: current_time(),
message,
error,
});
}
/// Environment variables that make the WebView render without a GPU.
///
/// The window is never created when 3D acceleration is unavailable: no
/// monitor, the default Microsoft display driver, a virtual machine without
/// 3D enabled (by-sonic/tglock#10, by-sonic/tglock#17). For a small status
/// panel software rendering costs nothing noticeable, so preferring it is the
/// safer default.
///
/// Values already present in the environment are never overwritten, and
/// `TGLOCK_FORCE_GPU` disables the whole mechanism.
fn software_rendering_vars(
force_gpu: bool,
is_set: impl Fn(&str) -> bool,
) -> Vec<(&'static str, &'static str)> {
if force_gpu {
return Vec::new();
}
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
&[(
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
"--disable-gpu --disable-gpu-compositing",
)]
} else if cfg!(any(
target_os = "macos",
target_os = "android",
target_os = "ios"
)) {
// WebKit on macOS falls back to software rendering on its own.
&[]
} else {
&[
("WEBKIT_DISABLE_COMPOSITING_MODE", "1"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
]
};
candidates
.iter()
.filter(|(key, _)| !is_set(key))
.copied()
.collect()
}
fn prefer_software_rendering() {
let force_gpu = std::env::var_os("TGLOCK_FORCE_GPU").is_some();
for (key, value) in software_rendering_vars(force_gpu, |key| std::env::var_os(key).is_some()) {
std::env::set_var(key, value);
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
prefer_software_rendering();
let builder = tauri::Builder::default().plugin(tauri_plugin_opener::init());
#[cfg(target_os = "android")]
let builder = builder.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("tunnel")
.setup(|app, api| {
let handle = api.register_android_plugin("com.bysonic.tglock", "TunnelPlugin")?;
app.manage(TunnelPlugin(handle));
Ok(())
})
.build(),
);
builder
.setup(|app| {
let settings_path = app
.path()
.app_config_dir()
.map_err(|error| error.to_string())?
.join("settings.json");
let secret_path = settings_path.with_file_name("secret");
let stored = crate::mtproto::load_or_create_secret_at_with_migration(
&secret_path,
crate::mtproto::legacy_secret_path().as_deref(),
);
let state = AppState::new(settings_path, proxy::Stats::with_stored_secret(stored));
// Если секрет не удалось записать, ссылка tg://proxy изменится после
// перезапуска и Telegram откажется подключаться к сохранённой.
// Раньше это происходило молча (by-sonic/tglock#37).
if let Some(error) = state.stats.secret_write_error() {
state.log(
format!("Секрет не записан ({error}). Проверьте ссылку после перезапуска"),
true,
);
}
app.manage(state);
#[cfg(desktop)]
if let Err(error) = setup_tray(app) {
app.state::<AppState>().log(
format!("Значок в трее недоступен ({error}). Закрытие окна выключит защиту"),
true,
);
}
Ok(())
})
.on_window_event(|_window, _event| {
#[cfg(desktop)]
hide_to_tray_on_close(_window, _event);
})
.invoke_handler(tauri::generate_handler![
get_status,
get_settings,
save_settings,
start_proxy,
stop_proxy,
get_telegram_link,
open_telegram
])
.build(tauri::generate_context!())
.expect("failed to build TGLock")
.run(|_app, _event| {
// В macOS окно, спрятанное в трей, возвращает и клик по значку в Dock.
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Reopen { .. } = _event {
show_main_window(_app);
}
});
}
#[cfg(desktop)]
const TRAY_ID: &str = "main";
/// Закрытие окна при включённой защите прячет его в трей, и прокси продолжает
/// работать, как в tg-ws-proxy. Выйти можно из меню трея (by-sonic/tglock#70).
/// Если защита выключена или значка в трее нет, окно закрывается как раньше:
/// иначе приложение осталось бы работать без способа до него добраться.
#[cfg(desktop)]
fn hide_to_tray_on_close(window: &tauri::Window, event: &tauri::WindowEvent) {
let tauri::WindowEvent::CloseRequested { api, .. } = event else {
return;
};
let app = window.app_handle();
let running = app.state::<AppState>().stats.running.load(Ordering::SeqCst);
if running && app.tray_by_id(TRAY_ID).is_some() {
api.prevent_close();
let _ = window.hide();
}
}
#[cfg(desktop)]
fn show_main_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
#[cfg(desktop)]
fn setup_tray(app: &tauri::App) -> Result<(), String> {
// В Linux значок держится на libayatana-appindicator, и без неё tray-icon
// паникует, а не возвращает ошибку. Без трея окно просто закрывается.
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray(app)))
.map_err(|_| "нет libayatana-appindicator3".to_string())?
.map_err(|error| error.to_string())
}
#[cfg(desktop)]
fn build_tray(app: &tauri::App) -> tauri::Result<()> {
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
let show = MenuItem::with_id(app, "show", "Открыть TGLock", true, None::<&str>)?;
let telegram = MenuItem::with_id(app, "telegram", "Открыть Telegram", true, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &telegram, &separator, &quit])?;
let mut tray = TrayIconBuilder::with_id(TRAY_ID)
.tooltip("TGLock")
.menu(&menu)
// В Windows и Linux левый клик открывает окно, меню — по правому.
// В macOS меню по клику привычнее.
.show_menu_on_left_click(cfg!(target_os = "macos"))
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => show_main_window(app),
"telegram" => {
let state = app.state::<AppState>();
if let Err(error) = open_telegram_link(app, &state) {
state.log(error, true);
}
}
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if cfg!(target_os = "macos") {
return;
}
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
});
if let Some(icon) = app.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.build(app)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_settings_file_from_an_older_version_keeps_its_values() {
let old: Settings =
serde_json::from_str(r#"{"lanMode":true,"port":1443,"workerDomain":"a.workers.dev"}"#)
.unwrap();
assert!(old.lan_mode);
assert_eq!(old.port, 1443);
assert_eq!(old.worker_domain, "a.workers.dev");
assert_eq!(old.cf_proxy_domain, "");
}
#[test]
fn nothing_to_share_when_the_proxy_is_off_or_local() {
assert_eq!(
share_address(None),
None,
"выключенный прокси нечего делить"
);
assert_eq!(
share_address(Some(ListenConfig::loopback(1080))),
None,
"на loopback снаружи никто не подключится"
);
}
#[test]
fn lan_mode_shares_a_reachable_address() {
let shown = share_address(Some(ListenConfig::lan(1443))).expect("в LAN-режиме адрес нужен");
assert!(shown.ends_with(":1443"), "порт должен быть виден: {shown}");
assert!(
!shown.starts_with("0.0.0.0"),
"0.0.0.0 нельзя вписать в Telegram: {shown}"
);
assert!(
!shown.starts_with("127.0.0.1"),
"127.0.0.1 на другом устройстве означает само устройство: {shown}"
);
}
#[test]
fn software_rendering_is_requested_by_default() {
let vars = software_rendering_vars(false, |_| false);
if cfg!(any(
target_os = "macos",
target_os = "android",
target_os = "ios"
)) {
assert!(vars.is_empty(), "macOS needs no override");
} else {
assert!(
!vars.is_empty(),
"a machine without 3D acceleration must still get a window"
);
}
}
#[test]
fn force_gpu_disables_the_override() {
assert!(software_rendering_vars(true, |_| false).is_empty());
}
#[test]
fn an_operators_own_value_is_never_overwritten() {
assert!(software_rendering_vars(false, |_| true).is_empty());
}
#[test]
fn windows_uses_webview2_arguments_and_linux_uses_webkit_ones() {
let keys: Vec<_> = software_rendering_vars(false, |_| false)
.into_iter()
.map(|(key, _)| key)
.collect();
if cfg!(target_os = "windows") {
assert_eq!(keys, ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"]);
} else if cfg!(target_os = "linux") {
assert_eq!(
keys,
[
"WEBKIT_DISABLE_COMPOSITING_MODE",
"WEBKIT_DISABLE_DMABUF_RENDERER"
]
);
}
}
}
+5 -1
View File
@@ -1,7 +1,7 @@
//! TGLock core: the MTProto/WebSocket transport shared by the desktop GUI and //! TGLock core: the MTProto/WebSocket transport shared by the desktop GUI and
//! the headless CLI. //! the headless CLI.
//! //!
//! Nothing in this crate depends on Tauri or on a windowing system, so the //! With the `gui` feature disabled this crate does not depend on Tauri, so the
//! `tglock-cli` binary can be built with `--no-default-features` on a server //! `tglock-cli` binary can be built with `--no-default-features` on a server
//! that has neither a GPU nor a monitor. //! that has neither a GPU nor a monitor.
@@ -14,3 +14,7 @@ pub mod transport;
/// Настройки headless-версии: файл конфигурации и сведение с флагами. /// Настройки headless-версии: файл конфигурации и сведение с флагами.
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
pub mod cli_settings; pub mod cli_settings;
/// Desktop and Android graphical application; absent from headless builds.
#[cfg(feature = "gui")]
pub mod gui;
+1 -431
View File
@@ -1,435 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tauri::{Manager, State};
use tglock::config::ListenConfig;
use tglock::{proxy, transport};
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct Settings {
lan_mode: bool,
port: u16,
worker_domain: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
lan_mode: false,
port: proxy::DEFAULT_PORT,
worker_domain: String::new(),
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LogLine {
timestamp: String,
message: String,
error: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StatusSnapshot {
running: bool,
active_connections: u32,
tunnels: u32,
data_center: Option<u16>,
route: String,
failures: u32,
/// Падения отдельных маршрутов. Растёт даже когда соединение в итоге
/// состоялось через запасной адрес (by-sonic/tglock#32).
route_failures: u32,
/// Запросы, отклонённые политикой «в LAN-режиме только Telegram».
///
/// Ноль при неработающем телефоне означает, что он вообще не дотянулся до
/// этой машины; не ноль — что дотянулся, и разбираться надо с адресами
/// (by-sonic/tglock#42).
blocked: u32,
/// Клиенты, которые дошли, но не сумели договориться. Почти всегда это
/// ссылка `tg://proxy` от прошлого запуска, то есть другой секрет.
unknown_clients: u32,
/// Соединения, которые открылись и ничего не прислали до таймаута.
///
/// Растущее число в такт с переподключениями клиента означает, что он
/// открывает соединения впрок, а мы закрываем их по таймауту
/// (by-sonic/tglock#42).
silent_clients: u32,
uptime_seconds: u64,
port: u16,
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// В LAN-режиме это адрес этого компьютера в локальной сети. Люди искали
/// его в интерфейсе и не находили: вписывали `127.0.0.1`, который на
/// телефоне или в эмуляторе означает само устройство, и подключение не
/// работало (by-sonic/tglock#36).
share_address: Option<String>,
logs: Vec<LogLine>,
}
struct AppState {
stats: Arc<proxy::Stats>,
settings: Mutex<Settings>,
active_port: Mutex<u16>,
/// Слушатель работающего прокси. Нужен, чтобы показать адрес для других
/// устройств именно тот, на котором прокси реально поднят, а не тот, что
/// сейчас выбран в настройках.
active_listen: Mutex<Option<ListenConfig>>,
started_at: Mutex<Option<Instant>>,
logs: Arc<Mutex<Vec<LogLine>>>,
settings_path: PathBuf,
}
impl AppState {
fn new(settings_path: PathBuf) -> Self {
let settings = std::fs::read(&settings_path)
.ok()
.and_then(|contents| serde_json::from_slice(&contents).ok())
.unwrap_or_default();
Self {
stats: proxy::Stats::new(),
settings: Mutex::new(settings),
active_port: Mutex::new(proxy::DEFAULT_PORT),
active_listen: Mutex::new(None),
started_at: Mutex::new(None),
logs: Arc::new(Mutex::new(Vec::new())),
settings_path,
}
}
fn log(&self, message: impl Into<String>, error: bool) {
let mut logs = self.logs.lock().unwrap();
logs.push(LogLine {
timestamp: current_time(),
message: message.into(),
error,
});
if logs.len() > 100 {
logs.remove(0);
}
}
fn snapshot(&self) -> StatusSnapshot {
// События прокси доходят до журнала только здесь: у ядра нет своего
// способа что-то показать, а интерфейс и так опрашивает состояние.
for event in self.stats.drain_events() {
self.log(event, false);
}
let data_center = self.stats.last_dc();
let route = transport::route_label(self.stats.last_route());
StatusSnapshot {
running: self.stats.running.load(Ordering::SeqCst),
active_connections: self.stats.active.load(Ordering::Relaxed),
tunnels: self.stats.ws.load(Ordering::Relaxed),
data_center: (data_center > 0).then_some(data_center),
route: route.to_owned(),
failures: self.stats.ws_failures.load(Ordering::Relaxed),
route_failures: self.stats.route_failures(),
blocked: self.stats.blocked.load(Ordering::Relaxed),
unknown_clients: self.stats.unknown_clients.load(Ordering::Relaxed),
silent_clients: self.stats.silent_clients.load(Ordering::Relaxed),
uptime_seconds: self
.started_at
.lock()
.unwrap()
.map_or(0, |started| started.elapsed().as_secs()),
port: *self.active_port.lock().unwrap(),
share_address: share_address(*self.active_listen.lock().unwrap()),
logs: self.logs.lock().unwrap().clone(),
}
}
fn persist_settings(&self, settings: &Settings) -> Result<(), String> {
if let Some(parent) = self.settings_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("Не удалось создать папку настроек: {error}"))?;
}
let contents = serde_json::to_vec_pretty(settings)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))?;
std::fs::write(&self.settings_path, contents)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))
}
}
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// Только для слушателя на `0.0.0.0`: на loopback делиться нечем, туда никто
/// извне не достучится. Возвращается адрес этой машины в сети, а не `0.0.0.0`
/// и не `127.0.0.1` — последний на телефоне или в эмуляторе означает само
/// устройство, и именно на этом спотыкались (by-sonic/tglock#36).
fn share_address(listen: Option<ListenConfig>) -> Option<String> {
listen
.filter(|listen| listen.addr.ip().is_unspecified())
.map(|listen| format!("{}:{}", listen.advertised_host(), listen.addr.port()))
}
fn current_time() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!(
"{:02}:{:02}:{:02}",
(seconds / 3600) % 24,
(seconds / 60) % 60,
seconds % 60
)
}
#[tauri::command]
fn get_status(state: State<'_, AppState>) -> StatusSnapshot {
state.snapshot()
}
#[tauri::command]
fn get_settings(state: State<'_, AppState>) -> Settings {
state.settings.lock().unwrap().clone()
}
#[tauri::command]
fn save_settings(settings: Settings, state: State<'_, AppState>) -> Result<Settings, String> {
if state.stats.running.load(Ordering::SeqCst) {
return Err("Сначала выключите защиту".into());
}
if settings.port == 0 {
return Err("Порт должен быть от 1 до 65535".into());
}
state.persist_settings(&settings)?;
*state.settings.lock().unwrap() = settings.clone();
state.log("Настройки сохранены", false);
Ok(settings)
}
#[tauri::command]
fn start_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
if state.stats.running.load(Ordering::SeqCst) {
return Ok(state.snapshot());
}
let settings = state.settings.lock().unwrap().clone();
state.stats.set_worker_domain(&settings.worker_domain);
*state.active_port.lock().unwrap() = settings.port;
*state.started_at.lock().unwrap() = Some(Instant::now());
state.log("Запускаю защищённый маршрут…", false);
let listen = if settings.lan_mode {
ListenConfig::lan(settings.port)
} else {
ListenConfig::loopback(settings.port)
};
*state.active_listen.lock().unwrap() = Some(listen);
let stats = state.stats.clone();
let logs = state.logs.clone();
std::thread::spawn(move || {
let runtime = match tokio::runtime::Runtime::new() {
Ok(runtime) => runtime,
Err(error) => {
push_log(&logs, format!("Не удалось запустить сервис: {error}"), true);
return;
}
};
if let Err(error) = runtime.block_on(proxy::run(stats, listen)) {
push_log(&logs, format!("Ошибка подключения: {error}"), true);
}
});
std::thread::sleep(std::time::Duration::from_millis(220));
if !state.stats.running.load(Ordering::SeqCst) {
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
return Err(state
.logs
.lock()
.unwrap()
.last()
.map(|line| line.message.clone())
.unwrap_or_else(|| "Не удалось запустить прокси".into()));
}
state.log(format!("Прокси запущен на {}", listen.addr), false);
let _ = open::that(listen.telegram_link(&state.stats.telegram_secret()));
state.log("Открываю подключение в Telegram…", false);
Ok(state.snapshot())
}
#[tauri::command]
fn stop_proxy(state: State<'_, AppState>) -> StatusSnapshot {
state.stats.stop();
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
state.log("Защита выключена", false);
state.snapshot()
}
fn push_log(logs: &Arc<Mutex<Vec<LogLine>>>, message: String, error: bool) {
logs.lock().unwrap().push(LogLine {
timestamp: current_time(),
message,
error,
});
}
/// Environment variables that make the WebView render without a GPU.
///
/// The window is never created when 3D acceleration is unavailable: no
/// monitor, the default Microsoft display driver, a virtual machine without
/// 3D enabled (by-sonic/tglock#10, by-sonic/tglock#17). For a small status
/// panel software rendering costs nothing noticeable, so preferring it is the
/// safer default.
///
/// Values already present in the environment are never overwritten, and
/// `TGLOCK_FORCE_GPU` disables the whole mechanism.
fn software_rendering_vars(
force_gpu: bool,
is_set: impl Fn(&str) -> bool,
) -> Vec<(&'static str, &'static str)> {
if force_gpu {
return Vec::new();
}
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
&[(
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
"--disable-gpu --disable-gpu-compositing",
)]
} else if cfg!(target_os = "macos") {
// WebKit on macOS falls back to software rendering on its own.
&[]
} else {
&[
("WEBKIT_DISABLE_COMPOSITING_MODE", "1"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
]
};
candidates
.iter()
.filter(|(key, _)| !is_set(key))
.copied()
.collect()
}
fn prefer_software_rendering() {
let force_gpu = std::env::var_os("TGLOCK_FORCE_GPU").is_some();
for (key, value) in software_rendering_vars(force_gpu, |key| std::env::var_os(key).is_some()) {
std::env::set_var(key, value);
}
}
fn main() { fn main() {
prefer_software_rendering(); tglock_lib::gui::run();
tauri::Builder::default()
.setup(|app| {
let settings_path = app
.path()
.app_config_dir()
.map_err(|error| error.to_string())?
.join("settings.json");
let state = AppState::new(settings_path);
// Если секрет не удалось записать, ссылка tg://proxy изменится после
// перезапуска и Telegram откажется подключаться к сохранённой.
// Раньше это происходило молча (by-sonic/tglock#37).
if let Some(error) = state.stats.secret_write_error() {
state.log(
format!("Секрет не сохранён ({error}). После перезапуска ссылка изменится"),
true,
);
}
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_status,
get_settings,
save_settings,
start_proxy,
stop_proxy
])
.run(tauri::generate_context!())
.expect("failed to run TGLock");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_to_share_when_the_proxy_is_off_or_local() {
assert_eq!(
share_address(None),
None,
"выключенный прокси нечего делить"
);
assert_eq!(
share_address(Some(ListenConfig::loopback(1080))),
None,
"на loopback снаружи никто не подключится"
);
}
#[test]
fn lan_mode_shares_a_reachable_address() {
let shown = share_address(Some(ListenConfig::lan(1443))).expect("в LAN-режиме адрес нужен");
assert!(shown.ends_with(":1443"), "порт должен быть виден: {shown}");
assert!(
!shown.starts_with("0.0.0.0"),
"0.0.0.0 нельзя вписать в Telegram: {shown}"
);
assert!(
!shown.starts_with("127.0.0.1"),
"127.0.0.1 на другом устройстве означает само устройство: {shown}"
);
}
#[test]
fn software_rendering_is_requested_by_default() {
let vars = software_rendering_vars(false, |_| false);
if cfg!(target_os = "macos") {
assert!(vars.is_empty(), "macOS needs no override");
} else {
assert!(
!vars.is_empty(),
"a machine without 3D acceleration must still get a window"
);
}
}
#[test]
fn force_gpu_disables_the_override() {
assert!(software_rendering_vars(true, |_| false).is_empty());
}
#[test]
fn an_operators_own_value_is_never_overwritten() {
assert!(software_rendering_vars(false, |_| true).is_empty());
}
#[test]
fn windows_uses_webview2_arguments_and_linux_uses_webkit_ones() {
let keys: Vec<_> = software_rendering_vars(false, |_| false)
.into_iter()
.map(|(key, _)| key)
.collect();
if cfg!(target_os = "windows") {
assert_eq!(keys, ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"]);
} else if cfg!(target_os = "linux") {
assert_eq!(
keys,
[
"WEBKIT_DISABLE_COMPOSITING_MODE",
"WEBKIT_DISABLE_DMABUF_RENDERER"
]
);
}
}
} }
+120 -8
View File
@@ -2,9 +2,7 @@ use aes::Aes256;
use cipher::{KeyIvInit, StreamCipher}; use cipher::{KeyIvInit, StreamCipher};
use rand::{rngs::OsRng, RngCore}; use rand::{rngs::OsRng, RngCore};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::path::Path; use std::path::{Path, PathBuf};
#[cfg(not(test))]
use std::path::PathBuf;
type AesCtr = ctr::Ctr128BE<Aes256>; type AesCtr = ctr::Ctr128BE<Aes256>;
@@ -119,6 +117,12 @@ impl StoredSecret {
/// Секрет — половина ссылки `tg://proxy`, поэтому сервис, придумывающий новый /// Секрет — половина ссылки `tg://proxy`, поэтому сервис, придумывающий новый
/// при каждом старте, отключает всех уже настроенных клиентов. /// при каждом старте, отключает всех уже настроенных клиентов.
pub fn load_or_create_secret_at(path: &Path) -> StoredSecret { pub fn load_or_create_secret_at(path: &Path) -> StoredSecret {
load_or_create_secret_at_with_migration(path, None)
}
/// Preserve desktop proxy links when the GUI moves to Tauri's app directory.
/// The legacy file remains intact, including when copying it fails.
pub fn load_or_create_secret_at_with_migration(path: &Path, legacy: Option<&Path>) -> StoredSecret {
if let Ok(value) = std::fs::read_to_string(path) { if let Ok(value) = std::fs::read_to_string(path) {
if let Some(value) = parse_secret_hex(value.trim()) { if let Some(value) = parse_secret_hex(value.trim()) {
return StoredSecret { return StoredSecret {
@@ -128,7 +132,10 @@ pub fn load_or_create_secret_at(path: &Path) -> StoredSecret {
} }
} }
let value = generate_secret(); let value = legacy
.and_then(|legacy| std::fs::read_to_string(legacy).ok())
.and_then(|value| parse_secret_hex(value.trim()))
.unwrap_or_else(generate_secret);
let write_error = store_secret(path, &secret_hex(&value)) let write_error = store_secret(path, &secret_hex(&value))
.err() .err()
.map(|error| format!("{}: {error}", path.display())); .map(|error| format!("{}: {error}", path.display()));
@@ -146,7 +153,7 @@ fn store_secret(path: &Path, value: &str) -> std::io::Result<()> {
#[cfg(not(test))] #[cfg(not(test))]
pub fn load_or_create_secret() -> StoredSecret { pub fn load_or_create_secret() -> StoredSecret {
match secret_path() { match legacy_secret_path() {
Some(path) => load_or_create_secret_at(&path), Some(path) => load_or_create_secret_at(&path),
None => StoredSecret { None => StoredSecret {
value: generate_secret(), value: generate_secret(),
@@ -155,8 +162,7 @@ pub fn load_or_create_secret() -> StoredSecret {
} }
} }
#[cfg(not(test))] pub fn legacy_secret_path() -> Option<PathBuf> {
fn secret_path() -> Option<PathBuf> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
std::env::var_os("APPDATA") std::env::var_os("APPDATA")
@@ -221,7 +227,7 @@ pub fn parse_secret(value: &str) -> Option<[u8; 16]> {
} }
fn parse_secret_hex(value: &str) -> Option<[u8; 16]> { fn parse_secret_hex(value: &str) -> Option<[u8; 16]> {
if value.len() != 32 { if value.len() != 32 || !value.is_ascii() {
return None; return None;
} }
let mut secret = [0; 16]; let mut secret = [0; 16];
@@ -300,6 +306,7 @@ fn is_reserved_init(init: &[u8; INIT_LEN]) -> bool {
|| &init[..4] == b"HEAD" || &init[..4] == b"HEAD"
|| &init[..4] == b"POST" || &init[..4] == b"POST"
|| &init[..4] == b"GET " || &init[..4] == b"GET "
|| &init[..4] == b"OPTI"
|| init[..4] == [0xee; 4] || init[..4] == [0xee; 4]
|| init[..4] == [0xdd; 4] || init[..4] == [0xdd; 4]
|| init[..4] == [0x16, 0x03, 0x01, 0x02] || init[..4] == [0x16, 0x03, 0x01, 0x02]
@@ -682,11 +689,50 @@ mod tests {
"00112233445566778899aabbccddee", // 30 символов "00112233445566778899aabbccddee", // 30 символов
"00112233445566778899aabbccddeeffff", // 34 символа "00112233445566778899aabbccddeeffff", // 34 символа
"zz112233445566778899aabbccddeeff", // не hex "zz112233445566778899aabbccddeeff", // не hex
"0я00000000000000000000000000000", // 32 bytes, UTF-8 boundary at byte 2
] { ] {
assert!(parse_secret(bad).is_none(), "{bad:?} должен быть отвергнут"); assert!(parse_secret(bad).is_none(), "{bad:?} должен быть отвергнут");
} }
} }
#[test]
fn android_obfuscated2_vector_survives_fragmented_translation() {
// Independently generated with Node/OpenSSL AES-256-CTR and SHA-256,
// following Telegram Android Connection.cpp sendData/encryptKeyWithSecret.
// Fixed wire bytes avoid a symmetric mistake in the test peer helpers.
fn bytes(hex: &str) -> Vec<u8> {
hex.as_bytes()
.as_chunks::<2>()
.0
.iter()
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect()
}
let init: [u8; 64] = bytes(concat!(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223",
"2425262728292a2b2c2d2e2f303132333435363738043de6c25808afa3"
))
.try_into()
.unwrap();
let parsed = parse_client_init(&init, &[42; 16]).unwrap();
assert_eq!(parsed.dc, 4);
assert!(parsed.media);
let mut relay = test_relay_peer(&parsed.relay_init);
let (mut upstream, mut downstream) = parsed.crypto.split();
let mut request = bytes("31fc48bfc21f9210a57fd63ac344ae50a3a23620");
for chunk in request.chunks_mut(3) {
upstream.apply(chunk);
relay.decrypt(chunk);
}
assert_eq!(request, bytes("10000000112233445566778899aabbccddeeff00"));
let mut reply = bytes("04000000ecfeffff");
relay.encrypt(&mut reply);
for chunk in reply.chunks_mut(1) {
downstream.apply(chunk);
}
assert_eq!(reply, bytes("d5a69e839ec08ebf"));
}
#[test] #[test]
fn parses_persisted_secret() { fn parses_persisted_secret() {
assert_eq!( assert_eq!(
@@ -698,4 +744,70 @@ mod tests {
); );
assert_eq!(parse_secret_hex("not-a-secret"), None); assert_eq!(parse_secret_hex("not-a-secret"), None);
} }
#[test]
fn secret_migration_keeps_legacy_links_and_prefers_existing_destination() {
let root = std::env::temp_dir().join(format!(
"tglock-migration-{}",
secret_hex(&generate_secret())
));
std::fs::create_dir(&root).unwrap();
let legacy = root.join("legacy");
let destination = root.join("secret");
std::fs::write(&legacy, secret_hex(&[17; 16])).unwrap();
let first = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(first.value, [17; 16]);
assert!(first.is_persistent());
assert_eq!(
std::fs::read_to_string(&legacy).unwrap(),
secret_hex(&[17; 16])
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&destination)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
std::fs::write(&legacy, secret_hex(&[23; 16])).unwrap();
let second = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(
second.value, [17; 16],
"an existing destination wins on restart"
);
std::fs::remove_file(destination).unwrap();
std::fs::remove_file(legacy).unwrap();
std::fs::remove_dir(root).unwrap();
}
#[test]
fn failed_migration_reports_error_but_does_not_rotate_the_legacy_secret() {
let root = std::env::temp_dir().join(format!(
"tglock-migration-fail-{}",
secret_hex(&generate_secret())
));
std::fs::create_dir(&root).unwrap();
let legacy = root.join("legacy");
let blocker = root.join("blocker");
std::fs::write(&legacy, secret_hex(&[29; 16])).unwrap();
std::fs::write(&blocker, "not a directory").unwrap();
let destination = blocker.join("secret");
for _ in 0..2 {
let stored = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(stored.value, [29; 16]);
assert!(stored.write_error.is_some());
}
assert_eq!(
std::fs::read_to_string(&legacy).unwrap(),
secret_hex(&[29; 16])
);
std::fs::remove_file(blocker).unwrap();
std::fs::remove_file(legacy).unwrap();
std::fs::remove_dir(root).unwrap();
}
} }
+501 -19
View File
@@ -1,5 +1,5 @@
use crate::config::ListenConfig; use crate::config::ListenConfig;
use std::collections::{HashSet, VecDeque}; use std::collections::VecDeque;
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -71,15 +71,17 @@ pub struct Stats {
shutdown: Mutex<Option<tokio::sync::watch::Sender<bool>>>, shutdown: Mutex<Option<tokio::sync::watch::Sender<bool>>>,
} }
/// Однократные сообщения о том, что происходит с подключениями. /// Сообщения о подключениях со склейкой недавних повторов.
/// ///
/// Однократные намеренно: отклонённый адрес повторяется десятки раз в минуту, /// Склейка намеренна: отклонённый адрес повторяется десятки раз в минуту,
/// и без склейки журнал превратился бы в одну строку, повторённую сто раз. /// и без склейки журнал превратился бы в одну строку, повторённую сто раз.
/// Число повторов при этом не теряется — оно в счётчике `blocked`. /// Число повторов при этом не теряется — оно в счётчике `blocked`.
#[derive(Default)] #[derive(Default)]
struct Events { struct Events {
pending: VecDeque<String>, pending: VecDeque<String>,
seen: HashSet<String>, // FIFO of recent distinct messages, independent from the consumer queue.
// Once full, evict the oldest entry rather than silencing all future logs.
seen: VecDeque<String>,
} }
/// Сколько разных событий помним, чтобы буфер не рос без границы. /// Сколько разных событий помним, чтобы буфер не рос без границы.
@@ -132,13 +134,20 @@ impl Stats {
}) })
} }
/// Запомнить событие, если такого ещё не было. /// Запомнить событие, если его нет среди последних EVENT_LIMIT разных сообщений.
pub fn note(&self, message: impl Into<String>) { pub fn note(&self, message: impl Into<String>) {
let message = message.into(); let message = message.into();
let mut events = self.events.lock().unwrap(); let mut events = self.events.lock().unwrap();
if events.seen.len() >= EVENT_LIMIT || !events.seen.insert(message.clone()) { if events.seen.contains(&message) {
return; return;
} }
if events.seen.len() == EVENT_LIMIT {
events.seen.pop_front();
}
events.seen.push_back(message.clone());
if events.pending.len() == EVENT_LIMIT {
events.pending.pop_front();
}
events.pending.push_back(message); events.pending.push_back(message);
} }
@@ -260,6 +269,26 @@ impl Stats {
} }
} }
/// Свои домены на Cloudflare для CF-прокси (`kwsN.<домен>`, docs/CF_PROXY.md).
pub fn set_cf_proxy_domain(&self, domain: &str) {
let domains = domain
.split([',', ';', ' '])
.filter(|value| !value.trim().is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
let result = self.transport.set_cf_proxy_domains(&domains);
for rejected in &result.rejected {
self.note(format!(
"CF-прокси «{rejected}» не похож на имя домена — маршрут не добавлен. Нужен только домен, без https:// и без kws: example.com"
));
}
for accepted in &result.accepted {
self.note(format!(
"CF-прокси в списке маршрутов: kws1.{accepted} … kws5.{accepted}, kws203.{accepted}"
));
}
}
pub fn stop(&self) { pub fn stop(&self) {
if let Some(sender) = self.shutdown.lock().unwrap().take() { if let Some(sender) = self.shutdown.lock().unwrap().take() {
let _ = sender.send(true); let _ = sender.send(true);
@@ -683,13 +712,9 @@ fn dc_from_init(init: &[u8; 64]) -> Option<(u16, bool)> {
// -- WebSocket tunnel ------------------------------------------------------- // -- WebSocket tunnel -------------------------------------------------------
/// Keeps `Stats::ws` equal to the number of *established* tunnels. /// Counts opened upstream transports, not pending connection attempts.
/// /// WebSocket routes have completed their upgrade; native CDN routes have an
/// Counting attempts instead would let the interface announce «Telegram на /// open TCP socket. Neither alone proves a successful Telegram protocol reply.
/// связи» while the WebSocket handshake is still failing over between routes,
/// which takes seconds per route. Reporting a working tunnel that does not
/// exist yet is the whole reason users saw «прокси подключён, а Telegram не
/// работает».
struct EstablishedTunnel<'a>(&'a Stats); struct EstablishedTunnel<'a>(&'a Stats);
impl<'a> EstablishedTunnel<'a> { impl<'a> EstablishedTunnel<'a> {
@@ -715,7 +740,7 @@ async fn ws_tunnel(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
let (ws, connected) = match stats.transport.connect(dc, media).await { let (connection, connected) = match stats.transport.connect(dc, media).await {
Ok(connected) => connected, Ok(connected) => connected,
Err(error) => { Err(error) => {
// Единственное место, где известно, ПОЧЕМУ туннеля нет. Раньше // Единственное место, где известно, ПОЧЕМУ туннеля нет. Раньше
@@ -729,6 +754,13 @@ async fn ws_tunnel(
let _tunnel = EstablishedTunnel::new(stats); let _tunnel = EstablishedTunnel::new(stats);
stats.note_tunnel(dc, connected.route.kind.ui_code()); stats.note_tunnel(dc, connected.route.kind.ui_code());
let ws = match connection {
crate::transport::TelegramConnection::WebSocket(ws) => *ws,
crate::transport::TelegramConnection::Tcp(remote) => {
return cdn_tcp_tunnel(tcp, remote, init, crypto, stats, &connected, dc).await;
}
};
let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp); let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp);
let (mut ws_w, mut ws_r) = ws.split(); let (mut ws_w, mut ws_r) = ws.split();
let (upstream_crypto, downstream_crypto) = match crypto.map(|crypto| crypto.split()) { let (upstream_crypto, downstream_crypto) = match crypto.map(|crypto| crypto.split()) {
@@ -738,7 +770,8 @@ async fn ws_tunnel(
// Send buffered init as first frame // Send buffered init as first frame
ws_w.send(tungstenite::Message::Binary(init.to_vec())) ws_w.send(tungstenite::Message::Binary(init.to_vec()))
.await?; .await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
// Ping приходит в половину, которая читает, а отвечать на него должна та, // Ping приходит в половину, которая читает, а отвечать на него должна та,
// которая пишет: владелец у отправляющей половины строго один. // которая пишет: владелец у отправляющей половины строго один.
@@ -752,9 +785,11 @@ async fn ws_tunnel(
// сессия встаёт при живом туннеле (by-sonic/tglock#42, #32). // сессия встаёт при живом туннеле (by-sonic/tglock#42, #32).
let downstream = async { let downstream = async {
let mut crypto = downstream_crypto; let mut crypto = downstream_crypto;
let mut received_payload = false;
while let Some(message) = ws_r.next().await { while let Some(message) = ws_r.next().await {
match message { match message {
Ok(tungstenite::Message::Binary(mut data)) => { Ok(tungstenite::Message::Binary(mut data)) => {
received_payload |= !data.is_empty();
if let Some(crypto) = &mut crypto { if let Some(crypto) = &mut crypto {
crypto.apply(data.as_mut()); crypto.apply(data.as_mut());
} }
@@ -766,10 +801,46 @@ async fn ws_tunnel(
break; break;
} }
} }
Ok(tungstenite::Message::Close(_)) | Err(_) => break, Ok(tungstenite::Message::Close(frame)) => {
// Telegram may send code 1000 even for transport errors;
// the actual error is a signed decimal in the reason.
// https://core.telegram.org/mtproto/transports#websocket
let transport_error = frame.as_ref().is_some_and(|frame| {
frame
.reason
.trim()
.parse::<i32>()
.is_ok_and(|code| code != 0)
|| frame.code != tungstenite::protocol::frame::coding::CloseCode::Normal
});
if !received_payload || transport_error {
let reason = match frame {
Some(frame) => describe_ws_close(frame.code.into(), &frame.reason),
None => "WebSocket закрыт без ответа Telegram".to_owned(),
};
return Err(tunnel_route_error(stats, &connected, dc, &reason));
}
return Ok(());
}
Err(error) => {
return Err(tunnel_route_error(
stats,
&connected,
dc,
&error.to_string(),
));
}
Ok(_) => {} Ok(_) => {}
} }
} }
if !received_payload {
return Err(tunnel_route_error(
stats,
&connected,
dc,
"WebSocket завершён без ответа Telegram",
));
}
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(()) Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}; };
@@ -779,19 +850,22 @@ async fn ws_tunnel(
loop { loop {
tokio::select! { tokio::select! {
read = tcp_r.read(&mut buf) => match read { read = tcp_r.read(&mut buf) => match read {
Ok(0) | Err(_) => break, Ok(0) => break,
Err(error) => return Err(error.into()),
Ok(read) => { Ok(read) => {
if let Some(crypto) = &mut crypto { if let Some(crypto) = &mut crypto {
crypto.apply(&mut buf[..read]); crypto.apply(&mut buf[..read]);
} }
ws_w ws_w
.send(tungstenite::Message::Binary(buf[..read].to_vec())) .send(tungstenite::Message::Binary(buf[..read].to_vec()))
.await?; .await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
} }
}, },
payload = pong_rx.recv() => match payload { payload = pong_rx.recv() => match payload {
Some(payload) => { Some(payload) => {
ws_w.send(tungstenite::Message::Pong(payload)).await?; ws_w.send(tungstenite::Message::Pong(payload)).await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
} }
None => break, None => break,
}, },
@@ -809,6 +883,112 @@ async fn ws_tunnel(
Ok(()) Ok(())
} }
/// DC203's pinned CDN endpoint speaks obfuscated MTProto over TCP. Preserve the
/// same generated init and independent CTR streams used for a WebSocket route.
/// The transport engine restricts this connection to the exact CDN destination.
async fn cdn_tcp_tunnel(
client: TcpStream,
mut remote: TcpStream,
init: &[u8; 64],
crypto: Option<crate::mtproto::CryptoContext>,
stats: &Stats,
connected: &crate::transport::ConnectedRoute,
dc: u16,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
remote
.write_all(init)
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
let (mut client_r, mut client_w) = client.into_split();
let (mut remote_r, mut remote_w) = remote.into_split();
let (upstream_crypto, downstream_crypto) = match crypto.map(|crypto| crypto.split()) {
Some((upstream, downstream)) => (Some(upstream), Some(downstream)),
None => (None, None),
};
let upstream = async {
let mut crypto = upstream_crypto;
let mut buffer = vec![0; 65536];
loop {
let read = client_r.read(&mut buffer).await?;
if read == 0 {
return Ok::<_, Box<dyn std::error::Error + Send + Sync>>(());
}
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buffer[..read]);
}
remote_w
.write_all(&buffer[..read])
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
}
};
let downstream = async {
let mut crypto = downstream_crypto;
let mut buffer = vec![0; 65536];
let mut received_payload = false;
loop {
let read = remote_r
.read(&mut buffer)
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
if read == 0 {
return if received_payload {
Ok(())
} else {
Err(tunnel_route_error(
stats,
connected,
dc,
"MTProto TCP закрыт без ответа Telegram",
))
};
}
received_payload = true;
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buffer[..read]);
}
client_w.write_all(&buffer[..read]).await?;
}
};
tokio::select! {
result = upstream => result,
result = downstream => result,
}
}
/// Текст для журнала, когда WebSocket закрыла другая сторона.
///
/// `1003 binary messages required` отвечает только старый скрипт Worker'а: с
/// compatibility date 2026-03-17 Cloudflare отдаёт бинарные кадры как Blob, и
/// такой скрипт рвал каждый туннель сразу после рукопожатия (by-sonic/tglock#61).
/// Обновление TGLock код в чужом Cloudflare-аккаунте не меняет, поэтому
/// пользователю надо сказать прямо, что делать.
fn describe_ws_close(code: u16, reason: &str) -> String {
let mut text = format!("WebSocket закрыт: {code} {reason}");
if code == 1003 && reason.contains("binary messages required") {
text.push_str(
" — скрипт Cloudflare Worker устарел: опубликуйте заново \
worker/tglock-worker.js из репозитория",
);
}
text
}
fn tunnel_route_error(
stats: &Stats,
connected: &crate::transport::ConnectedRoute,
dc: u16,
reason: &str,
) -> Box<dyn std::error::Error + Send + Sync> {
stats.transport.report_route_failure(connected);
let message = format!(
"Сбой туннеля DC{dc} через {}: {reason}",
connected.route.connect_host
);
stats.note(message.clone());
message.into()
}
async fn tcp_relay(a: TcpStream, b: TcpStream) { async fn tcp_relay(a: TcpStream, b: TcpStream) {
let (mut ar, mut aw) = tokio::io::split(a); let (mut ar, mut aw) = tokio::io::split(a);
let (mut br, mut bw) = tokio::io::split(b); let (mut br, mut bw) = tokio::io::split(b);
@@ -825,6 +1005,17 @@ mod tests {
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
#[test]
fn an_outdated_worker_close_says_to_redeploy_it() {
let outdated = describe_ws_close(1003, "binary messages required");
assert!(outdated.starts_with("WebSocket закрыт: 1003 binary messages required"));
assert!(outdated.contains("worker/tglock-worker.js"));
assert_eq!(
describe_ws_close(1011, "Telegram socket failed"),
"WebSocket закрыт: 1011 Telegram socket failed"
);
}
/// Start the proxy on a kernel-assigned port. /// Start the proxy on a kernel-assigned port.
/// ///
/// Binding first and reading the port back removes the reserve-then-rebind /// Binding first and reading the port back removes the reserve-then-rebind
@@ -953,6 +1144,7 @@ mod tests {
.send(Message::Binary(wire)) .send(Message::Binary(wire))
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
websocket.close(None).await.map_err(|e| e.to_string())?;
Ok((requested, init, request)) Ok((requested, init, request))
} }
@@ -1040,6 +1232,52 @@ mod tests {
); );
} }
#[test]
fn diagnostics_keep_new_events_after_the_first_sixty_four() {
let stats = Stats::with_secret([0; 16]);
for index in 0..=EVENT_LIMIT {
stats.note(format!("route failure {index}"));
}
{
let events = stats.events.lock().unwrap();
assert_eq!(events.seen.len(), EVENT_LIMIT);
assert_eq!(events.pending.len(), EVENT_LIMIT);
}
let events = stats.drain_events();
assert_eq!(events.first().unwrap(), "route failure 1");
assert_eq!(
events.last().unwrap(),
&format!("route failure {EVENT_LIMIT}")
);
// Draining must not discard deduplication history, but must not stop
// the next new error from reaching the operator either.
stats.note(format!("route failure {EVENT_LIMIT}"));
stats.note("a new upstream error");
assert_eq!(stats.drain_events(), vec!["a new upstream error"]);
}
#[test]
fn an_evicted_event_can_be_reported_again_without_repeating_recent_events() {
let stats = Stats::with_secret([0; 16]);
stats.note("original error");
assert_eq!(stats.drain_events(), vec!["original error"]);
stats.note("original error");
assert!(stats.drain_events().is_empty());
// More distinct events than the retained history used to silence
// diagnostics permanently, even when the frontend drained every one.
for index in 0..EVENT_LIMIT * 2 {
let message = format!("error {index}");
stats.note(message.clone());
assert_eq!(stats.drain_events(), vec![message]);
}
stats.note("original error");
stats.note("original error");
assert_eq!(stats.drain_events(), vec!["original error"]);
assert_eq!(stats.events.lock().unwrap().seen.len(), EVENT_LIMIT);
}
/// Диагностика обязана показывать пару из одного соединения. /// Диагностика обязана показывать пару из одного соединения.
/// ///
/// Пока это были два независимых поля, при десятках одновременных /// Пока это были два независимых поля, при десятках одновременных
@@ -1411,6 +1649,250 @@ mod tests {
let _ = server.await.unwrap(); let _ = server.await.unwrap();
} }
#[allow(clippy::result_large_err)]
#[tokio::test]
async fn post_handshake_failures_are_reported_and_penalize_the_route() {
use futures_util::{SinkExt, StreamExt};
use tungstenite::protocol::{frame::coding::CloseCode, CloseFrame};
// No reply, abrupt socket loss, and a Telegram transport error carried
// in a nominally normal WebSocket close all used to disappear silently.
for mode in 0..4 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_port = listener.local_addr().unwrap().port();
let relay = tokio::spawn(async move {
let (tcp, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_hdr_async(
tcp,
|_: &Request, mut response: Response| {
response
.headers_mut()
.insert("Sec-WebSocket-Protocol", "binary".parse().unwrap());
Ok(response)
},
)
.await
.unwrap();
assert!(matches!(ws.next().await, Some(Ok(Message::Binary(_)))));
if mode >= 2 {
ws.send(Message::Binary(vec![1, 2, 3, 4])).await.unwrap();
}
if mode != 1 {
ws.close(Some(CloseFrame {
code: CloseCode::Normal,
reason: if mode == 2 { " -404 " } else { "" }.into(),
}))
.await
.unwrap();
}
});
let stats = Stats::new();
stats.transport.force_local_route(relay_port);
let (port, server) = start_proxy(stats.clone(), false).await;
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
client
.write_all(&unambiguous_client_init(&stats.secret, 2))
.await
.unwrap();
let mut received = Vec::new();
tokio::time::timeout(Duration::from_secs(5), client.read_to_end(&mut received))
.await
.unwrap()
.unwrap();
relay.await.unwrap();
wait_until("connection task finished", || {
stats.active.load(Ordering::Relaxed) == 0
})
.await;
let failed = u32::from(mode != 3);
assert_eq!(
stats.ws_failures.load(Ordering::Relaxed),
failed,
"mode {mode}"
);
assert_eq!(stats.transport.route_failures(), failed, "mode {mode}");
let events = stats.drain_events();
assert_eq!(
events
.iter()
.any(|event| event.contains("Сбой туннеля DC2 через 127.0.0.1")),
mode != 3
);
if mode == 2 {
assert!(events.iter().any(|event| event.contains("-404")));
}
stats.stop();
server.await.unwrap().unwrap();
}
}
#[tokio::test]
async fn fragmented_encrypted_upload_and_download_preserve_both_streams() {
check_fragmented_encrypted_duplex(false).await;
}
#[tokio::test]
async fn cdn_tcp_preserves_fragmented_encrypted_duplex_and_clean_close() {
check_fragmented_encrypted_duplex(true).await;
}
#[tokio::test]
async fn cdn_tcp_closing_before_a_reply_is_reported_as_a_route_failure() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_port = listener.local_addr().unwrap().port();
let relay = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut init = [0; INIT_LEN];
stream.read_exact(&mut init).await.unwrap();
});
let stats = Stats::new();
stats.transport.force_local_route_with(
relay_port,
crate::transport::RouteKind::TelegramTcp,
String::new(),
);
let (port, server) = start_proxy(stats.clone(), false).await;
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
client
.write_all(&unambiguous_client_init(&stats.secret, 203))
.await
.unwrap();
wait_until("CDN failure is counted", || {
stats.ws_failures.load(Ordering::Relaxed) == 1
})
.await;
assert_eq!(stats.transport.route_failures(), 1);
assert!(stats
.drain_events()
.iter()
.any(|event| event.contains("MTProto TCP закрыт без ответа")));
relay.await.unwrap();
stats.stop();
server.await.unwrap().unwrap();
}
#[allow(clippy::result_large_err)]
async fn check_fragmented_encrypted_duplex(raw_tcp: bool) {
use futures_util::{SinkExt, StreamExt};
let request: Vec<u8> = (0..131_072).map(|i| (i % 251) as u8).collect();
let response: Vec<u8> = (0..262_144).map(|i| (i % 239) as u8).collect();
let expected_request = request.clone();
let relay_response = response.clone();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_port = listener.local_addr().unwrap().port();
let relay = tokio::spawn(async move {
let (mut tcp, _) = listener.accept().await.unwrap();
if raw_tcp {
let mut init = [0; INIT_LEN];
tcp.read_exact(&mut init).await.unwrap();
let mut peer = crate::mtproto::test_relay_peer(&init);
let mut download = relay_response;
peer.encrypt(&mut download);
let (mut reader, mut writer) = tcp.into_split();
let send = async {
for chunk in download.chunks(8191) {
writer.write_all(chunk).await.unwrap();
tokio::task::yield_now().await;
}
};
let receive = async {
let mut received = vec![0; expected_request.len()];
reader.read_exact(&mut received).await.unwrap();
peer.decrypt(&mut received);
assert_eq!(received, expected_request);
};
tokio::join!(send, receive);
writer.shutdown().await.unwrap();
return;
}
let mut ws =
tokio_tungstenite::accept_hdr_async(tcp, |_: &Request, mut response: Response| {
response
.headers_mut()
.insert("Sec-WebSocket-Protocol", "binary".parse().unwrap());
Ok(response)
})
.await
.unwrap();
let init = match ws.next().await.unwrap().unwrap() {
Message::Binary(init) => init,
other => panic!("expected init: {other:?}"),
};
let mut peer = crate::mtproto::test_relay_peer(&init.try_into().unwrap());
let mut download = relay_response;
peer.encrypt(&mut download);
let (mut sink, mut stream) = ws.split();
let send = async {
for chunk in download.chunks(8191) {
sink.send(Message::Binary(chunk.to_vec())).await.unwrap();
}
};
let receive = async {
let mut received = Vec::new();
while received.len() < expected_request.len() {
if let Message::Binary(mut chunk) = stream.next().await.unwrap().unwrap() {
peer.decrypt(&mut chunk);
received.extend_from_slice(&chunk);
}
}
assert_eq!(received, expected_request);
};
tokio::join!(send, receive);
sink.close().await.unwrap();
});
let stats = Stats::new();
if raw_tcp {
stats.transport.force_local_route_with(
relay_port,
crate::transport::RouteKind::TelegramTcp,
String::new(),
);
} else {
stats.transport.force_local_route(relay_port);
}
let (port, server) = start_proxy(stats.clone(), false).await;
let init = unambiguous_client_init(&stats.secret, if raw_tcp { 203 } else { -4 });
let mut peer = crate::mtproto::test_client_peer(&init, &stats.secret);
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
// A mobile TCP stream can split the 64-byte init and subsequent data
// at arbitrary boundaries. WebSocket boundaries need not match these.
for chunk in init.chunks(7) {
client.write_all(chunk).await.unwrap();
tokio::task::yield_now().await;
}
let mut upload = request;
peer.encrypt(&mut upload);
let (mut reader, mut writer) = client.into_split();
let send = async {
for chunk in upload.chunks(137) {
writer.write_all(chunk).await.unwrap();
tokio::task::yield_now().await;
}
};
let receive = async {
let mut received = vec![0; response.len()];
reader.read_exact(&mut received).await.unwrap();
peer.decrypt(&mut received);
assert_eq!(received, response);
};
tokio::time::timeout(Duration::from_secs(10), async {
tokio::join!(send, receive);
relay.await.unwrap();
})
.await
.expect("both encrypted directions must make progress");
assert_eq!(stats.ws_failures.load(Ordering::Relaxed), 0);
if raw_tcp {
assert_eq!(
stats.last_route(),
crate::transport::RouteKind::TelegramTcp.ui_code()
);
assert_eq!(stats.last_dc(), 203);
}
stats.stop();
server.await.unwrap().unwrap();
}
#[tokio::test] #[tokio::test]
async fn connects_through_the_documented_worker_contract() { async fn connects_through_the_documented_worker_contract() {
// Locks the contract in docs/CLOUDFLARE_WORKER.md: a server that // Locks the contract in docs/CLOUDFLARE_WORKER.md: a server that
+771 -93
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [{ "label": "main", "title": "TGLock", "backgroundColor": "#070A10" }]
},
"bundle": { "android": { "minSdkVersion": 24 } }
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "TGLock", "productName": "TGLock",
"version": "2.0.0-beta.14", "version": "2.0.0-beta.18",
"identifier": "com.bysonic.tglock", "identifier": "com.bysonic.tglock",
"mainBinaryName": "tglock", "mainBinaryName": "tglock",
"build": { "build": {
+4
View File
@@ -33,6 +33,10 @@
# docs/CLOUDFLARE_WORKER.md # docs/CLOUDFLARE_WORKER.md
# worker = ["my-name.workers.dev"] # worker = ["my-name.workers.dev"]
# Свой домен на Cloudflare (CF-прокси): записи kws1…kws5 и kws203 указывают
# на адреса Telegram, Worker не нужен. Настройка описана в docs/CF_PROXY.md
# cf_proxy = ["example.com"]
# Пропускать и не-Telegram адреса. # Пропускать и не-Telegram адреса.
# На loopback это включено само. На сетевом адресе означает открытый # На loopback это включено само. На сетевом адресе означает открытый
# SOCKS5-прокси для всего интернета — включайте осознанно. # SOCKS5-прокси для всего интернета — включайте осознанно.
+144 -20
View File
@@ -34,6 +34,7 @@ type Settings = {
lanMode: boolean; lanMode: boolean;
port: number; port: number;
workerDomain: string; workerDomain: string;
cfProxyDomain: string;
}; };
const root = document.querySelector<HTMLDivElement>("#app")!; const root = document.querySelector<HTMLDivElement>("#app")!;
@@ -55,9 +56,11 @@ let status: Status = {
shareAddress: null, shareAddress: null,
logs: [], logs: [],
}; };
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "" }; let settings: Settings = { lanMode: false, port: 1080, workerDomain: "", cfProxyDomain: "" };
let busy = false; let busy = false;
let toastTimer: number | undefined; let toastTimer: number | undefined;
let toastMessage = "";
let toastError = false;
const icons = { const icons = {
arrowLeft: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>`, arrowLeft: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>`,
@@ -110,19 +113,60 @@ function connectionState(): { title: string; subtitle: string; className: string
}; };
} }
// Статус приходит раз в секунду. Если каждый раз переписывать root.innerHTML,
// кнопки становятся новыми узлами, и тап, начатый на старой кнопке и
// законченный на новой, Chromium не превращает в click. Поэтому разметка
// накладывается на уже существующие узлы: меняются только текст и атрибуты,
// а элемент заменяется лишь там, где на его месте теперь другой тег или id.
function patchRoot(html: string): void {
const next = document.createElement("template");
next.innerHTML = html;
patchChildren(root, next.content);
}
function patchChildren(current: Node, next: Node): void {
const nextChildren = Array.from(next.childNodes);
nextChildren.forEach((nextChild, index) => {
const currentChild = current.childNodes[index];
if (!currentChild) current.appendChild(nextChild);
else if (!sameSlot(currentChild, nextChild)) current.replaceChild(nextChild, currentChild);
else patchNode(currentChild, nextChild);
});
while (current.childNodes.length > nextChildren.length) current.lastChild?.remove();
}
function sameSlot(current: Node, next: Node): boolean {
if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) return false;
return !(current instanceof Element && next instanceof Element) || current.id === next.id;
}
function patchNode(current: Node, next: Node): void {
if (!(current instanceof Element && next instanceof Element)) {
if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
return;
}
for (const { name } of Array.from(current.attributes)) {
if (!next.hasAttribute(name)) current.removeAttribute(name);
}
for (const { name, value } of Array.from(next.attributes)) {
if (current.getAttribute(name) !== value) current.setAttribute(name, value);
}
patchChildren(current, next);
}
function shell(content: string, pageClass = ""): string { function shell(content: string, pageClass = ""): string {
return ` return `
<main class="app-shell ${pageClass}"> <main class="app-shell ${pageClass}">
<div class="drag-region" data-tauri-drag-region></div> <div class="drag-region" data-tauri-drag-region></div>
${content} ${content}
<div id="toast" class="toast" role="status"></div> <div id="toast" class="toast${toastMessage ? " visible" : ""}${toastError ? " error" : ""}" role="status">${escapeHtml(toastMessage)}</div>
</main> </main>
`; `;
} }
function renderHome(): void { function renderHome(): void {
const connection = connectionState(); const connection = connectionState();
root.innerHTML = shell(` patchRoot(shell(`
<section class="home"> <section class="home">
<header class="brand"> <header class="brand">
<div class="wordmark"><span>TG</span>Lock</div> <div class="wordmark"><span>TG</span>Lock</div>
@@ -152,6 +196,11 @@ function renderHome(): void {
<span class="button-arrow">→</span> <span class="button-arrow">→</span>
</button> </button>
${status.running ? `<div class="connection-actions">
<button id="open-telegram" class="nav-button">Открыть Telegram</button>
<button id="copy-link" class="nav-button">Скопировать ссылку</button>
</div>` : ""}
<div class="route-pill"> <div class="route-pill">
<span class="route-pulse"></span> <span class="route-pulse"></span>
<span>${escapeHtml(status.route)}</span> <span>${escapeHtml(status.route)}</span>
@@ -183,12 +232,7 @@ function renderHome(): void {
</button> </button>
</nav> </nav>
</section> </section>
`, "home-page"); `, "home-page"));
document.querySelector("#power")?.addEventListener("click", toggleProtection);
document.querySelector("#copy-address")?.addEventListener("click", copyShareAddress);
document.querySelector("#settings-nav")?.addEventListener("click", () => navigate("settings"));
document.querySelector("#diagnostics-nav")?.addEventListener("click", () => navigate("diagnostics"));
} }
function pageHeader(title: string, subtitle: string): string { function pageHeader(title: string, subtitle: string): string {
@@ -204,7 +248,7 @@ function pageHeader(title: string, subtitle: string): string {
} }
function renderSettings(): void { function renderSettings(): void {
root.innerHTML = shell(` patchRoot(shell(`
<section class="subpage"> <section class="subpage">
${pageHeader("Настройки", "Подключение и локальная сеть")} ${pageHeader("Настройки", "Подключение и локальная сеть")}
@@ -223,6 +267,20 @@ function renderSettings(): void {
<p class="field-hint">Необязательно. Используется как резервный маршрут.</p> <p class="field-hint">Необязательно. Используется как резервный маршрут.</p>
</div> </div>
<div class="setting-card">
<label class="field-label" for="cf-proxy">Свой домен на Cloudflare</label>
<input
id="cf-proxy"
name="cf-proxy"
type="text"
value="${escapeHtml(settings.cfProxyDomain)}"
placeholder="example.com"
autocomplete="off"
${status.running ? "disabled" : ""}
/>
<p class="field-hint">Необязательно. CF-прокси без Worker: записи kws1…kws5 и kws203 на адреса Telegram.</p>
</div>
<div class="setting-list"> <div class="setting-list">
<label class="setting-row" for="lan-mode"> <label class="setting-row" for="lan-mode">
<span> <span>
@@ -251,15 +309,12 @@ function renderSettings(): void {
</button> </button>
</form> </form>
</section> </section>
`, "subpage-shell"); `, "subpage-shell"));
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
document.querySelector("#settings-form")?.addEventListener("submit", saveSettings);
} }
function renderDiagnostics(): void { function renderDiagnostics(): void {
const recentLogs = status.logs.slice(-4).reverse(); const recentLogs = status.logs.slice(-4).reverse();
root.innerHTML = shell(` patchRoot(shell(`
<section class="subpage"> <section class="subpage">
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")} ${pageHeader("Диагностика", "Состояние маршрута в реальном времени")}
@@ -326,6 +381,8 @@ function renderDiagnostics(): void {
настроен неверно», а адрес такого клиента появится в журнале ниже. настроен неверно», а адрес такого клиента появится в журнале ниже.
</p> </p>
<button id="copy-diagnostics" class="save-button">Скопировать диагностику</button>
<div class="log-panel"> <div class="log-panel">
<div class="log-heading"> <div class="log-heading">
<span>Последние события</span> <span>Последние события</span>
@@ -346,11 +403,31 @@ function renderDiagnostics(): void {
</div> </div>
</div> </div>
</section> </section>
`, "subpage-shell"); `, "subpage-shell"));
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
} }
// Обработчики висят на #app, а не на кнопках: patchRoot сохраняет кнопки,
// но новые (например, «Открыть Telegram» после включения) создаёт сам.
const clickActions: Record<string, () => void> = {
power: toggleProtection,
"open-telegram": openTelegram,
"copy-link": copyTelegramLink,
"copy-address": copyShareAddress,
"settings-nav": () => navigate("settings"),
"diagnostics-nav": () => navigate("diagnostics"),
"copy-diagnostics": copyDiagnostics,
back: () => navigate("home"),
};
root.addEventListener("click", (event) => {
const button = (event.target as Element).closest<HTMLButtonElement>("button[id]");
if (button && !button.disabled) clickActions[button.id]?.();
});
root.addEventListener("submit", (event) => {
if ((event.target as Element).id === "settings-form") void saveSettings(event);
});
function render(): void { function render(): void {
if (view === "settings") renderSettings(); if (view === "settings") renderSettings();
else if (view === "diagnostics") renderDiagnostics(); else if (view === "diagnostics") renderDiagnostics();
@@ -391,9 +468,10 @@ async function saveSettings(event: Event): Promise<void> {
event.preventDefault(); event.preventDefault();
if (busy || status.running) return; if (busy || status.running) return;
const worker = document.querySelector<HTMLInputElement>("#worker"); const worker = document.querySelector<HTMLInputElement>("#worker");
const cfProxy = document.querySelector<HTMLInputElement>("#cf-proxy");
const lanMode = document.querySelector<HTMLInputElement>("#lan-mode"); const lanMode = document.querySelector<HTMLInputElement>("#lan-mode");
const port = document.querySelector<HTMLInputElement>("#port"); const port = document.querySelector<HTMLInputElement>("#port");
if (!worker || !lanMode || !port) return; if (!worker || !cfProxy || !lanMode || !port) return;
const parsedPort = Number(port.value); const parsedPort = Number(port.value);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
@@ -404,6 +482,7 @@ async function saveSettings(event: Event): Promise<void> {
busy = true; busy = true;
settings = { settings = {
workerDomain: worker.value.trim(), workerDomain: worker.value.trim(),
cfProxyDomain: cfProxy.value.trim(),
lanMode: lanMode.checked, lanMode: lanMode.checked,
port: parsedPort, port: parsedPort,
}; };
@@ -419,6 +498,46 @@ async function saveSettings(event: Event): Promise<void> {
} }
} }
async function openTelegram(): Promise<void> {
try { await invoke("open_telegram"); }
catch (error) { showToast(String(error), true); }
}
async function copyTelegramLink(): Promise<void> {
try {
const link = await invoke<string>("get_telegram_link");
await navigator.clipboard.writeText(link);
showToast("Ссылка скопирована; она содержит секрет прокси");
} catch (error) {
showToast(`Не удалось скопировать ссылку: ${String(error)}`, true);
}
}
async function copyDiagnostics(): Promise<void> {
try {
// Public reports exclude proxy links, worker domains and free-form logs.
const report = {
application: "TGLock",
version: "2.0.0-beta.18",
running: status.running,
activeConnections: status.activeConnections,
tunnels: status.tunnels,
dataCenter: status.dataCenter,
route: status.route,
failures: status.failures,
routeFailures: status.routeFailures,
blocked: status.blocked,
unknownClients: status.unknownClients,
silentClients: status.silentClients,
uptimeSeconds: status.uptimeSeconds,
port: status.port,
lanMode: settings.lanMode,
};
await navigator.clipboard.writeText(JSON.stringify(report, null, 2));
showToast("Диагностика скопирована без адресов и секретов");
} catch { showToast("Буфер обмена недоступен", true); }
}
async function copyShareAddress(): Promise<void> { async function copyShareAddress(): Promise<void> {
const address = status.shareAddress; const address = status.shareAddress;
if (!address) return; if (!address) return;
@@ -433,12 +552,17 @@ async function copyShareAddress(): Promise<void> {
function showToast(message: string, error = false): void { function showToast(message: string, error = false): void {
window.clearTimeout(toastTimer); window.clearTimeout(toastTimer);
toastMessage = message.replace(/^["']|["']$/g, "");
toastError = error;
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
const toast = document.querySelector<HTMLDivElement>("#toast"); const toast = document.querySelector<HTMLDivElement>("#toast");
if (!toast) return; if (!toast) return;
toast.textContent = message.replace(/^["']|["']$/g, ""); toast.textContent = message.replace(/^["']|["']$/g, "");
toast.className = `toast visible${error ? " error" : ""}`; toast.className = `toast visible${error ? " error" : ""}`;
toastTimer = window.setTimeout(() => toast.classList.remove("visible"), 2800); toastTimer = window.setTimeout(() => {
toastMessage = "";
document.querySelector("#toast")?.classList.remove("visible");
}, 4500);
}); });
} }
+18
View File
@@ -897,3 +897,21 @@ input:disabled {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px; font-size: 11px;
} }
/* Keep actions reachable with mobile safe areas and accessibility text scaling. */
.app-shell { overflow-y: auto; }
.home, .subpage { height: auto; min-height: 100%; }
.connection-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 12px;
}
.connection-actions .nav-button { min-height: 44px; padding: 8px 12px; }
@media (max-width: 500px) {
.home { padding: max(32px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); }
.subpage { padding-left: 20px; padding-right: 20px; padding-top: max(28px, env(safe-area-inset-top)); }
.drag-region { display: none; }
.share-address { overflow-wrap: anywhere; }
}
+123 -16
View File
@@ -28,16 +28,56 @@ const ALLOWED_DESTINATIONS = new Set([
]); ]);
const TELEGRAM_PORT = 443; const TELEGRAM_PORT = 443;
const CONNECT_TIMEOUT_MS = 3000;
// WebSocket events cannot be paused. Bound the outstanding writes instead of
// retaining an unlimited chain of promises when Telegram stops reading.
const MAX_PENDING_BYTES = 1024 * 1024;
const MAX_PENDING_MESSAGES = 256;
// Видна в ответе на проверку из браузера (/apiws без WebSocket). Редактор
// Cloudflare показывает сохранённый черновик, а работает опубликованная
// версия: без этой строки старый скрипт за новым текстом в редакторе было не
// отличить (by-sonic/tglock#53). Меняется при каждом изменении скрипта.
const WORKER_VERSION = "2026-10-26";
// Размер бинарного кадра и способ достать его байты; null — текстовый кадр.
//
// С compatibility date 2026-03-17 бинарные кадры приходят как Blob, если
// binaryType не "arraybuffer". Свойство выставлено при accept(); Blob всё
// равно принимается — на случай рантайма, который его не учитывает. Раньше
// такой кадр закрывал туннель кодом 1003 сразу после рукопожатия (#61).
//
// Каждая ветка проверяет тип напрямую, чтобы проверка типов в редакторе
// Cloudflare не подчёркивала доступ к полям (by-sonic/tglock#66).
/** @param {unknown} data */
function binaryFrame(data) {
if (typeof Blob !== "undefined" && data instanceof Blob) {
const blob = data;
return { size: blob.size, read: async () => new Uint8Array(await blob.arrayBuffer()) };
}
if (data instanceof ArrayBuffer) {
const bytes = new Uint8Array(data);
return { size: bytes.byteLength, read: async () => bytes };
}
if (ArrayBuffer.isView(data)) {
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
return { size: bytes.byteLength, read: async () => bytes };
}
return null;
}
export default { export default {
async fetch(request, env) { async fetch(request, env = {}) {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname !== "/apiws") { if (url.pathname !== "/apiws") {
return new Response("not found", { status: 404 }); return new Response("not found", { status: 404 });
} }
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") { if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
return new Response("expected a websocket upgrade", { status: 426 }); return new Response(`expected a websocket upgrade\nTGLock Worker ${WORKER_VERSION}\n`, {
status: 426,
headers: { "X-TGLock-Worker": WORKER_VERSION },
});
} }
// Необязательный общий секрет: задай переменную TGLOCK_TOKEN в настройках // Необязательный общий секрет: задай переменную TGLOCK_TOKEN в настройках
// воркера, и посторонние подключиться не смогут. // воркера, и посторонние подключиться не смогут.
@@ -50,19 +90,49 @@ export default {
return new Response("destination not allowed", { status: 403 }); return new Response("destination not allowed", { status: 403 });
} }
if (!request.headers.get("Sec-WebSocket-Protocol")?.split(",").some((p) => p.trim() === "binary")) {
return new Response("binary websocket subprotocol required", { status: 400 });
}
let upstream;
let timer;
let timedOut = false;
try {
upstream = connect({ hostname: destination, port: TELEGRAM_PORT });
// Both promises can reject on connect failure; observe closed immediately.
upstream.closed.catch(() => {});
await Promise.race([
upstream.opened,
new Promise((_, reject) => {
timer = setTimeout(() => {
timedOut = true;
reject(new Error("connect timeout"));
}, CONNECT_TIMEOUT_MS);
}),
]);
} catch {
if (upstream) await upstream.close().catch(() => {});
return new Response(timedOut ? "Telegram TCP connect timeout" : "Telegram TCP connect failed", {
status: timedOut ? 504 : 502,
});
} finally {
clearTimeout(timer);
}
const [client, server] = Object.values(new WebSocketPair()); const [client, server] = Object.values(new WebSocketPair());
server.binaryType = "arraybuffer";
server.accept(); server.accept();
const upstream = connect({ hostname: destination, port: TELEGRAM_PORT });
const writer = upstream.writable.getWriter(); const writer = upstream.writable.getWriter();
let closed = false; let closed = false;
const shutdown = () => { const shutdown = (code = 1000, reason = "") => {
if (closed) return; if (closed) return;
closed = true; closed = true;
writer.close().catch(() => {}); // close() cancels both directions, including a blocked write/read.
upstream.close().catch(() => {});
try { try {
server.close(); server.close(code, reason);
} catch { } catch {
// соединение уже закрыто // соединение уже закрыто
} }
@@ -77,31 +147,68 @@ export default {
// проявлялось. Как только голодание починили, в воркер пошёл настоящий // проявлялось. Как только голодание починили, в воркер пошёл настоящий
// поток (by-sonic/tglock#42). // поток (by-sonic/tglock#42).
let pending = Promise.resolve(); let pending = Promise.resolve();
let pendingBytes = 0;
let pendingMessages = 0;
let uploaded = 0;
server.addEventListener("message", (event) => { server.addEventListener("message", (event) => {
const chunk = if (closed) return;
event.data instanceof ArrayBuffer const frame = binaryFrame(event.data);
? new Uint8Array(event.data) if (!frame) {
: event.data; shutdown(1003, "binary messages required");
return;
}
const size = frame.size;
if (pendingBytes + size > MAX_PENDING_BYTES || pendingMessages >= MAX_PENDING_MESSAGES) {
shutdown(1009, "Telegram write queue full");
return;
}
pendingBytes += size;
pendingMessages += 1;
pending = pending pending = pending
.then(() => writer.ready) .then(async () => {
.then(() => writer.write(chunk)) if (closed) return;
.catch(shutdown); const bytes = await frame.read();
await writer.ready;
if (!closed) {
await writer.write(bytes);
uploaded += bytes.byteLength;
}
})
.catch(() => shutdown(1011, "Telegram write failed"))
.finally(() => {
pendingBytes -= size;
pendingMessages -= 1;
});
}); });
server.addEventListener("close", shutdown); server.addEventListener("close", () => shutdown());
server.addEventListener("error", shutdown); server.addEventListener("error", () => shutdown(1011, "WebSocket failed"));
upstream.closed.catch(() => shutdown(1011, "Telegram socket failed"));
// Обратное направление: всё, что приходит от Telegram, уходит клиенту. // Обратное направление: всё, что приходит от Telegram, уходит клиенту.
(async () => { (async () => {
const reader = upstream.readable.getReader(); const reader = upstream.readable.getReader();
let downloaded = 0;
try { try {
for (;;) { for (;;) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) break; if (done) break;
downloaded += value.byteLength;
server.send(value); server.send(value);
} }
} catch { } catch {
// разрыв соединения — обычная ситуация, не ошибка shutdown(1011, "Telegram read failed");
} finally {
reader.releaseLock();
}
// Telegram закрыл TCP, не ответив ни байтом. Раньше это было обычное
// закрытие 1000 без причины, и в журнале клиента не было видно, кто и на
// каком шаге оборвал туннель (by-sonic/tglock#53). Строка уходит и в
// журнал Worker'а в панели Cloudflare.
if (!downloaded && !closed) {
console.warn(`Telegram ${destination} closed TCP before replying (client sent ${uploaded} bytes)`);
shutdown(1011, "Telegram closed before replying");
return;
} }
shutdown(); shutdown();
})(); })();
+235
View File
@@ -0,0 +1,235 @@
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: [], warnings: [] };
const server = {
accept() { state.accepted = true; state.binaryTypeOnAccept = this.binaryType; },
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, Blob, console: { warn: (line) => state.warnings.push(line) }, 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();
const check = await f.fetch("/apiws", { Upgrade: "" });
assert.equal(check.status, 426);
// The browser check names the published version (#53).
assert.match(check.body, /^expected a websocket upgrade\nTGLock Worker \d{4}-\d{2}-\d{2}\n$/);
assert.match(check.headers["X-TGLock-Worker"], /^\d{4}-\d{2}-\d{2}$/);
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("binary frames arrive as ArrayBuffer on new compatibility dates", async () => {
// With compatibility date 2026-03-17+ Cloudflare delivers Blob unless
// binaryType is set before accept(); the Worker then closed every tunnel
// with 1003 right after the handshake (#61).
const f = await fixture();
await f.fetch();
assert.equal(f.state.binaryTypeOnAccept, "arraybuffer");
f.event("close");
});
test("Blob frames are still relayed in order, never closed as text", async () => {
const f = await fixture();
await f.fetch();
f.message(new Blob([new Uint8Array([7, 8])]));
f.message(new Uint8Array([9]).buffer);
for (let i = 0; i < 5; i++) await tick();
assert.deepEqual(f.writes, [[7, 8], [9]]);
assert.deepEqual(f.state.closes, []);
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("Telegram closing TCP before any reply is named, not a silent 1000", async () => {
// A tunnel that never carried a byte from Telegram used to end as a plain
// 1000 close, which hid where it broke (#53). The reason also reaches the
// Worker log in the Cloudflare dashboard.
const f = await fixture();
await f.fetch();
f.message(new Uint8Array([1, 2, 3]).buffer);
await tick();
f.eof();
for (let i = 0; i < 5; i++) await tick();
assert.deepEqual(f.state.closes[0], { code: 1011, reason: "Telegram closed before replying" });
assert.match(f.state.warnings[0], /149\.154\.167\.51 closed TCP before replying \(client sent 3 bytes\)/);
});
test("CDN worker keeps the requested DC203 destination", async () => {
const f = await fixture();
await f.fetch("/apiws?dst=91.105.192.100&dc=203");
assert.equal(f.state.address.hostname, "91.105.192.100");
assert.equal(f.state.address.port, 443);
f.event("close");
});
+3
View File
@@ -0,0 +1,3 @@
name = "tglock"
main = "tglock-worker.js"
compatibility_date = "2026-09-19"