Compare commits

..

11 Commits

Author SHA1 Message Date
amurcanov 691e3b6e3f Merge branch 'main' of https://github.com/amurcanov/proxy-turn-vk-android
Build WDTT Release / Build release APK (push) Has been cancelled
2026-05-26 22:48:55 +03:00
amurcanov bc0c8f5fc9 Update v1.2.0 2026-05-26 22:48:52 +03:00
amurcanov ca53e4804c Clean up table of contents in README.md
Removed unnecessary line breaks from the table of contents.
2026-05-26 16:52:45 +03:00
amurcanov 55057b836a Update README.md with additional section links 2026-05-26 13:45:59 +03:00
amurcanov 95d2fd5614 Update README.md 2026-05-26 13:36:57 +03:00
amurcanov d0a8fd3f4e Обновить README.md с содержанием и новыми ссылками
Добавлено содержание и ссылки на другие рабочие решения, обновлены разделы о возможностях и лицензии.
2026-05-26 13:30:18 +03:00
amurcanov 899adfa517 Merge pull request #99 from GAME-OVER-op/main
add automatic workflow assembly
2026-05-25 04:42:40 +03:00
GAME-OVER-op c58febcbcf add automatic workflow assembly 2026-05-24 23:17:47 +03:00
amurcanov 4a9620ab8b Добавлен видеогайд по WDTT в README
Добавлен видеогайд по настройке и использованию WDTT с ссылкой на YouTube.
2026-05-24 06:59:01 +03:00
amurcanov 63ba2cf1d9 Merge branch 'main' of https://github.com/amurcanov/proxy-turn-vk-android 2026-05-24 05:00:57 +03:00
amurcanov 6384446a44 Update README.md 2026-05-23 22:46:40 +03:00
35 changed files with 2083 additions and 592 deletions
+303
View File
@@ -0,0 +1,303 @@
name: Build WDTT Release
on:
push:
branches:
- "**"
workflow_dispatch:
jobs:
build-release:
name: Build release APK
runs-on: ubuntu-latest
permissions:
contents: read
env:
ANDROID_MIN_API: "29"
ANDROID_COMPILE_SDK: "35"
ANDROID_BUILD_TOOLS: "35.0.0"
ANDROID_NDK_VERSION: "27.2.12479018"
SERVER_BINARY_NAME: "server"
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
shell: bash
run: |
set -euo pipefail
sdkmanager \
"platforms;android-${ANDROID_COMPILE_SDK}" \
"build-tools;${ANDROID_BUILD_TOOLS}" \
"ndk;${ANDROID_NDK_VERSION}"
echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${ANDROID_NDK_VERSION}" >> "$GITHUB_ENV"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26.x"
cache: true
cache-dependency-path: |
go.mod
go_client/go.mod
- name: Generate Go sums
shell: bash
run: |
set -euo pipefail
echo "== Root Go module =="
go mod tidy
echo "== Go client module =="
cd go_client
go mod tidy
- name: Build Linux server binaries
shell: bash
run: |
set -euo pipefail
mkdir -p build/server
echo "== Build server linux/amd64 =="
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build \
-trimpath \
-ldflags="-s -w -checklinkname=0" \
-o build/server/wdtt-server-linux-amd64 \
./server.go
echo "== Build server linux/arm64 =="
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build \
-trimpath \
-ldflags="-s -w -checklinkname=0" \
-o build/server/wdtt-server-linux-arm64 \
./server.go
chmod +x build/server/wdtt-server-linux-amd64
chmod +x build/server/wdtt-server-linux-arm64
- name: Put server binary into Android assets
shell: bash
run: |
set -euo pipefail
mkdir -p app/src/main/assets
# Android deploy code expects this exact asset name.
cp build/server/wdtt-server-linux-amd64 app/src/main/assets/${SERVER_BINARY_NAME}
chmod +x app/src/main/assets/${SERVER_BINARY_NAME}
echo "Server asset:"
ls -lh app/src/main/assets/${SERVER_BINARY_NAME}
- name: Build Android Go client libraries
shell: bash
run: |
set -euo pipefail
NDK_ROOT="${ANDROID_NDK_HOME}"
TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin"
if [ ! -d "$TOOLCHAIN" ]; then
echo "Android NDK toolchain not found: $TOOLCHAIN"
exit 1
fi
echo "Using NDK: $NDK_ROOT"
echo "Using toolchain: $TOOLCHAIN"
mkdir -p app/src/main/jniLibs/arm64-v8a
mkdir -p app/src/main/jniLibs/armeabi-v7a
mkdir -p app/src/main/jniLibs/x86_64
mkdir -p build/go-client
cd go_client
echo "== Build libclient.so for arm64-v8a =="
CGO_ENABLED=1 \
GOOS=android \
GOARCH=arm64 \
CC="$TOOLCHAIN/aarch64-linux-android${ANDROID_MIN_API}-clang" \
go build \
-buildmode=c-shared \
-trimpath \
-ldflags="-s -w -checklinkname=0" \
-o ../app/src/main/jniLibs/arm64-v8a/libclient.so \
.
cp ../app/src/main/jniLibs/arm64-v8a/libclient.so \
../build/go-client/libclient-arm64-v8a.so
echo "== Build libclient.so for armeabi-v7a =="
CGO_ENABLED=1 \
GOOS=android \
GOARCH=arm \
GOARM=7 \
CC="$TOOLCHAIN/armv7a-linux-androideabi${ANDROID_MIN_API}-clang" \
go build \
-buildmode=c-shared \
-trimpath \
-ldflags="-s -w -checklinkname=0" \
-o ../app/src/main/jniLibs/armeabi-v7a/libclient.so \
.
cp ../app/src/main/jniLibs/armeabi-v7a/libclient.so \
../build/go-client/libclient-armeabi-v7a.so
echo "== Build libclient.so for x86_64 =="
CGO_ENABLED=1 \
GOOS=android \
GOARCH=amd64 \
CC="$TOOLCHAIN/x86_64-linux-android${ANDROID_MIN_API}-clang" \
go build \
-buildmode=c-shared \
-trimpath \
-ldflags="-s -w -checklinkname=0" \
-o ../app/src/main/jniLibs/x86_64/libclient.so \
.
cp ../app/src/main/jniLibs/x86_64/libclient.so \
../build/go-client/libclient-x86_64.so
cd ..
echo "Built JNI libraries:"
find app/src/main/jniLibs -type f -name "libclient.so" -exec ls -lh {} \;
- name: Make Gradle wrapper executable
shell: bash
run: chmod +x ./gradlew
- name: Generate temporary release keystore
shell: bash
run: |
set -euo pipefail
KEYSTORE_PASSWORD="wdtt_temp_release_password"
KEY_PASSWORD="$KEYSTORE_PASSWORD"
KEY_ALIAS="wdtt-release"
rm -f release.keystore local.properties signing.env
keytool -genkeypair -v -keystore release.keystore -storetype PKCS12 -storepass "$KEYSTORE_PASSWORD" -keypass "$KEY_PASSWORD" -alias "$KEY_ALIAS" -keyalg RSA -keysize 4096 -validity 10000 -dname "CN=WDTT,O=WDTT,C=LV"
{
echo "KEYSTORE_PASSWORD=$KEYSTORE_PASSWORD"
echo "KEY_PASSWORD=$KEY_PASSWORD"
echo "KEY_ALIAS=$KEY_ALIAS"
} > signing.env
echo "Generated temporary release keystore"
ls -lh release.keystore
- name: Build Android release APK
shell: bash
run: |
set -euo pipefail
# No local.properties is created here, so Gradle produces unsigned release APKs.
# The workflow signs them manually in the next step using apksigner.
./gradlew clean :app:assembleRelease --stacktrace
- name: Sign release APKs manually
shell: bash
run: |
set -euo pipefail
source signing.env
BUILD_TOOLS="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)"
ZIPALIGN="$BUILD_TOOLS/zipalign"
APKSIGNER="$BUILD_TOOLS/apksigner"
echo "Using zipalign: $ZIPALIGN"
echo "Using apksigner: $APKSIGNER"
mkdir -p build/signed-apk
shopt -s nullglob
unsigned_apks=(app/build/outputs/apk/release/*-unsigned.apk)
if [ ${#unsigned_apks[@]} -eq 0 ]; then
echo "No unsigned release APKs found"
echo "Available APK outputs:"
find app/build/outputs/apk -type f -name "*.apk" -print || true
exit 1
fi
for apk in "${unsigned_apks[@]}"; do
name="$(basename "$apk" -unsigned.apk)"
aligned="build/signed-apk/${name}-aligned.apk"
signed="build/signed-apk/${name}-signed.apk"
echo "== Align $apk =="
"$ZIPALIGN" -p -f 4 "$apk" "$aligned"
echo "== Sign $aligned =="
"$APKSIGNER" sign --ks release.keystore --ks-key-alias "$KEY_ALIAS" --ks-pass "pass:$KEYSTORE_PASSWORD" --key-pass "pass:$KEY_PASSWORD" --v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true --v4-signing-enabled false --out "$signed" "$aligned"
echo "== Verify $signed =="
"$APKSIGNER" verify --verbose --print-certs "$signed"
done
echo "Signed APKs:"
ls -lh build/signed-apk/*-signed.apk
- name: Collect build outputs
shell: bash
run: |
set -euo pipefail
mkdir -p build/artifacts/apk
mkdir -p build/artifacts/server
mkdir -p build/artifacts/go-client
echo "== Signed APK outputs =="
find build/signed-apk -type f -name "*-signed.apk" -print -exec ls -lh {} \;
cp build/signed-apk/*-signed.apk build/artifacts/apk/
cp build/server/* build/artifacts/server/
cp build/go-client/* build/artifacts/go-client/
echo "== Final artifacts =="
find build/artifacts -type f -exec ls -lh {} \;
- name: Upload release APK artifact
uses: actions/upload-artifact@v4
with:
name: WDTT-release-apk
path: build/artifacts/apk/*.apk
if-no-files-found: error
retention-days: 14
- name: Upload server binaries artifact
uses: actions/upload-artifact@v4
with:
name: WDTT-server-binaries
path: build/artifacts/server/*
if-no-files-found: error
retention-days: 14
- name: Upload Go client libraries artifact
uses: actions/upload-artifact@v4
with:
name: WDTT-go-client-libraries
path: build/artifacts/go-client/*
if-no-files-found: error
retention-days: 14
+91 -46
View File
@@ -2,7 +2,7 @@
# WDTT — WireGuard over TURN Tunnel # WDTT — WireGuard over TURN Tunnel
<br> <br>
<img src="https://img.shields.io/badge/Android-SDK_29--36-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Android SDK"> <img src="https://img.shields.io/badge/Android-SDK_29--35-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Android SDK">
<img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=for-the-badge&logo=go&logoColor=white" alt="Go Version"> <img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=for-the-badge&logo=go&logoColor=white" alt="Go Version">
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white" alt="Kotlin"> <img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white" alt="Kotlin">
<a href="https://github.com/amurcanov/proxy-turn-vk-android/stargazers"> <a href="https://github.com/amurcanov/proxy-turn-vk-android/stargazers">
@@ -13,69 +13,111 @@
**WDTT** — это Android-приложение для создания защищённого **WireGuard-туннеля поверх TURN/DTLS**. Клиент поднимает локальный VPN-интерфейс на устройстве, получает WireGuard-конфигурацию от вашего VPS и передаёт транспорт через TURN-серверы VK, маскируя соединение под обычный зашифрованный медиатрафик звонка. **WDTT** — это Android-приложение для создания защищённого **WireGuard-туннеля поверх TURN/DTLS**. Клиент поднимает локальный VPN-интерфейс на устройстве, получает WireGuard-конфигурацию от вашего VPS и передаёт транспорт через TURN-серверы VK, маскируя соединение под обычный зашифрованный медиатрафик звонка.
--- <img width="1000" height="738" alt="MyCollages (1)" src="https://github.com/user-attachments/assets/549c6624-978c-4b00-aae2-d195e470a1d1" />
<img width="1000" height="675" alt="MyCollages (2)" src="https://github.com/user-attachments/assets/39c1cf09-c3a9-4bf3-9de5-e5827134b1c3" /> ## Содержание
- [Возможности Android-версии](#возможности-android-версии)
- [Что нового в версии 1.1.8](#что-нового-в-версии-118)
- [**Другие рабочие решения**](#другие-рабочие-решения)
- [Как это работает](#как-это-работает)
- [Быстрый старт](#быстрый-старт)
- [Получение VK-хеша](#получение-vk-хеша)
- [Деплой VPS](#деплой-vps)
- [Управление доступом](#управление-доступом)
- [Дополнительные возможности](#дополнительные-возможности)
- [Лицензия](#лицензия)
## Возможности Android-версии ## Возможности Android-версии
- **Полноценный VPN-режим:** приложение использует `VpnService` и WireGuard GoBackend, поэтому трафик выбранных приложений проходит через системный VPN-интерфейс без ручного импорта конфигов. - **Полноценный VPN-режим:** приложение использует `VpnService` и WireGuard GoBackend, поэтому трафик выбранных приложений проходит через системный VPN-интерфейс без ручного импорта конфигов.
- **TURN/DTLS-транспорт:** нативный Go-клиент получает временные TURN-учётные данные VK-звонка и поднимает DTLS-соединения к relay-серверу, через который передаётся трафик до вашего VPS. - **TURN/DTLS-транспорт:** нативный Go-клиент получает временные TURN-учётные данные VK-звонка и поднимает DTLS-соединения к relay-серверу, через который передаётся трафик до вашего VPS.
- **WRAP без захардкоженного ключа:** внешний RTP AEAD/ChaCha20-Poly1305 слой выводится из пароля подключения через HKDF. Один пароль — один WRAP-ключ, ключ не хранится в APK.
- **RTP AEAD обфускация:** транспорт маскируется под RTP/WebRTC аудиотрафик с OPUS payload type, а полезная нагрузка шифруется AEAD-слоем поверх DTLS/WireGuard.
- **Деплой с телефона:** вкладка **«Деплой»** подключается к серверу по SSH, загружает `wdtt-server`, создаёт `systemd`-сервис, включает NAT/firewall и открывает рабочие UDP-порты. - **Деплой с телефона:** вкладка **«Деплой»** подключается к серверу по SSH, загружает `wdtt-server`, создаёт `systemd`-сервис, включает NAT/firewall и открывает рабочие UDP-порты.
- **Парольная модель доступа:** сервер поддерживает главный пароль, одноразовые/срочные пароли, привязку пароля к устройству и управление через Telegram-бота. - **Парольная модель доступа:** сервер поддерживает главный пароль, до 10 активных пользовательских паролей, 16-символьную генерацию, привязку пароля к устройству, истечение срока и управление через Telegram-бота.
- **Горячее обновление WRAP-ключей:** создание, удаление и истечение паролей обновляют набор WRAP-ключей на сервере без перезапуска ядра.
- **Исключения приложений:** можно выбрать, какие приложения идут через туннель, а какие работают напрямую. Поддерживаются режимы ЧС и БС, а изменения применяются перезагрузкой WireGuard без полного перенастраивания. - **Исключения приложений:** можно выбрать, какие приложения идут через туннель, а какие работают напрямую. Поддерживаются режимы ЧС и БС, а изменения применяются перезагрузкой WireGuard без полного перенастраивания.
- **Капча VK Smart Captcha:** основной рабочий режим в текущей Android-сборке — `WBV/WebView` с ручным решением или автоматической попыткой для простых сценариев. RJS-логика есть в Go-ядре, но в UI текущей сборки временно отключена. - **Капча VK Smart Captcha:** по умолчанию включён режим **«Авто капча»**: Go v2-решатель, Auto WebView и ручной WebView используются по цепочке без выбора режима пользователем. При отключении авто-режима доступны ручные настройки метода.
- **Живой лог-вьюер:** события Go-клиента, DTLS, WireGuard, капчи, деплоя и статистики отображаются в приложении с группировкой одинаковых сообщений и счётчиками повторов. - **Живой лог-вьюер:** события Go-клиента, DTLS, WireGuard, WRAP, капчи, деплоя и статистики отображаются в приложении с группировкой одинаковых сообщений и счётчиками повторов.
- **Фоновая устойчивость:** `Foreground Service`, `WakeLock`, `WifiLock`, мониторинг смены сети и watchdog помогают переживать Doze, смену Wi-Fi/LTE и падение нативного процесса. - **Фоновая устойчивость:** `Foreground Service`, `WakeLock`, `WifiLock`, мониторинг смены сети и watchdog помогают переживать Doze, смену Wi-Fi/LTE и падение нативного процесса.
- **VK Auth fallback:** клиент использует 2 проверенных VK `client_id` и переключается между ними при ошибках авторизации.
- **DNS fallback:** сначала используются Yandex DNS `77.88.8.8` / `77.88.8.1`, а при отказе или таймаутах выполняется fallback на системный провайдерский DNS устройства.
- **Темы и оформление:** Material 3, Jetpack Compose, Inter, светлая/тёмная тема, Dynamic Colors на Android 12+ и встроенные палитры. - **Темы и оформление:** Material 3, Jetpack Compose, Inter, светлая/тёмная тема, Dynamic Colors на Android 12+ и встроенные палитры.
- **Автообновления:** приложение проверяет GitHub releases, показывает диалог обновления и позволяет перейти на страницу актуального релиза. - **Автообновления:** приложение проверяет GitHub releases, показывает диалог обновления и периодически повторяет проверку в фоне.
## Что нового в версии 1.1.0 ## Что нового в версии 1.1.8
> [!IMPORTANT] > [!IMPORTANT]
> После обновления до **1.1.0** необходимо заново выполнить **деплой сервера** из приложения. > Если сервер уже деплоился под актуальный WRAP/RTP AEAD протокол из ветки `1.1.7+`, повторный деплой для **1.1.8** не нужен. Если вы обновляетесь со старой серверной версии без password-based WRAP и RTP AEAD, выполните деплой сервера заново.
* **Изоляция WDTT:** деплой, удаление и рабочая среда **WDTT** теперь не должны влиять на другие компоненты VPS. Серверная часть изолирована в собственной конфигурации, интерфейсе и правилах firewall/NAT. * **VK Auth:** переход с 5 `client_id` на 2 проверенных `client_id`: `6287487` и `8202606`. Клиент сначала использует `6287487`, затем `8202606`, после чего повторяет fallback-цикл.
* **Автообновление:** помимо ручной проверки, приложение теперь самостоятельно проверяет наличие новых версий и предлагает обновиться до актуального релиза. * **Стабильность VK API:** удалены проблемные `client_id`, из-за которых могло появляться `Unknown method passed` для `calls.getAnonymousToken`.
* **Ручные порты:** добавлена возможность управлять портами. При включении режима ручных портов можно задать **DTLS** и **WG** порты на сервере, а также локальный VPN-порт в туннеле. Если это не нужно, режим лучше не включать. * **DNS:** добавлен fallback на системный провайдерский DNS, если наблюдаются проблемы с Yandex DNS `77.88.8.8` / `77.88.8.1`.
* **Капча:** возвращён режим **Авто-WBV** для прохождения капчи **«Я не робот»**. Режим **RJS** временно заблокирован до следующих улучшений. Если с **Авто-WBV** возникают проблемы, используйте ручной режим. * **VK Join Link:** исправлена обработка полных ссылок VK Call Join, чтобы вместо корректного хеша в запрос не попадало значение вроде `https:`.
* **Разделение архитектур:** релизы теперь делятся на **arm64-v8a**, **armeabi-v7a** и **x86_64**, чтобы уменьшить размер APK. Если вы не знаете, какой APK выбрать, используйте **Universal** — он содержит все 3 архитектуры, но весит больше. * **WRAP/DTLS:** исправлен ложный стоп с сообщением **«Неверный пароль подключения»**, когда один поток ловил DTLS timeout, но остальные потоки уже были активны.
* **Сборка:** обновлены **AGP**, **Gradle** и **Kotlin** до актуальных версий (`9.0.1`, `9.1.0`, `2.x`), что положительно влияет на стабильность и работу приложения. * **WRAP-ключи:** захардкоженный WRAP-ключ заменён на HKDF-деривацию из пароля подключения. Сервер держит активные ключи в памяти и обновляет их при изменении паролей.
* **Багфиксы и стабильность:** удалён **DataSync**, который мог вызывать краши на **Android 14+**; изменено поведение уведомления, чтобы оно не скакало в шторке; улучшен запуск **VPN Service** и передача WireGuard-конфига. * **Обфускация:** добавлен RTP-over-ChaCha20-Poly1305 AEAD слой, маскирующий внешний транспорт под WebRTC аудиопоток.
* **Интерфейс и информация:** проведён небольшой редизайн, добавлены тени и орбы на фон. Раздел **«Инфо»** переработан и теперь позволяет собрать отчёт с данными об устройстве для более точного разбора ошибок. * **Диспетчер:** переход с Round-Robin на Adaptive Chunking в single-flow режиме с сохранением высокой агрегатной скорости multi-flow.
* **В планах:** заменить стандартный протокол **WireGuard** на **AmneziaWG** в версиях `1.5-2.0`, чтобы лучше решать проблемы региональных блокировок. * **Потоки и хеши:** добавлена поддержка 4-го хеша VK-звонка; при 4 хешах доступно до 108 потоков. Исправлен баг слайдера потоков.
* **Откат при проблемах:** если после обновления появились ошибки, которых раньше точно не было, можно открыть `issue` и временно откатиться на версию **1.0.6**. * **Капча:** режим прохождения стал автоматическим: сначала Go v2, затем Auto WebView, затем финальная Go-попытка и только после этого ручной WebView.
* **Автообновление:** исправлена проверка обновлений, которая раньше могла выполняться только один раз при старте приложения.
* **Keepalive:** добавлен DTLS keepalive для длительных сессий, чтобы снизить вероятность `reader EOF`.
--- ## Другие рабочие решения
Рабочие решения с обходом шейпинга скорости и частыми обновлениями:
**Моё любимое**
- [Moroka8/vk-turn-proxy](https://github.com/Moroka8/vk-turn-proxy) — Качественные ядра клиента и сервера
**Android**
- [samosvalishe/turn-proxy-android](https://github.com/samosvalishe/turn-proxy-android) — альтернативная андроид версия
**iOS**
- [anton48/vk-turn-proxy-ios](https://github.com/anton48/vk-turn-proxy-ios) — версия под ios
## Как это работает ## Как это работает
```text ```text
Android-приложение → VpnService / WireGuard GoBackend → локальный UDP 127.0.0.1:9000 Android-приложение → VpnService / WireGuard GoBackend → локальный UDP 127.0.0.1:9000
→ Go-клиент WDTT → VK TURN / DTLS → wdtt-server на VPS → интернет → Go-клиент WDTT → WRAP RTP AEAD → VK TURN / DTLS → wdtt-server на VPS → интернет
``` ```
1. Приложение запускает нативный Go-клиент `libclient.so` и передаёт ему адрес VPS, VK-хеши звонка, пароль туннеля, протокол TURN и количество потоков. 1. Приложение запускает нативный Go-клиент `libclient.so` и передаёт ему адрес VPS, VK-хеши звонка, пароль туннеля и количество потоков.
2. Go-клиент получает TURN-учётные данные через VK-звонок, при необходимости решает VK Smart Captcha и устанавливает DTLS-соединения через TURN relay. 2. Go-клиент выводит WRAP-ключ из пароля подключения, получает TURN-учётные данные через VK-звонок, при необходимости решает VK Smart Captcha и устанавливает DTLS-соединения через TURN relay.
3. Первый рабочий канал запрашивает у VPS WireGuard-конфигурацию через `GETCONF`, передавая локальный порт, `device-id` и пароль подключения. 3. Внешний WRAP-слой упаковывает DTLS-пакеты в RTP AEAD/ChaCha20-Poly1305, чтобы ключ не был захардкожен в APK и мог отличаться у каждого пароля.
4. Сервер проверяет пароль: главный пароль работает как владелец, сгенерированные пароли могут иметь срок действия и привязываются к первому устройству. 4. Первый рабочий канал запрашивает у VPS WireGuard-конфигурацию через `GETCONF`, передавая локальный порт, `device-id` и пароль подключения.
5. Android-часть парсит полученный WireGuard-конфиг, поднимает системный VPN-туннель и применяет исключения приложений. 5. Сервер проверяет пароль: главный пароль работает как владелец, сгенерированные пароли имеют срок действия и привязываются к первому устройству.
6. Watchdog следит за Go-процессом, активными воркерами и сетевыми изменениями, перезапуская транспорт при сбоях. 6. Android-часть парсит полученный WireGuard-конфиг, поднимает системный VPN-туннель и применяет исключения приложений.
7. Watchdog следит за Go-процессом, активными воркерами и сетевыми изменениями, перезапуская транспорт при сбоях.
<div align="center">
## Видеогайд по настройке и использованию WDTT (Показано на версии v1.1.4.)
<img width="720" height="404" alt="hq720" src="https://github.com/user-attachments/assets/6538d2bb-2eeb-4de8-a3d7-5a6595e8175f" />
[**Смотреть гайд на YouTube**](https://www.youtube.com/watch?v=JFHn9jmPbfY&t=54s)
<br>
```Важно гайд показан на уже устаревшей версии 1.1.4 но в нем есть много полезной информации, показана работа и настройка. Приобретение VPS и т,д. В новых версиях 1.1.8+ формат хешей изменен - достаточно вставить просто чистый хеш или проще = целиком ссылку звонка.```
</div>
## Быстрый старт ## Быстрый старт
1. Скачайте актуальный `APK` со **[страницы релизов](https://github.com/amurcanov/proxy-turn-vk-android/releases)**. 1. Скачайте актуальный `APK` со **[страницы релизов](https://github.com/amurcanov/proxy-turn-vk-android/releases)**.
2. Установите приложение на Android-смартфон. 2. Установите приложение на Android-смартфон.
3. Подготовьте VPS с root-доступом или пользователем с `sudo`. 3. Подготовьте айпи, логин, пароль от имеющегося VPS.
4. В VK создайте или откройте групповой звонок и скопируйте ссылку вида `vk.com/call/join/xxxxxxxxxxx`. 4. В VK создайте или откройте групповой звонок и скопируйте ссылку вида `vk.com/call/join/xxxxxxxxxxx`. где xxxxxxxxxxx ваш хеш, или просто вставьте ссылку.
5. Откройте **WDTT** и перейдите во вкладку **«Деплой»**. 5. Откройте **WDTT** и перейдите во вкладку **«Деплой»**.
6. Введите IP/домен VPS, SSH-логин, пароль и SSH-порт. 6. Введите IP/домен VPS, SSH-логин, пароль и SSH-порт.
7. В **«Секретах»** задайте пароль туннеля. При необходимости добавьте Telegram `admin_id` и `bot_token` для управления паролями. 7. В **«Секретах»** задайте главный пароль туннеля. При необходимости добавьте Telegram `admin_id` / `bot_token` для управления доступом.
8. Нажмите **«Установить»** и дождитесь завершения деплоя. 8. Нажмите **«Установить»** и дождитесь завершения деплоя.
9. Во вкладке **«Туннель»** укажите IP/домен сервера, VK-хеши, пароль туннеля и количество потоков. 9. Во вкладке **«Туннель»** укажите IP/домен сервера, VK-хеши, пароль подключения и количество потоков.
10. Нажмите **«Подключить»** и выдайте Android-разрешение на VPN. 10. Нажмите **«Подключить»** предварительно выдав все необходимые разрешения приложению.
---
## Получение VK-хеша ## Получение VK-хеша
@@ -86,8 +128,8 @@ VK → группа → звонок → ссылка приглашения →
1. Откройте VK и создайте пустую группу или используйте существующую. 1. Откройте VK и создайте пустую группу или используйте существующую.
2. Начните групповой звонок. 2. Начните групповой звонок.
3. Скопируйте ссылку приглашения. 3. Скопируйте ссылку приглашения.
4. Вставьте в WDTT всю ссылку или только хеш после последнего слэша. 4. Вставьте в WDTT всю ссылку или только хеш после `/join/`.
5. Можно использовать до **3 хешей** одновременно для распределения нагрузки и увеличения доступного числа потоков. 5. Можно использовать до **4 хешей** одновременно для распределения нагрузки и увеличения доступного числа потоков.
> [!IMPORTANT] > [!IMPORTANT]
> При выходе из звонка нажимайте **«Просто завершить»**, а не **«Завершить для всех»**. Если закрыть комнату для всех участников, хеш перестанет работать. > При выходе из звонка нажимайте **«Просто завершить»**, а не **«Завершить для всех»**. Если закрыть комнату для всех участников, хеш перестанет работать.
@@ -115,11 +157,11 @@ Android → SSH → /tmp/deploy.sh + /tmp/wdtt-server → /usr/local/bin/wdtt-se
WDTT-сервер поддерживает две модели подключения: WDTT-сервер поддерживает две модели подключения:
- **Главный пароль:** задаётся при деплое и используется владельцем сервера. - **Главный пароль:** задаётся при деплое и используется владельцем сервера.
- **Сгенерированные пароли:** создаются через Telegram-бота командой `/new`, имеют срок действия и привязываются к первому устройству. - **Сгенерированные пароли:** создаются при деплое или через Telegram-бота, имеют срок действия, привязываются к первому устройству и обновляют WRAP-ключи на сервере без перезапуска.
Команда `/list` показывает активные пароли и устройства. Через inline-кнопки можно отвязать устройство или удалить пароль. Один сервер может держать до **10 активных паролей**. Новые пароли генерируются длиной **16 символов**.
--- Команда `/list` показывает активные пароли и устройства. Через inline-кнопки можно отвязать устройство или удалить пароль. При удалении или истечении пароля соответствующий WRAP-ключ удаляется из памяти сервера.
## Дополнительные возможности ## Дополнительные возможности
@@ -127,23 +169,28 @@ WDTT-сервер поддерживает две модели подключе
Вкладка **«Исключ.»** показывает установленные приложения с поиском. В режиме ЧС выбранные приложения исключаются из VPN, а в режиме БС логика инвертируется: неотмеченные приложения добавляются в туннель. Само приложение WDTT и VK-клиенты исключаются автоматически, чтобы не ломать TURN-соединение. Вкладка **«Исключ.»** показывает установленные приложения с поиском. В режиме ЧС выбранные приложения исключаются из VPN, а в режиме БС логика инвертируется: неотмеченные приложения добавляются в туннель. Само приложение WDTT и VK-клиенты исключаются автоматически, чтобы не ломать TURN-соединение.
#### Капча
По умолчанию включён режим **«Авто капча»**. Оркестратор делает до двух попыток Go v2-решателя, затем две попытки Auto WebView с коротким таймаутом, затем финальную Go-попытку. Если всё это не помогло, приложение открывает ручной WebView. Авто-режим можно отключить, тогда в UI появляются ручные настройки метода и режима прохождения.
#### Логирование #### Логирование
Вкладка **«Логи»** показывает статус получения VK-учётных данных, решение капчи, DTLS-handshake, готовность WireGuard, статистику активных воркеров и ошибки. Повторяющиеся строки схлопываются в одну запись со счётчиком. Вкладка **«Логи»** показывает статус получения VK-учётных данных, решение капчи, DTLS-handshake, WRAP-состояние, готовность WireGuard, статистику активных воркеров и ошибки. Повторяющиеся строки схлопываются в одну запись со счётчиком.
> [!NOTE]
> Если видны DTLS handshake, но `Активных: 0`, чаще всего указан неправильный пароль подключения или используется сервер без актуального WRAP-протокола. Если часть потоков уже активна, одиночные DTLS timeout больше не считаются неверным паролем и не останавливают туннель.
#### Обновления #### Обновления
Приложение проверяет **GitHub releases** репозитория [amurcanov/proxy-turn-vk-android](https://github.com/amurcanov/proxy-turn-vk-android), умеет показывать диалог новой версии и открывать страницу релиза в браузере. Приложение проверяет **GitHub releases** репозитория [amurcanov/proxy-turn-vk-android](https://github.com/amurcanov/proxy-turn-vk-android), умеет показывать диалог новой версии и открывать страницу релиза в браузере. Проверка выполняется при запуске и периодически в фоне.
#### Отчёт для issue #### Отчёт для issue
В разделе **«Информация»** есть кнопка **«Собрать отчёт»**. Она копирует версию приложения, Android SDK, ABI, модель устройства, SoC, ROM и fingerprint — эти данные полезны при разборе крашей и проблем с запуском. В разделе **«Информация»** есть кнопка **«Собрать отчёт»**. Она копирует версию приложения, Android SDK, ABI, модель устройства, SoC, ROM и fingerprint — эти данные полезны при разборе крашей и проблем с запуском.
---
> [!NOTE] > [!NOTE]
> ### Отчёты об ошибках > ### Отчёты об ошибках
> WDTT зависит от мобильной сети, Android-ограничений фоновой работы, состояния VK-звонка, TURN-квот и настроек VPS. > WDTT зависит от мобильной сети, Android-ограничений фоновой работы, состояния VK-звонка, TURN-квот, DNS и настроек VPS.
> >
> Если возникла проблема, приложите к `issue` отчёт из раздела **«Информация»**, скриншот вкладки **«Логи»**, версию APK, ABI сборки и описание сети. Мелкие повторяющиеся ошибки в логах не всегда означают поломку, если туннель остаётся активным. > Если возникла проблема, приложите к `issue` отчёт из раздела **«Информация»**, скриншот вкладки **«Логи»**, версию APK, ABI сборки и описание сети. Мелкие повторяющиеся ошибки в логах не всегда означают поломку, если туннель остаётся активным.
@@ -151,8 +198,6 @@ WDTT-сервер поддерживает две модели подключе
> ### Назначение проекта > ### Назначение проекта
> Приложение является техническим инструментом для защищённого туннелирования собственного трафика через ваш сервер. Автор не призывает использовать WDTT для противоправных целей или нарушения правил сторонних сервисов. > Приложение является техническим инструментом для защищённого туннелирования собственного трафика через ваш сервер. Автор не призывает использовать WDTT для противоправных целей или нарушения правил сторонних сервисов.
---
## Лицензия ## Лицензия
Этот проект распространяется под лицензией **GNU General Public License v3.0**. Этот проект распространяется под лицензией **GNU General Public License v3.0**.
+3 -3
View File
@@ -11,10 +11,10 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.wdtt.client" applicationId = "com.wdtt.client"
minSdk = 29 minSdk = 28
targetSdk = 35 targetSdk = 35
versionCode = 118 versionCode = 120
versionName = "1.1.8" versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
+27
View File
@@ -29,6 +29,9 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.ACTION_QS_TILE_PREFERENCES" />
</intent-filter>
</activity> </activity>
<service <service
@@ -41,6 +44,17 @@
android:value="persistent_vpn_tunnel_transport" /> android:value="persistent_vpn_tunnel_transport" />
</service> </service>
<service
android:name=".QuickToggleTileService"
android:exported="true"
android:icon="@drawable/ic_tile_logo_w"
android:label="WDTT"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<activity <activity
android:name=".ManlCaptchaActivity" android:name=".ManlCaptchaActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar" android:theme="@android:style/Theme.Translucent.NoTitleBar"
@@ -51,6 +65,19 @@
<receiver <receiver
android:name=".CaptchaCancelReceiver" android:name=".CaptchaCancelReceiver"
android:exported="false" /> android:exported="false" />
<receiver
android:name=".VpnWidgetProvider"
android:label="WDTT ВПН Виджет"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.wdtt.client.ACTION_WIDGET_TOGGLE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/vpn_widget_info" />
</receiver>
</application> </application>
@@ -190,17 +190,18 @@ class MainActivity : ComponentActivity() {
// ═══ Навигация ═══ // ═══ Навигация ═══
private data class NavItem( private data class NavItem(
val id: Int,
val label: String, val label: String,
val selectedIcon: ImageVector, val selectedIcon: ImageVector,
val unselectedIcon: ImageVector, val unselectedIcon: ImageVector,
) )
private val navItems = listOf( private val navItems = listOf(
NavItem("Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey), NavItem(0, "Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey),
NavItem("Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud), NavItem(1, "Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud),
NavItem("Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList), NavItem(2, "Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList),
NavItem("Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal), NavItem(3, "Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal),
NavItem("Инфо", Icons.Filled.Info, Icons.Outlined.Info), NavItem(4, "Инфо", Icons.Filled.Info, Icons.Outlined.Info),
) )
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -220,6 +221,8 @@ fun MainScreen(
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.current val density = LocalDensity.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val activeProfile by settingsStore.activeProfile.collectAsStateWithLifecycle(initialValue = 0)
val wdttLinkMode by settingsStore.wdttLinkMode.collectAsStateWithLifecycle(initialValue = false)
var selectedTab by rememberSaveable { mutableIntStateOf(0) } var selectedTab by rememberSaveable { mutableIntStateOf(0) }
var dragTargetIndex by remember { mutableIntStateOf(-1) } var dragTargetIndex by remember { mutableIntStateOf(-1) }
var dragProgress by remember { mutableFloatStateOf(0f) } var dragProgress by remember { mutableFloatStateOf(0f) }
@@ -231,6 +234,24 @@ fun MainScreen(
val safeBottomInset = with(density) { WindowInsets.safeDrawing.getBottom(density).toDp() } val safeBottomInset = with(density) { WindowInsets.safeDrawing.getBottom(density).toDp() }
val navOverlayReserve = safeBottomInset + 96.dp val navOverlayReserve = safeBottomInset + 96.dp
val activeNavItems = remember(wdttLinkMode) {
if (wdttLinkMode) {
navItems.filter { it.id != 1 }
} else {
navItems
}
}
val actionsExpanded = rememberSaveable { mutableStateOf(false) }
val projectExpanded = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(wdttLinkMode) {
if (wdttLinkMode && selectedTab == 1) {
selectedTab = 0
}
}
LaunchedEffect(selectedTab) { LaunchedEffect(selectedTab) {
if (selectedTab == 3) TunnelManager.clearUnreadErrors() if (selectedTab == 3) TunnelManager.clearUnreadErrors()
} }
@@ -297,7 +318,7 @@ fun MainScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
.pointerInput(selectedTab) { .pointerInput(selectedTab, wdttLinkMode) {
var totalDrag = 0f var totalDrag = 0f
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragStart = { onDragStart = {
@@ -310,8 +331,8 @@ fun MainScreen(
dragProgress = 0f dragProgress = 0f
}, },
onDragEnd = { onDragEnd = {
if (dragTargetIndex in navItems.indices && dragProgress >= 0.5f) { if (dragTargetIndex in activeNavItems.indices && dragProgress >= 0.5f) {
selectedTab = dragTargetIndex selectedTab = activeNavItems[dragTargetIndex].id
if (selectedTab == 3) TunnelManager.clearUnreadErrors() if (selectedTab == 3) TunnelManager.clearUnreadErrors()
} }
dragTargetIndex = -1 dragTargetIndex = -1
@@ -326,8 +347,9 @@ fun MainScreen(
return@detectHorizontalDragGestures return@detectHorizontalDragGestures
} }
val candidate = if (totalDrag < 0f) selectedTab + 1 else selectedTab - 1 val currentActiveIndex = activeNavItems.indexOfFirst { it.id == selectedTab }
if (candidate !in navItems.indices) { val candidate = if (totalDrag < 0f) currentActiveIndex + 1 else currentActiveIndex - 1
if (candidate !in activeNavItems.indices) {
dragTargetIndex = -1 dragTargetIndex = -1
dragProgress = 0f dragProgress = 0f
return@detectHorizontalDragGestures return@detectHorizontalDragGestures
@@ -350,15 +372,15 @@ fun MainScreen(
) { tab -> ) { tab ->
when (tab) { when (tab) {
0 -> SettingsTab() 0 -> SettingsTab()
1 -> DeployTab() 1 -> if (!wdttLinkMode) DeployTab() else Spacer(modifier = Modifier.fillMaxSize())
2 -> ExceptionsTab() 2 -> ExceptionsTab()
3 -> LogsTab() 3 -> LogsTab()
4 -> InfoTab() 4 -> InfoTab(actionsExpandedState = actionsExpanded, projectExpandedState = projectExpanded)
} }
} }
ProxyNavigationBar( ProxyNavigationBar(
navItems = navItems, navItems = activeNavItems,
selectedTab = selectedTab, selectedTab = selectedTab,
dragTargetIndex = dragTargetIndex, dragTargetIndex = dragTargetIndex,
dragProgress = dragProgress, dragProgress = dragProgress,
@@ -380,6 +402,10 @@ fun MainScreen(
// Floating theme toolbar overlay // Floating theme toolbar overlay
FloatingToolbar( FloatingToolbar(
activeProfile = activeProfile,
onActiveProfileChange = { profile ->
scope.launch { settingsStore.saveActiveProfile(profile) }
},
currentTheme = themeMode, currentTheme = themeMode,
onThemeChange = onThemeChange, onThemeChange = onThemeChange,
isDynamicColor = isDynamicColor, isDynamicColor = isDynamicColor,
@@ -453,13 +479,16 @@ private fun ProxyNavigationBar(
} else { } else {
lerp(colors.primaryContainer, colors.surface, 0.18f).copy(alpha = 0.97f) lerp(colors.primaryContainer, colors.surface, 0.18f).copy(alpha = 0.97f)
} }
val indicatorIndex = remember { Animatable(selectedTab.toFloat()) } val selectedVisualIndex = remember(selectedTab, navItems) {
navItems.indexOfFirst { it.id == selectedTab }.coerceAtLeast(0)
}
val indicatorIndex = remember { Animatable(selectedVisualIndex.toFloat()) }
val dragVisualIndex = indicatorIndex.value val dragVisualIndex = indicatorIndex.value
LaunchedEffect(selectedTab) { LaunchedEffect(selectedVisualIndex) {
if (dragTargetIndex !in navItems.indices) { if (dragTargetIndex !in navItems.indices) {
indicatorIndex.animateTo( indicatorIndex.animateTo(
targetValue = selectedTab.toFloat(), targetValue = selectedVisualIndex.toFloat(),
animationSpec = tween( animationSpec = tween(
durationMillis = 720, durationMillis = 720,
easing = CubicBezierEasing(0.2f, 0.9f, 0.24f, 1f) easing = CubicBezierEasing(0.2f, 0.9f, 0.24f, 1f)
@@ -468,9 +497,9 @@ private fun ProxyNavigationBar(
} }
} }
LaunchedEffect(selectedTab, dragTargetIndex, dragProgress) { LaunchedEffect(selectedVisualIndex, dragTargetIndex, dragProgress) {
if (dragTargetIndex in navItems.indices) { if (dragTargetIndex in navItems.indices) {
val target = selectedTab.toFloat() + (dragTargetIndex - selectedTab) * dragProgress val target = selectedVisualIndex.toFloat() + (dragTargetIndex - selectedVisualIndex) * dragProgress
indicatorIndex.snapTo(target) indicatorIndex.snapTo(target)
} }
} }
@@ -522,7 +551,7 @@ private fun ProxyNavigationBar(
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.clip(RoundedCornerShape(22.dp)) .clip(RoundedCornerShape(22.dp))
.clickable { onTabSelected(index) }, .clickable { onTabSelected(item.id) },
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
@@ -533,7 +562,7 @@ private fun ProxyNavigationBar(
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
tint = iconColor tint = iconColor
) )
if (index == 3 && unreadErrors > 0) { if (item.id == 3 && unreadErrors > 0) {
Badge( Badge(
containerColor = if (tunnelRunning) colors.primary else WDTTColors.warning, containerColor = if (tunnelRunning) colors.primary else WDTTColors.warning,
contentColor = colors.onPrimary, contentColor = colors.onPrimary,
@@ -0,0 +1,167 @@
package com.wdtt.client
import android.app.PendingIntent
import android.content.Intent
import android.graphics.drawable.Icon
import android.net.VpnService
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class QuickToggleTileService : TileService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var stateJob: Job? = null
override fun onStartListening() {
super.onStartListening()
// Реактивно подписываемся на статус активности туннеля.
// Плитка будет строго отражать РЕАЛЬНОЕ состояние туннеля на 100% без рассинхронизаций.
stateJob?.cancel()
stateJob = scope.launch {
try {
TunnelManager.running.collect { running ->
updateTile(running)
}
} catch (e: Exception) {
Log.e("QuickToggleTile", "Error collecting running state", e)
}
}
}
override fun onStopListening() {
stateJob?.cancel()
super.onStopListening()
}
override fun onClick() {
super.onClick()
runCatching {
if (TunnelManager.running.value) {
// Если запущен — останавливаем. Состояние плитки изменится автоматически,
// когда TunnelManager остановит процессы и обновит статус running в false.
val stopIntent = Intent(this, TunnelService::class.java).apply { action = "STOP" }
startService(stopIntent)
return
}
// Проверяем наличие выданного разрешения VPN перед стартом
if (VpnService.prepare(this) != null) {
Toast.makeText(this, "Откройте WDTT и выдайте VPN-разрешение", Toast.LENGTH_LONG).show()
openMainActivity()
return
}
// Запускаем старт туннеля в фоне
scope.launch {
try {
val intent = buildStartIntent()
if (intent == null) {
Toast.makeText(this@QuickToggleTileService, "Заполните настройки подключения в WDTT", Toast.LENGTH_LONG).show()
openMainActivity()
return@launch
}
if (Build.VERSION.SDK_INT >= 26) {
startForegroundService(intent)
} else {
startService(intent)
}
} catch (e: Exception) {
Log.e("QuickToggleTile", "Failed to start tunnel via QS tile", e)
Toast.makeText(this@QuickToggleTileService, "Ошибка запуска: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
}
}
}.onFailure { e ->
Log.e("QuickToggleTile", "Crash prevented in onClick", e)
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private suspend fun buildStartIntent(): Intent? {
return runCatching {
val store = SettingsStore(applicationContext)
val basePeer = store.peer.first()
val hashes = store.vkHashes.first()
val password = store.connectionPassword.first()
if (basePeer.isBlank() || hashes.isBlank() || password.isBlank()) return null
val manualPortsEnabled = store.manualPortsEnabled.first()
val serverDtlsPort = if (manualPortsEnabled) store.serverDtlsPort.first() else 56000
val localPort = if (manualPortsEnabled) store.listenPort.first() else 9000
val peerWithPort = if (basePeer.contains(":")) basePeer else "$basePeer:$serverDtlsPort"
Intent(this, TunnelService::class.java).apply {
action = "START"
putExtra("peer", peerWithPort)
putExtra("vk_hashes", hashes)
putExtra("secondary_vk_hash", store.secondaryVkHash.first())
putExtra("workers_per_hash", store.workersPerHash.first())
putExtra("port", localPort)
putExtra("sni", store.sni.first())
putExtra("connection_password", password)
putExtra("captcha_mode", sanitizeCaptchaMode(store.captchaMode.first()))
putExtra("captcha_solve_method", store.captchaSolveMethod.first())
}
}.getOrNull()
}
private fun updateTile(running: Boolean) {
runCatching {
qsTile?.apply {
label = "WDTT"
icon = Icon.createWithResource(this@QuickToggleTileService, R.drawable.ic_tile_logo_w)
state = if (running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
if (Build.VERSION.SDK_INT >= 29) {
subtitle = if (running) "Подключено" else "Отключено"
}
updateTile()
}
}.onFailure { e ->
Log.e("QuickToggleTile", "Failed to update QS tile state", e)
}
}
private fun openMainActivity() {
runCatching {
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (Build.VERSION.SDK_INT >= 34) {
val pendingIntent = PendingIntent.getActivity(
this,
100,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}.onFailure { e ->
Log.e("QuickToggleTile", "Failed to open MainActivity", e)
}
}
private fun sanitizeCaptchaMode(mode: String?): String {
return when (mode?.lowercase()) {
"auto" -> "auto"
"rjs" -> "rjs"
"wv" -> "wv"
else -> "auto"
}
}
}
@@ -16,10 +16,18 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import android.os.Build
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
companion object { companion object {
private val Context.dataStore by preferencesDataStore("settings") private val Context.dataStore by preferencesDataStore("settings")
private val ACTIVE_PROFILE = intPreferencesKey("active_profile")
private val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
private val LOGGING_ENABLED = booleanPreferencesKey("logging_enabled")
private val WDTT_LINK = stringPreferencesKey("wdtt_link")
private val WDTT_LINK_MODE = booleanPreferencesKey("wdtt_link_mode")
private val PEER = stringPreferencesKey("peer") private val PEER = stringPreferencesKey("peer")
private val VK_HASHES = stringPreferencesKey("vk_hashes") private val VK_HASHES = stringPreferencesKey("vk_hashes")
private val SECONDARY_VK_HASH = stringPreferencesKey("secondary_vk_hash") private val SECONDARY_VK_HASH = stringPreferencesKey("secondary_vk_hash")
@@ -83,6 +91,18 @@ class SettingsStore(context: Context) {
private val UPDATE_DIALOG_LAST_ACTION_VERSION = stringPreferencesKey("update_dialog_last_action_version") private val UPDATE_DIALOG_LAST_ACTION_VERSION = stringPreferencesKey("update_dialog_last_action_version")
private val UPDATE_DIALOG_LAST_ACTION = stringPreferencesKey("update_dialog_last_action") private val UPDATE_DIALOG_LAST_ACTION = stringPreferencesKey("update_dialog_last_action")
private val UPDATE_DIALOG_LAST_ACTION_AT = longPreferencesKey("update_dialog_last_action_at") private val UPDATE_DIALOG_LAST_ACTION_AT = longPreferencesKey("update_dialog_last_action_at")
private fun <T> getProfileKey(baseKey: Preferences.Key<T>, profile: Int): Preferences.Key<T> {
if (profile == 0) return baseKey
val newName = "${baseKey.name}_$profile"
@Suppress("UNCHECKED_CAST")
return when (baseKey) {
PEER, VK_HASHES, SECONDARY_VK_HASH, PROTOCOL, SNI, USER_AGENT, DEPLOY_IP, DEPLOY_LOGIN, DEPLOY_PASSWORD, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_SSH_PORT, EXCLUDED_APPS, CONNECTION_PASSWORD, CONNECTION_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_ADMIN_ID, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_BOT_TOKEN, DEPLOY_BOT_TOKEN_ENCRYPTED, PROXY_MODE, PROXY_HOST, CAPTCHA_MODE, CAPTCHA_SOLVE_METHOD, CAPTCHA_WBV_SOLVE_METHOD, WDTT_LINK -> stringPreferencesKey(newName) as Preferences.Key<T>
WORKERS_PER_HASH, LISTEN_PORT, SERVER_DTLS_PORT, SERVER_WG_PORT, PROXY_PORT -> intPreferencesKey(newName) as Preferences.Key<T>
MANUAL_PORTS_ENABLED, NO_DTLS, NO_DNS, IS_WHITELIST, WDTT_LINK_MODE -> booleanPreferencesKey(newName) as Preferences.Key<T>
else -> throw IllegalArgumentException("Unsupported key type: ${baseKey.name}")
}
}
} }
private val dataStore = appContext.dataStore private val dataStore = appContext.dataStore
@@ -94,65 +114,151 @@ class SettingsStore(context: Context) {
} }
} }
val peer: Flow<String> = dataStore.data.map { it[PEER] ?: "" } val activeProfile: Flow<Int> = dataStore.data.map { it[ACTIVE_PROFILE] ?: 0 }
val vkHashes: Flow<String> = dataStore.data.map { it[VK_HASHES] ?: "" } val showSystemApps: Flow<Boolean> = dataStore.data.map { it[SHOW_SYSTEM_APPS] ?: true }
val secondaryVkHash: Flow<String> = dataStore.data.map { it[SECONDARY_VK_HASH] ?: "" } val loggingEnabled: Flow<Boolean> = dataStore.data.map { it[LOGGING_ENABLED] ?: true }
val workersPerHash: Flow<Int> = dataStore.data.map { it[WORKERS_PER_HASH] ?: 16 } val wdttLink: Flow<String> = dataStore.data.map { prefs ->
val protocol: Flow<String> = dataStore.data.map { it[PROTOCOL] ?: "udp" } val profile = prefs[ACTIVE_PROFILE] ?: 0
val listenPort: Flow<Int> = dataStore.data.map { it[LISTEN_PORT] ?: 9000 } prefs[getProfileKey(WDTT_LINK, profile)] ?: ""
val manualPortsEnabled: Flow<Boolean> = dataStore.data.map { it[MANUAL_PORTS_ENABLED] ?: false } }
val serverDtlsPort: Flow<Int> = dataStore.data.map { it[SERVER_DTLS_PORT] ?: 56000 } val wdttLinkMode: Flow<Boolean> = dataStore.data.map { prefs ->
val serverWgPort: Flow<Int> = dataStore.data.map { it[SERVER_WG_PORT] ?: 56001 } val profile = prefs[ACTIVE_PROFILE] ?: 0
val sni: Flow<String> = dataStore.data.map { it[SNI] ?: "" } prefs[getProfileKey(WDTT_LINK_MODE, profile)] ?: false
val noDns: Flow<Boolean> = dataStore.data.map { it[NO_DNS] ?: false } }
val userAgent: Flow<String> = dataStore.data.map { it[USER_AGENT] ?: "" }
val peer: Flow<String> = dataStore.data.map { prefs ->
val deployIp: Flow<String> = dataStore.data.map { it[DEPLOY_IP] ?: "" } val profile = prefs[ACTIVE_PROFILE] ?: 0
val deployLogin: Flow<String> = dataStore.data.map { it[DEPLOY_LOGIN] ?: "" } prefs[getProfileKey(PEER, profile)] ?: ""
val deployPassword: Flow<String> = dataStore.data.map { }
readSecret(it, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD) val vkHashes: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(VK_HASHES, profile)] ?: ""
}
val secondaryVkHash: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SECONDARY_VK_HASH, profile)] ?: ""
}
val workersPerHash: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WORKERS_PER_HASH, profile)] ?: 16
}
val protocol: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROTOCOL, profile)] ?: "udp"
}
val listenPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(LISTEN_PORT, profile)] ?: 9000
}
val manualPortsEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(MANUAL_PORTS_ENABLED, profile)] ?: false
}
val serverDtlsPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SERVER_DTLS_PORT, profile)] ?: 56000
}
val serverWgPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SERVER_WG_PORT, profile)] ?: 56001
}
val sni: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SNI, profile)] ?: ""
}
val noDns: Flow<Boolean> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(NO_DNS, profile)] ?: false
}
val userAgent: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(USER_AGENT, profile)] ?: ""
}
val deployIp: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DEPLOY_IP, profile)] ?: ""
}
val deployLogin: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DEPLOY_LOGIN, profile)] ?: ""
}
val deployPassword: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, profile)
}
val deploySshPort: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] ?: ""
}
val excludedApps: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(EXCLUDED_APPS, profile)] ?: ""
} }
val deploySshPort: Flow<String> = dataStore.data.map { it[DEPLOY_SSH_PORT] ?: "" }
val excludedApps: Flow<String> = dataStore.data.map { it[EXCLUDED_APPS] ?: "" }
val detailedLogs: Flow<Boolean> = dataStore.data.map { it[DETAILED_LOGS] ?: false } val detailedLogs: Flow<Boolean> = dataStore.data.map { it[DETAILED_LOGS] ?: false }
// ═══ Пароли и Управление ═══ // ═══ Пароли и Управление ═══
val connectionPassword: Flow<String> = dataStore.data.map { val connectionPassword: Flow<String> = dataStore.data.map { prefs ->
readSecret(it, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD) val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, profile)
} }
val deployMainPassword: Flow<String> = dataStore.data.map { val deployMainPassword: Flow<String> = dataStore.data.map { prefs ->
readSecret(it, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD) val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, profile)
} }
val deployAdminId: Flow<String> = dataStore.data.map { val deployAdminId: Flow<String> = dataStore.data.map { prefs ->
readSecret(it, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID) val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, profile)
} }
val deployBotToken: Flow<String> = dataStore.data.map { val deployBotToken: Flow<String> = dataStore.data.map { prefs ->
readSecret(it, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN) val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, profile)
} }
// ═══ Proxy Mode ═══ // ═══ Proxy Mode ═══
val proxyMode: Flow<String> = dataStore.data.map { it[PROXY_MODE] ?: "tun" } val proxyMode: Flow<String> = dataStore.data.map { prefs ->
val proxyHost: Flow<String> = dataStore.data.map { it[PROXY_HOST] ?: "127.0.0.1" } val profile = prefs[ACTIVE_PROFILE] ?: 0
val proxyPort: Flow<Int> = dataStore.data.map { it[PROXY_PORT] ?: 1080 } prefs[getProfileKey(PROXY_MODE, profile)] ?: "tun"
}
val proxyHost: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROXY_HOST, profile)] ?: "127.0.0.1"
}
val proxyPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROXY_PORT, profile)] ?: 1080
}
// ═══ Captcha Solve Mode ═══ // ═══ Captcha Solve Mode ═══
val captchaMode: Flow<String> = dataStore.data.map { it[CAPTCHA_MODE] ?: "auto" } val captchaMode: Flow<String> = dataStore.data.map { prefs ->
val captchaSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_SOLVE_METHOD] ?: "auto" } val profile = prefs[ACTIVE_PROFILE] ?: 0
val captchaWbvSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_WBV_SOLVE_METHOD] ?: "auto" } prefs[getProfileKey(CAPTCHA_MODE, profile)] ?: "auto"
}
val captchaSolveMethod: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] ?: "auto"
}
val captchaWbvSolveMethod: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_WBV_SOLVE_METHOD, profile)] ?: "auto"
}
// ═══ VPN Exclusions Mode ═══ // ═══ VPN Exclusions Mode ═══
val isWhitelist: Flow<Boolean> = dataStore.data.map { it[IS_WHITELIST] ?: false } val isWhitelist: Flow<Boolean> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(IS_WHITELIST, profile)] ?: false
}
// ═══ Theme Mode ═══ // ═══ Theme Mode ═══
val themeMode: Flow<String> = dataStore.data.map { it[THEME_MODE] ?: "system" } val themeMode: Flow<String> = dataStore.data.map { it[THEME_MODE] ?: "system" }
val isDynamicColor: Flow<Boolean> = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: false } val isDynamicColor: Flow<Boolean> = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) }
val themePalette: Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "indigo" } val themePalette: Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "indigo" }
val updateLastCheckAt: Flow<Long> = dataStore.data.map { it[UPDATE_LAST_CHECK_AT] ?: 0L } val updateLastCheckAt: Flow<Long> = dataStore.data.map { it[UPDATE_LAST_CHECK_AT] ?: 0L }
val updateLatestVersion: Flow<String> = dataStore.data.map { it[UPDATE_LATEST_VERSION] ?: "" } val updateLatestVersion: Flow<String> = dataStore.data.map { it[UPDATE_LATEST_VERSION] ?: "" }
val updateLastError: Flow<String> = dataStore.data.map { it[UPDATE_LAST_ERROR] ?: "" } val updateLastError: Flow<String> = dataStore.data.map { it[UPDATE_LAST_ERROR] ?: "" }
val updateCheckIntervalHours: Flow<Int> = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: DEFAULT_UPDATE_CHECK_INTERVAL_HOURS } val updateCheckIntervalHours: Flow<Int> = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: 24 }
val updatePostponeUntil: Flow<Long> = dataStore.data.map { it[UPDATE_POSTPONE_UNTIL] ?: 0L } val updatePostponeUntil: Flow<Long> = dataStore.data.map { it[UPDATE_POSTPONE_UNTIL] ?: 0L }
val updatePostponeVersion: Flow<String> = dataStore.data.map { it[UPDATE_POSTPONE_VERSION] ?: "" } val updatePostponeVersion: Flow<String> = dataStore.data.map { it[UPDATE_POSTPONE_VERSION] ?: "" }
val updateDialogLastShownVersion: Flow<String> = dataStore.data.map { it[UPDATE_DIALOG_LAST_SHOWN_VERSION] ?: "" } val updateDialogLastShownVersion: Flow<String> = dataStore.data.map { it[UPDATE_DIALOG_LAST_SHOWN_VERSION] ?: "" }
@@ -215,6 +321,38 @@ class SettingsStore(context: Context) {
} }
} }
suspend fun saveActiveProfile(profile: Int) {
dataStore.edit { prefs ->
prefs[ACTIVE_PROFILE] = profile
}
}
suspend fun saveShowSystemApps(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[SHOW_SYSTEM_APPS] = enabled
}
}
suspend fun saveLoggingEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[LOGGING_ENABLED] = enabled
}
}
suspend fun saveWdttLink(link: String) {
dataStore.edit { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WDTT_LINK, profile)] = link
}
}
suspend fun saveWdttLinkMode(enabled: Boolean) {
dataStore.edit { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WDTT_LINK_MODE, profile)] = enabled
}
}
suspend fun save( suspend fun save(
peer: String, peer: String,
vkHashes: String, vkHashes: String,
@@ -226,102 +364,115 @@ class SettingsStore(context: Context) {
noDns: Boolean = false noDns: Boolean = false
) { ) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[PEER] = peer val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[VK_HASHES] = vkHashes prefs[getProfileKey(PEER, profile)] = peer
prefs[SECONDARY_VK_HASH] = secondaryVkHash prefs[getProfileKey(VK_HASHES, profile)] = vkHashes
prefs[WORKERS_PER_HASH] = workersPerHash prefs[getProfileKey(SECONDARY_VK_HASH, profile)] = secondaryVkHash
prefs[PROTOCOL] = protocol prefs[getProfileKey(WORKERS_PER_HASH, profile)] = workersPerHash
prefs[LISTEN_PORT] = listenPort prefs[getProfileKey(PROTOCOL, profile)] = protocol
prefs[SNI] = sni prefs[getProfileKey(LISTEN_PORT, profile)] = listenPort
prefs[NO_DNS] = noDns prefs[getProfileKey(SNI, profile)] = sni
prefs[getProfileKey(NO_DNS, profile)] = noDns
} }
} }
suspend fun saveManualPortsEnabled(enabled: Boolean) { suspend fun saveManualPortsEnabled(enabled: Boolean) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[MANUAL_PORTS_ENABLED] = enabled val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(MANUAL_PORTS_ENABLED, profile)] = enabled
} }
} }
suspend fun savePorts(serverDtlsPort: Int, serverWgPort: Int, listenPort: Int) { suspend fun savePorts(serverDtlsPort: Int, serverWgPort: Int, listenPort: Int) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[SERVER_DTLS_PORT] = serverDtlsPort val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[SERVER_WG_PORT] = serverWgPort prefs[getProfileKey(SERVER_DTLS_PORT, profile)] = serverDtlsPort
prefs[LISTEN_PORT] = listenPort prefs[getProfileKey(SERVER_WG_PORT, profile)] = serverWgPort
prefs[getProfileKey(LISTEN_PORT, profile)] = listenPort
} }
} }
suspend fun saveUserAgent(ua: String) { suspend fun saveUserAgent(ua: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[USER_AGENT] = ua val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(USER_AGENT, profile)] = ua
} }
} }
suspend fun saveDeploy(ip: String, login: String, pass: String, sshPort: String) { suspend fun saveDeploy(ip: String, login: String, pass: String, sshPort: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[DEPLOY_IP] = ip val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[DEPLOY_LOGIN] = login prefs[getProfileKey(DEPLOY_IP, profile)] = ip
prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass) prefs[getProfileKey(DEPLOY_LOGIN, profile)] = login
prefs[DEPLOY_SSH_PORT] = sshPort prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass, profile)
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] = sshPort
} }
} }
suspend fun saveExcludedApps(packages: String) { suspend fun saveExcludedApps(packages: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[EXCLUDED_APPS] = packages val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(EXCLUDED_APPS, profile)] = packages
} }
} }
suspend fun saveDetailedLogs(enabled: Boolean) { suspend fun saveDetailedLogs(enabled: Boolean) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[DETAILED_LOGS] = enabled val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DETAILED_LOGS, profile)] = enabled
} }
} }
// ═══ Сохранение пароля подключения ═══ // ═══ Сохранение пароля подключения ═══
suspend fun saveConnectionPassword(password: String) { suspend fun saveConnectionPassword(password: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs.putSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, password) val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs.putSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, password, profile)
} }
} }
// ═══ Сохранение секретов деплоя ═══ // ═══ Сохранение секретов деплоя ═══
suspend fun saveDeploySecrets(mainPass: String, adminId: String, botToken: String, sshPort: String) { suspend fun saveDeploySecrets(mainPass: String, adminId: String, botToken: String, sshPort: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass) val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId) prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass, profile)
prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken) prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId, profile)
prefs[DEPLOY_SSH_PORT] = sshPort prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken, profile)
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] = sshPort
} }
} }
// ═══ Сохранение proxy mode ═══ // ═══ Сохранение proxy mode ═══
suspend fun saveProxyMode(mode: String, host: String, port: Int) { suspend fun saveProxyMode(mode: String, host: String, port: Int) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[PROXY_MODE] = mode val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[PROXY_HOST] = host prefs[getProfileKey(PROXY_MODE, profile)] = mode
prefs[PROXY_PORT] = port prefs[getProfileKey(PROXY_HOST, profile)] = host
prefs[getProfileKey(PROXY_PORT, profile)] = port
} }
} }
// ═══ Сохранение режима обхода капчи ═══ // ═══ Сохранение режима обхода капчи ═══
suspend fun saveCaptchaMode(mode: String) { suspend fun saveCaptchaMode(mode: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[CAPTCHA_MODE] = mode val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_MODE, profile)] = mode
} }
} }
suspend fun saveCaptchaSolveMethod(method: String) { suspend fun saveCaptchaSolveMethod(method: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[CAPTCHA_SOLVE_METHOD] = method val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] = method
} }
} }
suspend fun saveWbvCaptchaSolveMethod(method: String) { suspend fun saveWbvCaptchaSolveMethod(method: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[CAPTCHA_WBV_SOLVE_METHOD] = method val profile = prefs[ACTIVE_PROFILE] ?: 0
if (prefs[CAPTCHA_MODE] == "wv") { prefs[getProfileKey(CAPTCHA_WBV_SOLVE_METHOD, profile)] = method
prefs[CAPTCHA_SOLVE_METHOD] = method if (prefs[getProfileKey(CAPTCHA_MODE, profile)] == "wv") {
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] = method
} }
} }
} }
@@ -329,47 +480,57 @@ class SettingsStore(context: Context) {
// ═══ Сохранение режима списка (ЧС/БС) ═══ // ═══ Сохранение режима списка (ЧС/БС) ═══
suspend fun saveIsWhitelist(enabled: Boolean) { suspend fun saveIsWhitelist(enabled: Boolean) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[IS_WHITELIST] = enabled val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(IS_WHITELIST, profile)] = enabled
} }
} }
// Атомарное сохранение обоих параметров для исключения гонки при перезагрузке // Атомарное сохранение обоих параметров для исключения гонки при перезагрузке
suspend fun saveExceptionsMode(packages: String, isWhitelist: Boolean) { suspend fun saveExceptionsMode(packages: String, isWhitelist: Boolean) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[EXCLUDED_APPS] = packages val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[IS_WHITELIST] = isWhitelist prefs[getProfileKey(EXCLUDED_APPS, profile)] = packages
prefs[getProfileKey(IS_WHITELIST, profile)] = isWhitelist
} }
} }
private suspend fun migrateSecretsToKeystore() { private suspend fun migrateSecretsToKeystore() {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs.migrateSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD) for (profile in 0..2) {
prefs.migrateSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD) prefs.migrateSecret(getProfileKey(DEPLOY_PASSWORD_ENCRYPTED, profile), getProfileKey(DEPLOY_PASSWORD, profile))
prefs.migrateSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD) prefs.migrateSecret(getProfileKey(CONNECTION_PASSWORD_ENCRYPTED, profile), getProfileKey(CONNECTION_PASSWORD, profile))
prefs.migrateSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID) prefs.migrateSecret(getProfileKey(DEPLOY_MAIN_PASSWORD_ENCRYPTED, profile), getProfileKey(DEPLOY_MAIN_PASSWORD, profile))
prefs.migrateSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN) prefs.migrateSecret(getProfileKey(DEPLOY_ADMIN_ID_ENCRYPTED, profile), getProfileKey(DEPLOY_ADMIN_ID, profile))
prefs.migrateSecret(getProfileKey(DEPLOY_BOT_TOKEN_ENCRYPTED, profile), getProfileKey(DEPLOY_BOT_TOKEN, profile))
}
} }
} }
private fun readSecret( private fun readSecret(
prefs: Preferences, prefs: Preferences,
encryptedKey: Preferences.Key<String>, encryptedKey: Preferences.Key<String>,
legacyKey: Preferences.Key<String> legacyKey: Preferences.Key<String>,
profile: Int
): String { ): String {
return secureStore.decrypt(prefs[encryptedKey]) ?: prefs[legacyKey] ?: "" val profEncryptedKey = getProfileKey(encryptedKey, profile)
val profLegacyKey = getProfileKey(legacyKey, profile)
return secureStore.decrypt(prefs[profEncryptedKey]) ?: prefs[profLegacyKey] ?: ""
} }
private fun MutablePreferences.putSecret( private fun MutablePreferences.putSecret(
encryptedKey: Preferences.Key<String>, encryptedKey: Preferences.Key<String>,
legacyKey: Preferences.Key<String>, legacyKey: Preferences.Key<String>,
value: String value: String,
profile: Int
) { ) {
val profEncryptedKey = getProfileKey(encryptedKey, profile)
val profLegacyKey = getProfileKey(legacyKey, profile)
if (value.isBlank()) { if (value.isBlank()) {
remove(encryptedKey) remove(profEncryptedKey)
remove(legacyKey) remove(profLegacyKey)
} else { } else {
this[encryptedKey] = secureStore.encrypt(value) this[profEncryptedKey] = secureStore.encrypt(value)
remove(legacyKey) remove(profLegacyKey)
} }
} }
@@ -40,7 +40,7 @@ object TunnelManager {
private var refusedCount = 0 private var refusedCount = 0
private var currentHashErrorCount = 0 private var currentHashErrorCount = 0
private var wrapAuthTimeoutCount = 0 private var wrapAuthTimeoutCount = 0
private var processStartedAtMs = 0L var processStartedAtMs = 0L
private var lastActiveAtMs = 0L private var lastActiveAtMs = 0L
private var activeHashIndex = 0 // 0: primary, 1: secondary private var activeHashIndex = 0 // 0: primary, 1: secondary
private var currentParams: TunnelParams? = null private var currentParams: TunnelParams? = null
@@ -49,6 +49,9 @@ object TunnelManager {
private var currentCaptchaMode = "wv" // режим обхода капчи: "wv" или "rjs" private var currentCaptchaMode = "wv" // режим обхода капчи: "wv" или "rjs"
private var currentCaptchaSolveMethod = "auto" // "manual" или "auto" private var currentCaptchaSolveMethod = "auto" // "manual" или "auto"
@Volatile
var isLoggingEnabled = true
val running = MutableStateFlow(false) val running = MutableStateFlow(false)
val logs = MutableStateFlow<List<LogEntry>>(emptyList()) val logs = MutableStateFlow<List<LogEntry>>(emptyList())
val unreadErrorCount = MutableStateFlow(0) val unreadErrorCount = MutableStateFlow(0)
@@ -56,7 +59,7 @@ object TunnelManager {
val stats = MutableStateFlow("Ожидание данных...") val stats = MutableStateFlow("Ожидание данных...")
val activeWorkers = MutableStateFlow(0) val activeWorkers = MutableStateFlow(0)
val cooldownSeconds = MutableStateFlow(0) val cooldownActive = MutableStateFlow(false)
private var cooldownJob: Job? = null private var cooldownJob: Job? = null
fun clearUnreadErrors() { fun clearUnreadErrors() {
@@ -75,6 +78,7 @@ object TunnelManager {
} }
private fun updateLog(key: String, message: String, priority: Int, isError: Boolean = false) { private fun updateLog(key: String, message: String, priority: Int, isError: Boolean = false) {
if (!isLoggingEnabled) return
if (isError) { if (isError) {
val list = logs.value val list = logs.value
if (list.none { it.key == key }) { if (list.none { it.key == key }) {
@@ -152,13 +156,11 @@ object TunnelManager {
} }
val hashCount = hashList.size.coerceIn(1, 3) val hashCount = hashList.size.coerceIn(1, 3)
val totalWorkers = params.workersPerHash.coerceIn(1, 128) // Максимум ограничивается UI (80), но тут ставим хард-лимит побольше на случай запаса val totalWorkers = params.workersPerHash.coerceIn(1, 128)
val hashMode = if (activeHashIndex == 0) "Основной" else "Запасной" val hashMode = if (activeHashIndex == 0) "Основной" else "Запасной"
updateLog("config_info", "[$hashMode] Хешей=$hashCount, Потоков=$totalWorkers", 1) updateLog("config_info", "[$hashMode] Хешей=$hashCount, Потоков=$totalWorkers", 1)
// CRITICAL FIX: Use nativeLibraryDir with extractNativeLibs="true"
val binaryPath = context.applicationInfo.nativeLibraryDir + "/libclient.so" val binaryPath = context.applicationInfo.nativeLibraryDir + "/libclient.so"
val binaryFile = File(binaryPath) val binaryFile = File(binaryPath)
@@ -182,15 +184,13 @@ object TunnelManager {
cmd.add("-password") cmd.add("-password")
cmd.add(params.connectionPassword) cmd.add(params.connectionPassword)
// Captcha mode: wv или rjs
cmd.add("-captcha-mode") cmd.add("-captcha-mode")
cmd.add(params.captchaMode) cmd.add(params.captchaMode)
val pb = ProcessBuilder(cmd) val pb = ProcessBuilder(cmd)
pb.directory(context.filesDir) // Устанавливаем рабочую директорию pb.directory(context.filesDir)
pb.redirectErrorStream(true) pb.redirectErrorStream(true)
// Set LD_LIBRARY_PATH
val env = pb.environment() val env = pb.environment()
env["LD_LIBRARY_PATH"] = context.applicationInfo.nativeLibraryDir env["LD_LIBRARY_PATH"] = context.applicationInfo.nativeLibraryDir
@@ -220,7 +220,6 @@ object TunnelManager {
var lastResetTime = System.currentTimeMillis() var lastResetTime = System.currentTimeMillis()
reader.forEachLine { line -> reader.forEachLine { line ->
// Периодический сброс счетчиков ошибок (раз в 60 сек)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastResetTime > 60000) { if (now - lastResetTime > 60000) {
refusedCount = 0 refusedCount = 0
@@ -230,13 +229,11 @@ object TunnelManager {
lastResetTime = now lastResetTime = now
} }
// Чистим лог от даты из Go (например, "2023/10/24 12:34:56.123456 [ВОРКЕР...")
val msgPrefixReplaced = line.replace(Regex("^\\d{4}/\\d{2}/\\d{2}\\s\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\s"), "") val msgPrefixReplaced = line.replace(Regex("^\\d{4}/\\d{2}/\\d{2}\\s\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\s"), "")
val lineTrim = msgPrefixReplaced.trim() val lineTrim = msgPrefixReplaced.trim()
val isError = lineTrim.contains("Ошибка", true) || lineTrim.contains("error", true) || lineTrim.contains("FAIL", true) || lineTrim.contains("timeout", true) || lineTrim.contains("refused", true) || lineTrim.contains("FATAL_AUTH", true) val isError = lineTrim.contains("Ошибка", true) || lineTrim.contains("error", true) || lineTrim.contains("FAIL", true) || lineTrim.contains("timeout", true) || lineTrim.contains("refused", true) || lineTrim.contains("FATAL_AUTH", true)
// 0. FATAL AUTH — мгновенная остановка
if (lineTrim.contains("FATAL_AUTH")) { if (lineTrim.contains("FATAL_AUTH")) {
val isWrapHandshakeTimeout = lineTrim.contains("DTLS timeout", true) || val isWrapHandshakeTimeout = lineTrim.contains("DTLS timeout", true) ||
lineTrim.contains("WRAP_AUTH_TIMEOUT", true) lineTrim.contains("WRAP_AUTH_TIMEOUT", true)
@@ -271,30 +268,27 @@ object TunnelManager {
return@forEachLine return@forEachLine
} }
// 0a. WRAP auth timeout — не фатально для отдельного воркера.
// Критичным считаем только ситуацию, когда за стартовое окно не поднялся ни один поток.
if (lineTrim.contains("WRAP_AUTH_TIMEOUT", true)) { if (lineTrim.contains("WRAP_AUTH_TIMEOUT", true)) {
if (activeWorkers.value > 0) { if (activeWorkers.value > 0) {
wrapAuthTimeoutCount = 0 wrapAuthTimeoutCount = 0
updateLog( updateLog(
"wrap_timeout_recovered", "wrap_timeout_recovered",
"[WRAP] Один поток не прошёл handshake, активных=${activeWorkers.value}; повторяем", "[WRAP] Один поток не прошёл handshake, активных=${activeWorkers.value}; повторяем",
50, 50,
true true
) )
} else { } else {
wrapAuthTimeoutCount++ wrapAuthTimeoutCount++
updateLog( updateLog(
"wrap_timeout_wait", "wrap_timeout_wait",
"[WRAP] Handshake не подтвердился, проверяем пароль/сеть ($wrapAuthTimeoutCount)", "[WRAP] Handshake не подтвердился, проверяем пароль/сеть ($wrapAuthTimeoutCount)",
50, 50,
true true
) )
} }
return@forEachLine return@forEachLine
} }
// 0b. CAPTCHA_SOLVE — запрос от Go для WBV-режима.
if (lineTrim.startsWith("CAPTCHA_SOLVE|")) { if (lineTrim.startsWith("CAPTCHA_SOLVE|")) {
val payload = lineTrim.substringAfter("CAPTCHA_SOLVE|") val payload = lineTrim.substringAfter("CAPTCHA_SOLVE|")
val parts = payload.split("|", limit = 3) val parts = payload.split("|", limit = 3)
@@ -321,7 +315,6 @@ object TunnelManager {
return@forEachLine return@forEachLine
} }
// 1. ПРЕДОХРАНИТЕЛЬ (Circuit Breaker)
if (isError) { if (isError) {
when { when {
lineTrim.contains("Flood control", true) -> { lineTrim.contains("Flood control", true) -> {
@@ -339,7 +332,6 @@ object TunnelManager {
} }
} }
lineTrim.contains("connection refused", true) || lineTrim.contains("timeout", true) -> { lineTrim.contains("connection refused", true) || lineTrim.contains("timeout", true) -> {
// Огромный лимит, потому что каждый воркер кидает эту ошибку при смене сети
refusedCount++ refusedCount++
if (refusedCount >= 400) { if (refusedCount >= 400) {
handleCriticalError("Критическое отсутствие сети (400+ таймаутов). Отключение.") handleCriticalError("Критическое отсутствие сети (400+ таймаутов). Отключение.")
@@ -348,7 +340,6 @@ object TunnelManager {
} }
lineTrim.contains("9000") || lineTrim.contains("Call not found", true) -> { lineTrim.contains("9000") || lineTrim.contains("Call not found", true) -> {
currentHashErrorCount++ currentHashErrorCount++
// Нужно больше попыток, так как 1 воркер может спамить
if (currentHashErrorCount >= 10) { if (currentHashErrorCount >= 10) {
handleHashError() handleHashError()
return@forEachLine return@forEachLine
@@ -357,7 +348,6 @@ object TunnelManager {
} }
} }
// 1. Статистика (Обновляемая строка)
if (lineTrim.contains("[СТАТИСТИКА]")) { if (lineTrim.contains("[СТАТИСТИКА]")) {
val msg = lineTrim.substringAfter("[СТАТИСТИКА]").trim() val msg = lineTrim.substringAfter("[СТАТИСТИКА]").trim()
stats.value = msg stats.value = msg
@@ -376,10 +366,7 @@ object TunnelManager {
return@forEachLine return@forEachLine
} }
// 2. Этапы подключения и Ошибки
when { when {
// ═══ Авто-оркестратор капчи ═══
lineTrim.contains("[КАПЧА] AUTO:") -> { lineTrim.contains("[КАПЧА] AUTO:") -> {
var text = lineTrim.substringAfter("[КАПЧА] AUTO:").trim() var text = lineTrim.substringAfter("[КАПЧА] AUTO:").trim()
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim() text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
@@ -399,9 +386,7 @@ object TunnelManager {
updateLog(stableKey, "[КАПЧА AUTO] $text", 5, isErr) updateLog(stableKey, "[КАПЧА AUTO] $text", 5, isErr)
} }
// ═══ RJS капча логи: [КАПЧА RJS] со стабильными ключами-шагами ═══
lineTrim.contains("[КАПЧА] RJS:") -> { lineTrim.contains("[КАПЧА] RJS:") -> {
// Удаляем тайминги и лишние скобки: (123мс), (diff=2), (общее время...)
var text = lineTrim.substringAfter("[КАПЧА] RJS:").trim() var text = lineTrim.substringAfter("[КАПЧА] RJS:").trim()
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim() text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
@@ -417,15 +402,14 @@ object TunnelManager {
updateLog(stableKey, "[КАПЧА RJS] $text", 5, false) updateLog(stableKey, "[КАПЧА RJS] $text", 5, false)
} }
// ═══ WV капча логи от Go: [КАПЧА WBV] со стабильными ключами ═══
lineTrim.contains("[КАПЧА] WBV:") -> { lineTrim.contains("[КАПЧА] WBV:") -> {
var text = lineTrim.substringAfter("[КАПЧА] WBV:").trim() var text = lineTrim.substringAfter("[КАПЧА] WBV:").trim()
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim() text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
val isErr = text.contains("Ошибка") val isErr = text.contains("Ошибка")
val stableKey = when { val stableKey = when {
text.contains("Запрос") -> "captcha_wv_step_2" // Step 2 (после создания WV) text.contains("Запрос") -> "captcha_wv_step_2"
text.contains("Токен") -> "captcha_wv_step_5" // Step 5 (перед уничтожением) text.contains("Токен") -> "captcha_wv_step_5"
isErr -> "captcha_wv_err" isErr -> "captcha_wv_err"
else -> "captcha_wv_go_other" else -> "captcha_wv_go_other"
} }
@@ -462,9 +446,7 @@ object TunnelManager {
lineTrim.contains("Активна ✓") -> lineTrim.contains("Активна ✓") ->
updateLog("ready", "[READY] Туннель готов к работе ✓", 2, false) updateLog("ready", "[READY] Туннель готов к работе ✓", 2, false)
// Ошибки (в конец)
isError -> { isError -> {
// Формируем уникальный ключ ошибки на основе её типа (группируем по типу ошибки)
val errorKey = when { val errorKey = when {
lineTrim.contains("lookup login.vk.ru", true) -> "err_vk_dns" lineTrim.contains("lookup login.vk.ru", true) -> "err_vk_dns"
lineTrim.contains("connection refused") -> "err_conn_refused" lineTrim.contains("connection refused") -> "err_conn_refused"
@@ -482,7 +464,6 @@ object TunnelManager {
} }
} }
// 3. Обработка конфига (Скрываем от пользователя)
if (line.contains("") && line.contains("WireGuard")) { if (line.contains("") && line.contains("WireGuard")) {
collectingConfig = true collectingConfig = true
configBuilder.clear() configBuilder.clear()
@@ -528,7 +509,7 @@ object TunnelManager {
val context = lastContext ?: return val context = lastContext ?: return
currentHashErrorCount = 0 currentHashErrorCount = 0
forceRegenerateUA = true // Перегенерируем UA при следующих ошибках forceRegenerateUA = true
if (params.secondaryVkHash.isNotEmpty() && activeHashIndex == 0) { if (params.secondaryVkHash.isNotEmpty() && activeHashIndex == 0) {
updateLog("hash_switch", "Основной хеш мертв. Переключение на запасной...", 50, true) updateLog("hash_switch", "Основной хеш мертв. Переключение на запасной...", 50, true)
@@ -541,18 +522,14 @@ object TunnelManager {
} }
} }
// ==================== WATCHDOG ====================
// Проверяет, жив ли Go-процесс. Если умер — перезапускает.
// Если процесс жив, но 0 воркеров уже 30 сек — тоже перезапуск (зомби).
private fun startWatchdog(context: Context, params: TunnelParams) { private fun startWatchdog(context: Context, params: TunnelParams) {
watchdogJob?.cancel() watchdogJob?.cancel()
watchdogJob = scope.launch { watchdogJob = scope.launch {
var zeroWorkersSince = 0L var zeroWorkersSince = 0L
delay(10_000) // Даём 10 сек на старт delay(10_000)
while (isActive && running.value) { while (isActive && running.value) {
val proc = process val proc = process
if (proc == null || !proc.isAlive) { if (proc == null || !proc.isAlive) {
// Go-процесс мёртв!
updateLog("watchdog", "⚠ Процесс упал. Перезапуск...", 50, true) updateLog("watchdog", "⚠ Процесс упал. Перезапуск...", 50, true)
activeWorkers.value = 0 activeWorkers.value = 0
forceRegenerateUA = true forceRegenerateUA = true
@@ -561,10 +538,9 @@ object TunnelManager {
if (running.value) { if (running.value) {
start(context, params, isSwitching = true) start(context, params, isSwitching = true)
} }
return@launch // startWatchdog будет перезапущен из start() return@launch
} }
// Детекция зомби: процесс жив, но 0 воркеров
val workers = activeWorkers.value val workers = activeWorkers.value
if (workers <= 0) { if (workers <= 0) {
if (zeroWorkersSince == 0L) { if (zeroWorkersSince == 0L) {
@@ -601,7 +577,7 @@ object TunnelManager {
val params = currentParams ?: return val params = currentParams ?: return
val context = lastContext ?: return val context = lastContext ?: return
updateLog("network_restart", "[СЕТЬ] Перезапуск транспорта из-за смены сети...", 50, false) updateLog("network_restart", "[СЕТЬ] Перезапуск транспорта из-за смены сети...", 50, false)
killProcess() // Только убиваем процесс, running не трогаем! killProcess()
scope.launch { scope.launch {
delay(1500) delay(1500)
start(context, params, isSwitching = true) start(context, params, isSwitching = true)
@@ -610,7 +586,7 @@ object TunnelManager {
fun pause() { fun pause() {
if (!running.value) return if (!running.value) return
killProcess() // Не ставим running=false, чтоб сервис не умер killProcess()
activeWorkers.value = 0 activeWorkers.value = 0
} }
@@ -622,7 +598,6 @@ object TunnelManager {
} }
} }
// Убивает процесс без изменения running
private fun killProcess() { private fun killProcess() {
watchdogJob?.cancel() watchdogJob?.cancel()
readerJob?.cancel() readerJob?.cancel()
@@ -630,7 +605,6 @@ object TunnelManager {
process = null process = null
if (proc != null) { if (proc != null) {
try { proc.destroy() } catch (_: Exception) {} try { proc.destroy() } catch (_: Exception) {}
// Даём 500мс на graceful shutdown
try { proc.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {} try { proc.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {}
if (proc.isAlive) { if (proc.isAlive) {
try { proc.destroyForcibly() } catch (_: Exception) {} try { proc.destroyForcibly() } catch (_: Exception) {}
@@ -644,10 +618,6 @@ object TunnelManager {
running.value = false running.value = false
} }
private fun log(message: String) {
updateLog("internal_${message.hashCode()}", message, 50, false)
}
fun stop() { fun stop() {
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
wgHelper?.stopTunnel() wgHelper?.stopTunnel()
@@ -659,9 +629,7 @@ object TunnelManager {
ManlCaptchaWebViewManager.cancelCaptcha() ManlCaptchaWebViewManager.cancelCaptcha()
} }
// Suspend-версия: гарантирует что процесс мёртв и порт свободен
suspend fun stopAndWait() { suspend fun stopAndWait() {
// Сначала останавливаем WireGuard и ждём завершения
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
wgHelper?.stopTunnel() wgHelper?.stopTunnel()
} }
@@ -671,11 +639,10 @@ object TunnelManager {
activeWorkers.value = 0 activeWorkers.value = 0
currentParams = null currentParams = null
ManlCaptchaWebViewManager.cancelCaptcha() ManlCaptchaWebViewManager.cancelCaptcha()
// Ждём освобождения порта 9000 (до 3 секунд)
repeat(30) { repeat(30) {
try { try {
java.net.ServerSocket(9000, 1, java.net.InetAddress.getByName("127.0.0.1")).use { it.close() } java.net.ServerSocket(9000, 1, java.net.InetAddress.getByName("127.0.0.1")).use { it.close() }
return@withContext // Порт свободен! return@withContext
} catch (_: Exception) { } catch (_: Exception) {
delay(100) delay(100)
} }
@@ -691,15 +658,6 @@ object TunnelManager {
} }
} }
// ==================== CAPTCHA SOLVER (WebView Mode) ====================
/**
* Вызывается при получении CAPTCHA_SOLVE от Go-процесса.
* auto: одна короткая скрытая попытка для Go-оркестратора.
* manual: сразу видимый WebView.
* selected: старое поведение из UI, когда пользователь сам выбрал режим.
* Результат ВСЕГДА отправляется обратно в Go через writeCaptchaResult.
*/
private suspend fun handleCaptchaSolve(requestMode: String, redirectUri: String, sessionToken: String) { private suspend fun handleCaptchaSolve(requestMode: String, redirectUri: String, sessionToken: String) {
val ctx = lastContext ?: run { val ctx = lastContext ?: run {
writeCaptchaResult("error:context is null") writeCaptchaResult("error:context is null")
@@ -743,7 +701,6 @@ object TunnelManager {
writeCaptchaResult("error:$errorMsg") writeCaptchaResult("error:$errorMsg")
} }
// WebView уничтожен в finally блоке соответствующего менеджера.
updateLog("captcha_wv_step_6", "[КАПЧА WBV] WebView уничтожен", 5, false) updateLog("captcha_wv_step_6", "[КАПЧА WBV] WebView уничтожен", 5, false)
} }
@@ -785,9 +742,6 @@ object TunnelManager {
return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken) return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken)
} }
/**
* Записывает результат решения капчи в stdin Go-процесса.
*/
private fun writeCaptchaResult(result: String) { private fun writeCaptchaResult(result: String) {
val proc = process val proc = process
if (proc == null || !proc.isAlive) return if (proc == null || !proc.isAlive) return
@@ -802,17 +756,17 @@ object TunnelManager {
fun clearLogs() { fun clearLogs() {
logs.value = emptyList() logs.value = emptyList()
activeWorkers.value = 0 if (!running.value) {
activeWorkers.value = 0
}
} }
fun startCooldown(seconds: Int) { fun startCooldown(millis: Long) {
cooldownJob?.cancel() cooldownJob?.cancel()
cooldownSeconds.value = seconds cooldownActive.value = true
cooldownJob = scope.launch(Dispatchers.Main) { cooldownJob = scope.launch(Dispatchers.Main) {
while (cooldownSeconds.value > 0) { delay(millis)
delay(1000) cooldownActive.value = false
cooldownSeconds.update { it - 1 }
}
} }
} }
@@ -831,6 +785,6 @@ data class TunnelParams(
val sni: String = "", val sni: String = "",
val connectionPassword: String = "", val connectionPassword: String = "",
val protocol: String = "udp", val protocol: String = "udp",
val captchaMode: String = "auto", // "auto", "wv" или "rjs" val captchaMode: String = "auto",
val captchaSolveMethod: String = "auto" // "manual" или "auto" val captchaSolveMethod: String = "auto"
) )
@@ -283,6 +283,15 @@ class TunnelService : Service() {
stopSelf() stopSelf()
break break
} }
if (TunnelManager.running.value && !isTunnelPaused) {
val helper = WireGuardHelper(applicationContext)
val startupWindow = System.currentTimeMillis() - TunnelManager.processStartedAtMs < 6000
if (!startupWindow && !helper.isTunnelUp()) {
Log.w("TunnelService", "Обнаружена пропажа или замена VPN-интерфейса! Экстренное выключение туннеля.")
stopTunnel()
break
}
}
if (!isTunnelPaused) { if (!isTunnelPaused) {
updateNotification(buildTunnelNotificationText()) updateNotification(buildTunnelNotificationText())
} }
@@ -0,0 +1,190 @@
package com.wdtt.client
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.util.Log
import android.widget.RemoteViews
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class VpnWidgetProvider : AppWidgetProvider() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
companion object {
const val ACTION_WIDGET_TOGGLE = "com.wdtt.client.ACTION_WIDGET_TOGGLE"
fun updateAllWidgets(context: Context) {
runCatching {
val appWidgetManager = AppWidgetManager.getInstance(context)
val thisWidget = ComponentName(context, VpnWidgetProvider::class.java)
val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget)
if (appWidgetIds.isNotEmpty()) {
val intent = Intent(context, VpnWidgetProvider::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
}
context.sendBroadcast(intent)
}
}
}
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
val running = TunnelManager.running.value
for (appWidgetId in appWidgetIds) {
updateWidgetState(context, appWidgetManager, appWidgetId, running)
}
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == ACTION_WIDGET_TOGGLE) {
runCatching {
if (TunnelManager.running.value) {
// Останавливаем туннель
val stopIntent = Intent(context, TunnelService::class.java).apply { action = "STOP" }
context.startService(stopIntent)
updateAllWidgets(context)
return
}
if (VpnService.prepare(context) != null) {
Toast.makeText(context, "Откройте WDTT и выдайте VPN-разрешение", Toast.LENGTH_LONG).show()
openMainActivity(context)
return
}
// Запуск туннеля в фоне
scope.launch {
try {
val startIntent = buildStartIntent(context)
if (startIntent == null) {
Toast.makeText(context, "Заполните настройки подключения в WDTT", Toast.LENGTH_LONG).show()
openMainActivity(context)
return@launch
}
if (Build.VERSION.SDK_INT >= 26) {
context.startForegroundService(startIntent)
} else {
context.startService(startIntent)
}
} catch (e: Exception) {
Log.e("VpnWidget", "Failed to start tunnel from widget", e)
Toast.makeText(context, "Ошибка запуска: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
}
}
}.onFailure { e ->
Log.e("VpnWidget", "Error handling widget click", e)
}
}
}
private fun updateWidgetState(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
running: Boolean
) {
val views = RemoteViews(context.packageName, R.layout.vpn_widget)
// Обновляем текст статуса и неоновую иконку кнопки
if (running) {
views.setTextViewText(R.id.widget_status, "Подключено")
views.setTextColor(R.id.widget_status, 0xFF00E5FF.toInt()) // Неоновый голубой
views.setInt(R.id.widget_toggle_btn, "setBackgroundResource", R.drawable.bg_widget_button_active)
} else {
views.setTextViewText(R.id.widget_status, "Отключено")
views.setTextColor(R.id.widget_status, 0xFF888888.toInt()) // Матовый серый
views.setInt(R.id.widget_toggle_btn, "setBackgroundResource", R.drawable.bg_widget_button_inactive)
}
// Клик по всей карточке открывает приложение
val openIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val openPendingIntent = PendingIntent.getActivity(
context,
appWidgetId,
openIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
views.setOnClickPendingIntent(R.id.widget_container, openPendingIntent)
// Клик по кнопке запускает/останавливает VPN
val toggleIntent = Intent(context, VpnWidgetProvider::class.java).apply {
action = ACTION_WIDGET_TOGGLE
}
val togglePendingIntent = PendingIntent.getBroadcast(
context,
appWidgetId + 1000,
toggleIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
views.setOnClickPendingIntent(R.id.widget_toggle_btn, togglePendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
private suspend fun buildStartIntent(context: Context): Intent? {
val store = SettingsStore(context.applicationContext)
val basePeer = store.peer.first()
val hashes = store.vkHashes.first()
val password = store.connectionPassword.first()
if (basePeer.isBlank() || hashes.isBlank() || password.isBlank()) return null
val manualPortsEnabled = store.manualPortsEnabled.first()
val serverDtlsPort = if (manualPortsEnabled) store.serverDtlsPort.first() else 56000
val localPort = if (manualPortsEnabled) store.listenPort.first() else 9000
val peerWithPort = if (basePeer.contains(":")) basePeer else "$basePeer:$serverDtlsPort"
return Intent(context, TunnelService::class.java).apply {
action = "START"
putExtra("peer", peerWithPort)
putExtra("vk_hashes", hashes)
putExtra("secondary_vk_hash", store.secondaryVkHash.first())
putExtra("workers_per_hash", store.workersPerHash.first())
putExtra("port", localPort)
putExtra("sni", store.sni.first())
putExtra("connection_password", password)
putExtra("captcha_mode", sanitizeCaptchaMode(store.captchaMode.first()))
putExtra("captcha_solve_method", store.captchaSolveMethod.first())
}
}
private fun openMainActivity(context: Context) {
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching {
val pendingIntent = PendingIntent.getActivity(
context,
200,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
pendingIntent.send()
}.onFailure {
context.startActivity(intent)
}
}
private fun sanitizeCaptchaMode(mode: String?): String {
return when (mode?.lowercase()) {
"auto" -> "auto"
"rjs" -> "rjs"
"wv" -> "wv"
else -> "auto"
}
}
}
@@ -2,7 +2,13 @@ package com.wdtt.client
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.util.Log
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.Tunnel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class WdttApplication : Application() { class WdttApplication : Application() {
@Volatile @Volatile
@@ -14,6 +20,43 @@ class WdttApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
DeployManager.init(this) DeployManager.init(this)
// Очищаем фантомный VPN при холодном старте приложения (например, после перезагрузки телефона).
// Если телефон перезагрузился, система Android пытается сама восстановить VpnService,
// что приводит к фантомному ключу без интернета. Этот код мгновенно сбрасывает статус в DOWN.
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
runCatching {
val backend = getBackend(this@WdttApplication)
val tunnel = WireGuardHelper.WgTunnel()
backend.setState(tunnel, Tunnel.State.DOWN, null)
Log.d("WdttApp", "Успешно очищен фантомный VPN при холодном старте")
}.onFailure {
Log.w("WdttApp", "Не удалось очистить фантомный VPN: ${it.message}")
}
}
// Реактивно обновляем все виджеты на домашнем экране при изменении состояния туннеля
CoroutineScope(SupervisorJob() + Dispatchers.Main).launch {
try {
TunnelManager.running.collect {
VpnWidgetProvider.updateAllWidgets(this@WdttApplication)
}
} catch (e: Exception) {
Log.e("WdttApp", "Не удалось обновить виджеты: ${e.message}")
}
}
// Реактивно отслеживаем флаг логирования
val settingsStore = SettingsStore(this)
CoroutineScope(SupervisorJob() + Dispatchers.Main).launch {
try {
settingsStore.loggingEnabled.collect { enabled ->
TunnelManager.isLoggingEnabled = enabled
}
} catch (e: Exception) {
Log.e("WdttApp", "Не удалось отслеживать флаг логирования: ${e.message}")
}
}
} }
fun getBackend(context: Context): GoBackend { fun getBackend(context: Context): GoBackend {
@@ -137,6 +137,15 @@ class WireGuardHelper(context: Context) {
} }
} }
suspend fun isTunnelUp(): Boolean = wgMutex.withLock {
val current = sharedTunnel ?: return false
return try {
backend.getState(current) == Tunnel.State.UP
} catch (e: Exception) {
false
}
}
suspend fun stopTunnel() = wgMutex.withLock { suspend fun stopTunnel() = wgMutex.withLock {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
@@ -51,8 +51,8 @@ fun AppSectionCard(
color = appSectionCardColor(), color = appSectionCardColor(),
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(1.dp, appSectionCardBorderColor()), border = BorderStroke(1.dp, appSectionCardBorderColor()),
shadowElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 2.dp else 10.dp, shadowElevation = 0.dp,
tonalElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 0.dp else 2.dp, tonalElevation = 0.dp,
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
Column( Column(
@@ -91,9 +91,9 @@ fun DeployTab() {
val deployProgress by DeployManager.deployProgress.collectAsStateWithLifecycle() val deployProgress by DeployManager.deployProgress.collectAsStateWithLifecycle()
val currentStep by DeployManager.currentStep.collectAsStateWithLifecycle() val currentStep by DeployManager.currentStep.collectAsStateWithLifecycle()
LaunchedEffect(savedIp) { if (savedIp.isNotEmpty()) ip = savedIp } LaunchedEffect(savedIp) { ip = savedIp }
LaunchedEffect(savedLogin) { if (savedLogin.isNotEmpty()) login = savedLogin } LaunchedEffect(savedLogin) { login = savedLogin }
LaunchedEffect(savedPassword) { if (savedPassword.isNotEmpty()) password = savedPassword } LaunchedEffect(savedPassword) { password = savedPassword }
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
targetValue = deployProgress, targetValue = deployProgress,
animationSpec = tween(durationMillis = 1200, easing = androidx.compose.animation.core.FastOutSlowInEasing), animationSpec = tween(durationMillis = 1200, easing = androidx.compose.animation.core.FastOutSlowInEasing),
@@ -38,7 +38,8 @@ import androidx.compose.runtime.Stable
data class AppItem( data class AppItem(
val name: String, val name: String,
val packageName: String, val packageName: String,
val icon: ImageBitmap? val icon: ImageBitmap?,
val isSystem: Boolean
) )
object AppCache { object AppCache {
@@ -61,6 +62,8 @@ fun ExceptionsTab() {
var isLoading by remember { mutableStateOf(AppCache.cachedList == null) } var isLoading by remember { mutableStateOf(AppCache.cachedList == null) }
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
val showSystemAppsOpt by settingsStore.showSystemApps.collectAsStateWithLifecycle(initialValue = null)
val isWhitelist by settingsStore.isWhitelist.collectAsStateWithLifecycle(initialValue = false) val isWhitelist by settingsStore.isWhitelist.collectAsStateWithLifecycle(initialValue = false)
// Load Apps // Load Apps
@@ -76,10 +79,12 @@ fun ExceptionsTab() {
if (app.packageName != context.packageName && if (app.packageName != context.packageName &&
!app.packageName.contains("vkontakte") && !app.packageName.contains("vkontakte") &&
!app.packageName.contains("vk.calls")) { !app.packageName.contains("vk.calls")) {
val isSys = (app.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
list.add(AppItem( list.add(AppItem(
name = app.loadLabel(pm).toString(), name = app.loadLabel(pm).toString(),
packageName = app.packageName, packageName = app.packageName,
icon = app.loadIcon(pm)?.toBitmap()?.asImageBitmap() icon = app.loadIcon(pm)?.toBitmap()?.asImageBitmap(),
isSystem = isSys
)) ))
} }
} }
@@ -91,8 +96,17 @@ fun ExceptionsTab() {
val filteredApps by remember { val filteredApps by remember {
derivedStateOf { derivedStateOf {
if (searchQuery.isBlank()) appsList val showSystemApps = showSystemAppsOpt ?: true
else appsList.filter { val list = if (showSystemApps) {
appsList
} else {
appsList.filter {
!it.isSystem || it.packageName == "com.google.android.youtube" || it.packageName == "com.android.vending"
}
}
if (searchQuery.isBlank()) list
else list.filter {
it.name.contains(searchQuery, ignoreCase = true) || it.name.contains(searchQuery, ignoreCase = true) ||
it.packageName.contains(searchQuery, ignoreCase = true) it.packageName.contains(searchQuery, ignoreCase = true)
} }
@@ -174,10 +188,37 @@ fun ExceptionsTab() {
} }
} }
} }
HorizontalDivider(
modifier = Modifier.padding(vertical = 12.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Системные приложения",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
Switch(
checked = showSystemAppsOpt ?: true,
onCheckedChange = { enabled ->
scope.launch {
settingsStore.saveShowSystemApps(enabled)
}
}
)
}
} }
// List // List
if (isLoading) { if (isLoading || showSystemAppsOpt == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() CircularProgressIndicator()
} }
@@ -36,8 +36,13 @@ import android.os.Build
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlin.math.roundToInt import kotlin.math.roundToInt
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
@Composable @Composable
fun FloatingToolbar( fun FloatingToolbar(
activeProfile: Int,
onActiveProfileChange: (Int) -> Unit,
currentTheme: String, currentTheme: String,
onThemeChange: (String) -> Unit, onThemeChange: (String) -> Unit,
isDynamicColor: Boolean, isDynamicColor: Boolean,
@@ -55,6 +60,9 @@ fun FloatingToolbar(
with(density) { configuration.screenWidthDp.dp.toPx() } with(density) { configuration.screenWidthDp.dp.toPx() }
} }
var parentWidthPx by remember { mutableFloatStateOf(0f) }
var parentHeightPx by remember { mutableFloatStateOf(0f) }
var offsetY by rememberSaveable { mutableFloatStateOf(-1f) } var offsetY by rememberSaveable { mutableFloatStateOf(-1f) }
var isRightSide by rememberSaveable { mutableStateOf(true) } var isRightSide by rememberSaveable { mutableStateOf(true) }
var isExpanded by rememberSaveable { mutableStateOf(false) } var isExpanded by rememberSaveable { mutableStateOf(false) }
@@ -76,12 +84,16 @@ fun FloatingToolbar(
} else { } else {
effectiveTabHeightPx effectiveTabHeightPx
} }
val minOffsetY = safeTopPx + edgePaddingPx
val maxOffsetY = (screenHeightPx - safeBottomPx - floatingHeightPx - edgePaddingPx) val currentParentHeight = if (parentHeightPx > 0f) parentHeightPx else screenHeightPx
.coerceAtLeast(minOffsetY) val currentParentWidth = if (parentWidthPx > 0f) parentWidthPx else screenWidthPx
val defaultOffsetY = (screenHeightPx * 0.24f).coerceIn(minOffsetY, maxOffsetY)
val targetXPx = if (isRightSide) screenWidthPx - tabWidthPx else 0f val minOffsetY = safeTopPx + edgePaddingPx
val maxOffsetY = (currentParentHeight - safeBottomPx - floatingHeightPx - edgePaddingPx)
.coerceAtLeast(minOffsetY)
val defaultOffsetY = (currentParentHeight * 0.24f).coerceIn(minOffsetY, maxOffsetY)
val targetXPx = if (isRightSide) currentParentWidth - tabWidthPx else 0f
val animatedTabXPx by animateFloatAsState( val animatedTabXPx by animateFloatAsState(
targetValue = targetXPx, targetValue = targetXPx,
@@ -93,7 +105,14 @@ fun FloatingToolbar(
offsetY = if (offsetY < 0f) defaultOffsetY else offsetY.coerceIn(minOffsetY, maxOffsetY) offsetY = if (offsetY < 0f) defaultOffsetY else offsetY.coerceIn(minOffsetY, maxOffsetY)
} }
Box(modifier = modifier.fillMaxSize()) { Box(
modifier = modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
parentWidthPx = coordinates.size.width.toFloat()
parentHeightPx = coordinates.size.height.toFloat()
}
) {
Surface( Surface(
onClick = { isExpanded = !isExpanded }, onClick = { isExpanded = !isExpanded },
modifier = Modifier modifier = Modifier
@@ -114,16 +133,16 @@ fun FloatingToolbar(
else else
RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp), RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
shadowElevation = 6.dp, shadowElevation = 0.dp,
tonalElevation = 4.dp, tonalElevation = 0.dp,
) { ) {
Box( Box(
modifier = Modifier.size(tabWidthDp, tabHeightDp), modifier = Modifier.size(tabWidthDp, tabHeightDp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_palette), imageVector = Icons.Filled.Settings,
contentDescription = "Тема", contentDescription = "Настройки",
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer tint = MaterialTheme.colorScheme.onPrimaryContainer
) )
@@ -151,13 +170,54 @@ fun FloatingToolbar(
}, },
shape = RoundedCornerShape(32.dp), shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp, shadowElevation = 0.dp,
tonalElevation = 4.dp, tonalElevation = 0.dp,
) { ) {
Column( Column(
modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp), modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
Text(
"Профиль",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(0, 1, 2).forEach { profile ->
val selected = profile == activeProfile
Surface(
onClick = { onActiveProfileChange(profile) },
shape = RoundedCornerShape(12.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Пр. $profile",
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp
)
}
}
}
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
Text( Text(
"Тема", "Тема",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@@ -1,5 +1,7 @@
package com.wdtt.client.ui package com.wdtt.client.ui
import androidx.compose.runtime.MutableState
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
@@ -156,7 +158,10 @@ private fun openUrlInBrowser(context: Context, url: String) {
} }
@Composable @Composable
fun InfoTab() { fun InfoTab(
actionsExpandedState: MutableState<Boolean> = rememberSaveable { mutableStateOf(true) },
projectExpandedState: MutableState<Boolean> = rememberSaveable { mutableStateOf(true) }
) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val settingsStore = remember { SettingsStore(context) } val settingsStore = remember { SettingsStore(context) }
@@ -165,8 +170,8 @@ fun InfoTab() {
var pendingManualRelease by remember { mutableStateOf<com.wdtt.client.AppReleaseInfo?>(null) } var pendingManualRelease by remember { mutableStateOf<com.wdtt.client.AppReleaseInfo?>(null) }
var showHelpDialog by remember { mutableStateOf(false) } var showHelpDialog by remember { mutableStateOf(false) }
var showDonateDialog by remember { mutableStateOf(false) } var showDonateDialog by remember { mutableStateOf(false) }
var actionsExpanded by rememberSaveable { mutableStateOf(true) } var actionsExpanded by actionsExpandedState
var projectExpanded by rememberSaveable { mutableStateOf(true) } var projectExpanded by projectExpandedState
val updateLatestVersion by settingsStore.updateLatestVersion.collectAsStateWithLifecycle(initialValue = "") val updateLatestVersion by settingsStore.updateLatestVersion.collectAsStateWithLifecycle(initialValue = "")
val updateLastError by settingsStore.updateLastError.collectAsStateWithLifecycle(initialValue = "") val updateLastError by settingsStore.updateLastError.collectAsStateWithLifecycle(initialValue = "")
val updateStatus = remember(isCheckingUpdates, updateLatestVersion, updateLastError, currentVersion) { val updateStatus = remember(isCheckingUpdates, updateLatestVersion, updateLastError, currentVersion) {
@@ -29,11 +29,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.wdtt.client.LogEntry import com.wdtt.client.LogEntry
import com.wdtt.client.TunnelManager import com.wdtt.client.TunnelManager
import com.wdtt.client.WDTTColors import com.wdtt.client.WDTTColors
import com.wdtt.client.SettingsStore
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LogsTab() { fun LogsTab() {
val context = LocalContext.current val context = LocalContext.current
val settingsStore = remember { SettingsStore(context) }
val loggingEnabled by settingsStore.loggingEnabled.collectAsStateWithLifecycle(initialValue = true)
val scope = rememberCoroutineScope()
val currentLogs by TunnelManager.logs.collectAsStateWithLifecycle() val currentLogs by TunnelManager.logs.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -65,6 +70,38 @@ fun LogsTab() {
} }
} }
// Карточка-выключатель логирования
AppSectionCard(
modifier = Modifier.padding(bottom = 12.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Активное логирование",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
Switch(
checked = loggingEnabled,
onCheckedChange = { enabled ->
scope.launch {
settingsStore.saveLoggingEnabled(enabled)
if (!enabled) {
TunnelManager.clearLogs()
}
}
}
)
}
}
// Logs container — адаптивный к теме // Logs container — адаптивный к теме
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val terminalBg = if (isDark) WDTTColors.terminalBgDark else WDTTColors.terminalBg val terminalBg = if (isDark) WDTTColors.terminalBgDark else WDTTColors.terminalBg
@@ -1,6 +1,9 @@
package com.wdtt.client.ui package com.wdtt.client.ui
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -83,14 +86,18 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
val savedServerWgPort by settingsStore.serverWgPort.collectAsStateWithLifecycle(initialValue = 56001) val savedServerWgPort by settingsStore.serverWgPort.collectAsStateWithLifecycle(initialValue = 56001)
val savedListenPort by settingsStore.listenPort.collectAsStateWithLifecycle(initialValue = 9000) val savedListenPort by settingsStore.listenPort.collectAsStateWithLifecycle(initialValue = 9000)
val activeProfile by settingsStore.activeProfile.collectAsStateWithLifecycle(initialValue = 0)
val wdttLinkMode by settingsStore.wdttLinkMode.collectAsStateWithLifecycle(initialValue = false)
val wdttLink by settingsStore.wdttLink.collectAsStateWithLifecycle(initialValue = "")
val tunnelRunning by TunnelManager.running.collectAsStateWithLifecycle() val tunnelRunning by TunnelManager.running.collectAsStateWithLifecycle()
val cooldownSeconds by TunnelManager.cooldownSeconds.collectAsStateWithLifecycle() val cooldownActive by TunnelManager.cooldownActive.collectAsStateWithLifecycle()
var wasRunning by remember { mutableStateOf(false) } var wasRunning by remember { mutableStateOf(false) }
LaunchedEffect(tunnelRunning) { LaunchedEffect(tunnelRunning) {
if (wasRunning && !tunnelRunning) { if (wasRunning && !tunnelRunning) {
TunnelManager.startCooldown(5) TunnelManager.startCooldown(1500L)
} }
wasRunning = tunnelRunning wasRunning = tunnelRunning
} }
@@ -112,7 +119,18 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
val allHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { listOf(vkHash1, vkHash2, vkHash3, vkHash4) } val allHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { listOf(vkHash1, vkHash2, vkHash3, vkHash4) }
val uniqueHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { allHashes.filter { it.isNotBlank() && it.length >= 16 }.distinct() } val uniqueHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { allHashes.filter { it.isNotBlank() && it.length >= 16 }.distinct() }
val filledHashCount = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.size } val parsedLinkHashes = remember(wdttLink) {
if (wdttLink.trim().startsWith("wdtt://")) {
val clean = wdttLink.trim().removePrefix("wdtt://")
val parts = clean.split(":")
if (parts.size >= 6) {
parts[5].split(",").filter { stripVkUrlStatic(it).isNotBlank() }
} else emptyList()
} else emptyList()
}
val filledHashCount = remember(vkHash1, vkHash2, vkHash3, vkHash4, wdttLinkMode, parsedLinkHashes) {
if (wdttLinkMode) parsedLinkHashes.size else uniqueHashes.size
}
val combinedHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.joinToString(",") } val combinedHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.joinToString(",") }
val dynamicMaxWorkers = remember(filledHashCount) { (filledHashCount.coerceAtLeast(1) * 27).toFloat() } val dynamicMaxWorkers = remember(filledHashCount) { (filledHashCount.coerceAtLeast(1) * 27).toFloat() }
var portInput by rememberSaveable { mutableStateOf("9000") } var portInput by rememberSaveable { mutableStateOf("9000") }
@@ -156,7 +174,7 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
.joinToString(",") .joinToString(",")
} }
LaunchedEffect(Unit) { LaunchedEffect(activeProfile) {
val peer = settingsStore.peer.first() val peer = settingsStore.peer.first()
val hashes = settingsStore.vkHashes.first() val hashes = settingsStore.vkHashes.first()
val workers = settingsStore.workersPerHash.first() val workers = settingsStore.workersPerHash.first()
@@ -238,7 +256,9 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
val isPeerValid = peerInput.isNotBlank() && !peerInput.contains(":") val isPeerValid = peerInput.isNotBlank() && !peerInput.contains(":")
val isHashesValid = combinedHashes.isNotBlank() val isHashesValid = combinedHashes.isNotBlank()
val isValid = isPeerValid && isHashesValid && savedConnectionPassword.isNotBlank() && !hasInputHashErrors val isLinkValid = wdttLink.trim().startsWith("wdtt://") && wdttLink.trim().split(":").size >= 6 && wdttLink.trim().split(":")[5].isNotBlank()
val isManualValid = isPeerValid && isHashesValid && savedConnectionPassword.isNotBlank() && !hasInputHashErrors
val isValid = if (wdttLinkMode) isLinkValid else isManualValid
val effectiveServerDtlsPort = if (manualPortsEnabled) serverDtlsPortInput.toIntOrNull()?.coerceIn(1, 65535) ?: 56000 else 56000 val effectiveServerDtlsPort = if (manualPortsEnabled) serverDtlsPortInput.toIntOrNull()?.coerceIn(1, 65535) ?: 56000 else 56000
val effectiveLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000 val effectiveLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000
var pendingStartAfterVpnPermission by remember { mutableStateOf(false) } var pendingStartAfterVpnPermission by remember { mutableStateOf(false) }
@@ -255,15 +275,37 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
settingsStore.saveCaptchaMode(effectiveCaptchaMode) settingsStore.saveCaptchaMode(effectiveCaptchaMode)
settingsStore.saveCaptchaSolveMethod(effectiveCaptchaSolveMethod) settingsStore.saveCaptchaSolveMethod(effectiveCaptchaSolveMethod)
} }
var finalPeer = "$peerInput:$effectiveServerDtlsPort"
var finalHashes = combinedHashes
var finalLocalPort = effectiveLocalPort
var finalPassword = savedConnectionPassword
if (wdttLinkMode && wdttLink.trim().startsWith("wdtt://")) {
val clean = wdttLink.trim().removePrefix("wdtt://")
val parts = clean.split(":")
if (parts.size >= 5) {
val ip = parts[0]
val dtls = parts[1].toIntOrNull() ?: 56000
finalLocalPort = parts[3].toIntOrNull() ?: 9000
finalPassword = parts[4]
val hash = if (parts.size >= 6) parts[5] else ""
finalPeer = "$ip:$dtls"
val rawHash = stripVkUrlStatic(hash)
finalHashes = if (rawHash.isNotBlank()) rawHash else normalizeHashes(hash)
}
}
val intent = Intent(context, TunnelService::class.java).apply { val intent = Intent(context, TunnelService::class.java).apply {
action = "START" action = "START"
putExtra("peer", "$peerInput:$effectiveServerDtlsPort") putExtra("peer", finalPeer)
putExtra("vk_hashes", combinedHashes) putExtra("vk_hashes", finalHashes)
putExtra("secondary_vk_hash", "") putExtra("secondary_vk_hash", "")
putExtra("workers_per_hash", workersInput.toInt()) putExtra("workers_per_hash", workersInput.toInt())
putExtra("port", effectiveLocalPort) putExtra("port", finalLocalPort)
putExtra("sni", sniInput) putExtra("sni", sniInput)
putExtra("connection_password", savedConnectionPassword) putExtra("connection_password", finalPassword)
putExtra("captcha_mode", effectiveCaptchaMode) putExtra("captcha_mode", effectiveCaptchaMode)
putExtra("captcha_solve_method", effectiveCaptchaSolveMethod) putExtra("captcha_solve_method", effectiveCaptchaSolveMethod)
} }
@@ -342,263 +384,322 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// ═══ Заголовок раздела ═══ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( if (!wdttLinkMode) {
"Настройки туннеля", Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), // ═══ Заголовок раздела ═══
color = MaterialTheme.colorScheme.onSurface
)
// ═══ Настройки туннеля ═══
AppSectionCard(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = peerInput,
onValueChange = {
peerInput = it.filter { c -> c != ' ' }
scheduleSave()
},
label = { Text("IP сервера или домен (без порта)") },
placeholder = { Text("1.2.3.4 (или test.com)") },
singleLine = true,
isError = !isPeerValid && peerInput.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
)
OutlinedButton(
onClick = { showHashesDialog = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
contentColor = MaterialTheme.colorScheme.onSurface
),
border = BorderStroke(
1.dp,
if (hasInputHashErrors) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
)
) {
Icon(Icons.Default.Tag, null, Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Настройка VK Хешей ($filledHashCount/4)", fontWeight = FontWeight.SemiBold)
}
val errorTexts = hashErrors.filter { !it.contains("короткий") }
if (errorTexts.isNotEmpty()) {
Text(
text = errorTexts.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
// ═══ Мощность + Капча ═══
AppSectionCard(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
// — Мощность —
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
"Мощность", "Настройки туннеля",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
Text(
text = "${currentWorkers.toInt()}",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.onSurface
) )
}
Spacer(Modifier.height(4.dp)) // ═══ Настройки туннеля ═══
AppSectionCard(
val maxWorkers = dynamicMaxWorkers contentPadding = PaddingValues(16.dp),
val minWorkers = WORKERS_PER_GROUP.toFloat() verticalArrangement = Arrangement.spacedBy(12.dp)
val currentWorkersVal = roundToGroup(currentWorkers.coerceIn(minWorkers, maxWorkers), maxWorkers) ) {
OutlinedTextField(
CompactSteppedSlider( value = peerInput,
value = currentWorkersVal, onValueChange = {
onValueChange = { raw -> peerInput = it.filter { c -> c != ' ' }
workersInput = roundToGroup(raw, maxWorkers) scheduleSave()
scheduleSave() },
}, label = { Text("IP сервера или домен (без порта)") },
valueRange = minWorkers..maxWorkers, placeholder = { Text("1.2.3.4 (или test.com)") },
stepSize = WORKERS_PER_GROUP.toFloat(), singleLine = true,
enabled = !tunnelRunning, isError = !isPeerValid && peerInput.isNotEmpty(),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
// — Разделитель — focusedBorderColor = MaterialTheme.colorScheme.primary,
HorizontalDivider( unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.padding(vertical = 4.dp), )
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
// — Авто капча —
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
if (autoCaptchaEnabled) "Авто капча" else "Ручная капча",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Switch(
checked = autoCaptchaEnabled,
onCheckedChange = { enabled ->
autoCaptchaEnabled = enabled
scope.launch {
if (enabled) {
settingsStore.saveCaptchaMode("auto")
settingsStore.saveCaptchaSolveMethod("auto")
} else {
val mode = if (useWVCaptcha) "wv" else "rjs"
settingsStore.saveCaptchaMode(mode)
settingsStore.saveCaptchaSolveMethod(if (mode == "wv" && isManualMode) "manual" else "auto")
}
}
}
)
}
AnimatedVisibility(
visible = !autoCaptchaEnabled,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
// — Разделитель —
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
) )
// — Метод обхода капчи — OutlinedButton(
Row( onClick = { showHashesDialog = true },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
verticalAlignment = Alignment.CenterVertically, shape = RoundedCornerShape(16.dp),
horizontalArrangement = Arrangement.SpaceBetween colors = ButtonDefaults.outlinedButtonColors(
) { containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
Text( contentColor = MaterialTheme.colorScheme.onSurface
"Метод обхода капчи", ),
style = MaterialTheme.typography.bodyMedium, border = BorderStroke(
fontWeight = FontWeight.Medium, 1.dp,
modifier = Modifier.weight(1f) if (hasInputHashErrors) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ) {
ProtocolChip("WBV", useWVCaptcha, enabled = true) { Icon(Icons.Default.Tag, null, Modifier.size(18.dp))
useWVCaptcha = true Spacer(Modifier.width(8.dp))
isManualMode = wbvManualMode Text("Настройка VK Хешей ($filledHashCount/4)", fontWeight = FontWeight.SemiBold)
scope.launch { }
settingsStore.saveCaptchaMode("wv")
settingsStore.saveCaptchaSolveMethod(if (wbvManualMode) "manual" else "auto") val errorTexts = hashErrors.filter { !it.contains("короткий") }
} if (errorTexts.isNotEmpty()) {
} Text(
ProtocolChip("RJS", !useWVCaptcha, enabled = true, isError = false) { text = errorTexts.joinToString(", "),
useWVCaptcha = false style = MaterialTheme.typography.bodySmall,
isManualMode = false color = MaterialTheme.colorScheme.error
scope.launch { )
settingsStore.saveCaptchaMode("rjs") }
}
}
}
// ═══ Мощность + Капча ═══
AppSectionCard(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
// — Мощность —
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Мощность",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
Text(
text = "${currentWorkers.toInt()}",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.height(4.dp))
val maxWorkers = dynamicMaxWorkers
val minWorkers = WORKERS_PER_GROUP.toFloat()
val currentWorkersVal = roundToGroup(currentWorkers.coerceIn(minWorkers, maxWorkers), maxWorkers)
CompactSteppedSlider(
value = currentWorkersVal,
onValueChange = { raw ->
workersInput = roundToGroup(raw, maxWorkers)
scheduleSave()
},
valueRange = minWorkers..maxWorkers,
stepSize = WORKERS_PER_GROUP.toFloat(),
enabled = !tunnelRunning,
modifier = Modifier.fillMaxWidth()
)
// — Разделитель —
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
// — Авто капча —
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
if (autoCaptchaEnabled) "Авто капча" else "Ручная капча",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Switch(
checked = autoCaptchaEnabled,
onCheckedChange = { enabled ->
autoCaptchaEnabled = enabled
scope.launch {
if (enabled) {
settingsStore.saveCaptchaMode("auto")
settingsStore.saveCaptchaSolveMethod("auto") settingsStore.saveCaptchaSolveMethod("auto")
} else {
val mode = if (useWVCaptcha) "wv" else "rjs"
settingsStore.saveCaptchaMode(mode)
settingsStore.saveCaptchaSolveMethod(if (mode == "wv" && isManualMode) "manual" else "auto")
} }
} }
} }
}
// — Разделитель —
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
) )
}
// — Режим обхода — AnimatedVisibility(
Row( visible = !autoCaptchaEnabled,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), enter = fadeIn() + expandVertically(),
verticalAlignment = Alignment.CenterVertically, exit = fadeOut() + shrinkVertically()
horizontalArrangement = Arrangement.SpaceBetween ) {
) { Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
Text( // — Разделитель —
"Режим обхода", HorizontalDivider(
style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 4.dp),
fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
modifier = Modifier.weight(1f)
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (useWVCaptcha) { // — Метод обхода капчи —
ProtocolChip( Row(
"РУЧ", modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
isManualMode, verticalAlignment = Alignment.CenterVertically,
enabled = true, horizontalArrangement = Arrangement.SpaceBetween
isError = false ) {
) { Text(
isManualMode = true "Метод обхода капчи",
wbvManualMode = true style = MaterialTheme.typography.bodyMedium,
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("manual") } fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ProtocolChip("WBV", useWVCaptcha, enabled = true) {
useWVCaptcha = true
isManualMode = wbvManualMode
scope.launch {
settingsStore.saveCaptchaMode("wv")
settingsStore.saveCaptchaSolveMethod(if (wbvManualMode) "manual" else "auto")
}
} }
ProtocolChip( ProtocolChip("RJS", !useWVCaptcha, enabled = true, isError = false) {
"АВТ", useWVCaptcha = false
!isManualMode,
enabled = true,
isError = false
) {
isManualMode = false isManualMode = false
wbvManualMode = false scope.launch {
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("auto") } settingsStore.saveCaptchaMode("rjs")
settingsStore.saveCaptchaSolveMethod("auto")
}
}
}
}
// — Разделитель —
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
// — Режим обхода —
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Режим обхода",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (useWVCaptcha) {
ProtocolChip(
"РУЧ",
isManualMode,
enabled = true,
isError = false
) {
isManualMode = true
wbvManualMode = true
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("manual") }
}
ProtocolChip(
"АВТ",
!isManualMode,
enabled = true,
isError = false
) {
isManualMode = false
wbvManualMode = false
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("auto") }
}
} else {
ProtocolChip(
"АВТ",
selected = true,
enabled = true,
isError = false
) {}
} }
} else {
ProtocolChip(
"АВТ",
selected = true,
enabled = true,
isError = false
) {}
} }
} }
} }
} }
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
// — Режим ссылки —
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Режим ссылки",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Switch(
checked = wdttLinkMode,
onCheckedChange = { enabled ->
scope.launch {
settingsStore.saveWdttLinkMode(enabled)
}
}
)
}
if (wdttLinkMode) {
Column {
var linkText by remember(wdttLink) { mutableStateOf(wdttLink) }
OutlinedTextField(
value = linkText,
onValueChange = {
linkText = it.trim()
scope.launch { settingsStore.saveWdttLink(it.trim()) }
},
label = { Text("Ссылка wdtt://") },
placeholder = { Text("Ссылка wdtt://") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
)
}
}
} }
} }
// ═══ Кнопки: Секреты + Подключить ═══ // ═══ Кнопки: Секреты + Подключить ═══
val tunnelSecretsMissing = savedConnectionPassword.isBlank() val tunnelSecretsMissing = savedConnectionPassword.isBlank()
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
OutlinedButton( if (!wdttLinkMode) {
onClick = { showSecretsDialog = true }, OutlinedButton(
modifier = Modifier.height(52.dp), onClick = { showSecretsDialog = true },
shape = RoundedCornerShape(16.dp), modifier = Modifier.weight(1f).height(52.dp),
colors = ButtonDefaults.outlinedButtonColors( shape = RoundedCornerShape(16.dp),
containerColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface, colors = ButtonDefaults.outlinedButtonColors(
contentColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface containerColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface,
), contentColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface
border = BorderStroke( ),
1.dp, border = BorderStroke(
if (tunnelSecretsMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) 1.dp,
) if (tunnelSecretsMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
) { )
Icon(imageVector = Icons.Default.Key, contentDescription = null, modifier = Modifier.size(18.dp)) ) {
Spacer(modifier = Modifier.width(8.dp)) Icon(imageVector = Icons.Default.Key, contentDescription = null, modifier = Modifier.size(18.dp))
Text("Секреты", fontWeight = FontWeight.SemiBold) Spacer(modifier = Modifier.width(8.dp))
Text("Секреты", fontWeight = FontWeight.SemiBold, maxLines = 1)
}
} }
val buttonColor by animateColorAsState( val buttonColor by animateColorAsState(
@@ -617,8 +718,10 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
requestVpnAndStart() requestVpnAndStart()
} }
}, },
enabled = (isValid && cooldownSeconds == 0) || tunnelRunning, enabled = (isValid && !cooldownActive) || tunnelRunning,
modifier = Modifier.weight(1f).height(52.dp), modifier = Modifier
.weight(1f)
.height(52.dp),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = buttonColor, containerColor = buttonColor,
@@ -634,14 +737,14 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
Text( Text(
text = when { text = when {
tunnelRunning -> "Остановить" tunnelRunning -> "Остановить"
cooldownSeconds > 0 -> "Подождите ($cooldownSeconds)" cooldownActive -> "Подождите..."
else -> "Подключить" else -> "Подключить"
}, },
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
maxLines = 1
) )
} }
} }
} }
} }
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#00E5FF" />
<stroke android:width="1dp" android:color="#33F0FF" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#2C2C2C" />
<stroke android:width="1dp" android:color="#444444" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="24dp" />
<solid android:color="#1A1A1A" />
<stroke android:width="1dp" android:color="#333333" />
</shape>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M2,4 L6.5,4 L10,14.5 L12,9 L14,14.5 L17.5,4 L22,4 L17,19 L13,19 L12,14.5 L11,19 L7,19 Z" />
</vector>
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_widget_card"
android:padding="12dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
android:orientation="vertical">
<TextView
android:id="@+id/widget_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="WDTT"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Отключено"
android:textColor="#888888"
android:textSize="12sp"
android:layout_marginTop="4dp" />
</LinearLayout>
<ImageButton
android:id="@+id/widget_toggle_btn"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:background="@drawable/bg_widget_button_inactive"
android:src="@drawable/ic_tile_logo_w"
android:contentDescription="Toggle VPN"
android:padding="10dp"
android:scaleType="fitCenter" />
</RelativeLayout>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="40dp"
android:updatePeriodMillis="86400000"
android:initialLayout="@layout/vpn_widget"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen">
</appwidget-provider>
+4 -6
View File
@@ -374,15 +374,13 @@ func applySliderSwapsV2(gridSize int, swaps []int) ([]int, error) {
} }
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle { func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
w := bounds.Dx() / gridSize
h := bounds.Dy() / gridSize
col := index % gridSize col := index % gridSize
row := index / gridSize row := index / gridSize
return image.Rect( return image.Rect(
bounds.Min.X+col*w, bounds.Min.X+(col*bounds.Dx())/gridSize,
bounds.Min.Y+row*h, bounds.Min.Y+(row*bounds.Dy())/gridSize,
bounds.Min.X+(col+1)*w, bounds.Min.X+((col+1)*bounds.Dx())/gridSize,
bounds.Min.Y+(row+1)*h, bounds.Min.Y+((row+1)*bounds.Dy())/gridSize,
) )
} }
+27 -1
View File
@@ -9,6 +9,27 @@ import (
"time" "time"
) )
var pktPool = sync.Pool{
New: func() interface{} {
return make([]byte, 2048)
},
}
func getPktBuf(size int) []byte {
b := pktPool.Get().([]byte)
if cap(b) < size {
b = make([]byte, size)
}
return b[:size]
}
func putPktBuf(b []byte) {
if cap(b) < 2048 {
return
}
pktPool.Put(b[:cap(b)])
}
const ( const (
returnChBuf = 384 returnChBuf = 384
@@ -125,13 +146,14 @@ func (d *Dispatcher) readLoop() {
d.clientAddr.Store(&addr) d.clientAddr.Store(&addr)
atomic.AddInt64(&d.stats.TotalBytesUp, int64(n)) atomic.AddInt64(&d.stats.TotalBytesUp, int64(n))
pkt := make([]byte, n) pkt := getPktBuf(n)
copy(pkt, buf[:n]) copy(pkt, buf[:n])
d.mu.Lock() d.mu.Lock()
nw := len(d.workers) nw := len(d.workers)
if nw == 0 { if nw == 0 {
d.mu.Unlock() d.mu.Unlock()
putPktBuf(pkt)
continue continue
} }
@@ -169,6 +191,7 @@ func (d *Dispatcher) readLoop() {
// Все workers перегружены — сдвигаем указатель, пакет дропается // Все workers перегружены — сдвигаем указатель, пакет дропается
d.rrIndex = (idx + 1) % nw d.rrIndex = (idx + 1) % nw
d.rrCount = 0 d.rrCount = 0
putPktBuf(pkt)
} }
d.mu.Unlock() d.mu.Unlock()
} }
@@ -184,15 +207,18 @@ func (d *Dispatcher) writeLoop() {
case pkt := <-d.ReturnCh: case pkt := <-d.ReturnCh:
addrPtr := d.clientAddr.Load() addrPtr := d.clientAddr.Load()
if addrPtr == nil { if addrPtr == nil {
putPktBuf(pkt)
continue continue
} }
addr := *addrPtr addr := *addrPtr
if _, err := d.localConn.WriteTo(pkt, addr); err != nil { if _, err := d.localConn.WriteTo(pkt, addr); err != nil {
if d.ctx.Err() != nil { if d.ctx.Err() != nil {
putPktBuf(pkt)
return return
} }
} }
atomic.AddInt64(&d.stats.TotalBytesDown, int64(len(pkt))) atomic.AddInt64(&d.stats.TotalBytesDown, int64(len(pkt)))
putPktBuf(pkt)
} }
} }
} }
+1 -5
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"fmt"
"log" "log"
"math/rand" "math/rand"
"net" "net"
@@ -12,7 +11,6 @@ import (
"time" "time"
) )
var groupAuthMutex sync.Mutex
const ( const (
workersPerGroup = 9 workersPerGroup = 9
@@ -32,7 +30,6 @@ func WorkerGroup(
getConfig bool, getConfig bool,
configCh chan<- string, configCh chan<- string,
workerIDs []int, workerIDs []int,
cycleDuration time.Duration,
pauseFlag *int32, pauseFlag *int32,
deviceID, password string, deviceID, password string,
stats *Stats, stats *Stats,
@@ -296,5 +293,4 @@ type Credentials struct {
CacheStreamID int CacheStreamID int
} }
// Unused import suppressor
var _ = fmt.Sprintf
+3 -5
View File
@@ -13,7 +13,6 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
"time"
) )
// CaptchaResultChan — канал для получения токена капчи из внешнего решателя (WebView) // CaptchaResultChan — канал для получения токена капчи из внешнего решателя (WebView)
@@ -284,18 +283,17 @@ func main() {
} }
gID := g + 1 gID := g + 1
cycle := time.Duration(defaultCycleSecs) * time.Second
var cc chan<- string var cc chan<- string
if isFirst { if isFirst {
cc = configCh cc = configCh
} }
wg.Add(1) wg.Add(1)
go func(groupID int, cycleDir time.Duration, isFirstGroup bool, configChan chan<- string, workerIds []int, startHashIndex int, waitR <-chan struct{}, sigR chan<- struct{}) { go func(groupID int, isFirstGroup bool, configChan chan<- string, workerIds []int, startHashIndex int, waitR <-chan struct{}, sigR chan<- struct{}) {
defer wg.Done() defer wg.Done()
WorkerGroup(ctx, groupID, startHashIndex, tp, peer, disp, localPort, WorkerGroup(ctx, groupID, startHashIndex, tp, peer, disp, localPort,
isFirstGroup, configChan, workerIds, cycleDir, &pauseFlag, *deviceID, *connPassword, stats, waitR, sigR) isFirstGroup, configChan, workerIds, &pauseFlag, *deviceID, *connPassword, stats, waitR, sigR)
}(gID, cycle, isFirst, cc, ids, g, myWaitReady, mySignalReady) }(gID, isFirst, cc, ids, g, myWaitReady, mySignalReady)
} }
wg.Wait() wg.Wait()
+35 -13
View File
@@ -12,6 +12,7 @@
package main package main
import ( import (
"crypto/cipher"
"crypto/rand" "crypto/rand"
"encoding/binary" "encoding/binary"
"errors" "errors"
@@ -21,6 +22,24 @@ import (
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
) )
var aeadCache sync.Map
func getAEAD(key []byte) (cipher.AEAD, error) {
if len(key) != wrapKeyLen {
return nil, fmt.Errorf("obfs: key must be %d bytes", wrapKeyLen)
}
keyStr := string(key)
if val, ok := aeadCache.Load(keyStr); ok {
return val.(cipher.AEAD), nil
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
aeadCache.Store(keyStr, aead)
return aead, nil
}
// ─── Configuration ─── // ─── Configuration ───
// ObfsConfig holds per-session obfuscation parameters. // ObfsConfig holds per-session obfuscation parameters.
@@ -43,20 +62,22 @@ func NewObfsConfig() *ObfsConfig {
// ─── Per-direction state (sequence + timestamp counters) ─── // ─── Per-direction state (sequence + timestamp counters) ───
// ObfsState tracks monotonically increasing RTP sequence number and timestamp. // ObfsState tracks monotonically increasing RTP sequence number and timestamp using a 48-bit packet counter.
type ObfsState struct { type ObfsState struct {
mu sync.Mutex mu sync.Mutex
seq uint16 initSeq uint16
ts uint32 initTs uint32
count uint64
} }
// NewObfsState creates a state with random initial seq/ts. // NewObfsState creates a state with random initial seq/ts and count=0.
func NewObfsState() *ObfsState { func NewObfsState() *ObfsState {
var buf [6]byte var buf [6]byte
rand.Read(buf[:]) rand.Read(buf[:])
return &ObfsState{ return &ObfsState{
seq: binary.BigEndian.Uint16(buf[0:2]), initSeq: binary.BigEndian.Uint16(buf[0:2]),
ts: binary.BigEndian.Uint32(buf[2:6]), initTs: binary.BigEndian.Uint32(buf[2:6]),
count: 0,
} }
} }
@@ -89,12 +110,13 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b
} }
state.mu.Lock() state.mu.Lock()
seq := state.seq c := state.count
ts := state.ts state.count++
state.seq++
state.ts += 960 // 20ms frame @ 48kHz (OPUS standard)
state.mu.Unlock() state.mu.Unlock()
seq := state.initSeq + uint16(c)
ts := state.initTs + uint32(c)*960 + uint32(c>>16)
// Build nonce from RTP fields // Build nonce from RTP fields
nonce := obfsBuildNonce(cfg.SSRC, seq, ts) nonce := obfsBuildNonce(cfg.SSRC, seq, ts)
@@ -118,7 +140,7 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b
binary.BigEndian.PutUint32(out[4:8], ts) binary.BigEndian.PutUint32(out[4:8], ts)
binary.BigEndian.PutUint32(out[8:12], cfg.SSRC) binary.BigEndian.PutUint32(out[8:12], cfg.SSRC)
aead, err := chacha20poly1305.New(key) aead, err := getAEAD(key)
if err != nil { if err != nil {
return nil, fmt.Errorf("obfs: cipher init: %w", err) return nil, fmt.Errorf("obfs: cipher init: %w", err)
} }
@@ -178,7 +200,7 @@ func obfsUnwrapPacket(key, wire, dst []byte) (int, error) {
// Build nonce and decrypt // Build nonce and decrypt
nonce := obfsBuildNonce(ssrc, seq, ts) nonce := obfsBuildNonce(ssrc, seq, ts)
aead, err := chacha20poly1305.New(key) aead, err := getAEAD(key)
if err != nil { if err != nil {
return 0, fmt.Errorf("obfs: cipher init: %w", err) return 0, fmt.Errorf("obfs: cipher init: %w", err)
} }
+1 -7
View File
@@ -35,13 +35,7 @@ func LoadProfileFromDisk() (*SavedProfile, error) {
return &sp, nil return &sp, nil
} }
func SaveProfileToDisk(sp SavedProfile) error {
data, err := json.MarshalIndent(sp, "", " ")
if err != nil {
return err
}
return os.WriteFile(profileFile, data, 0644)
}
// profileList contains paired User-Agent and Client Hints strings. // profileList contains paired User-Agent and Client Hints strings.
var profileList = []Profile{ var profileList = []Profile{
+5 -2
View File
@@ -371,7 +371,9 @@ func RunSession(
return return
} }
_ = dtlsConn.SetWriteDeadline(time.Now().Add(sessionReadTimeout)) _ = dtlsConn.SetWriteDeadline(time.Now().Add(sessionReadTimeout))
if _, writeErr := dtlsConn.Write(pkt); writeErr != nil { _, writeErr := dtlsConn.Write(pkt)
putPktBuf(pkt)
if writeErr != nil {
log.Printf("[ВОРКЕР #%d] Ошибка Writer: %v", sessionID, writeErr) log.Printf("[ВОРКЕР #%d] Ошибка Writer: %v", sessionID, writeErr)
return return
} }
@@ -403,11 +405,12 @@ func RunSession(
continue continue
} }
pkt := make([]byte, n) pkt := getPktBuf(n)
copy(pkt, b[:n]) copy(pkt, b[:n])
select { select {
case d.ReturnCh <- pkt: case d.ReturnCh <- pkt:
case <-sessCtx.Done(): case <-sessCtx.Done():
putPktBuf(pkt)
return return
} }
} }
-2
View File
@@ -8,10 +8,8 @@ import (
type Stats struct { type Stats struct {
ActiveConnections int32 ActiveConnections int32
Reconnects int64
TotalBytesUp int64 TotalBytesUp int64
TotalBytesDown int64 TotalBytesDown int64
CredsErrors int64
} }
func NewStats() *Stats { func NewStats() *Stats {
+6 -1
View File
@@ -1,3 +1,8 @@
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx8G -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1024m
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configuration-cache=true
org.gradle.vfs.watch=true
kotlin.caching.enabled=true
+240 -56
View File
@@ -27,6 +27,8 @@ import (
"syscall" "syscall"
"time" "time"
"crypto/cipher"
"github.com/pion/dtls/v3" "github.com/pion/dtls/v3"
"github.com/pion/dtls/v3/pkg/crypto/selfsign" "github.com/pion/dtls/v3/pkg/crypto/selfsign"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
@@ -45,8 +47,6 @@ import (
const ( const (
wgIfaceName = "wdtt0" wgIfaceName = "wdtt0"
wgServerAddr = "10.66.66.1" wgServerAddr = "10.66.66.1"
wgClientAddr = "10.66.66.2"
wgClientCIDR = wgClientAddr + "/32"
wgServerCIDR = wgServerAddr + "/24" wgServerCIDR = wgServerAddr + "/24"
defaultInternalWGPort = 56001 defaultInternalWGPort = 56001
dns = "1.1.1.1" dns = "1.1.1.1"
@@ -64,23 +64,16 @@ type ClientDevice struct {
} }
type PasswordEntry struct { type PasswordEntry struct {
DeviceID string `json:"device_id"` // пусто = ещё не привязан DeviceID string `json:"device_id"` // пусто = ещё не привязан
ExpiresAt int64 `json:"expires_at"` // unix timestamp ExpiresAt int64 `json:"expires_at"` // unix timestamp
DownBytes int64 `json:"down_bytes"` // скачано клиентом DownBytes int64 `json:"down_bytes"` // скачано клиентом
UpBytes int64 `json:"up_bytes"` // отдано клиентом UpBytes int64 `json:"up_bytes"` // отдано клиентом
VkHash string `json:"vk_hash,omitempty"`
Ports string `json:"ports,omitempty"` // "dtls,wg,tun"
IsDeactivated bool `json:"is_deactivated,omitempty"`
} }
// Трафик главного пароля (владельца)
var (
mainPassDown int64
mainPassUp int64
)
// Онлайн-статус устройств
var (
activeDevices = make(map[string]int32) // deviceID -> кол-во активных коннектов
activeDevicesMu sync.Mutex
)
type Database struct { type Database struct {
MainPassword string `json:"main_password"` MainPassword string `json:"main_password"`
@@ -120,6 +113,37 @@ func generatePassword() string {
return string(b) return string(b)
} }
var publicIP string = ""
func getPublicIP() string {
if publicIP != "" {
return publicIP
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://api.ipify.org")
if err != nil {
return "YOUR_SERVER_IP"
}
defer resp.Body.Close()
ipBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "YOUR_SERVER_IP"
}
publicIP = string(bytes.TrimSpace(ipBytes))
return publicIP
}
func stripVkUrl(url string) string {
url = strings.TrimSpace(url)
if idx := strings.LastIndex(url, "/"); idx != -1 {
url = url[idx+1:]
}
if idx := strings.Index(url, "?"); idx != -1 {
url = url[:idx]
}
return strings.TrimSpace(url)
}
type wrapKeyEntry struct { type wrapKeyEntry struct {
id string id string
key []byte key []byte
@@ -340,7 +364,7 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
// Устанавливаем команды для синей кнопки Menu // Устанавливаем команды для синей кнопки Menu
go func() { go func() {
cmds := `{"commands":[{"command":"new","description":"Создать временный пароль"},{"command":"list","description":"Управление доступами"}]}` cmds := `{"commands":[{"command":"start","description":"Главное меню"},{"command":"new","description":"Создать временный пароль"},{"command":"list","description":"Управление доступами"}]}`
resp, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/setMyCommands", token), "application/json", strings.NewReader(cmds)) resp, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/setMyCommands", token), "application/json", strings.NewReader(cmds))
if err == nil { if err == nil {
resp.Body.Close() resp.Body.Close()
@@ -350,8 +374,14 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
offset := 0 offset := 0
client := &http.Client{Timeout: 65 * time.Second} client := &http.Client{Timeout: 65 * time.Second}
// Состояние ожидания ввода дней // Состояние ожидания ввода
var waitingForDays bool var waitingForDays bool
var waitingForPorts bool
var waitingForHash bool
var targetPassword string
var tempDays int
var tempPorts string // "dtls,wg,tun"
for { for {
url := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?timeout=60&offset=%d", token, offset) url := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?timeout=60&offset=%d", token, offset)
@@ -410,6 +440,22 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
continue continue
} }
txt := fmt.Sprintf("🔑 *Пароль:* `%s`\n", pass) txt := fmt.Sprintf("🔑 *Пароль:* `%s`\n", pass)
if entry.VkHash != "" {
ports := entry.Ports
if ports == "" {
ports = "56000,56001,9000"
}
pts := strings.Split(ports, ",")
srvIP := getPublicIP()
link := fmt.Sprintf("wdtt://%s:%s:%s:%s:%s:%s", srvIP, pts[0], pts[1], pts[2], pass, entry.VkHash)
txt += fmt.Sprintf("🔗 *Быстрая ссылка:* `%s`\n", link)
}
if entry.IsDeactivated {
txt += "🔴 Статус: *ДЕАКТИВИРОВАН*\n"
} else {
txt += "🟢 Статус: *АКТИВЕН*\n"
}
if entry.ExpiresAt > 0 { if entry.ExpiresAt > 0 {
expireTime := time.Unix(entry.ExpiresAt, 0) expireTime := time.Unix(entry.ExpiresAt, 0)
remaining := time.Until(expireTime) remaining := time.Until(expireTime)
@@ -421,6 +467,8 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
} else { } else {
txt += "⏰ Бессрочный ♾\n" txt += "⏰ Бессрочный ♾\n"
} }
txt += fmt.Sprintf("\n📊 *Трафик:*\n• Скачано: %.2f MB\n• Отдано: %.2f MB\n", float64(entry.DownBytes)/(1024*1024), float64(entry.UpBytes)/(1024*1024))
txt += "\n📱 *Привязанное устройство:*\n" txt += "\n📱 *Привязанное устройство:*\n"
var kb []map[string]interface{} var kb []map[string]interface{}
if entry.DeviceID == "" { if entry.DeviceID == "" {
@@ -438,6 +486,17 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
}) })
} }
dbMutex.Unlock() dbMutex.Unlock()
if entry.IsDeactivated {
kb = append(kb, map[string]interface{}{
"text": "✅ Активировать",
"callback_data": "react_" + pass,
})
} else {
kb = append(kb, map[string]interface{}{
"text": "⏸ Деактивировать",
"callback_data": "deact_" + pass,
})
}
kb = append(kb, map[string]interface{}{ kb = append(kb, map[string]interface{}{
"text": "❌ Удалить пароль", "text": "❌ Удалить пароль",
"callback_data": "delpass_" + pass, "callback_data": "delpass_" + pass,
@@ -452,6 +511,44 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
} }
sendTelegram(token, adminID, txt, map[string]interface{}{"inline_keyboard": keyboard}) sendTelegram(token, adminID, txt, map[string]interface{}{"inline_keyboard": keyboard})
} else if strings.HasPrefix(data, "deact_") {
pass := strings.TrimPrefix(data, "deact_")
dbMutex.Lock()
entry, exists := db.Passwords[pass]
if exists && entry != nil {
entry.IsDeactivated = true
// Отключаем активное устройство от WG если нужно
if entry.DeviceID != "" {
if dev, devExists := db.Devices[entry.DeviceID]; devExists {
pubHex, _ := b64ToHex(dev.PubKey)
wgDev.IpcSet(fmt.Sprintf("public_key=%s\nremove=true\n", pubHex))
}
}
saveDB()
}
dbMutex.Unlock()
sendTelegram(token, adminID, fmt.Sprintf("⏸ Пароль `%s` деактивирован", pass), nil)
} else if strings.HasPrefix(data, "react_") {
pass := strings.TrimPrefix(data, "react_")
dbMutex.Lock()
entry, exists := db.Passwords[pass]
if exists && entry != nil {
entry.IsDeactivated = false
saveDB()
}
dbMutex.Unlock()
sendTelegram(token, adminID, fmt.Sprintf("✅ Пароль `%s` активирован", pass), nil)
} else if data == "mainlink" {
targetPassword = "main"
var keyboard [][]map[string]interface{}
keyboard = append(keyboard, []map[string]interface{}{
{"text": "Да", "callback_data": "ports_def"},
{"text": "Нет", "callback_data": "ports_custom"},
})
sendTelegram(token, adminID, "⚙️ Использовать стандартные порты для главного пароля (56000, 56001, 9000)?", map[string]interface{}{"inline_keyboard": keyboard})
} else if strings.HasPrefix(data, "unbind_") { } else if strings.HasPrefix(data, "unbind_") {
pass := strings.TrimPrefix(data, "unbind_") pass := strings.TrimPrefix(data, "unbind_")
dbMutex.Lock() dbMutex.Lock()
@@ -509,6 +606,13 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
} else if data == "backlist" { } else if data == "backlist" {
sendPasswordList(token, adminID, wgDev) sendPasswordList(token, adminID, wgDev)
} else if data == "ports_def" {
tempPorts = "56000,56001,9000"
waitingForHash = true
sendTelegram(token, adminID, "🔑 Укажите VK хеш (или несколько через запятую):", nil)
} else if data == "ports_custom" {
waitingForPorts = true
sendTelegram(token, adminID, "⚙️ Укажите через запятую 3 порта (DTLS,WG,TUN):\nНапример: 56000,56001,9000", nil)
} }
} }
@@ -528,7 +632,68 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
sendTelegram(token, adminID, "❌ Неверное значение. Укажите число от 1 до 365, или отправьте /new заново.", nil) sendTelegram(token, adminID, "❌ Неверное значение. Укажите число от 1 до 365, или отправьте /new заново.", nil)
continue continue
} }
expiresAt := time.Now().Add(time.Duration(days) * 24 * time.Hour).Unix() tempDays = days
var keyboard [][]map[string]interface{}
keyboard = append(keyboard, []map[string]interface{}{
{"text": "Да", "callback_data": "ports_def"},
{"text": "Нет", "callback_data": "ports_custom"},
})
sendTelegram(token, adminID, "⚙️ Использовать стандартные порты (56000, 56001, 9000)?", map[string]interface{}{"inline_keyboard": keyboard})
continue
}
if waitingForPorts {
parts := strings.Split(cmd, ",")
if len(parts) != 3 {
sendTelegram(token, adminID, "❌ Неверный формат. Укажите 3 порта через запятую (например: 56000,56001,9000):", nil)
continue
}
p1 := strings.TrimSpace(parts[0])
p2 := strings.TrimSpace(parts[1])
p3 := strings.TrimSpace(parts[2])
if _, err := strconv.Atoi(p1); err != nil {
sendTelegram(token, adminID, "❌ Неверный порт. Повторите ввод:", nil)
continue
}
if _, err := strconv.Atoi(p2); err != nil {
sendTelegram(token, adminID, "❌ Неверный порт. Повторите ввод:", nil)
continue
}
if _, err := strconv.Atoi(p3); err != nil {
sendTelegram(token, adminID, "❌ Неверный порт. Повторите ввод:", nil)
continue
}
waitingForPorts = false
tempPorts = fmt.Sprintf("%s,%s,%s", p1, p2, p3)
waitingForHash = true
sendTelegram(token, adminID, "🔑 Укажите VK хеш (или несколько через запятую):", nil)
continue
}
if waitingForHash {
hash := strings.ReplaceAll(cmd, " ", "")
if strings.Contains(hash, "http") || strings.Contains(hash, "/") {
sendTelegram(token, adminID, "❌ Пожалуйста, отправьте только хеш (или несколько хешей через запятую). Ссылки не поддерживаются.", nil)
continue
}
if hash == "" {
sendTelegram(token, adminID, "❌ Хеш не должен быть пустым.", nil)
continue
}
waitingForHash = false
if targetPassword == "main" {
targetPassword = ""
srvIP := getPublicIP()
pts := strings.Split(tempPorts, ",")
link := fmt.Sprintf("wdtt://%s:%s:%s:%s:%s:%s", srvIP, pts[0], pts[1], pts[2], db.MainPassword, hash)
sendTelegram(token, adminID, fmt.Sprintf("🔗 *Ссылка для главного пароля:*\n`%s`", link), nil)
continue
}
dbMutex.Lock() dbMutex.Lock()
if cleanupExpiredPasswordsLocked(wgDev) > 0 { if cleanupExpiredPasswordsLocked(wgDev) > 0 {
saveDB() saveDB()
@@ -556,11 +721,21 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
sendTelegram(token, adminID, "❌ Не удалось создать WRAP-ключ для пароля. Повторите /new.", nil) sendTelegram(token, adminID, "❌ Не удалось создать WRAP-ключ для пароля. Повторите /new.", nil)
continue continue
} }
db.Passwords[newPass] = &PasswordEntry{ExpiresAt: expiresAt} expiresAt := time.Now().Add(time.Duration(tempDays) * 24 * time.Hour).Unix()
db.Passwords[newPass] = &PasswordEntry{
ExpiresAt: expiresAt,
VkHash: hash,
Ports: tempPorts,
}
saveDB() saveDB()
dbMutex.Unlock() dbMutex.Unlock()
expDate := time.Unix(expiresAt, 0).Format("02.01.2006") expDate := time.Unix(expiresAt, 0).Format("02.01.2006")
sendTelegram(token, adminID, fmt.Sprintf("🔑 Новый пароль:\n`%s`\n\n⏰ Действует %d дн. (до %s)\n📱 Ожидает первого подключения", newPass, days, expDate), nil) srvIP := getPublicIP()
pts := strings.Split(tempPorts, ",")
link := fmt.Sprintf("wdtt://%s:%s:%s:%s:%s:%s", srvIP, pts[0], pts[1], pts[2], newPass, hash)
sendTelegram(token, adminID, fmt.Sprintf("🔑 Новый пароль:\n`%s`\n\n⏰ Действует %d дн. (до %s)\n📱 Ожидает первого подключения\n\n🔗 *Быстрая ссылка:* `%s`", newPass, tempDays, expDate, link), nil)
continue continue
} }
@@ -677,6 +852,10 @@ func sendPasswordList(token string, adminID int64, wgDev *device.Device) {
txt += fmt.Sprintf("🔒 Главный: `%s` (владелец)\n\n", db.MainPassword) txt += fmt.Sprintf("🔒 Главный: `%s` (владелец)\n\n", db.MainPassword)
var inlineKb []map[string]interface{} var inlineKb []map[string]interface{}
inlineKb = append(inlineKb, map[string]interface{}{
"text": "🔗 Ссылка на главный пароль",
"callback_data": "mainlink",
})
if len(db.Passwords) == 0 { if len(db.Passwords) == 0 {
txt += "_Нет сгенерированных паролей._\n" txt += "_Нет сгенерированных паролей._\n"
@@ -1224,7 +1403,6 @@ func main() {
func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgDev *device.Device, keys *wgKeys) { func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgDev *device.Device, keys *wgKeys) {
atomic.AddInt64(&totalConns, 1) atomic.AddInt64(&totalConns, 1)
var connDeviceID string
var connPassword string var connPassword string
var connIsMainPass bool var connIsMainPass bool
@@ -1276,14 +1454,16 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD
entry, isGenPass := db.Passwords[password] entry, isGenPass := db.Passwords[password]
valid := isMainPass || (isGenPass && !isPasswordExpired(entry)) valid := isMainPass || (isGenPass && !isPasswordExpired(entry))
// Для сгенерированных паролей — проверяем привязку к устройству if valid && isGenPass && entry.IsDeactivated {
if valid && isGenPass && entry.DeviceID != "" && entry.DeviceID != deviceID { clientConn.Write([]byte("DENIED:deactivated"))
log.Printf("[WG] Отказ: пароль %s деактивирован, запрос от %s", maskPassword(password), deviceID)
dbMutex.Unlock()
} else if valid && isGenPass && entry.DeviceID != "" && entry.DeviceID != deviceID {
// Пароль уже привязан к другому устройству // Пароль уже привязан к другому устройству
clientConn.Write([]byte("DENIED:device_mismatch")) clientConn.Write([]byte("DENIED:device_mismatch"))
log.Printf("[WG] Отказ: пароль %s привязан к %s, запрос от %s", maskPassword(password), entry.DeviceID, deviceID) log.Printf("[WG] Отказ: пароль %s привязан к %s, запрос от %s", maskPassword(password), entry.DeviceID, deviceID)
dbMutex.Unlock() dbMutex.Unlock()
} else if valid { } else if valid {
connDeviceID = deviceID
connPassword = password connPassword = password
connIsMainPass = isMainPass connIsMainPass = isMainPass
@@ -1364,20 +1544,7 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD
} }
atomic.AddInt64(&totalBytesFromClient, int64(len(firstPacket))) atomic.AddInt64(&totalBytesFromClient, int64(len(firstPacket)))
// Трекинг онлайн-статуса
if connDeviceID != "" {
activeDevicesMu.Lock()
activeDevices[connDeviceID]++
activeDevicesMu.Unlock()
defer func() {
activeDevicesMu.Lock()
activeDevices[connDeviceID]--
if activeDevices[connDeviceID] <= 0 {
delete(activeDevices, connDeviceID)
}
activeDevicesMu.Unlock()
}()
}
pctx, pcancel := context.WithCancel(ctx) pctx, pcancel := context.WithCancel(ctx)
defer pcancel() defer pcancel()
@@ -1413,9 +1580,7 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD
} }
atomic.AddInt64(&totalBytesFromClient, int64(nn)) atomic.AddInt64(&totalBytesFromClient, int64(nn))
// Per-password upload tracking // Per-password upload tracking
if connIsMainPass { if connPassword != "" && !connIsMainPass {
atomic.AddInt64(&mainPassUp, int64(nn))
} else if connPassword != "" {
dbMutex.Lock() dbMutex.Lock()
e, ok := db.Passwords[connPassword] e, ok := db.Passwords[connPassword]
if !ok || e == nil || isPasswordExpired(e) { if !ok || e == nil || isPasswordExpired(e) {
@@ -1456,9 +1621,7 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD
} }
atomic.AddInt64(&totalBytesToClient, int64(nn)) atomic.AddInt64(&totalBytesToClient, int64(nn))
// Per-password download tracking // Per-password download tracking
if connIsMainPass { if connPassword != "" && !connIsMainPass {
atomic.AddInt64(&mainPassDown, int64(nn))
} else if connPassword != "" {
dbMutex.Lock() dbMutex.Lock()
e, ok := db.Passwords[connPassword] e, ok := db.Passwords[connPassword]
if !ok || e == nil || isPasswordExpired(e) { if !ok || e == nil || isPasswordExpired(e) {
@@ -1482,6 +1645,24 @@ const (
wrapKeyLen = 32 wrapKeyLen = 32
) )
var aeadCache sync.Map
func getAEAD(key []byte) (cipher.AEAD, error) {
if len(key) != wrapKeyLen {
return nil, fmt.Errorf("obfs: key must be %d bytes", wrapKeyLen)
}
keyStr := string(key)
if val, ok := aeadCache.Load(keyStr); ok {
return val.(cipher.AEAD), nil
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
aeadCache.Store(keyStr, aead)
return aead, nil
}
// ==================== RTP Обфускация ==================== // ==================== RTP Обфускация ====================
type ObfsConfig struct { type ObfsConfig struct {
@@ -1491,9 +1672,10 @@ type ObfsConfig struct {
} }
type ObfsState struct { type ObfsState struct {
mu sync.Mutex mu sync.Mutex
seq uint16 initSeq uint16
ts uint32 initTs uint32
count uint64
} }
func NewObfsConfig() *ObfsConfig { func NewObfsConfig() *ObfsConfig {
@@ -1510,8 +1692,9 @@ func NewObfsState() *ObfsState {
var buf [6]byte var buf [6]byte
rand.Read(buf[:]) rand.Read(buf[:])
return &ObfsState{ return &ObfsState{
seq: binary.BigEndian.Uint16(buf[0:2]), initSeq: binary.BigEndian.Uint16(buf[0:2]),
ts: binary.BigEndian.Uint32(buf[2:6]), initTs: binary.BigEndian.Uint32(buf[2:6]),
count: 0,
} }
} }
@@ -1531,12 +1714,13 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b
return nil, errors.New("obfs: empty payload") return nil, errors.New("obfs: empty payload")
} }
state.mu.Lock() state.mu.Lock()
seq := state.seq c := state.count
ts := state.ts state.count++
state.seq++
state.ts += 960
state.mu.Unlock() state.mu.Unlock()
seq := state.initSeq + uint16(c)
ts := state.initTs + uint32(c)*960 + uint32(c>>16)
nonce := obfsBuildNonce(cfg.SSRC, seq, ts) nonce := obfsBuildNonce(cfg.SSRC, seq, ts)
padRand := 0 padRand := 0
if cfg.PaddingMax > 0 { if cfg.PaddingMax > 0 {
@@ -1554,7 +1738,7 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b
binary.BigEndian.PutUint32(out[4:8], ts) binary.BigEndian.PutUint32(out[4:8], ts)
binary.BigEndian.PutUint32(out[8:12], cfg.SSRC) binary.BigEndian.PutUint32(out[8:12], cfg.SSRC)
aead, err := chacha20poly1305.New(key) aead, err := getAEAD(key)
if err != nil { if err != nil {
return nil, fmt.Errorf("obfs: cipher init: %w", err) return nil, fmt.Errorf("obfs: cipher init: %w", err)
} }
@@ -1597,7 +1781,7 @@ func obfsUnwrapPacket(key, wire, dst []byte) (int, error) {
return 0, errors.New("obfs: dst buffer too small") return 0, errors.New("obfs: dst buffer too small")
} }
nonce := obfsBuildNonce(ssrc, seq, ts) nonce := obfsBuildNonce(ssrc, seq, ts)
aead, err := chacha20poly1305.New(key) aead, err := getAEAD(key)
if err != nil { if err != nil {
return 0, fmt.Errorf("obfs: cipher init: %w", err) return 0, fmt.Errorf("obfs: cipher init: %w", err)
} }