mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
Compare commits
180 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fef537cb83 | |||
| d0dbec3e03 | |||
| a2d9a88333 | |||
| d16e9226ce | |||
| 58924dc91a | |||
| 6f84a18b43 | |||
| a8b8363b79 | |||
| c13490ff55 | |||
| 5b175bf4be | |||
| 6f81528a34 | |||
| 639582c876 | |||
| 5b6ac18332 | |||
| b0e3cfe20a | |||
| 5a1c6e299c | |||
| b1cbafda73 | |||
| f4e8e0cfe0 | |||
| 192dd27a1e | |||
| af33b47fb2 | |||
| 05bbe996c1 | |||
| 1e3782c546 | |||
| d3e4cfc1db | |||
| caa8fea5bf | |||
| 5508cd65a7 | |||
| f0f6c3ee6d | |||
| 0ee0d5333e | |||
| ee30afc922 | |||
| 7df3a3fd91 | |||
| c116e427f7 | |||
| 7e5b2d4cde | |||
| 273a5fa977 | |||
| a0c4656025 | |||
| ac43e63ef8 | |||
| b621182ce4 | |||
| fd5c4f92a5 | |||
| f18e694f1a | |||
| 69dd5f9488 | |||
| 0a3becc74d | |||
| 3f9b3b6cd0 | |||
| dd0bc211b7 | |||
| 8b3bea7b1a | |||
| ece494aa1b | |||
| 3b48a17173 | |||
| 56dfc9aedd | |||
| 322c7001fb | |||
| f422acb927 | |||
| e32008bba2 | |||
| eb8ae03b50 | |||
| 802ea7d0af | |||
| a7ec2b949a | |||
| 4865a73b5f | |||
| 64f8b63548 | |||
| e102acbc4b | |||
| 061e3b882b | |||
| 6570cd9dd7 | |||
| e72157f4f7 | |||
| 065385e656 | |||
| 092ea5916a | |||
| 8d551c61ae | |||
| b894608110 | |||
| 5568670f51 | |||
| 5dc5095e2a | |||
| e1bf369db1 | |||
| 9da2c8ebd2 | |||
| 2544be2ed6 | |||
| c435605fe2 | |||
| 82907f3cc6 | |||
| 4b8c75ee92 | |||
| c447fc405b | |||
| 408b68e960 | |||
| 032d1fac56 | |||
| ace2ab54c6 | |||
| 02c696fa28 | |||
| daf8cece80 | |||
| 1cc5e93d03 | |||
| 67f9a4fe4e | |||
| 2812ded2ed | |||
| fa9549e6e0 | |||
| d82fe0f962 | |||
| c2058dc013 | |||
| 77fbdec707 | |||
| c6fc903c25 | |||
| f7129099f7 | |||
| e63279db18 | |||
| 03eb41e2fb | |||
| 748d5a07ce | |||
| 623ef46f2f | |||
| 1547aaeed7 | |||
| f61dc8c957 | |||
| 48631616f1 | |||
| 7fc7fe2e79 | |||
| c2a9f0f1d7 | |||
| aed2839c7d | |||
| 9d454dbd41 | |||
| ec1b1fce77 | |||
| c3fc9396d4 | |||
| 022692ea18 | |||
| c6703783c4 | |||
| 60e3a69a7d | |||
| 9c2cc93a1c | |||
| fd9c6abf1e | |||
| bc0673c19e | |||
| d3271ab4ab | |||
| 49eecb3478 | |||
| 65bc8a8993 | |||
| 53b8345aa0 | |||
| 0bef2e8ab5 | |||
| 5fdd8c3112 | |||
| d208a0d3df | |||
| 6bc4faf942 | |||
| 3cf78316df | |||
| a033cfb9a2 | |||
| 5b5c93fae1 | |||
| 74241de764 | |||
| 648c55415b | |||
| 6b14a95434 | |||
| 681dde8a6d | |||
| 0a6b0e0e85 | |||
| 25451e8e25 | |||
| 5bc164f78d | |||
| 95803f481a | |||
| 235ab1a646 | |||
| 1dcaa46659 | |||
| 7dc23ce3f0 | |||
| 1ca3451b76 | |||
| 59a8c84157 | |||
| 4174f29c63 | |||
| 4fa7810afa | |||
| 8eb3873d27 | |||
| ee1356308b | |||
| d9a0554489 | |||
| ea477a3bc2 | |||
| 7c0112d494 | |||
| 2a9cf9a0c8 | |||
| cdc7ee8d3d | |||
| 8524284eb8 | |||
| a7798cd851 | |||
| 1f86fda104 | |||
| 68485cbee7 | |||
| c5bb9c4d9d | |||
| f2b689b81d | |||
| 7c28498f23 | |||
| 760c511fe8 | |||
| 2e08a2bfab | |||
| d1b769a062 | |||
| 1d5eb2cdf2 | |||
| 9734b37f38 | |||
| 2506aece56 | |||
| a63032d42c | |||
| 34746e27e0 | |||
| 3211ed9ad5 | |||
| 62064a5eca | |||
| d67c829532 | |||
| e678ee1fd8 | |||
| e5874e3c30 | |||
| 8311a2e2f9 | |||
| 5a347bece9 | |||
| 305754cd60 | |||
| e009db0b9a | |||
| 321a0995c3 | |||
| 6f934f10f4 | |||
| ea410a044a | |||
| b4189f8f43 | |||
| 2121b55190 | |||
| 2df39d460e | |||
| 4c0bed1797 | |||
| ea1cb930cc | |||
| adcf0a0d21 | |||
| 15111ea72f | |||
| 10e6b375c6 | |||
| 3b33d33b9e | |||
| 623d9a22c2 | |||
| 8a309fbf7e | |||
| 4493fc63a2 | |||
| 859c7ff683 | |||
| f3d8da6919 | |||
| b391d7bf25 | |||
| 0a6bc0ae4f | |||
| 805228e62a | |||
| 3481de79a3 | |||
| 014f964ad9 |
@@ -0,0 +1,4 @@
|
||||
# Ensure shell scripts and configs always have LF line endings
|
||||
*.sh text eol=lf
|
||||
*.bats text eol=lf
|
||||
*.conf text eol=lf
|
||||
@@ -12,12 +12,12 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.26.4"
|
||||
GO_VERSION: "1.26.7"
|
||||
GOLANGCI_VERSION: v2.12.2
|
||||
GOVULN_VERSION: v1.1.4
|
||||
GORELEASER_VERSION: v2.15.4
|
||||
GOMOBILE_VERSION: latest
|
||||
TASK_VERSION: 3.51.1
|
||||
GOVULN_VERSION: v1.6.0
|
||||
GORELEASER_VERSION: v2.17.1
|
||||
GOMOBILE_VERSION: v0.0.0-20260821190718-4776eadac327
|
||||
TASK_VERSION: 3.52.0
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -82,6 +82,18 @@ jobs:
|
||||
|
||||
- run: govulncheck ./...
|
||||
|
||||
install-sh:
|
||||
name: install.sh (bats)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Install bats
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq bats
|
||||
|
||||
- name: Run tests
|
||||
run: bats scripts/tests
|
||||
|
||||
build-snapshot:
|
||||
name: Snapshot build (all targets)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -96,6 +108,10 @@ jobs:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
# checksum.extra_files глобит mobile-dist, а снапшот идёт без mobile-джоб
|
||||
- name: Stub mobile artifacts
|
||||
run: mkdir -p mobile-dist && touch mobile-dist/freeturn.aar mobile-dist/freeturn-sources.jar mobile-dist/Mobile.xcframework.zip
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v7.2.2
|
||||
with:
|
||||
distribution: goreleaser
|
||||
|
||||
@@ -10,10 +10,10 @@ permissions:
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.26.4"
|
||||
GORELEASER_VERSION: v2.15.4
|
||||
GOMOBILE_VERSION: latest
|
||||
TASK_VERSION: 3.51.1
|
||||
GO_VERSION: "1.26.7"
|
||||
GORELEASER_VERSION: v2.17.1
|
||||
GOMOBILE_VERSION: v0.0.0-20260821190718-4776eadac327
|
||||
TASK_VERSION: 3.52.0
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
@@ -81,7 +81,9 @@ jobs:
|
||||
- uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: android-aar
|
||||
path: dist/mobile.aar
|
||||
path: |
|
||||
dist/freeturn.aar
|
||||
dist/freeturn-sources.jar
|
||||
retention-days: 7
|
||||
|
||||
goreleaser:
|
||||
@@ -129,3 +131,41 @@ jobs:
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
notify-telegram:
|
||||
name: Telegram announce
|
||||
runs-on: ubuntu-latest
|
||||
needs: goreleaser
|
||||
steps:
|
||||
- name: Send release message
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: |
|
||||
BODY=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json body -q .body | head -c 3000)
|
||||
URL=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json url -q .url)
|
||||
# GH-markdown -> Telegram-HTML: сначала экранируем HTML, потом размечаем.
|
||||
# Хвосты "by @user in <PR-url>" и ссылки на коммит из авто-нотов убираем - в посте это шум.
|
||||
HTML=$(printf '%s' "$BODY" \
|
||||
| sed 's/&/\&/g; s/</\</g; s/>/\>/g' \
|
||||
| sed -E \
|
||||
-e 's| by @[[:alnum:]_-]+ in https://[^[:space:]]+||g' \
|
||||
-e 's|[[:space:]]*\(\[[0-9a-f]{6,}\]\(https?://[^)]+/commit/[^)]+\)\)||g' \
|
||||
-e 's|\[([^]]+)\]\((https?://[^)]+)\)|<a href="\2">\1</a>|g' \
|
||||
-e 's|\*\*([^*]+)\*\*|<b>\1</b>|g' \
|
||||
-e 's|`([^`]+)`|<code>\1</code>|g' \
|
||||
-e 's|^#{1,6}[[:space:]]+(.*)$|<b>\1</b>|' \
|
||||
-e 's|^[-*][[:space:]]+([0-9a-f]{7,40}:[[:space:]]+)?|• |' \
|
||||
-e 's|\(@([[:alnum:]_-]+)\)|(<a href="https://github.com/\1">\1</a>)|g')
|
||||
TEXT=$(printf '🐒💨 <b>FreeTurn (Go) %s</b>\n\n%s\n\n<a href="%s">Релиз на GitHub</a>' "$GITHUB_REF_NAME" "$HTML" "$URL")
|
||||
send() {
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${CHAT_ID}" \
|
||||
--data-urlencode "text=$1" \
|
||||
--data-urlencode "parse_mode=${2:-}" \
|
||||
--data-urlencode "disable_web_page_preview=true" \
|
||||
-o /dev/null
|
||||
}
|
||||
# Кривой HTML роняет весь sendMessage - фолбэк plain text, чтобы анонс не пропал
|
||||
send "$TEXT" HTML || send "$(printf '🚀 FreeTurn %s\n\n%s\n\n%s' "$GITHUB_REF_NAME" "$BODY" "$URL")"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.idea
|
||||
.claude
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
*.exe
|
||||
*.so
|
||||
*.tmp
|
||||
@@ -15,6 +16,9 @@ coverage.out
|
||||
.env
|
||||
clients.json
|
||||
client_config.json
|
||||
vk_persona.json
|
||||
vk_turn_creds.json
|
||||
captcha_page.html
|
||||
node_modules
|
||||
dist
|
||||
/mobile-dist/
|
||||
|
||||
@@ -15,7 +15,17 @@ linters:
|
||||
- prealloc
|
||||
- gosec
|
||||
- staticcheck
|
||||
- cyclop
|
||||
- funlen
|
||||
- nestif
|
||||
settings:
|
||||
cyclop:
|
||||
max-complexity: 15
|
||||
funlen:
|
||||
lines: -1
|
||||
statements: 60
|
||||
nestif:
|
||||
min-complexity: 5
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
exclude-functions:
|
||||
@@ -66,6 +76,15 @@ linters:
|
||||
- gocritic
|
||||
path: "^cmd/"
|
||||
text: "exitAfterDefer"
|
||||
- linters:
|
||||
- cyclop
|
||||
- funlen
|
||||
- nestif
|
||||
path: "_test\\.go$"
|
||||
- linters:
|
||||
- cyclop
|
||||
- nestif
|
||||
path: "internal/provider/vk/internal/(captcha|vkauth)/"
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
+15
-16
@@ -59,12 +59,10 @@ builds:
|
||||
- windows_386
|
||||
- freebsd_amd64
|
||||
|
||||
# Android client only. Pure Go (CGO=0), packaged as native .so name
|
||||
# for use as Android native library (JNI / standalone shipment).
|
||||
# Android client only. Pure Go (CGO=0), для standalone-запуска в Termux.
|
||||
- id: client-android
|
||||
main: ./cmd/client
|
||||
binary: libfreeturn.so
|
||||
no_unique_dist_dir: false
|
||||
binary: client
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
@@ -75,14 +73,6 @@ builds:
|
||||
- android_arm64
|
||||
|
||||
archives:
|
||||
# Android: ship the .so binary directly (no archive wrapper), with
|
||||
# arch suffix so arm64 and arm coexist on the release page.
|
||||
- id: android
|
||||
ids:
|
||||
- client-android
|
||||
formats: [binary]
|
||||
name_template: "libfreeturn-{{ .Version }}-android-{{ .Arch }}"
|
||||
|
||||
# Raw, stable-named binaries (no version in filename) so docs and scripts
|
||||
# can fetch /releases/latest/download/<binary>-<os>-<arch> directly without
|
||||
# unpacking a tarball. Shipped uncompressed — no tar.gz/zip archives.
|
||||
@@ -96,8 +86,7 @@ archives:
|
||||
{{- with .Arm }}v{{ . }}{{ end }}
|
||||
{{- with .Mips }}-{{ . }}{{ end }}
|
||||
|
||||
# Android client as a raw, stable-named binary for Termux standalone use
|
||||
# (the libfreeturn-* archive above targets Android-app native-lib shipment).
|
||||
# Android client как raw-бинарь со стабильным именем - для Termux
|
||||
- id: raw-android
|
||||
ids:
|
||||
- client-android
|
||||
@@ -106,6 +95,13 @@ archives:
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
# Mobile artifacts and install.sh ship via release.extra_files and are not build outputs;
|
||||
# list them here so hosts (Android Gradle fetch) can verify what they download.
|
||||
extra_files:
|
||||
- glob: '{{ if not .IsSnapshot }}mobile-dist/freeturn.aar{{ end }}'
|
||||
- glob: '{{ if not .IsSnapshot }}mobile-dist/freeturn-sources.jar{{ end }}'
|
||||
- glob: '{{ if not .IsSnapshot }}mobile-dist/Mobile.xcframework.zip{{ end }}'
|
||||
- glob: scripts/install.sh
|
||||
|
||||
sboms:
|
||||
- artifacts: archive
|
||||
@@ -150,8 +146,11 @@ release:
|
||||
draft: false
|
||||
name_template: "{{ .ProjectName }} {{ .Version }}"
|
||||
extra_files:
|
||||
- glob: mobile-dist/mobile.aar
|
||||
- glob: mobile-dist/Mobile.xcframework.zip
|
||||
- glob: '{{ if not .IsSnapshot }}mobile-dist/freeturn.aar{{ end }}'
|
||||
- glob: '{{ if not .IsSnapshot }}mobile-dist/freeturn-sources.jar{{ end }}'
|
||||
- glob: '{{ if not .IsSnapshot }}mobile-dist/Mobile.xcframework.zip{{ end }}'
|
||||
# Android-приложение стримит этот скрипт по SSH: версия RPC = версия релиза.
|
||||
- glob: scripts/install.sh
|
||||
|
||||
dockers_v2:
|
||||
- id: server
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.6.0"
|
||||
".": "4.0.1"
|
||||
}
|
||||
|
||||
+333
-2
@@ -1,5 +1,336 @@
|
||||
# Changelog
|
||||
|
||||
## [4.0.1](https://github.com/samosvalishe/free-turn-proxy/compare/v4.0.0...v4.0.1) (2026-09-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* naturalyiiBlondinNaVsyStranuTakoy1 ([a2d9a88](https://github.com/samosvalishe/free-turn-proxy/commit/a2d9a88333dc7627216adb7968291078dde8fa23))
|
||||
|
||||
## [4.0.0](https://github.com/samosvalishe/free-turn-proxy/compare/v3.4.0...v4.0.0) (2026-09-23)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **auth:** подтверждение Client ID с повтором по таймеру
|
||||
* **install:** install.sh с JSON RPC proto 3 и голым WireGuard вместо AWG
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** подтверждение Client ID с повтором по таймеру ([d3e4cfc](https://github.com/samosvalishe/free-turn-proxy/commit/d3e4cfc1dbd20f121b15e7f9cc07cdbb39bd4eb3))
|
||||
* **client:** провайдер direct - прямо на сервер без TURN-реле ([ee30afc](https://github.com/samosvalishe/free-turn-proxy/commit/ee30afc922acbfce9f2bf961f0b7e8fc05cb06c8))
|
||||
* **diagnostics:** связывать события сессии и попытки восстановления ([e102acb](https://github.com/samosvalishe/free-turn-proxy/commit/e102acbc4be2d73704fb3965083bc44153dfedfa))
|
||||
* **installer:** вывод всех 3 форматов QR-кодов и поддержка скачивания картинок на ПК ([092ea59](https://github.com/samosvalishe/free-turn-proxy/commit/092ea5916a724251a3cdd1b118d605320a2de157))
|
||||
* **installer:** генерация QR в PNG, M3 TUI для клиентов и фикс утечки escape-последовательностей ([b894608](https://github.com/samosvalishe/free-turn-proxy/commit/b8946081101241620d2ddf8a9e940ae1243c20b8))
|
||||
* **installer:** глобальный CLI freeturn, авторизация по Client ID по умолчанию и компактный QR ([8d551c6](https://github.com/samosvalishe/free-turn-proxy/commit/8d551c61aedb9b4125834c2b24f4c486a1f495b1))
|
||||
* **installer:** раздача QR-кодов и конфигов по веб-ссылкам без вывода в терминал ([065385e](https://github.com/samosvalishe/free-turn-proxy/commit/065385e656085a06bf8079958bf1b563901db64f))
|
||||
* **installer:** токен на клиента и TTL для веб-раздачи ([e72157f](https://github.com/samosvalishe/free-turn-proxy/commit/e72157f4f7dcd6f1ad866211e4e82dface26cd55))
|
||||
* **install:** install.sh с JSON RPC proto 3 и голым WireGuard вместо AWG ([0ee0d53](https://github.com/samosvalishe/free-turn-proxy/commit/0ee0d5333ea5020e321ae1c5e1368417ea2e42d3))
|
||||
* **mobile:** отключаемый кольцевой буфер логов ([a7ec2b9](https://github.com/samosvalishe/free-turn-proxy/commit/a7ec2b949a636de4b32f5a2b2a517ad2e89ec543))
|
||||
* rebranding ([58924dc](https://github.com/samosvalishe/free-turn-proxy/commit/58924dc91aba0a3a23f20398fd5e2aa8bd787a6e))
|
||||
* **tcp:** вернуть поддержку bond ([6f84a18](https://github.com/samosvalishe/free-turn-proxy/commit/6f84a18b43da17a03fb5fdab2c0d235569a50f9e))
|
||||
* **uri:** ссылка на звонок и пейсинг обфускации в freeturn:// ([64f8b63](https://github.com/samosvalishe/free-turn-proxy/commit/64f8b63548edff2ab521be013502680fc5f04df7))
|
||||
* загрузка локально собранного бинаря на сервер ([af33b47](https://github.com/samosvalishe/free-turn-proxy/commit/af33b47fb20d5a5bb5ebe50481b54f5e33b9a838))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** слать баннер ручной капчи в логгер, а не в stdout ([3b48a17](https://github.com/samosvalishe/free-turn-proxy/commit/3b48a171738e3590a305b60d92ef2181c72a65da))
|
||||
* **clients:** останавливать горячую перезагрузку базы по контексту ([b621182](https://github.com/samosvalishe/free-turn-proxy/commit/b621182ce4330992f101d59a49871f00ddd17b84))
|
||||
* **clients:** сохранять авторизацию при ошибке записи базы ([061e3b8](https://github.com/samosvalishe/free-turn-proxy/commit/061e3b882b815aca19e46221eea6b3bbab7019ef))
|
||||
* **clients:** фиксировать базу на диск через fsync ([ac43e63](https://github.com/samosvalishe/free-turn-proxy/commit/ac43e63ef86e7d27625ce74172cff3c47af2a014))
|
||||
* **cli:** предупреждать, что встроенный туннель из ссылки не поднимается ([f18e694](https://github.com/samosvalishe/free-turn-proxy/commit/f18e694f1aa320c9c84c30fa01e14e83c806d3ff))
|
||||
* **dns:** не залипать на первом резолвере doh-форвардера ([ece494a](https://github.com/samosvalishe/free-turn-proxy/commit/ece494aa1b3bbcf0609c926acfbd55a64475d671))
|
||||
* **dns:** проба udp/53 не теряет второй сервер, doh до смены сети ([b0e3cfe](https://github.com/samosvalishe/free-turn-proxy/commit/b0e3cfe20ae87c7f8893de3f032db9dc5d4c0945))
|
||||
* **dns:** сбрасывать udp-резолверы, если сессия их не задала ([4865a73](https://github.com/samosvalishe/free-turn-proxy/commit/4865a73b5fc8338ca5e64c0409df960a21ee2c76))
|
||||
* **installer:** передача obf_profile, obf_key и proxy_mode в systemd и docker args ([5dc5095](https://github.com/samosvalishe/free-turn-proxy/commit/5dc5095e2a6c23e73e63398639bfc91b44d70920))
|
||||
* **mobile:** отказ protect возвращать ошибкой сокета ([5a1c6e2](https://github.com/samosvalishe/free-turn-proxy/commit/5a1c6e299c1493f04bf0f309340fa8d7f9d473e3))
|
||||
* **netconn:** отдавать остаток очереди при закрытии пира ([273a5fa](https://github.com/samosvalishe/free-turn-proxy/commit/273a5fa977c407c6573bede0847d6d04ab048eae))
|
||||
* **relay:** восстановление потоков при отказах turn и занятой квоте ([6570cd9](https://github.com/samosvalishe/free-turn-proxy/commit/6570cd9dd760ae00093778cd5a4a095fd6701118))
|
||||
* **server:** не ронять процесс паникой в обработчике соединения ([fd5c4f9](https://github.com/samosvalishe/free-turn-proxy/commit/fd5c4f92a59f1d93f7dad6eea974a8a04a9c74f9))
|
||||
* **sub:** резолвить подписку через dns-диалер приложения ([8b3bea7](https://github.com/samosvalishe/free-turn-proxy/commit/8b3bea7b1a4d57a18d7b629b11f80d182893dddc))
|
||||
* **task:** заглушки mobile-dist в windows ([639582c](https://github.com/samosvalishe/free-turn-proxy/commit/639582c876289a30add9e1844f6633594cda742e))
|
||||
* **turn:** отмена контекста прерывает allocate ([6f81528](https://github.com/samosvalishe/free-turn-proxy/commit/6f81528a3473bcffefb69186a4448b186534b36a))
|
||||
* **udp:** датаграммы до 2048 байт на всём пути ([5b175bf](https://github.com/samosvalishe/free-turn-proxy/commit/5b175bf4be4c48e0c609122ec24fee18cacf1eea))
|
||||
* **udp:** завершать релей при отказе чтения локального сокета ([f4e8e0c](https://github.com/samosvalishe/free-turn-proxy/commit/f4e8e0cfe06b1ed0440a2a0b2009409ac4c87212))
|
||||
* **udp:** логировать причину обрыва DTLS-сессии перед паузой ([caa8fea](https://github.com/samosvalishe/free-turn-proxy/commit/caa8fea5bf08fec9053c2aa7f475c719556cae49))
|
||||
* **udp:** ограничить очередь между сокетом и DTLS ([5508cd6](https://github.com/samosvalishe/free-turn-proxy/commit/5508cd65a79812e5352fda83eb0eb7ab1d9c7db5))
|
||||
* **udp:** ошибка obf до аллокации, без петли allocate ([c13490f](https://github.com/samosvalishe/free-turn-proxy/commit/c13490ff5514e54dcaf6f44483bd3be8e4802d4e))
|
||||
* **ui:** убрать утечку OSC 11/CPR escape-последовательностей при выводе плашек ([5568670](https://github.com/samosvalishe/free-turn-proxy/commit/5568670f515e34b23fc67b7ae17b63c80b510303))
|
||||
* **vk:** определять ошибки авторизации по sentinel, а не по тексту ([3f9b3b6](https://github.com/samosvalishe/free-turn-proxy/commit/3f9b3b6cd008dd0d6505eed080f8c78321cb249b))
|
||||
* гард для релизера ([192dd27](https://github.com/samosvalishe/free-turn-proxy/commit/192dd27a1e7bd5a32beb2e6d57ad59b12867edbe))
|
||||
* отбрасывать опоздавший повтор client id, общий backoff квоты ([05bbe99](https://github.com/samosvalishe/free-turn-proxy/commit/05bbe996c1c44aa94484a6e7af28daf16aaca23f))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* **netconn:** адрес пайпа без аллокации на каждое чтение ([b1cbafd](https://github.com/samosvalishe/free-turn-proxy/commit/b1cbafda736b5efbb607b602379f13a487104f5d))
|
||||
* **obf:** держать поток ChaCha8 в соединении rtpopus3 ([f422acb](https://github.com/samosvalishe/free-turn-proxy/commit/f422acb92794149eb8a45d9004736152d39f700f))
|
||||
* **randx:** rejection sampling вместо math/big ([e32008b](https://github.com/samosvalishe/free-turn-proxy/commit/e32008bba28670c7fda00bff007f6f9fb5f42580))
|
||||
* **tcp:** смерть smux-сессии без поллинга ([5b6ac18](https://github.com/samosvalishe/free-turn-proxy/commit/5b6ac183321bcba27d4890a8a9d30854293d9e0e))
|
||||
* **udp:** убрать сисколлы и аллокации с горячего пути релея ([a0c4656](https://github.com/samosvalishe/free-turn-proxy/commit/a0c46560251497d021932789aa339618f0caa700))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* **cli:** общий обработчик сигналов для клиента и сервера ([69dd5f9](https://github.com/samosvalishe/free-turn-proxy/commit/69dd5f9488805b31f2fdf81e569d0228773ddc94))
|
||||
* **config:** брать режимы dns из dnsdial ([dd0bc21](https://github.com/samosvalishe/free-turn-proxy/commit/dd0bc211b7c9113c385e4a9fde8178de5fb015d7))
|
||||
* **dtls:** убрать дублирующий doc.go ([7df3a3f](https://github.com/samosvalishe/free-turn-proxy/commit/7df3a3fd91c9d933bf8841e86d1cd45d039f116d))
|
||||
* **logx:** общий Holder вместо пакетных переменных Log ([56dfc9a](https://github.com/samosvalishe/free-turn-proxy/commit/56dfc9aedd0aa5c6cacdc36c4cf4c62dd40eb9f3))
|
||||
* **vk:** не держать write-lock кеша реквизитов во время запроса ([0a3becc](https://github.com/samosvalishe/free-turn-proxy/commit/0a3becc74d751fb4e1185bffd597cbb8c9c2c1f7))
|
||||
* **wake:** убрать неиспользуемый Notifier ([7e5b2d4](https://github.com/samosvalishe/free-turn-proxy/commit/7e5b2d4cde87c73a4d20df17de2e770e1ef37089))
|
||||
* **wire:** один listener на все профили обфускации ([322c700](https://github.com/samosvalishe/free-turn-proxy/commit/322c7001fb58b489ee8fbb992afadf165719c2de))
|
||||
* сложность функций под лимиты линтера ([a8b8363](https://github.com/samosvalishe/free-turn-proxy/commit/a8b8363b79495dbc990764c45758e1e70378421c))
|
||||
|
||||
## [3.4.0](https://github.com/samosvalishe/free-turn-proxy/compare/v3.3.2...v3.4.0) (2026-09-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* профиль freeturn vpn со вшитым wg и фикс ui_spin ([9da2c8e](https://github.com/samosvalishe/free-turn-proxy/commit/9da2c8ebd27f26e52cd79bceb14dbc5eac732575))
|
||||
|
||||
## [3.3.2](https://github.com/samosvalishe/free-turn-proxy/compare/v3.3.1...v3.3.2) (2026-09-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* небольшие улучшения ([c435605](https://github.com/samosvalishe/free-turn-proxy/commit/c435605fe20ef6b5cbb9b6d6149f374d3aa9a3df))
|
||||
* собственный iface для awg ([82907f3](https://github.com/samosvalishe/free-turn-proxy/commit/82907f3cc60cdb5a59aafe2633ec99c519f916bb))
|
||||
|
||||
## [3.3.1](https://github.com/samosvalishe/free-turn-proxy/compare/v3.3.0...v3.3.1) (2026-09-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* installer ([408b68e](https://github.com/samosvalishe/free-turn-proxy/commit/408b68e960a668898ad39056f9f0e3b87a187b37))
|
||||
* installer ([032d1fa](https://github.com/samosvalishe/free-turn-proxy/commit/032d1fac562498b1d4c54f06978d38807b7d435b))
|
||||
* release awg img ([c447fc4](https://github.com/samosvalishe/free-turn-proxy/commit/c447fc405b0fb918e5ba2576f60bd60f726dd442))
|
||||
|
||||
## [3.3.0](https://github.com/samosvalishe/free-turn-proxy/compare/v3.2.0...v3.3.0) (2026-09-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **scripts:** поддержка amneziawg 3.1, управление клиентами и qr-коды в установщике ([daf8cec](https://github.com/samosvalishe/free-turn-proxy/commit/daf8cece80ba388788b67596168c79f4b0741e69))
|
||||
* прямой туннель без релея ([2812ded](https://github.com/samosvalishe/free-turn-proxy/commit/2812ded2ed4a6c2d9c0f44bf0e79e218a5a801dc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fmt ([67f9a4f](https://github.com/samosvalishe/free-turn-proxy/commit/67f9a4fe4e3f6578b5fb88c976878fcc42e87648))
|
||||
* **tunnel:** race ([02c696f](https://github.com/samosvalishe/free-turn-proxy/commit/02c696fa28ac687e979a4c11f9bf630746ef3098))
|
||||
* **tunnel:** обработка защиты сокетов и стабильность прямого режима ([1cc5e93](https://github.com/samosvalishe/free-turn-proxy/commit/1cc5e93d0333d107b7193c6d74c87d27b737ab21))
|
||||
|
||||
## [3.2.0](https://github.com/samosvalishe/free-turn-proxy/compare/v3.1.1...v3.2.0) (2026-08-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **proxy:** tcp-режим для xray/sing-box поверх turn ([f712909](https://github.com/samosvalishe/free-turn-proxy/commit/f7129099f711d627f6645fd18fd4c9be2b8a1437))
|
||||
* **session:** реконнект релея без пересоздания туннеля ([f61dc8c](https://github.com/samosvalishe/free-turn-proxy/commit/f61dc8c9578eb76f6cdf0a686af024ebbeab5b6b))
|
||||
* **tunnel:** параметры обфускации awg 3+ ([77fbdec](https://github.com/samosvalishe/free-turn-proxy/commit/77fbdec70703fd7222d58867cf033b371ab41849))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** не жечь персону, когда страница не дошла до вердикта ([1547aae](https://github.com/samosvalishe/free-turn-proxy/commit/1547aaeed74bb23d45dd15f1d5cc030000c43481))
|
||||
* **core:** паника горутины больше не убивает процесс приложения ([748d5a0](https://github.com/samosvalishe/free-turn-proxy/commit/748d5a07ce21bec1620e3a4c55b5f2f33171fb99))
|
||||
* **logs:** убрать пострим-шум из лога без debug ([c2a9f0f](https://github.com/samosvalishe/free-turn-proxy/commit/c2a9f0f1d7054669eda7817a2f9d8d067573dcc4))
|
||||
* **udprelay:** не зависать на фатальной ошибке провайдера ([4863161](https://github.com/samosvalishe/free-turn-proxy/commit/48631616f143bc07f93d4a6cf6d5d2647022129e))
|
||||
* **udprelay:** фатальная ошибка не ждёт барьер прогрева ([03eb41e](https://github.com/samosvalishe/free-turn-proxy/commit/03eb41e2fb5cad671c52bb4807d424688145ba36))
|
||||
* **vkauth:** не считать отменённый поход за реквизитами нагрузкой на провайдера ([623ef46](https://github.com/samosvalishe/free-turn-proxy/commit/623ef46f2f908d4b5d011b1b099928d7a2e3714c))
|
||||
* **vkauth:** сбрасывать реквизиты при неподтверждённом deallocate ([7fc7fe2](https://github.com/samosvalishe/free-turn-proxy/commit/7fc7fe2e79906d5cd386da65e2064c2e159ae891))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* **udprelay:** общий шлюз на allocate вместо per-stream джиттера ([e63279d](https://github.com/samosvalishe/free-turn-proxy/commit/e63279db185ae6205080fab6b4a9d4dd36e5c29c))
|
||||
|
||||
## [3.1.1](https://github.com/samosvalishe/free-turn-proxy/compare/v3.1.0...v3.1.1) (2026-08-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** адаптация ([9d454db](https://github.com/samosvalishe/free-turn-proxy/commit/9d454dbd419961ccdd2eebfeaacede4f40672fa8))
|
||||
|
||||
## [3.1.0](https://github.com/samosvalishe/free-turn-proxy/compare/v3.0.0...v3.1.0) (2026-08-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **server:** адрес клиента в логах сессии и handshake ([c670378](https://github.com/samosvalishe/free-turn-proxy/commit/c6703783c4a796045c0c385a8705c2756a1d8710))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ci ([c3fc939](https://github.com/samosvalishe/free-turn-proxy/commit/c3fc9396d4a38f6c77644a6bce04f1ed615aa397))
|
||||
* ci ([022692e](https://github.com/samosvalishe/free-turn-proxy/commit/022692ea18e012f8bc8feb2c1e5bb96e4998fc0e))
|
||||
* **session:** рецикл после пробуждения только при тишине в канале ([9c2cc93](https://github.com/samosvalishe/free-turn-proxy/commit/9c2cc93a1caa2ecbea87be8501dd13e6fb358071))
|
||||
* **udprelay:** рецикл turn-аллокации пересоздаёт dtls-сессию ([fd9c6ab](https://github.com/samosvalishe/free-turn-proxy/commit/fd9c6abf1e3be6e570af41ab03522c584a06df46))
|
||||
* **vkauth:** отмена капчи не сжигает персону ([60e3a69](https://github.com/samosvalishe/free-turn-proxy/commit/60e3a69a7d237d1635cd840bbbeaaada6b56a562))
|
||||
|
||||
## [3.0.0](https://github.com/samosvalishe/free-turn-proxy/compare/v2.1.2...v3.0.0) (2026-08-17)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* флаги -mode и -bond удалены; конфиги и ссылки freeturn:// с tcp-режимом больше не принимаются.
|
||||
|
||||
### Features
|
||||
|
||||
* comment cleanup ([648c554](https://github.com/samosvalishe/free-turn-proxy/commit/648c55415b210eb0feea640e0a9f444b1fa07954))
|
||||
* **mobile:** переживание сна и рестарта на Android ([681dde8](https://github.com/samosvalishe/free-turn-proxy/commit/681dde8a6d98ccab10a2aa9eb865e5b3eaf86595))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** персонная модель PoW и кэш TURN-кредов ([6b14a95](https://github.com/samosvalishe/free-turn-proxy/commit/6b14a95434b31bf505ed02c6764d0b2fce7c5351))
|
||||
* **captcha:** устранение регрессий ([5b5c93f](https://github.com/samosvalishe/free-turn-proxy/commit/5b5c93fae16dfcaa3de7b125ce34c0991180b831))
|
||||
* readme ([d208a0d](https://github.com/samosvalishe/free-turn-proxy/commit/d208a0d3df5546ea883662d36b457df980cc416a))
|
||||
* soft reconnect on network handover without tunnel drop ([0bef2e8](https://github.com/samosvalishe/free-turn-proxy/commit/0bef2e8ab5cd7c3db3e8ff5f3ee9c8ae67f80832))
|
||||
* **udprelay:** рецикл аллокации на простое туннеля ([a033cfb](https://github.com/samosvalishe/free-turn-proxy/commit/a033cfb9a2097365e144fa79a55ecaf12dc07c3e))
|
||||
* **vkauth:** ловим 486 как auth-error для рецикла аллокации ([5fdd8c3](https://github.com/samosvalishe/free-turn-proxy/commit/5fdd8c31123f5bcbe9ba4fa2677b604bc6da9294))
|
||||
* возврат фонового детектора сна для рецикла аллокаций ([53b8345](https://github.com/samosvalishe/free-turn-proxy/commit/53b8345aa07dd4375ba5646a3f14a309351bb147))
|
||||
* убрал лишний артефакт ([6bc4faf](https://github.com/samosvalishe/free-turn-proxy/commit/6bc4faf9423be9278f46642fbf29ee92bb46b5d3))
|
||||
* устранение предупреждений линтера ([49eecb3](https://github.com/samosvalishe/free-turn-proxy/commit/49eecb34784017e01d8077e8bbe178ed9c0ba023))
|
||||
* устранение предупреждений линтера ([65bc8a8](https://github.com/samosvalishe/free-turn-proxy/commit/65bc8a89932bcbc5cdc27163adc46cf118428901))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* меньше фоновых таймеров при поднятом туннеле и опрос метрик только при видимом окне ([3cf7831](https://github.com/samosvalishe/free-turn-proxy/commit/3cf78316df14425e3811caaf483bf92eebe52cfc))
|
||||
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
* выпил tcp-режим туннеля ([74241de](https://github.com/samosvalishe/free-turn-proxy/commit/74241de764a428d9c3fee1aeae0d917851d1d542))
|
||||
|
||||
## [2.1.2](https://github.com/samosvalishe/free-turn-proxy/compare/v2.1.1...v2.1.2) (2026-08-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** fmt ([95803f4](https://github.com/samosvalishe/free-turn-proxy/commit/95803f481a683056651768225bc0c64e177ad468))
|
||||
* **captcha:** выровнять авторешение по живому браузеру ([235ab1a](https://github.com/samosvalishe/free-turn-proxy/commit/235ab1a6463a9758223d38f0eacc4c630d80a4a5))
|
||||
* **deps:** обновить Go 1.26.5 -> 1.26.6 ([5bc164f](https://github.com/samosvalishe/free-turn-proxy/commit/5bc164f78da2b54a2013c3e046f7d02799ceaf20))
|
||||
|
||||
## [2.1.1](https://github.com/samosvalishe/free-turn-proxy/compare/v2.1.0...v2.1.1) (2026-08-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** блокировать рекламные хосты в ручном режиме ([4174f29](https://github.com/samosvalishe/free-turn-proxy/commit/4174f29c63c0d5c7ffa645de9b0717a60d6849cb))
|
||||
* **captcha:** линтер в трассировке запросов ([59a8c84](https://github.com/samosvalishe/free-turn-proxy/commit/59a8c84157750be7b6219d57891c120eb2393230))
|
||||
* **captcha:** не жечь персону на эскалации checkbox -> slider ([ee13563](https://github.com/samosvalishe/free-turn-proxy/commit/ee1356308bb2a1b67b48c11d841a330d61a1b0cd))
|
||||
* **captcha:** обновить пин debug_info под виджет 1.1.1388 ([ea477a3](https://github.com/samosvalishe/free-turn-proxy/commit/ea477a3bc21d7fd0b8dedfe6ae201b6950fda301))
|
||||
* **captcha:** привести запросы к поведению живого виджета 1.1.1388 ([4fa7810](https://github.com/samosvalishe/free-turn-proxy/commit/4fa7810afa35bd086f30b66e3e0660df702ba3fe))
|
||||
* **captcha:** разносить попытки слайдера по дорожке ([8eb3873](https://github.com/samosvalishe/free-turn-proxy/commit/8eb3873d27ffb679eddc2a2f230b5a58f9d6708a))
|
||||
* **captcha:** убрать reverse-скор из ранжирования slider-кандидатов ([d9a0554](https://github.com/samosvalishe/free-turn-proxy/commit/d9a05544891e8f7354758bfbc06b0e5e9ea7eba7))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* **captcha:** убрать трассировку запросов ([1ca3451](https://github.com/samosvalishe/free-turn-proxy/commit/1ca3451b7690179e4d00f318f471251062a9ee65))
|
||||
|
||||
## [2.1.0](https://github.com/samosvalishe/free-turn-proxy/compare/v2.0.2...v2.1.0) (2026-08-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **proxy:** встроенное управление маршрутами к TURN-серверам ([a7798cd](https://github.com/samosvalishe/free-turn-proxy/commit/a7798cd851ab7091526a6131e71bf8e5c37a1ff2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **routes:** linter ([cdc7ee8](https://github.com/samosvalishe/free-turn-proxy/commit/cdc7ee8d3d71b9d04a6a1b238bbf2d5263f2603e))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* перенос логики сессии в internal/session и обновление mobile-фасада ([1f86fda](https://github.com/samosvalishe/free-turn-proxy/commit/1f86fda104b09f4b61e065c4b0bda97cec4c04bd))
|
||||
|
||||
## [2.0.2](https://github.com/samosvalishe/free-turn-proxy/compare/v2.0.1...v2.0.2) (2026-07-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** адаптация под новую версию виджета ([7c28498](https://github.com/samosvalishe/free-turn-proxy/commit/7c28498f2336967008e41542389f29c5637b6795))
|
||||
|
||||
## [2.0.1](https://github.com/samosvalishe/free-turn-proxy/compare/v2.0.0...v2.0.1) (2026-07-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** дожимать эскалацию checkbox -> slider в той же сессии ([d1b769a](https://github.com/samosvalishe/free-turn-proxy/commit/d1b769a0625a6d706e1cda8f6df6ab80871df856))
|
||||
|
||||
## [2.0.0](https://github.com/samosvalishe/free-turn-proxy/compare/v1.8.0...v2.0.0) (2026-07-28)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **captcha:** флаг -browser убран, семейство персоны всегда Chrome - только Chromium даёт NetworkInformation и Generic Sensors, без которых телеметрия captcha неполна. Класс устройства по-прежнему за -platform.
|
||||
|
||||
### Features
|
||||
|
||||
* **captcha:** телеметрия виджета и одна Chrome-персона на установку ([3211ed9](https://github.com/samosvalishe/free-turn-proxy/commit/3211ed9ad56b5e6ed6cfe679e000da197e17437d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update deps for vuln pass ([34746e2](https://github.com/samosvalishe/free-turn-proxy/commit/34746e27e03a31ccdcdb8f481414b7e6a6177b55))
|
||||
|
||||
## [1.8.0](https://github.com/samosvalishe/free-turn-proxy/compare/v1.7.3...v1.8.0) (2026-07-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* автопубликация телеграм анонса при релизе ([e678ee1](https://github.com/samosvalishe/free-turn-proxy/commit/e678ee1fd85c64eb42da3ba9ccc580fccbc79749))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** persona-модель вместо захвата профиля браузера ([305754c](https://github.com/samosvalishe/free-turn-proxy/commit/305754cd6037fe3b4ec5558b826a89eefc64834b))
|
||||
|
||||
## [1.7.3](https://github.com/samosvalishe/free-turn-proxy/compare/v1.7.2...v1.7.3) (2026-07-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** адаптировать авторешение под SPA-капчу VK ([b4189f8](https://github.com/samosvalishe/free-turn-proxy/commit/b4189f8f4317b1c5c919ab8e0e183ebfb927d8be))
|
||||
* **deps:** обновить Go 1.26.4 -> 1.26.5 ([ea410a0](https://github.com/samosvalishe/free-turn-proxy/commit/ea410a044ad1ebd3fbc14286f41213357a6732f4))
|
||||
|
||||
## [1.7.2](https://github.com/samosvalishe/free-turn-proxy/compare/v1.7.1...v1.7.2) (2026-07-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** linter ([4c0bed1](https://github.com/samosvalishe/free-turn-proxy/commit/4c0bed17976a81301005a4a68d3c660847bf7313))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* **captcha:** вынести inject.js из Go и убрать легаси image-капчу ([ea1cb93](https://github.com/samosvalishe/free-turn-proxy/commit/ea1cb930cc886471ec70567fe789cd807ff13da2))
|
||||
|
||||
## [1.7.1](https://github.com/samosvalishe/free-turn-proxy/compare/v1.7.0...v1.7.1) (2026-07-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **captcha:** устойчивый разбор debug_info и сигнал дрейфа версии скрипта ([3b33d33](https://github.com/samosvalishe/free-turn-proxy/commit/3b33d33b9e3c2d8d459b479e8d8a94fc562f18cb))
|
||||
|
||||
## [1.7.0](https://github.com/samosvalishe/free-turn-proxy/compare/v1.6.0...v1.7.0) (2026-07-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **mobile:** add host socket protector (SetProtect) for tun/VPN hosts ([0a6bc0a](https://github.com/samosvalishe/free-turn-proxy/commit/0a6bc0ae4f38f03722855f99461cc91f4181e057))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **vkauth:** fast-fail on terminal call/link errors (9000/9008, anonymous-only, full) ([805228e](https://github.com/samosvalishe/free-turn-proxy/commit/805228e62a42f71ab6c0d345e2a510af5cae32c3))
|
||||
* **vkauth:** классификация terminal-ошибок по коду перед текстом ([b391d7b](https://github.com/samosvalishe/free-turn-proxy/commit/b391d7bf255e6f027d655c7e1a20a21ce04ab22a))
|
||||
|
||||
## [1.6.0](https://github.com/samosvalishe/free-turn-proxy/compare/v1.5.2...v1.6.0) (2026-06-30)
|
||||
|
||||
|
||||
@@ -221,7 +552,7 @@
|
||||
* **docker:** исправить сборку, добавить compose, убрать VLESS_BOND ([dfbfae2](https://github.com/samosvalishe/free-turn-proxy/commit/dfbfae29014b3cd246550589640e871ee424807e))
|
||||
* **dtlsdial:** Dial использует хелпер GenerateSelfSignedCert внутри ([21138fe](https://github.com/samosvalishe/free-turn-proxy/commit/21138fef26b61f8ba4b72f8e5389d3b90b7026b2))
|
||||
* **dtlsdial:** унифицировать генерацию self-signed сертификата ([ef549dc](https://github.com/samosvalishe/free-turn-proxy/commit/ef549dc2b8405a1e8378d9c81f2ae3871495121f))
|
||||
* **install:** переписать установщик сервера — TUI, надёжность, non-interactive ([1b154a2](https://github.com/samosvalishe/free-turn-proxy/commit/1b154a2e84057c07344a4d7308ca7d26f5f127ec))
|
||||
* **install:** переписать установщик сервера - TUI, надёжность, non-interactive ([1b154a2](https://github.com/samosvalishe/free-turn-proxy/commit/1b154a2e84057c07344a4d7308ca7d26f5f127ec))
|
||||
* **lint:** устранить замечания golangci-lint + переход на dockers_v2 ([dd82332](https://github.com/samosvalishe/free-turn-proxy/commit/dd8233208ccbe60ea17745ddc17df7a8e6c56872))
|
||||
* **routes:** починить установку маршрутов в routes.ps1 на Windows ([7b0a907](https://github.com/samosvalishe/free-turn-proxy/commit/7b0a9076380bbcdc2f83d6da63ca3800b5d68412))
|
||||
* **server:** ограниченное ожидание второго сигнала; предупреждение при выключенном -wrap ([fb711c1](https://github.com/samosvalishe/free-turn-proxy/commit/fb711c12f5e1f78ee037ceed434b6cdde5a961d8))
|
||||
@@ -266,7 +597,7 @@
|
||||
* **tcpfwd:** заменить busy-loop poll пула на Ready-канал; тихий accept-цикл при shutdown ([2b24471](https://github.com/samosvalishe/free-turn-proxy/commit/2b24471553b7a9acb0c0117d7ce666bac758e97e))
|
||||
* **udprelay:** разбить на run/loop/listener ([d82258f](https://github.com/samosvalishe/free-turn-proxy/commit/d82258f844ef65ba06ba05aa7de4130c43f2c56e))
|
||||
* **vkauth:** разбить token.go на файлы по шагам ([0e2d42c](https://github.com/samosvalishe/free-turn-proxy/commit/0e2d42c9126505599db25d8c6f82956036c193be))
|
||||
* **wire:** переименовать srtpmimicry → rtpopus, заменить bool -obf на -obf-profile ([4bedd00](https://github.com/samosvalishe/free-turn-proxy/commit/4bedd00662bb75a5f2fdd8fc3cee9c8eb6d94e7c))
|
||||
* **wire:** переименовать srtpmimicry -> rtpopus, заменить bool -obf на -obf-profile ([4bedd00](https://github.com/samosvalishe/free-turn-proxy/commit/4bedd00662bb75a5f2fdd8fc3cee9c8eb6d94e7c))
|
||||
* **wrap:** заменить DTLS-мимикрию на noise-only AEAD ([e2dd09a](https://github.com/samosvalishe/free-turn-proxy/commit/e2dd09a7296f972285850786c6552d66006bf19d))
|
||||
* **wrap:** перейти на мимикрию под SRTP в обход content-фильтра VK TURN ([729557d](https://github.com/samosvalishe/free-turn-proxy/commit/729557d00d3f7b16e4d1d876adca23404eff738c))
|
||||
* **wrap:** переписать как мимикрию под DTLS 1.2 ApplicationData с AEAD ([39f95a8](https://github.com/samosvalishe/free-turn-proxy/commit/39f95a8e0c2ca3520c530e425aa55a5b586feadb))
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="logo.webp" height="250">
|
||||
|
||||

|
||||

|
||||

|
||||
@@ -10,14 +8,28 @@
|
||||
|
||||
## О проекте
|
||||
|
||||
**Free Turn Proxy** — универсальный прокси-туннель для инкапсуляции UDP/TCP трафика поверх протокола TURN. Клиент извлекает временные TURN-учётки из ссылок на WebRTC-звонки и прозрачно маршрутизирует ваш VPN-трафик (WireGuard, Xray/VLESS) до сервера на VPS, используя DTLS и мощные механизмы маскировки пакетов.
|
||||
**Free Turn Proxy** - универсальный прокси-туннель для инкапсуляции UDP/TCP трафика поверх протокола TURN. Клиент извлекает временные TURN-учётки из ссылок на WebRTC-звонки и прозрачно маршрутизирует ваш VPN-трафик (WireGuard, AmneziaWG, Xray/VLESS) до сервера на VPS, используя DTLS и механизмы маскировки пакетов.
|
||||
|
||||
## Android-приложение - рекомендуемый способ использования
|
||||
|
||||
- Добавляйте подключения по ссылке, QR-коду или вручную.
|
||||
- Устанавливайте и настраивайте сервер на VPS из приложения.
|
||||
- Переключайте серверы, настраивайте раздельное туннелирование и делитесь доступом.
|
||||
- Сохраняйте настройки в зашифрованную резервную копию.
|
||||
|
||||
[Скачать APK](https://github.com/hackdiaz-dev/turn-proxy-android/releases/latest)
|
||||
|
||||
Требования: Android 7.0+ и устройство ARM (arm64-v8a или armeabi-v7a). Для собственного подключения нужен VPS; в режиме Relay также нужна ссылка на звонок.
|
||||
|
||||
> [!WARNING]
|
||||
> При переходе Android-приложения на версию 5 потребуется настройка с нуля. Перед обновлением сохраните данные доступа к VPS и параметры подключения. Затем заново добавьте сервер или импортируйте новую ссылку и проверьте параметры VPN и раздельного туннелирования.
|
||||
|
||||
## Разработка
|
||||
|
||||
### Зависимости
|
||||
|
||||
- **Go** ≥ 1.26 — `https://go.dev/dl/`
|
||||
- **Task** (runner) — `go install github.com/go-task/task/v3/cmd/task@v3.40.0` или `winget install Task.Task` / `brew install go-task`
|
||||
- **Go** ≥ 1.26 - `https://go.dev/dl/`
|
||||
- **Task** (runner) - `go install github.com/go-task/task/v3/cmd/task@v3.40.0` или `winget install Task.Task` / `brew install go-task`
|
||||
|
||||
Остальные dev-инструменты (`golangci-lint`, `govulncheck`, `goimports`, `goreleaser`) ставит сам Task:
|
||||
|
||||
@@ -32,9 +44,9 @@ task # список доступных задач
|
||||
task build # собрать client + server в dist/ для текущего хоста
|
||||
task build:all # кросс-сборка всех target через goreleaser snapshot
|
||||
task build:ios # сборка XCFramework для iOS в dist/Mobile.xcframework
|
||||
task build:android # сборка AAR архива для Android в dist/mobile.aar
|
||||
task build:android # сборка AAR архива для Android в dist/freeturn.aar
|
||||
task test # go test -race
|
||||
task test:cover # тесты + покрытие → cover.html
|
||||
task test:cover # тесты + покрытие -> cover.html
|
||||
task lint # golangci-lint
|
||||
task fmt # gofmt + goimports (форматирование)
|
||||
task fmt:check # проверить форматирование (используется в CI)
|
||||
@@ -63,11 +75,4 @@ task clean # удалить dist/, cover.out, cover.html
|
||||
- [@cacggghp](https://github.com/cacggghp)
|
||||
- [@Moroka8](https://github.com/Moroka8)
|
||||
- [@alxmcp](https://github.com/alxmcp)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Telegram канал: [Free Turn](https://t.me/+5BdkU4q_CGQyNTdi)
|
||||
|
||||
</div>
|
||||
- [@TheAirBlow](https://github.com/TheAirBlow)
|
||||
|
||||
+33
-8
@@ -6,9 +6,10 @@ vars:
|
||||
sh: git describe --tags --always --dirty 2>/dev/null || echo dev
|
||||
LDFLAGS: -s -w -checklinkname=0 -X main.version={{.VERSION}}
|
||||
GOLANGCI_VERSION: v2.12.2
|
||||
GOVULN_VERSION: v1.1.4
|
||||
GORELEASER_VERSION: v2.15.4
|
||||
TASK_VERSION: v3.51.1
|
||||
GOVULN_VERSION: v1.6.0
|
||||
GOIMPORTS_VERSION: v0.49.0
|
||||
GORELEASER_VERSION: v2.17.1
|
||||
TASK_VERSION: v3.52.0
|
||||
|
||||
tasks:
|
||||
default:
|
||||
@@ -25,6 +26,11 @@ tasks:
|
||||
build:all:
|
||||
desc: Cross-compile all targets via goreleaser snapshot
|
||||
cmds:
|
||||
# checksum.extra_files глобит mobile-dist, а снапшот идёт без mobile-таргетов
|
||||
- cmd: mkdir -p mobile-dist && touch mobile-dist/freeturn.aar mobile-dist/freeturn-sources.jar mobile-dist/Mobile.xcframework.zip
|
||||
platforms: [linux, darwin]
|
||||
- cmd: powershell -NoProfile -Command 'New-Item -ItemType Directory -Force mobile-dist | Out-Null; "freeturn.aar","freeturn-sources.jar","Mobile.xcframework.zip" | ForEach-Object { $p = "mobile-dist/$_"; if (-not (Test-Path $p)) { New-Item -ItemType File $p | Out-Null } }'
|
||||
platforms: [windows]
|
||||
- goreleaser release --snapshot --clean --skip=publish,sign
|
||||
|
||||
build:ios:
|
||||
@@ -36,14 +42,23 @@ tasks:
|
||||
- gomobile bind -target ios,iossimulator -ldflags "-checklinkname=0" -o dist/Mobile.xcframework ./mobile
|
||||
|
||||
build:android:
|
||||
desc: Build Android Archive (AAR) to dist/mobile.aar
|
||||
desc: Build Android Archive (AAR) to dist/freeturn.aar
|
||||
vars:
|
||||
# ABI ровно те, что собирает приложение (splits в build.gradle.kts).
|
||||
# androidapi 24 - minSdk приложения.
|
||||
# -s -w срезают отладочную информацию: 23 МБ -> 13 МБ на оба ABI.
|
||||
GOMOBILE_FLAGS: >-
|
||||
-target android/arm64,android/arm
|
||||
-androidapi 24
|
||||
-javapkg com.freeturn.core
|
||||
-ldflags "-checklinkname=0 -s -w -X github.com/samosvalishe/free-turn-proxy/mobile.version={{.VERSION}}"
|
||||
cmds:
|
||||
- rm -rf dist/mobile.aar dist/mobile-sources.jar
|
||||
- rm -rf dist/freeturn.aar dist/freeturn-sources.jar
|
||||
- mkdir -p dist
|
||||
- cmd: gomobile bind -target android -androidapi 21 -ldflags "-checklinkname=0" -o dist/mobile.aar ./mobile
|
||||
- cmd: gomobile bind {{.GOMOBILE_FLAGS}} -o dist/freeturn.aar ./mobile
|
||||
platforms: [linux, darwin]
|
||||
- cmd: |
|
||||
powershell -NoProfile -Command '$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:PATH = "$env:JAVA_HOME\bin;$env:PATH"; gomobile bind -target android -androidapi 21 -ldflags "-checklinkname=0" -o dist/mobile.aar ./mobile'
|
||||
powershell -NoProfile -Command '$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:PATH = "$env:JAVA_HOME\bin;$env:PATH"; gomobile bind {{.GOMOBILE_FLAGS}} -o dist/freeturn.aar ./mobile'
|
||||
platforms: [windows]
|
||||
|
||||
test:
|
||||
@@ -51,6 +66,11 @@ tasks:
|
||||
cmds:
|
||||
- go test -race -count=1 ./...
|
||||
|
||||
test:sh:
|
||||
desc: Run install.sh tests (bats)
|
||||
cmds:
|
||||
- bats scripts/tests
|
||||
|
||||
test:cover:
|
||||
desc: Run tests with coverage report (cover.html)
|
||||
cmds:
|
||||
@@ -89,6 +109,11 @@ tasks:
|
||||
cmds:
|
||||
- govulncheck ./...
|
||||
|
||||
captcha:page:
|
||||
desc: Fetch a live VK captcha page (diff it against the parser fixture)
|
||||
cmds:
|
||||
- bash scripts/captcha_page.sh {{.CLI_ARGS | default "captcha_page.html"}}
|
||||
|
||||
tidy:
|
||||
desc: Tidy go.mod / go.sum
|
||||
cmds:
|
||||
@@ -101,7 +126,7 @@ tasks:
|
||||
tools:install:
|
||||
desc: Install dev tools (golangci-lint, govulncheck, goreleaser, goimports)
|
||||
cmds:
|
||||
- go install golang.org/x/tools/cmd/goimports@latest
|
||||
- go install golang.org/x/tools/cmd/goimports@{{.GOIMPORTS_VERSION}}
|
||||
- go install golang.org/x/vuln/cmd/govulncheck@{{.GOVULN_VERSION}}
|
||||
- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@{{.GOLANGCI_VERSION}}
|
||||
- go install github.com/goreleaser/goreleaser/v2@{{.GORELEASER_VERSION}}
|
||||
|
||||
+33
-240
@@ -6,45 +6,29 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/client/dnsdial"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/clientid"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/config"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/logx"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/multi"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/proxy/bondclient"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/proxy/tcpfwd"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/proxy/udprelay"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/session"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/shutdown"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/sub"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/transport/dtlsdial"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/wire/rtpopus"
|
||||
)
|
||||
|
||||
// version is populated at build time via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
const dtlsHandshakeConcurrency = 3
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
|
||||
// -sub: тянем список серверов до парсинга и подсовываем URI первой ноды
|
||||
// (Nodes[0], без failover) позиционным freeturn:// - ParseClient применит его
|
||||
// тем же путём, что и URI из CLI. Подписка должна стоять до парсинга: она даёт
|
||||
// peer, без которого ParseClient падает на валидации.
|
||||
// Резолв подписки до парсинга даёт обязательный peer для валидации.
|
||||
if subURL := config.PeekSubURL(args); subURL != "" {
|
||||
sub.SetLogger(logx.New(false))
|
||||
s, ferr := sub.Fetch(context.Background(), subURL)
|
||||
if ferr != nil {
|
||||
log.Fatalf("failed to fetch subscription: %v", ferr)
|
||||
@@ -57,250 +41,59 @@ func main() {
|
||||
|
||||
cfg, err := config.ParseClient(args, os.Stderr)
|
||||
if err != nil {
|
||||
// -help/-h: usage уже напечатан в ParseClient, выходим штатно.
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
os.Exit(0)
|
||||
}
|
||||
// логгер ещё не создан - единственный fatal до его инициализации.
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
cfg.ClientID = resolveClientID(cfg.ClientID)
|
||||
|
||||
logger := logx.New(cfg.Log.Debug)
|
||||
logger.Infof("Free Turn Proxy client version=%s", version)
|
||||
logger.Infof("Client ID: %s", cfg.ClientID)
|
||||
dnsdial.SetLogger(logger)
|
||||
signalChan := make(chan os.Signal, 1)
|
||||
signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT)
|
||||
go func() {
|
||||
<-signalChan
|
||||
logger.Infof("Terminating...")
|
||||
cancel()
|
||||
select {
|
||||
case <-signalChan:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
logger.Errorf("Exit...")
|
||||
cancel()
|
||||
os.Exit(1)
|
||||
}()
|
||||
|
||||
if cfg.DNS.Servers != nil {
|
||||
dnsdial.SetUDPDNSServers(cfg.DNS.Servers)
|
||||
logger.Infof("[DNS] using custom UDP servers: %v", cfg.DNS.Servers)
|
||||
}
|
||||
appDialer := dnsdial.AppDialer(cfg.DNS.Mode)
|
||||
dnsdial.InstallGlobalResolver(cfg.DNS.Mode)
|
||||
if cfg.Obf.GenKey {
|
||||
key, gerr := rtpopus.GenKeyHex()
|
||||
if gerr != nil {
|
||||
logger.Errorf("gen-obf-key: %v", gerr)
|
||||
os.Exit(1)
|
||||
log.Fatalf("gen-obf-key: %v", gerr)
|
||||
}
|
||||
fmt.Println(key)
|
||||
return
|
||||
}
|
||||
peer, err := net.ResolveUDPAddr("udp", cfg.Proxy.Peer)
|
||||
|
||||
logger := logx.New(cfg.Log.Debug)
|
||||
logger.Infof("Free Turn Proxy client version=%s", version)
|
||||
|
||||
idPaths := clientid.DefaultPaths()
|
||||
id, persisted, err := clientid.Resolve(cfg.ClientID, idPaths)
|
||||
if err != nil {
|
||||
logger.Errorf("resolve peer addr: %v", err)
|
||||
logger.Errorf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if cfg.Obf.Enabled() {
|
||||
logger.Infof("OBF profile=%s: peer server must use matching -obf-profile and -obf-key", cfg.Obf.Profile)
|
||||
if !persisted {
|
||||
logger.Warnf("client ID не сохранён ни по одному пути (%v) - будет новым при следующем запуске", idPaths)
|
||||
}
|
||||
cfg.ClientID = id
|
||||
logger.Infof("Client ID: %s", cfg.ClientID)
|
||||
|
||||
if cfg.Tunnel.Enabled() {
|
||||
logger.Warnf("ссылка содержит конфиг %s: CLI встроенный туннель не поднимает, запустите WireGuard/AmneziaWG отдельно", cfg.Tunnel.Mode)
|
||||
cfg.Tunnel.Mode = tunnel.ModeNone
|
||||
}
|
||||
|
||||
var connectedStreams atomic.Int32
|
||||
ctx, stop := shutdown.Watch(context.Background(), logger)
|
||||
defer stop()
|
||||
|
||||
prov, err := buildProvider(cfg, appDialer, &connectedStreams, logger)
|
||||
sess, err := session.New(cfg, session.Deps{
|
||||
Logger: logger,
|
||||
Solver: vk.DefaultManualSolver,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("provider init: %v", err)
|
||||
logger.Errorf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Infof("provider=%s", prov.Name())
|
||||
|
||||
getCreds := func(ctx context.Context, streamID int) (string, string, []string, error) {
|
||||
c, err := prov.GetCredentials(ctx, streamID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
return c.User, c.Pass, c.ServerAddrs, nil
|
||||
}
|
||||
|
||||
// Несколько -links расширяют пул: каждый звонок даёт cfg.TURN.N стримов,
|
||||
// все объединяются в общий пул (больше параллельных TURN-аллокаций).
|
||||
totalStreams := cfg.TURN.N * max(len(cfg.VK.Links), 1)
|
||||
|
||||
if cfg.Proxy.Mode != config.ProxyModeUDP {
|
||||
tcpDtlsDialer := &dtlsdial.Dialer{
|
||||
HandshakeTimeout: 30 * time.Second,
|
||||
HandshakeSem: make(chan struct{}, dtlsHandshakeConcurrency),
|
||||
}
|
||||
bondH := &bondclient.Handler{Deps: bondclient.Deps{Log: logger}}
|
||||
tcpDeps := &tcpfwd.Deps{
|
||||
DTLSDialer: tcpDtlsDialer,
|
||||
Log: logger,
|
||||
BondHandler: bondH.Handle,
|
||||
}
|
||||
tcpParams := &tcpfwd.Params{
|
||||
Host: cfg.TURN.Host,
|
||||
Port: cfg.TURN.Port,
|
||||
TransportUDP: cfg.TURN.TransportUDP,
|
||||
Profile: string(cfg.Obf.Profile),
|
||||
ObfKey: cfg.Obf.Key,
|
||||
GetCreds: tcpfwd.GetCredsFunc(getCreds),
|
||||
KCPProfile: cfg.KCP.Profile,
|
||||
KCPFEC: cfg.KCP.FEC,
|
||||
ClientID: cfg.ClientID,
|
||||
}
|
||||
if err := tcpfwd.Run(ctx, tcpDeps, tcpParams, peer, cfg.Proxy.Listen, totalStreams, cfg.Proxy.Mode == config.ProxyModeTCPFwdBond); err != nil {
|
||||
logger.Errorf("tcpfwd: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
udpDtlsDialer := &dtlsdial.Dialer{
|
||||
HandshakeTimeout: 20 * time.Second,
|
||||
HandshakeSem: make(chan struct{}, dtlsHandshakeConcurrency),
|
||||
}
|
||||
udpParams := &udprelay.Params{
|
||||
Host: cfg.TURN.Host,
|
||||
Port: cfg.TURN.Port,
|
||||
TransportUDP: cfg.TURN.TransportUDP,
|
||||
Profile: string(cfg.Obf.Profile),
|
||||
ObfKey: cfg.Obf.Key,
|
||||
ObfTiming: cfg.Obf.Timing,
|
||||
GetCreds: udprelay.GetCredsFunc(getCreds),
|
||||
ClientID: cfg.ClientID,
|
||||
}
|
||||
if err := udprelay.Run(ctx, udpDtlsDialer, prov, logger, &connectedStreams, udpParams, peer, cfg.Proxy.Listen, totalStreams); err != nil {
|
||||
if err := sess.Run(ctx); err != nil {
|
||||
if errors.Is(err, udprelay.ErrFatal) {
|
||||
logger.Errorf("udprelay: fatal: %v", err)
|
||||
logger.Errorf("fatal: %v", err)
|
||||
} else {
|
||||
logger.Errorf("udprelay: %v", err)
|
||||
logger.Errorf("%v", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// buildProvider строит provider.Provider по cfg.Provider.Name. Одна -links даёт
|
||||
// обычный vk.Provider; несколько - multi.Provider (один vk.Provider на ссылку).
|
||||
func buildProvider(cfg *config.Client, dialer net.Dialer, connected *atomic.Int32, logger logx.Logger) (provider.Provider, error) {
|
||||
switch cfg.Provider.Name {
|
||||
case config.ProviderVK:
|
||||
if len(cfg.VK.Links) == 0 {
|
||||
return nil, fmt.Errorf("vk: no links configured")
|
||||
}
|
||||
newVK := func(link string) (provider.Provider, error) {
|
||||
return vk.New(vk.Config{
|
||||
Link: link,
|
||||
Dialer: dialer,
|
||||
ManualOnly: cfg.VK.ManualCaptcha,
|
||||
Browser: string(cfg.VK.Browser),
|
||||
StreamsPerCache: cfg.VK.StreamsPerCred,
|
||||
StreamsAlive: connected.Load,
|
||||
Log: logger,
|
||||
Debug: cfg.Log.Debug,
|
||||
}, vk.DefaultManualSolver)
|
||||
}
|
||||
if len(cfg.VK.Links) == 1 {
|
||||
return newVK(cfg.VK.Links[0])
|
||||
}
|
||||
providers := make([]provider.Provider, 0, len(cfg.VK.Links))
|
||||
for i, link := range cfg.VK.Links {
|
||||
p, err := newVK(link)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vk provider [%d]: %w", i, err)
|
||||
}
|
||||
providers = append(providers, p)
|
||||
}
|
||||
logger.Infof("multi-provider: %d VK links, %d total streams", len(providers), cfg.TURN.N*len(providers))
|
||||
return multi.New(providers), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown provider %q", cfg.Provider.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveClientID(cliID string) string {
|
||||
if cliID != "" {
|
||||
return cliID
|
||||
}
|
||||
|
||||
type localCfg struct {
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
paths := clientConfigPaths()
|
||||
|
||||
// Чтение: первый файл с непустым client_id.
|
||||
for _, path := range paths {
|
||||
b, err := os.ReadFile(path) //nolint:gosec // фиксированное имя из clientConfigPaths, не пользовательский ввод
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var lc localCfg
|
||||
if err := json.Unmarshal(b, &lc); err == nil && lc.ClientID != "" {
|
||||
return lc.ClientID
|
||||
}
|
||||
}
|
||||
|
||||
// Generate 16 bytes hex ID
|
||||
idBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(idBytes); err != nil {
|
||||
log.Fatalf("failed to generate random client ID: %v", err)
|
||||
}
|
||||
newID := hex.EncodeToString(idBytes)
|
||||
|
||||
lc := localCfg{ClientID: newID}
|
||||
b, _ := json.MarshalIndent(lc, "", " ")
|
||||
|
||||
// Запись: первый доступный для записи путь. На Android каталог рядом с
|
||||
// бинарём (/data/app/.../lib/arm64) read-only - падаем на UserConfigDir,
|
||||
// затем TempDir. Иначе ID не сохраняется и ротируется на каждый запуск,
|
||||
// ломая allowlist (-clients-file) и статистику.
|
||||
for _, path := range paths {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o600); err == nil { //nolint:gosec // 0o600 для auth-токена
|
||||
return newID
|
||||
}
|
||||
}
|
||||
log.Printf("warning: failed to persist client ID to any writable path (%v); ID will rotate next launch", paths)
|
||||
|
||||
return newID
|
||||
}
|
||||
|
||||
// clientConfigPaths возвращает кандидатов client_config.json в порядке
|
||||
// предпочтения: рядом с бинарём (desktop, переносимость), затем per-user
|
||||
// UserConfigDir и TempDir (Android, где каталог бинаря read-only).
|
||||
func clientConfigPaths() []string {
|
||||
const name = "client_config.json"
|
||||
seen := map[string]bool{}
|
||||
var dirs []string
|
||||
add := func(d string) {
|
||||
if d == "" || seen[d] {
|
||||
return
|
||||
}
|
||||
seen[d] = true
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
add(filepath.Dir(exe))
|
||||
}
|
||||
add(filepath.Dir(os.Args[0]))
|
||||
if cfgDir, err := os.UserConfigDir(); err == nil {
|
||||
add(filepath.Join(cfgDir, "free-turn-proxy"))
|
||||
}
|
||||
add(os.TempDir())
|
||||
|
||||
paths := make([]string, 0, len(dirs))
|
||||
for _, d := range dirs {
|
||||
paths = append(paths, filepath.Join(d, name))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/clientsdb"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/config"
|
||||
)
|
||||
|
||||
func TestAdmitTCPAndBond(t *testing.T) {
|
||||
for _, mode := range []config.ProxyMode{config.ProxyModeTCP, config.ProxyModeUDP} {
|
||||
cfg := &config.Server{Proxy: config.ProxyOpts{Mode: mode}}
|
||||
for _, tag := range []byte{clientsdb.ModeUnset, clientsdb.ModeUDP, clientsdb.ModeTCP, clientsdb.ModeTCPBond, 255} {
|
||||
err := admitClient(cfg, nil, "test-client", tag)
|
||||
want := (mode == config.ProxyModeTCP && (tag == clientsdb.ModeTCP || tag == clientsdb.ModeTCPBond)) || (mode == config.ProxyModeUDP && (tag == clientsdb.ModeUnset || tag == clientsdb.ModeUDP))
|
||||
if (err == nil) != want {
|
||||
t.Fatalf("mode=%s tag=%d err=%v", mode, tag, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-75
@@ -8,18 +8,18 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/pion/dtls/v3"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/clientsdb"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/config"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/logx"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/proxy/bondserver"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/proxy/tcpfwdserver"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/proxy/bond"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/proxy/tcpserver"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/proxy/udpserver"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/safego"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/shutdown"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/transport/dtlsdial"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/wire"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/wire/rtpopus"
|
||||
@@ -36,11 +36,9 @@ func main() {
|
||||
|
||||
cfg, err := config.ParseServer(os.Args[1:], os.Stderr)
|
||||
if err != nil {
|
||||
// -help/-h: usage уже напечатан в ParseServer, выходим штатно.
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
os.Exit(0)
|
||||
}
|
||||
// логгер ещё не создан - единственный fatal до его инициализации.
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
logger := logx.New(cfg.Log.Debug)
|
||||
@@ -56,40 +54,53 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
signalChan := make(chan os.Signal, 1)
|
||||
signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT)
|
||||
go func() {
|
||||
<-signalChan
|
||||
logger.Infof("Terminating...")
|
||||
cancel()
|
||||
select {
|
||||
case <-signalChan:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
logger.Warnf("Forced exit after shutdown timeout")
|
||||
cancel()
|
||||
os.Exit(1)
|
||||
}()
|
||||
ctx, stop := shutdown.Watch(context.Background(), logger)
|
||||
defer stop()
|
||||
|
||||
addr, err := net.ResolveUDPAddr("udp", cfg.Proxy.Listen)
|
||||
if err != nil {
|
||||
logger.Errorf("resolve listen addr: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Infof("Starting server listen=%s connect=%s mode=%s obf-profile=%s bond-autodetect=true",
|
||||
cfg.Proxy.Listen, cfg.Proxy.Connect, cfg.Proxy.Mode, cfg.Obf.Profile)
|
||||
logger.Infof("Starting server listen=%s connect=%s obf-profile=%s",
|
||||
cfg.Proxy.Listen, cfg.Proxy.Connect, cfg.Obf.Profile)
|
||||
if !cfg.Obf.Enabled() {
|
||||
logger.Warnf("running with -obf-profile=none: any client reaching %s can relay to %s (no shared-key auth)", cfg.Proxy.Listen, cfg.Proxy.Connect)
|
||||
}
|
||||
|
||||
certificate, genErr := dtlsdial.GenerateSelfSignedCert()
|
||||
if genErr != nil {
|
||||
logger.Errorf("self-signed cert: %v", genErr)
|
||||
listener, err := listen(cfg, addr, logger)
|
||||
if err != nil {
|
||||
logger.Errorf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
context.AfterFunc(ctx, func() {
|
||||
if err := listener.Close(); err != nil {
|
||||
logger.Errorf("listener close: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
logger.Infof("Listening on %s", cfg.Proxy.Listen)
|
||||
|
||||
var db *clientsdb.DB
|
||||
if cfg.ClientsFile != "" {
|
||||
d, err := clientsdb.New(cfg.ClientsFile)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to open clients-file: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
d.StartHotReload(ctx, 10*time.Second)
|
||||
db = d
|
||||
logger.Infof("Client ID authorization enabled via %s", cfg.ClientsFile)
|
||||
}
|
||||
|
||||
serve(ctx, logger, listener, db, cfg)
|
||||
}
|
||||
|
||||
func listen(cfg *config.Server, addr *net.UDPAddr, logger logx.Logger) (net.Listener, error) {
|
||||
certificate, err := dtlsdial.GenerateSelfSignedCert()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("self-signed cert: %w", err)
|
||||
}
|
||||
dtlsOpts := []dtls.ServerOption{
|
||||
dtls.WithCertificates(certificate),
|
||||
dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
|
||||
@@ -101,40 +112,22 @@ func main() {
|
||||
logger.Infof("OBF profile=%s: listener only accepts clients with matching -obf-profile and -obf-key", cfg.Obf.Profile)
|
||||
obfListener, oerr := wire.Listen(string(cfg.Obf.Profile), addr, cfg.Obf.Key, cfg.Obf.Timing)
|
||||
if oerr != nil {
|
||||
logger.Errorf("obf listen: %v", oerr)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("obf listen: %w", oerr)
|
||||
}
|
||||
listener, err = dtls.NewListenerWithOptions(obfListener, dtlsOpts...)
|
||||
} else {
|
||||
listener, err = dtls.ListenWithOptions("udp", addr, dtlsOpts...)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Errorf("dtls listen: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
context.AfterFunc(ctx, func() {
|
||||
if err := listener.Close(); err != nil {
|
||||
logger.Errorf("listener close: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
logger.Infof("Listening on %s", cfg.Proxy.Listen)
|
||||
|
||||
registry := bondserver.NewRegistry(bondserver.Deps{Log: logger})
|
||||
|
||||
var db *clientsdb.DB
|
||||
if cfg.ClientsFile != "" {
|
||||
d, err := clientsdb.New(cfg.ClientsFile)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to open clients-file: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
d.StartHotReload(10 * time.Second)
|
||||
db = d
|
||||
logger.Infof("Client ID authorization enabled via %s", cfg.ClientsFile)
|
||||
return nil, fmt.Errorf("dtls listen: %w", err)
|
||||
}
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
func serve(ctx context.Context, logger logx.Logger, listener net.Listener, db *clientsdb.DB, cfg *config.Server) {
|
||||
bonds := &bond.Server{}
|
||||
var wg sync.WaitGroup
|
||||
var backoff time.Duration
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -148,16 +141,57 @@ func main() {
|
||||
wg.Wait()
|
||||
return
|
||||
}
|
||||
logger.Warnf("accept: %v", err)
|
||||
// Отказ обычно устойчив (EMFILE): без паузы цикл сжёг бы ядро на ретраях.
|
||||
backoff = nextAcceptBackoff(backoff)
|
||||
logger.Warnf("accept: %v, retry in %s", err, backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
wg.Wait()
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = 0
|
||||
wg.Go(func() {
|
||||
handleAccepted(ctx, logger, registry, db, conn, cfg)
|
||||
_ = safego.Run(logger, func() { handleAccepted(ctx, logger, db, conn, cfg, bonds) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleAccepted(ctx context.Context, logger logx.Logger, registry *bondserver.Registry, db *clientsdb.DB, conn net.Conn, cfg *config.Server) {
|
||||
const (
|
||||
minAcceptBackoff = 5 * time.Millisecond
|
||||
maxAcceptBackoff = time.Second
|
||||
)
|
||||
|
||||
func nextAcceptBackoff(d time.Duration) time.Duration {
|
||||
if d <= 0 {
|
||||
return minAcceptBackoff
|
||||
}
|
||||
if d *= 2; d > maxAcceptBackoff {
|
||||
return maxAcceptBackoff
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func wireMode(m config.ProxyMode) byte {
|
||||
if m == config.ProxyModeTCP {
|
||||
return clientsdb.ModeTCP
|
||||
}
|
||||
return clientsdb.ModeUDP
|
||||
}
|
||||
|
||||
func modeName(b byte) string {
|
||||
if b == clientsdb.ModeTCPBond {
|
||||
return "tcp-bond"
|
||||
}
|
||||
if b == clientsdb.ModeTCP {
|
||||
return string(config.ProxyModeTCP)
|
||||
}
|
||||
return string(config.ProxyModeUDP)
|
||||
}
|
||||
|
||||
func handleAccepted(ctx context.Context, logger logx.Logger, db *clientsdb.DB, conn net.Conn, cfg *config.Server, bonds *bond.Server) {
|
||||
defer func() {
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
logger.Warnf("failed to close incoming connection: %s", closeErr)
|
||||
@@ -175,35 +209,51 @@ func handleAccepted(ctx context.Context, logger logx.Logger, registry *bondserve
|
||||
}
|
||||
logger.Debugf("Start handshake")
|
||||
if err := dtlsConn.HandshakeContext(ctx1); err != nil {
|
||||
logger.Warnf("Handshake failed: %v", err)
|
||||
// Адрес обязателен: пачка таймаутов с новых адресов - признак того, что клиент
|
||||
// сменил relayed-адрес, а его сессия сюда не смигрировала.
|
||||
logger.Warnf("Handshake failed from %s: %v", conn.RemoteAddr(), err)
|
||||
return
|
||||
}
|
||||
logger.Debugf("Handshake done")
|
||||
|
||||
// Client ID читается всегда (клиент всегда шлёт его первой app-record) -
|
||||
// wire-контракт симметричен. -clients-file включает только enforce по allowlist.
|
||||
clientID, err := clientsdb.ReadClientID(dtlsConn)
|
||||
var clientMode byte
|
||||
clientID, data, err := clientsdb.AcceptClientID(dtlsConn, func(id string, mode byte) error {
|
||||
clientMode = mode
|
||||
return admitClient(cfg, db, id, mode)
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Read Client ID failed: %v", err)
|
||||
logger.Warnf("Client ID from %s rejected: %v", conn.RemoteAddr(), err)
|
||||
return
|
||||
}
|
||||
if db != nil {
|
||||
if !db.IsAuthorized(clientID) {
|
||||
logger.Warnf("Unauthorized Client ID: %s. Dropping connection.", clientID)
|
||||
return
|
||||
}
|
||||
logger.Debugf("Client %s authorized", clientID)
|
||||
} else {
|
||||
logger.Debugf("Client ID received (no allowlist): %s", clientID)
|
||||
}
|
||||
|
||||
if cfg.Proxy.Mode == config.ProxyModeTCPFwd {
|
||||
tcpfwdserver.Handle(ctx, logger, registry, dtlsConn, cfg.Proxy.Connect, cfg.KCP.Profile, cfg.KCP.FEC)
|
||||
} else {
|
||||
udpserver.Handle(ctx, logger, conn, cfg.Proxy.Connect)
|
||||
logger.Infof("Session up: client=%s from=%s", clientID, conn.RemoteAddr())
|
||||
switch {
|
||||
case clientMode == clientsdb.ModeTCPBond:
|
||||
tcpserver.HandleBond(ctx, logger, data, cfg.Proxy.Connect, cfg.KCP.Profile, bonds, clientID)
|
||||
case cfg.Proxy.Mode == config.ProxyModeTCP:
|
||||
tcpserver.Handle(ctx, logger, data, cfg.Proxy.Connect, cfg.KCP.Profile)
|
||||
default:
|
||||
udpserver.Handle(ctx, logger, data, cfg.Proxy.Connect)
|
||||
}
|
||||
logger.Infof("Session down: client=%s from=%s", clientID, conn.RemoteAddr())
|
||||
}
|
||||
|
||||
logger.Debugf("Connection closed: %s", conn.RemoteAddr())
|
||||
// admitClient: режим клиента обязан совпасть с сервером, ID - быть в allowlist (если он есть).
|
||||
func admitClient(cfg *config.Server, db *clientsdb.DB, id string, mode byte) error {
|
||||
if mode == clientsdb.ModeTCPBond {
|
||||
mode = clientsdb.ModeTCP
|
||||
}
|
||||
if want := wireMode(cfg.Proxy.Mode); mode != clientsdb.ModeUnset && mode != want {
|
||||
return fmt.Errorf("mode mismatch: клиент %s, сервер %s - приведите -mode к одному значению",
|
||||
modeName(mode), cfg.Proxy.Mode)
|
||||
}
|
||||
if mode == clientsdb.ModeUnset && cfg.Proxy.Mode == config.ProxyModeTCP {
|
||||
return errors.New("mode mismatch: клиент без тега режима (udp), сервер tcp")
|
||||
}
|
||||
if db != nil && !db.IsAuthorized(id) {
|
||||
return fmt.Errorf("unauthorized client ID %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleClientsCommand(args []string) {
|
||||
@@ -212,7 +262,6 @@ func handleClientsCommand(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Файл по умолчанию или из переменной окружения
|
||||
dbPath := "clients.json"
|
||||
if envPath := os.Getenv("CLIENTS_FILE"); envPath != "" {
|
||||
dbPath = envPath
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
free-turn-proxy:
|
||||
image: ghcr.io/samosvalishe/free-turn-proxy:latest
|
||||
image: ghcr.io/hackdiaz-dev/free-turn-proxy:latest
|
||||
# build: .
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
@@ -10,12 +10,13 @@ services:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
# Backend address: WireGuard → 127.0.0.1:51820 | Xray/sing-box → 127.0.0.1:443
|
||||
# Backend address: WireGuard -> 127.0.0.1:51820 | Xray/sing-box -> 127.0.0.1:443
|
||||
CONNECT_ADDR: "127.0.0.1:51820"
|
||||
# LISTEN_ADDR: "0.0.0.0:56000"
|
||||
# MODE: "tcp" # tcp = TCP-форвардер для Xray/sing-box; default udp = WireGuard
|
||||
# OBF_PROFILE: "rtpopus" # none (default) | rtpopus
|
||||
# OBF_TIMING: "20ms" # межпакетная задержка для RTP-мимикрии; 0=выкл; только с OBF_PROFILE и MODE=udp
|
||||
# OBF_TIMING: "20ms" # межпакетная задержка для RTP-мимикрии; 0=выкл; только с OBF_PROFILE
|
||||
# KCP_INTERVAL / KCP_SNDWND / KCP_RCVWND / KCP_MTU / KCP_ACKNODELAY ... # тюнинг ARQ, только с MODE=tcp
|
||||
# OBF_KEY задаётся в .env (не коммитьте ключ в git)
|
||||
# CLIENTS_FILE: "/app/clients.json" # раскомментируйте для авторизации по Client ID
|
||||
# DEBUG: "false"
|
||||
|
||||
+13
-2
@@ -11,10 +11,21 @@ esac
|
||||
|
||||
set -- -listen "$LISTEN" -connect "$CONNECT"
|
||||
|
||||
if [ "${MODE}" = "tcp" ]; then
|
||||
set -- "$@" -mode tcp
|
||||
if [ -n "${MODE}" ]; then
|
||||
set -- "$@" -mode "$MODE"
|
||||
fi
|
||||
|
||||
for kcp_var in NODELAY INTERVAL RESEND NC SNDWND RCVWND MTU ACKNODELAY; do
|
||||
eval "kcp_val=\${KCP_${kcp_var}}"
|
||||
if [ -n "${kcp_val}" ]; then
|
||||
kcp_flag="-kcp-$(echo "$kcp_var" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$kcp_var" in
|
||||
ACKNODELAY) set -- "$@" "${kcp_flag}=${kcp_val}" ;;
|
||||
*) set -- "$@" "$kcp_flag" "$kcp_val" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "${OBF_PROFILE}" ] && [ "${OBF_PROFILE}" != "none" ]; then
|
||||
OBF="${OBF_KEY:?OBF_KEY is required when OBF_PROFILE != none}"
|
||||
set -- "$@" -obf-profile "$OBF_PROFILE" -obf-key "$OBF"
|
||||
|
||||
+89
-120
@@ -1,138 +1,107 @@
|
||||
# Развёртывание сервера
|
||||
|
||||
Для работы сервера 24/7 его необходимо настроить как службу. Мы поддерживаем установку через скрипт (интерактивно или автоматически), а также ручную настройку через **Docker** или **systemd**.
|
||||
## Установка скриптом (рекомендуется)
|
||||
|
||||
---
|
||||
|
||||
## Автоматическая установка (Рекомендуется)
|
||||
|
||||
Проще всего использовать официальный скрипт. Он установит зависимости, сгенерирует ключи, настроит файрвол и запустит службу.
|
||||
|
||||
**Интерактивный режим** (задаст вопросы в терминале):
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | sudo bash
|
||||
curl -fsSL https://raw.githubusercontent.com/hackdiaz-dev/free-turn-proxy/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
> Скрипт идемпотентен: при повторном запуске он предложит обновить, переконфигурировать или удалить сервер.
|
||||
|
||||
**Неинтерактивный режим** (для автоматизации):
|
||||
Мастер спросит:
|
||||
|
||||
1. **VPN на сервере** - новый WireGuard (скрипт поднимет его сам) или уже установленный VPN (Amnezia, wg-easy, 3x-ui и т.п.: указываете его `host:port`).
|
||||
2. **Запуск** - `docker` или `systemd`.
|
||||
3. **Порт и обфускация.**
|
||||
|
||||
В конце - ссылка `freeturn://` для приложения (и WireGuard-конфиг, если VPN новый).
|
||||
|
||||
Управление:
|
||||
|
||||
```bash
|
||||
# Установка через Docker (UDP, порт бэкенда 51820)
|
||||
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | \
|
||||
sudo bash -s -- -y --method docker --mode udp --backend-port 51820
|
||||
|
||||
# Обновление до конкретной версии
|
||||
sudo bash install.sh -y --update --version v1.2.3
|
||||
|
||||
# Полное удаление
|
||||
sudo bash install.sh -y --uninstall --purge
|
||||
freeturn # меню: клиенты, настройки, обновление, логи, удаление
|
||||
freeturn client add phone # новый клиент и его ссылка
|
||||
freeturn client list
|
||||
freeturn client qr phone # показать ссылку и QR ещё раз
|
||||
freeturn client remove phone
|
||||
```
|
||||
*Все доступные флаги:* `sudo bash install.sh --help`
|
||||
|
||||
---
|
||||
Без вопросов:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/hackdiaz-dev/free-turn-proxy/master/scripts/install.sh | \
|
||||
sudo bash -s -- -y --backend=new --method=docker
|
||||
```
|
||||
|
||||
Все флаги: `freeturn --help`.
|
||||
|
||||
## Ручная установка
|
||||
|
||||
Если вы предпочитаете контролировать каждый шаг, используйте инструкции ниже.
|
||||
Сначала сгенерируйте 64-символьный hex-ключ обфускации (он должен совпадать на сервере и клиенте):
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
*В примерах ниже он обозначен как `<ВАШ_КЛЮЧ>`.*
|
||||
Ключ обфускации (одинаковый на сервере и клиенте): `openssl rand -hex 32`.
|
||||
|
||||
### Способ 1: Docker Compose
|
||||
### Docker Compose
|
||||
|
||||
1. Установите Docker:
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
```
|
||||
2. Создайте директорию и `docker-compose.yml`:
|
||||
```bash
|
||||
mkdir -p /opt/free-turn-proxy && cd /opt/free-turn-proxy
|
||||
nano docker-compose.yml
|
||||
```
|
||||
3. Вставьте конфигурацию:
|
||||
```yaml
|
||||
services:
|
||||
free-turn-proxy:
|
||||
image: ghcr.io/samosvalishe/free-turn-proxy:latest
|
||||
container_name: free-turn-proxy
|
||||
network_mode: "host" # Важно для доступа к локальному VPN (127.0.0.1)
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- CONNECT_ADDR=127.0.0.1:51820 # Порт ВАШЕГО VPN (WG/AmneziaWG/Xray)
|
||||
- LISTEN_ADDR=0.0.0.0:56000 # Внешний порт
|
||||
- MODE=udp # udp (WG/Amnezia) или tcp (Xray/VLESS)
|
||||
- OBF_PROFILE=rtpopus # Обязательная маскировка
|
||||
- OBF_KEY=<ВАШ_КЛЮЧ> # Ваш ключ
|
||||
# - CLIENTS_FILE=/opt/free-turn-proxy/clients.json # Для авторизации
|
||||
# volumes:
|
||||
# - /opt/free-turn-proxy/clients.json:/opt/free-turn-proxy/clients.json
|
||||
```
|
||||
4. Запустите: `docker compose up -d`
|
||||
|
||||
### Способ 2: systemd (Без Docker)
|
||||
|
||||
1. Скачайте бинарник:
|
||||
```bash
|
||||
sudo mkdir -p /opt/free-turn-proxy
|
||||
sudo curl -L -o /opt/free-turn-proxy/server https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/server-linux-amd64
|
||||
sudo chmod +x /opt/free-turn-proxy/server
|
||||
```
|
||||
*(Для ARM замените `-amd64` на `-arm64`)*
|
||||
2. Создайте службу: `sudo nano /etc/systemd/system/free-turn-proxy.service`
|
||||
3. Вставьте конфигурацию:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Free TURN Proxy Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/opt/free-turn-proxy/server -listen 0.0.0.0:56000 -connect 127.0.0.1:51820 -obf-profile rtpopus -obf-key <ВАШ_КЛЮЧ>
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=nobody
|
||||
Group=nogroup
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
4. Запустите: `sudo systemctl daemon-reload && sudo systemctl enable --now free-turn-proxy.service`
|
||||
|
||||
---
|
||||
|
||||
## Настройка Файрвола
|
||||
|
||||
Откройте внешний порт (сервер слушает по **UDP**, даже если `MODE=tcp`):
|
||||
```bash
|
||||
sudo ufw allow 56000/udp
|
||||
# Или iptables: sudo iptables -I INPUT -p udp --dport 56000 -j ACCEPT
|
||||
```yaml
|
||||
services:
|
||||
free-turn-proxy:
|
||||
image: ghcr.io/hackdiaz-dev/free-turn-proxy:latest
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- CONNECT_ADDR=127.0.0.1:51820 # ваш VPN
|
||||
- LISTEN_ADDR=0.0.0.0:56000
|
||||
- OBF_PROFILE=rtpopus3
|
||||
- OBF_KEY=<ВАШ_КЛЮЧ>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Авторизация по Client ID (Опционально)
|
||||
|
||||
По умолчанию доступ открыт всем, кто знает `-obf-key`. Чтобы ограничить доступ:
|
||||
|
||||
1. Создайте пустой список: `echo "[]" | sudo tee /opt/free-turn-proxy/clients.json`
|
||||
2. Включите авторизацию:
|
||||
- **Docker:** раскомментируйте `CLIENTS_FILE` и `volumes` в `docker-compose.yml`, затем `docker compose up -d`.
|
||||
- **systemd:** добавьте флаг `-clients-file /opt/free-turn-proxy/clients.json` в `ExecStart` и перезапустите службу.
|
||||
3. Добавляйте ID в `clients.json` (см. [flags.md](flags.md#управление-client-id-команды-сервера)). На клиенте используйте флаг `-client-id <id>`.
|
||||
|
||||
---
|
||||
|
||||
## Переменные окружения Docker
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
| Переменная | По умолчанию | |
|
||||
| --- | --- | --- |
|
||||
| `CONNECT_ADDR` | **обязательна** | IP и порт вашего VPN (бэкенда) |
|
||||
| `LISTEN_ADDR` | `0.0.0.0:56000` | Внешний адрес прослушивания |
|
||||
| `MODE` | `udp` | Режим туннеля: `udp` \| `tcp` |
|
||||
| `OBF_PROFILE` | `none` | Маскировка: `none` \| `rtpopus` \| `rtpopus2` |
|
||||
| `OBF_KEY` | пусто | Ключ маскировки |
|
||||
| `CLIENTS_FILE`| пусто | Путь к JSON-файлу авторизации |
|
||||
| `DEBUG` | `false` | Включить debug-логи |
|
||||
| `CONNECT_ADDR` | обязательна | адрес вашего VPN |
|
||||
| `LISTEN_ADDR` | `0.0.0.0:56000` | внешний адрес |
|
||||
| `MODE` | `udp` | `udp` (WireGuard) \| `tcp` (Xray/VLESS), как на клиенте |
|
||||
| `OBF_PROFILE` | `none` | `none` \| `rtpopus` \| `rtpopus2` \| `rtpopus3` |
|
||||
| `OBF_KEY` | - | ключ обфускации |
|
||||
| `CLIENTS_FILE` | - | список разрешённых Client ID |
|
||||
| `KCP_*` | - | тюнинг `MODE=tcp`, см. `docs/flags.md` |
|
||||
| `DEBUG` | `false` | подробные логи |
|
||||
|
||||
> **Внимание при Bridge Mode:** Если вы уберете `network_mode: "host"` и пробросите порты (`-p 56000:56000/udp`), `CONNECT_ADDR=127.0.0.1:51820` будет указывать внутрь контейнера. В этом случае прокси не найдет VPN. Используйте IP хоста или оставляйте `network_mode: "host"`.
|
||||
### systemd
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/free-turn-proxy
|
||||
sudo curl -L -o /opt/free-turn-proxy/server \
|
||||
https://github.com/hackdiaz-dev/free-turn-proxy/releases/latest/download/server-linux-amd64
|
||||
sudo chmod +x /opt/free-turn-proxy/server
|
||||
```
|
||||
|
||||
`/etc/systemd/system/free-turn-proxy.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Free Turn Proxy
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/opt/free-turn-proxy/server -listen 0.0.0.0:56000 -connect 127.0.0.1:51820 -obf-profile rtpopus3 -obf-key <ВАШ_КЛЮЧ>
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now free-turn-proxy
|
||||
```
|
||||
|
||||
### Доступ по Client ID
|
||||
|
||||
Без списка подключиться может любой, у кого есть ключ обфускации. Список:
|
||||
|
||||
```bash
|
||||
sudo CLIENTS_FILE=/opt/free-turn-proxy/clients.json \
|
||||
/opt/free-turn-proxy/server clients add $(openssl rand -hex 16) phone
|
||||
```
|
||||
|
||||
Запуск с `-clients-file /opt/free-turn-proxy/clients.json` (Docker: `CLIENTS_FILE` + volume). На клиенте - `-client-id` или ссылка `freeturn://`.
|
||||
|
||||
## Порт
|
||||
|
||||
Откройте внешний порт по **UDP** (при `MODE=tcp` тоже): `sudo ufw allow 56000/udp`. Скрипт делает это сам.
|
||||
|
||||
+43
-26
@@ -2,31 +2,48 @@
|
||||
|
||||
## Клиент
|
||||
|
||||
| Флаг | По умолчанию | Описание |
|
||||
|---------------------|---------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `-listen` | `127.0.0.1:9000` | локальный адрес `ip:port`, куда подключается WireGuard (UDP) или Xray/sing-box (TCP) клиент |
|
||||
| `-peer` | **обязательный** | адрес сервера на VPS, `host:port` |
|
||||
| `-provider` | `vk` | источник потоков: `vk` (TURN-реле сервиса звонков) \| `direct` (напрямую на `-peer`), см. `docs/providers.md` |
|
||||
| `-link` | пусто | (устарел) одна ссылка на звонок; используйте `-links`. Игнорируется, если задан `-links` |
|
||||
| `-links` | **обязательный для `-provider vk`** (или `-link`) | ссылки на звонки через запятую; каждая даёт свой пул из `-n` стримов |
|
||||
| `-n` | `12` (`direct`: `1`) | параллельных потоков; в `-mode tcp` это число сессий пула, по которым round-robin раскладываются локальные TCP-соединения |
|
||||
| `-transport` | `tcp` | транспорт до TURN-реле: `tcp` (TCP/TLS) \| `udp` |
|
||||
| `-mode` | `udp` | режим туннеля: `udp` (UDP-релей для WireGuard) \| `tcp` (TCP-форвардер для Xray/sing-box) |
|
||||
| `-bond` | `false` | объединять сессии для одного TCP-соединения; только `-mode tcp`, до 256 сессий суммарно; нужен обновлённый сервер |
|
||||
| `-turn` | из creds | переопределить IP TURN-сервера |
|
||||
| `-port` | из creds | переопределить порт TURN-сервера |
|
||||
| `-obf-profile` | `none` | wire-профиль обфускации payload: `none` \| `rtpopus` (RTP/opus + ChaCha20-Poly1305 AEAD) \| `rtpopus2` (+ RTP header extension, ближе к WebRTC) \| `rtpopus3` (+ abs-send-time, VAD, имитация потерь, вариативный timestamp); должен совпадать с сервером |
|
||||
| `-obf-key` | пусто | общий ключ для `-obf-profile != none`, 32 байта hex (64 символа) |
|
||||
| `-obf-timing` | `0` | межпакетная задержка для RTP-мимикрии (напр. `20ms`); только с `-obf-profile != none`; `0` = выкл. В `-mode tcp` особенно уместна: KCP гонит бёрсты полноразмерных пакетов, без выравнивания профиль на Opus не похож |
|
||||
| `-gen-obf-key` | `false` | напечатать новый ключ и выйти |
|
||||
| `-manual-captcha` | `false` | сразу ручной режим captcha (только `-provider vk`) |
|
||||
| `-streams-per-cred` | `12` | потоков на один кеш учёток (только `-provider vk`) |
|
||||
| `-platform` | `desktop` | класс устройства персоны авторизации (мобильность UA/device/client hints): `desktop` \| `mobile` (только `-provider vk`) |
|
||||
| `-dns-mode` | `auto` | `plain` (UDP/53) \| `doh` \| `auto` |
|
||||
| `-dns-servers` | пусто | свои UDP/53 резолверы, `ip[:port][,ip[:port]...]` |
|
||||
| `-client-id` | авто | уникальный ID клиента (автогенерация если не задан) |
|
||||
| `-sub` | пусто | URL подписки (sub.md) для получения списка серверов |
|
||||
| `-debug` | `false` | debug-логи |
|
||||
|
||||
## KCP (только `-mode tcp`, клиент и сервер)
|
||||
|
||||
Флаги настраивают ARQ-слой, который держит TCP-поток поверх датаграмм TURN. Задаются на каждой стороне отдельно; в `-mode udp` любое отличие от дефолта - ошибка запуска. В share-ссылке профиль едет полем `kcp`, так что гость получает тот же ARQ, что настроил владелец.
|
||||
|
||||
| Флаг | По умолчанию | Описание |
|
||||
| --- | --- | --- |
|
||||
| `-listen` | `127.0.0.1:9000` | локальный адрес `ip:port`, куда подключается WireGuard или Xray клиент |
|
||||
| `-peer` | **обязательный** | адрес сервера на VPS, `host:port` |
|
||||
| `-provider` | `vk` | источник TURN-creds: `vk` (см. `docs/providers.md`) |
|
||||
| `-link` | пусто | (устарел) одна ссылка VK Calls `https://vk.ru/call/join/...`; используйте `-links`. Игнорируется, если задан `-links` |
|
||||
| `-links` | **обязательный для `-provider vk`** (или `-link`) | ссылки VK Calls через запятую `https://vk.ru/call/join/A,https://vk.ru/call/join/B`; каждая даёт свой пул из `-n` стримов |
|
||||
| `-n` | `10` | параллельных TURN-потоков |
|
||||
| `-transport` | `tcp` | транспорт до TURN-реле: `tcp` (TCP/TLS) \| `udp` |
|
||||
| `-mode` | `udp` | режим туннеля: `udp` (UDP-релей для WireGuard) \| `tcp` (TCP-форвардер для Xray/sing-box) |
|
||||
| `-bond` | `false` | распределять одно TCP-соединение по всем активным smux-сессиям (только с `-mode tcp`) |
|
||||
| `-turn` | из creds | переопределить IP TURN-сервера |
|
||||
| `-port` | из creds | переопределить порт TURN-сервера |
|
||||
| `-obf-profile` | `none` | wire-профиль обфускации payload: `none` \| `rtpopus` (RTP/opus + ChaCha20-Poly1305 AEAD) \| `rtpopus2` (+ RTP header extension, ближе к WebRTC) \| `rtpopus3` (+ abs-send-time, VAD, имитация потерь, вариативный timestamp); должен совпадать с сервером |
|
||||
| `-obf-key` | пусто | общий ключ для `-obf-profile != none`, 32 байта hex (64 символа) |
|
||||
| `-obf-timing` | `0` | межпакетная задержка для RTP-мимикрии (напр. `20ms`); только с `-obf-profile != none` и `-mode udp`; `0` = выкл |
|
||||
| `-gen-obf-key` | `false` | напечатать новый ключ и выйти |
|
||||
| `-manual-captcha` | `false` | сразу ручной режим captcha (только `-provider vk`) |
|
||||
| `-streams-per-cred` | `10` | потоков на один кеш VK-учёток (только `-provider vk`) |
|
||||
| `-browser` | `firefox` | браузерный профиль VK-auth (UA + TLS JA3 + client hints): `chrome` \| `firefox` \| `safari` (только `-provider vk`) |
|
||||
| `-dns-mode` | `auto` | `plain` (UDP/53) \| `doh` \| `auto` |
|
||||
| `-dns-servers` | пусто | свои UDP/53 резолверы, `ip[:port][,ip[:port]...]` |
|
||||
| `-client-id` | авто | уникальный ID клиента (автогенерация если не задан) |
|
||||
| `-sub` | пусто | URL подписки (sub.md) для получения списка серверов |
|
||||
| `-debug` | `false` | debug-логи |
|
||||
| `-kcp-nodelay` | `1` | `0` (обычный) \| `1` (быстрый режим) |
|
||||
| `-kcp-interval` | `20` | интервал внутреннего цикла KCP, мс; больше = меньше служебного трафика |
|
||||
| `-kcp-resend` | `2` | быстрая повторная отправка после N дубликатов ACK; `0` = выкл |
|
||||
| `-kcp-nc` | `1` | congestion control: `0` (вкл) \| `1` (выкл) |
|
||||
| `-kcp-sndwnd` | `512` | окно отправки в пакетах |
|
||||
| `-kcp-rcvwnd` | `512` | окно приёма в пакетах |
|
||||
| `-kcp-mtu` | `1200` | MTU сегмента KCP, байты; допустимо `300..1350` |
|
||||
| `-kcp-acknodelay` | `true` | отправлять ACK сразу: ниже задержка, выше служебный трафик |
|
||||
|
||||
На мобильной сети дефолт заметно шумит; щадящий вариант - `-kcp-interval 40 -kcp-sndwnd 256 -kcp-rcvwnd 256 -kcp-acknodelay=false`.
|
||||
|
||||
## Сервер
|
||||
|
||||
@@ -34,9 +51,9 @@
|
||||
| --- | --- | --- |
|
||||
| `-listen` | `0.0.0.0:56000` | адрес прослушивания `ip:port` |
|
||||
| `-connect` | **обязательный** | локальный backend `host:port` (WG `127.0.0.1:51820` / Xray `127.0.0.1:443`) |
|
||||
| `-mode` | `udp` | режим туннеля: `udp` \| `tcp` (bond автоопределяется) |
|
||||
| `-mode` | `udp` | режим туннеля: `udp` \| `tcp`; должен совпадать с клиентом |
|
||||
| `-obf-profile` | `none` | wire-профиль обфускации payload: `none` \| `rtpopus` \| `rtpopus2` \| `rtpopus3`; должен совпадать с клиентом (описание профилей - в таблице клиента) |
|
||||
| `-obf-timing` | `0` | межпакетная задержка для RTP-мимикрии (напр. `10ms`); только с `-obf-profile != none` и `-mode udp`; `0` = выкл |
|
||||
| `-obf-timing` | `0` | межпакетная задержка для RTP-мимикрии (напр. `10ms`); только с `-obf-profile != none`; `0` = выкл |
|
||||
| `-obf-key` | пусто | общий ключ для `-obf-profile != none`, 32 байта hex |
|
||||
| `-gen-obf-key` | `false` | напечатать новый ключ и выйти |
|
||||
| `-clients-file` | пусто | путь к JSON-файлу (`clients.json`) для включения авторизации по Client ID |
|
||||
@@ -45,7 +62,7 @@
|
||||
## Управление Client ID (Команды Сервера)
|
||||
|
||||
> [!NOTE]
|
||||
> **Про авторизацию:** клиент **всегда** отправляет свой Client ID первой записью после DTLS-handshake, сервер **всегда** его читает — wire-контракт симметричен. Флаг `-clients-file` на сервере включает **проверку** ID по allowlist (`clients.json`). Без `-clients-file` ID читается и игнорируется.
|
||||
> **Про авторизацию:** клиент **всегда** отправляет свой Client ID первой записью после DTLS-handshake, сервер **всегда** его читает - wire-контракт симметричен. Флаг `-clients-file` на сервере включает **проверку** ID по allowlist (`clients.json`). Без `-clients-file` ID читается и игнорируется.
|
||||
|
||||
Сервер содержит встроенные команды для управления файлом `clients.json` (горячая перезагрузка поддерживается автоматически, перезапускать сервер после изменений не нужно).
|
||||
|
||||
|
||||
+27
-27
@@ -1,22 +1,38 @@
|
||||
# Мобильные устройства
|
||||
|
||||
## Android (Termux / Приложения)
|
||||
## Android-приложение - рекомендуемый способ использования
|
||||
|
||||
- Добавляйте подключения по ссылке, QR-коду или вручную.
|
||||
- Устанавливайте и настраивайте сервер на VPS из приложения.
|
||||
- Переключайте серверы, настраивайте раздельное туннелирование и делитесь доступом.
|
||||
- Сохраняйте настройки в зашифрованную резервную копию.
|
||||
|
||||
[Скачать APK](https://github.com/hackdiaz-dev/turn-proxy-android/releases/latest)
|
||||
|
||||
Требования: Android 7.0+ и устройство ARM (arm64-v8a или armeabi-v7a). Для собственного подключения нужен VPS; в режиме Relay также нужна ссылка на звонок.
|
||||
|
||||
> [!WARNING]
|
||||
> При переходе Android-приложения на версию 5 потребуется настройка с нуля. Перед обновлением сохраните данные доступа к VPS и параметры подключения. Затем заново добавьте сервер или импортируйте новую ссылку и проверьте параметры VPN и раздельного туннелирования.
|
||||
|
||||
## Android (Termux, ручная настройка)
|
||||
|
||||
Альтернативный вариант для работы с отдельным VPN-клиентом.
|
||||
|
||||
При работе на мобильных устройствах возникают две основные проблемы: перехват DNS оператором и зацикливание маршрутов VPN.
|
||||
|
||||
1. Установите Termux или используйте приложение (например, Free Turn).
|
||||
2. В клиенте WireGuard / AmneziaWG: `Endpoint = 127.0.0.1:9000`, `MTU = 1280` (если связь нестабильна, MTU можно опускать вплоть до 1120).
|
||||
3. **Критично:** Добавьте приложение, в котором запущен `free-turn-proxy` клиент (Termux, Free Turn), в **Исключения WireGuard** (разрешенные приложения, не пускать через VPN). Если этого не сделать, туннель завернется сам в себя, и соединения не будет.
|
||||
4. **Критично:** В большинстве случаев мобильные операторы связи перехватывают/блокируют сторонние DNS, включая DoH. Вам необходимо передавать IP-адрес DNS вашего оператора связи через флаг `-dns-servers`.
|
||||
1. Установите Termux.
|
||||
2. В клиенте WireGuard / AmneziaWG: `Endpoint = 127.0.0.1:9000`, `MTU = 1280` (если связь нестабильна, MTU можно снижать вплоть до 1120).
|
||||
3. **Критично:** Добавьте Termux в **Исключения WireGuard** (разрешенные приложения, не пускать через VPN). Если этого не сделать, туннель завернется сам в себя, и соединения не будет.
|
||||
4. **Критично:** В большинстве случаев мобильные операторы блокируют сторонние DNS, включая DoH. Передавайте IP-адрес DNS вашего оператора связи через флаг `-dns-servers`.
|
||||
|
||||
Пример для Termux:
|
||||
Пример запуска в Termux:
|
||||
|
||||
```bash
|
||||
termux-wake-lock
|
||||
curl -L -o client https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/client-android-arm64
|
||||
curl -L -o client https://github.com/hackdiaz-dev/free-turn-proxy/releases/latest/download/client-android-arm64
|
||||
chmod +x client
|
||||
# Обязательно замените <ip_dns_оператора> на DNS вашего провайдера
|
||||
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<vk-link>" -dns-servers <ip_dns_оператора>
|
||||
# Замените <ip_dns_оператора> на DNS вашего провайдера
|
||||
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<call-link>" -dns-servers <ip_dns_оператора>
|
||||
```
|
||||
|
||||
Снять wake lock: `termux-wake-unlock`.
|
||||
@@ -28,9 +44,9 @@ chmod +x client
|
||||
```bash
|
||||
apk update
|
||||
apk add curl
|
||||
curl -L -o client https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/client-linux-386
|
||||
curl -L -o client https://github.com/hackdiaz-dev/free-turn-proxy/releases/latest/download/client-linux-386
|
||||
chmod +x client
|
||||
GOMAXPROCS=1 GODEBUG=asyncpreemptoff=1 ./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<vk-link>"
|
||||
GOMAXPROCS=1 GODEBUG=asyncpreemptoff=1 ./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<call-link>"
|
||||
```
|
||||
|
||||
Дольше в фоне:
|
||||
@@ -38,19 +54,3 @@ GOMAXPROCS=1 GODEBUG=asyncpreemptoff=1 ./client -listen 127.0.0.1:9000 -peer <vp
|
||||
```bash
|
||||
cat /dev/location > /dev/null &
|
||||
```
|
||||
|
||||
## Нативная сборка библиотек (gomobile)
|
||||
|
||||
Для интеграции прокси-движка непосредственно в нативные мобильные приложения (iOS / Android) проект предоставляет пакет `mobile`, адаптированный для сборки через `gomobile bind`.
|
||||
|
||||
### iOS
|
||||
Сборка универсального XCFramework (`dist/Mobile.xcframework`) с поддержкой iOS и симулятора:
|
||||
```bash
|
||||
task build:ios
|
||||
```
|
||||
|
||||
### Android
|
||||
Сборка Android Archive (`dist/mobile.aar`) с готовыми JNI-обертками для Java/Kotlin:
|
||||
```bash
|
||||
task build:android
|
||||
```
|
||||
|
||||
+46
-22
@@ -1,41 +1,65 @@
|
||||
# Режимы
|
||||
# Режимы
|
||||
|
||||
## UDP-Релей (WireGuard / AmneziaWG / Hysteria)
|
||||
|
||||
Дефолтный режим (`-mode udp`). Клиент слушает локальный UDP-порт, сервер прозрачно форвардит UDP-пакеты в backend (например, WG `127.0.0.1:51820`).
|
||||
Отлично подходит для VPN-протоколов, работающих поверх UDP: **WireGuard, AmneziaWG, Hysteria**.
|
||||
|
||||
## Прямой режим (`-provider direct`)
|
||||
|
||||
Клиент шлёт тот же DTLS с OBF-обёрткой прямо на `-peer`, без реле:
|
||||
- **Сервер не меняется**: тот же `-listen` порт, те же `-obf-profile`/`-obf-key`.
|
||||
- **Без звонка**: ни ссылок, ни captcha; `-links`, `-turn`, `-port`, `-transport` игнорируются.
|
||||
- **Потоки**: по умолчанию `-n 1` - лимитов реле нет, лишние потоки только делят очередь.
|
||||
- **Ограничение**: при белых списках мобильного оператора IP VPS недоступен - там только `vk`.
|
||||
- Переключение TURN <-> direct только вручную, автоматического fallback нет.
|
||||
|
||||
```bash
|
||||
./client -provider direct -peer <vps>:56000 -obf-profile rtpopus3 -obf-key <key>
|
||||
```
|
||||
|
||||
## TCP-Форвардер (Xray / sing-box / VLESS)
|
||||
|
||||
Включается флагом `-mode tcp` на обеих сторонах. Сервер подключается к локальному TCP-бэкенду (например, Xray inbound `127.0.0.1:443`). Клиент слушает локальный TCP-порт, на который вы натравливаете свой Xray/v2rayN/sing-box.
|
||||
Подходит для TCP-прокси: **VLESS, VMess, Trojan, Shadowsocks**.
|
||||
|
||||
Поверх DTLS трафик инкапсулируется через KCP + smux.
|
||||
Relayed-данные TURN - всегда датаграммы, даже при `-transport tcp`, поэтому поверх DTLS трафик инкапсулируется через KCP (ARQ) + smux. Тюнинг ARQ - флаги `-kcp-*` (см. `docs/flags.md`).
|
||||
|
||||
```bash
|
||||
./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -mode tcp
|
||||
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<vk-link>" -mode tcp
|
||||
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<call-link>" -mode tcp
|
||||
```
|
||||
|
||||
**Bonding** — распределение одного TCP-соединения по всем активным smux-сессиям, флаг только клиентский:
|
||||
`-n` в этом режиме задаёт число сессий пула: каждое локальное TCP-соединение уходит целиком в одну из них (round-robin).
|
||||
|
||||
### Bond для одного TCP-соединения
|
||||
|
||||
Добавьте клиенту `-bond`: данные каждого TCP-соединения будут распределяться между доступными сессиями. На сервере достаточно `-mode tcp`, он одновременно принимает обычные и bond-клиенты. По умолчанию bond выключен.
|
||||
|
||||
```bash
|
||||
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<vk-link>" -mode tcp -bond -n 4
|
||||
./client -listen 127.0.0.1:9000 -peer <vps>:56000 -links "<call-link>" -mode tcp -bond -n 4
|
||||
```
|
||||
|
||||
Серверного `-bond` нет — сервер автоопределяет bond по magic-префиксу в стриме.
|
||||
Состав каналов фиксируется при открытии TCP-соединения; с одной доступной сессией bond тоже работает. Поддерживается до 256 сессий суммарно по всем ссылкам. Новые сессии используются следующими соединениями. При отказе участвующей сессии текущее соединение закрывается, а Xray может открыть новое через оставшиеся. Бесшовного переноса уже переданных данных между каналами нет. Медленный канал задерживает сборку потока; прирост скорости зависит от сети.
|
||||
|
||||
Для bond обновите и клиент, и сервер: сервер без его поддержки отклонит такой клиент при проверке режима. Обычный TCP сохраняет прежний протокол. В JSON клиента настройка называется `proxy.bond`, в `freeturn://` - `bond`.
|
||||
|
||||
Встроенный WireGuard (`-tunnel`) с `-mode tcp` не сочетается: он гонит датаграммы.
|
||||
|
||||
Режим должен совпадать на обеих сторонах: клиент помечает им первую запись, и сервер с другим `-mode` закрывает соединение с явной ошибкой вместо молчаливого зависания.
|
||||
|
||||
## OBF (wire-профили обфускации)
|
||||
|
||||
> [!WARNING]
|
||||
> **Обфускация обязательна для хорошей скорости.** VK агрессивно шейпит (режет) любой трафик, который не похож на медиаданные звонка (даже если он зашифрован в DTLS). Обфускация маскирует ваши пакеты под голосовой RTP-трафик, позволяя обходить шейпинг.
|
||||
> **Обфускация обязательна для хорошей скорости.** Сервис звонков шейпит (режет) любой трафик, который не похож на медиаданные звонка (даже если он зашифрован в DTLS). Обфускация маскирует ваши пакеты под голосовой RTP-трафик, позволяя обходить шейпинг.
|
||||
|
||||
`-obf-profile` выбирает wire-профиль маскировки TURN-payload. Профиль и ключ должны совпадать на клиенте и сервере.
|
||||
|
||||
Доступные профили:
|
||||
- **`none`** (default) - обфускация выключена (скорость может быть сильно урезана).
|
||||
- **`rtpopus`** - RTP/opus-заголовок + ChaCha20-Poly1305 AEAD на теле. Идеально для обхода шейпов.
|
||||
- **`rtpopus2`** - rtpopus + RTP header extension (RFC 8285): X=1, ssrc-audio-level + transport-cc. Ближе к современному WebRTC. Wire несовместим с rtpopus - профиль должен совпадать на обеих сторонах.
|
||||
- **`rtpopus3`** (рекомендуется) - RTP/opus + RFC 8285 extension (`ssrc-audio-level` + `transport-cc`) + ChaCha20-Poly1305. Полная эмуляция современного WebRTC-аудиопотока звонка.
|
||||
- **`rtpopus2`** - RTP/opus + базовый RFC 8285 header extension.
|
||||
- **`rtpopus`** - базовый RTP/opus-заголовок + ChaCha20-Poly1305 AEAD.
|
||||
- **`none`** - обфускация выключена (скорость может быть сильно урезана шейпингом).
|
||||
|
||||
Сгенерировать ключ:
|
||||
|
||||
@@ -46,8 +70,8 @@
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
./server ... -obf-profile rtpopus -obf-key <64-hex>
|
||||
./client ... -obf-profile rtpopus -obf-key <64-hex>
|
||||
./server ... -obf-profile rtpopus3 -obf-key <64-hex>
|
||||
./client ... -obf-profile rtpopus3 -obf-key <64-hex>
|
||||
```
|
||||
|
||||
## TURN Транспорт
|
||||
@@ -59,30 +83,30 @@
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Транспорт UDP часто режется или блокируется системами DPI (особенно на мобильных операторах). Если у вас нестабильная связь — используйте стандартный TCP/TLS (просто не указывайте этот флаг).
|
||||
> Транспорт UDP часто режется или блокируется системами DPI (особенно на мобильных операторах). Если у вас нестабильная связь - используйте стандартный TCP/TLS (просто не указывайте этот флаг).
|
||||
|
||||
## Потоки и Лимиты (-n)
|
||||
|
||||
Параметр `-n` задает количество параллельных потоков к TURN-серверу (по умолчанию 10).
|
||||
Увеличение потоков может помочь поднять скорость или улучшить стабильность, **но не стоит этим злоупотреблять**. Слишком высокое значение (например, больше 15-20) повышает риск получения бана со стороны VK. Практика показывает, что значений от `6` до `12` зачастую достаточно для комфортной работы.
|
||||
То же самое касается параметра `-streams-per-cred` — используйте разумные значения.
|
||||
Параметр `-n` задает количество параллельных потоков к TURN-серверу (по умолчанию 12, в `-provider direct` - 1).
|
||||
Увеличение потоков может помочь поднять скорость или улучшить стабильность, **но не стоит этим злоупотреблять**. Слишком высокое значение (например, больше 15-20) повышает риск бана со стороны сервиса звонков. Практика показывает, что значений от `6` до `12` зачастую достаточно для комфортной работы.
|
||||
То же самое касается параметра `-streams-per-cred` - используйте разумные значения.
|
||||
|
||||
## Captcha
|
||||
|
||||
Клиент сам проходит VK captcha. Если автоматика не справилась — открывается локальный браузер. Принудительно ручной:
|
||||
Клиент сам проходит captcha. Если автоматика не справилась - открывается локальный браузер. Принудительно ручной:
|
||||
|
||||
```bash
|
||||
./client -manual-captcha ...
|
||||
```
|
||||
|
||||
Профиль браузера: `vk_profile.json` рядом с бинарником.
|
||||
Персона auth-запросов (UA + TLS JA3 + client hints + порядок заголовков + device) - всегда Chrome; флаг `-platform` выбирает класс устройства (desktop/mobile). Значения самосогласованы, без внешнего состояния. Ручная captcha идёт через ту же персону.
|
||||
|
||||
## DNS
|
||||
|
||||
Флаг `-dns-mode` управляет транспортом резолвера самого клиента (VK API, токены, captcha) — не DNS-маршрутизацией пользовательского трафика.
|
||||
Флаг `-dns-mode` управляет транспортом резолвера самого клиента (API звонков, токены, captcha) - не DNS-маршрутизацией пользовательского трафика.
|
||||
|
||||
- `plain` — только UDP/53;
|
||||
- `doh` — только DNS-over-HTTPS (Yandex DoH);
|
||||
- `auto` (дефолт) — сначала UDP/53, sticky-fallback на DoH при полном отказе.
|
||||
- `plain` - только UDP/53;
|
||||
- `doh` - только DNS-over-HTTPS (Yandex DoH);
|
||||
- `auto` (дефолт) - сначала UDP/53, sticky-fallback на DoH при полном отказе.
|
||||
|
||||
Свои UDP/53 резолверы: `-dns-servers ip[:port][,ip[:port]...]`.
|
||||
|
||||
+15
-10
@@ -1,16 +1,21 @@
|
||||
# Providers
|
||||
# Провайдеры
|
||||
|
||||
Источник TURN-реквизитов выбирается флагом `-provider` (default `vk`). Реализации удовлетворяют интерфейс `internal/provider.Provider` и подключаются в `cmd/client/main.go` через `buildProvider`.
|
||||
Флаг `-provider` выбирает, как клиент доходит до сервера: через TURN-реле сервиса звонков (`vk`, по умолчанию) или напрямую (`direct`).
|
||||
|
||||
## Доступные провайдеры
|
||||
## `vk` (по умолчанию)
|
||||
|
||||
### `vk` (default)
|
||||
Трафик идёт через TURN-серверы VK Calls. Нужна активная ссылка на звонок:
|
||||
|
||||
VK Calls API. Перебирает встроенные `app_id/app_secret`, получает короткоживущие (≈10 мин) TURN-creds через 4-шаговый token chain. Solver captcha auto+manual.
|
||||
- `-link` - ссылка вида `https://vk.ru/call/join/<code>`.
|
||||
|
||||
**Обязательные флаги:**
|
||||
- `-link` — VK callroom URL вида `https://vk.ru/call/join/<code>` (нормализуется до join-кода).
|
||||
Необязательные:
|
||||
|
||||
**Опциональные:**
|
||||
- `-streams-per-cred` (default 10) — сколько TURN-стримов делят один кеш креденшалов.
|
||||
- `-manual-captcha` — пропустить auto-solver, сразу открыть браузер.
|
||||
- `-streams-per-cred` (по умолчанию 12) - сколько потоков делят одни учётные данные.
|
||||
- `-manual-captcha` - сразу открывать captcha в браузере.
|
||||
- `-platform` (`desktop` \| `mobile`, по умолчанию `desktop`) - каким устройством представляться при авторизации.
|
||||
|
||||
## `direct`
|
||||
|
||||
Без реле: трафик идёт прямо на `-peer`, сервер тот же. Флаги звонков и TURN (`-links`, `-turn`, `-port`, `-transport`) игнорируются, `-n` по умолчанию 1.
|
||||
|
||||
Не работает, если IP сервера недоступен (белые списки операторов) - там только `vk`. Подробнее - [modes.md](modes.md).
|
||||
|
||||
+27
-17
@@ -1,49 +1,58 @@
|
||||
# Быстрый Старт
|
||||
|
||||
## Требования
|
||||
- **VPS с публичным IP**, на котором установлен VPN-сервер (например, WireGuard). VPN должен слушать локальный порт (например, `127.0.0.1:51820/udp`).
|
||||
- **Активная ссылка VK Calls**: `https://vk.ru/call/join/...` (создайте сами, звонок не завершайте).
|
||||
- **VPS с публичным IP** (скрипт установки сам развернёт FreeTurn и, по желанию, WireGuard).
|
||||
- **Активная ссылка на звонок** (создайте сами, звонок не завершайте).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1: Запуск Сервера (VPS)
|
||||
|
||||
Интерактивный скрипт установит Docker или systemd, настроит файрвол и запустит сервер. Запускать от root:
|
||||
Интерактивный скрипт поднимет FreeTurn (Docker или systemd) и WireGuard `ft-wg0` либо подключит FreeTurn к вашему VPN, сгенерирует ключи, создаст клиента `owner` и выдаст **ссылку `freeturn://` и ссылку на QR-код**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/samosvalishe/free-turn-proxy/master/scripts/install.sh | sudo bash
|
||||
curl -fsSL https://raw.githubusercontent.com/hackdiaz-dev/free-turn-proxy/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
> Скопируйте параметры клиента, которые скрипт выдаст в конце. Для ручной установки или запуска скрипта без вопросов (non-interactive) см. [Развёртывание (deploy.md)](deploy.md).
|
||||
|
||||
> [!TIP]
|
||||
> Отсканируйте QR ссылки `freeturn://` в приложении FreeTurn: в ней уже есть ключ, Client ID и WireGuard-конфиг.
|
||||
> Ссылка равносильна ключам - делитесь осторожно. Показать снова: `freeturn client qr <имя>`.
|
||||
>
|
||||
> **Новый клиент:** `freeturn client add myphone`. Подробнее - [deploy.md](deploy.md).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2: Запуск Клиента (ПК)
|
||||
## Шаг 2: Запуск Клиента Free Turn Proxy (ПК)
|
||||
|
||||
Туннель замыкается сам на себя, если VPN перехватывает весь трафик. Чтобы прокси мог общаться с TURN-серверами напрямую, скрипт добавит маршруты-исключения.
|
||||
Если прямой UDP до вашего VPS блокируется провайдером, запустите клиент `free-turn-proxy`. Он перенаправит трафик через TURN-релей провайдера под видом аудиозвонка. Клиент автоматически создаёт маршруты-исключения для IP TURN-серверов (`-routes`).
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
curl -L -o client https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/client-linux-amd64
|
||||
curl -L -o client https://github.com/hackdiaz-dev/free-turn-proxy/releases/latest/download/client-linux-amd64
|
||||
chmod +x client
|
||||
./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<vk-link>" -obf-profile rtpopus -obf-key <ВАШ_КЛЮЧ> -debug 2>&1 | ./scripts/routes.sh
|
||||
sudo ./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<call-link>" -obf-profile rtpopus3 -obf-key <ВАШ_КЛЮЧ> -client-id <ВАШ_CLIENT_ID> -routes
|
||||
```
|
||||
|
||||
**Windows (PowerShell от администратора):**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/client-windows-amd64.exe -OutFile client.exe
|
||||
.\client.exe -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<vk-link>" -obf-profile rtpopus -obf-key <ВАШ_КЛЮЧ> -debug 2>&1 | .\scripts\routes.ps1
|
||||
Invoke-WebRequest -Uri https://github.com/hackdiaz-dev/free-turn-proxy/releases/latest/download/client-windows-amd64.exe -OutFile client.exe
|
||||
.\client.exe -peer <vps_ip>:56000 -provider vk -link "<call-link>" -listen 127.0.0.1:9000 -n 12 -streams-per-cred 12 -obf-profile rtpopus3 -obf-key <ВАШ_КЛЮЧ> -dns-servers 192.168.31.1 -dns-mode doh -client-id <ВАШ_CLIENT_ID> -routes
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
# Apple Silicon (M1/M2): client-darwin-arm64 | Intel: client-darwin-amd64
|
||||
./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<vk-link>" -obf-profile rtpopus -obf-key <ВАШ_КЛЮЧ> -debug 2>&1 | ./scripts/routes-macos.sh
|
||||
# Apple Silicon (M1/M2/M3): client-darwin-arm64 | Intel: client-darwin-amd64
|
||||
sudo ./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<call-link>" -obf-profile rtpopus3 -obf-key <ВАШ_КЛЮЧ> -client-id <ВАШ_CLIENT_ID> -routes
|
||||
```
|
||||
|
||||
> **Важно:** В настройках вашего VPN-клиента (например, WireGuard) укажите `Endpoint = 127.0.0.1:9000` и `MTU = 1280`. Включайте VPN *только после того*, как скрипт выведет `Ensuring route to ...`.
|
||||
> **Важно:** В настройках вашего VPN-клиента (AmneziaWG или WireGuard) при работе через прокси используйте конфиг клиента (`<имя>.conf` из `freeturn client qr`, `Endpoint = 127.0.0.1:9000`, `MTU = 1280`). Включайте VPN *только после того*, как клиент выведет `Ensuring route to ...`.
|
||||
|
||||
> [!TIP]
|
||||
> **Упрощение:** Вместо длинных флагов можно использовать ссылку `freeturn://` или подписку (`-sub`). Подробнее в [uri.md](uri.md) и [sub.md](sub.md).
|
||||
> **Упрощение:** Вместо длинных флагов можно передать ссылку `freeturn://` (генерируется установщиком) или подписку (`-sub`):
|
||||
> ```bash
|
||||
> sudo ./client "freeturn://eyJ2Ijox..." -link "<call-link>" -routes
|
||||
> ```
|
||||
> Подробнее в [uri.md](uri.md) и [sub.md](sub.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -53,10 +62,11 @@ Invoke-WebRequest -Uri https://github.com/samosvalishe/free-turn-proxy/releases/
|
||||
|
||||
```bash
|
||||
termux-wake-lock
|
||||
# Скачивание: curl -L -o client https://github.com/samosvalishe/free-turn-proxy/releases/latest/download/client-android-arm64 && chmod +x client
|
||||
# Скачивание: curl -L -o client https://github.com/hackdiaz-dev/free-turn-proxy/releases/latest/download/client-android-arm64 && chmod +x client
|
||||
|
||||
# Обязательно укажите ваш ключ и DNS оператора (можно узнать в настройках APN)
|
||||
./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<vk-link>" -obf-profile rtpopus -obf-key <ВАШ_КЛЮЧ> -dns-servers <ip_dns_оператора>
|
||||
./client -listen 127.0.0.1:9000 -peer <vps_ip>:56000 -link "<call-link>" -obf-profile rtpopus3 -obf-key <ВАШ_КЛЮЧ> -dns-servers <ip_dns_оператора> -client-id <ВАШ_CLIENT_ID>
|
||||
```
|
||||
|
||||
> Обязательно добавьте приложение Termux в исключения вашего VPN-клиента. Подробнее в [mobile.md](mobile.md).
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
## Использование в клиенте
|
||||
|
||||
```bash
|
||||
./client -sub https://example.com/sub.md -link "https://vk.ru/call/join/..."
|
||||
./client -sub https://example.com/sub.md -link "<call-link>"
|
||||
```
|
||||
|
||||
Клиент скачает подписку, найдет первый доступный сервер и автоматически применит его конфигурацию. Уникальная ссылка `-link` по-прежнему требуется.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
- **Сначала запускайте client, потом включайте WireGuard / Xray.**
|
||||
- **Трафик не идет (нет интернета):** Проверьте серверный флаг `-connect`. В нем должен быть указан точный локальный порт, на котором слушает ваш VPN (например, `51820` для WG или `443` для VLESS). Если порт не совпадает, прокси будет стучаться в пустоту.
|
||||
- **WireGuard забирает весь трафик:** пайп с `-debug 2>&1` и скриптом маршрутов обязателен (`scripts/routes.sh` / `scripts/routes.ps1` / `scripts/routes-macos.sh`). Маршруты не добавляются → убедитесь, что в команде есть пайп перед скриптом.
|
||||
- **WireGuard забирает весь трафик:** пайп с `-debug 2>&1` и скриптом маршрутов обязателен (`scripts/routes.sh` / `scripts/routes.ps1` / `scripts/routes-macos.sh`). Маршруты не добавляются -> убедитесь, что в команде есть пайп перед скриптом.
|
||||
- **DPI блокирует UDP:** Если используете `-transport udp` и ничего не работает, попробуйте убрать флаг (использовать TCP по умолчанию).
|
||||
- **Обход шейпинга (кастомный TURN):** Если вы знаете "чистый" IP-адрес TURN-сервера VK, где нет агрессивного шейпинга, вы можете передать его вручную: `-turn <ip_address> -port <port>`. Это перезапишет данные, полученные из ссылки.
|
||||
- **Обход шейпинга (кастомный TURN):** Если вы знаете "чистый" IP-адрес TURN-сервера, где нет агрессивного шейпинга, вы можете передать его вручную: `-turn <ip_address> -port <port>`. Это перезапишет данные, полученные из ссылки.
|
||||
- **Проблемы с DNS на мобильном интернете:** Резолв ломается. Передайте DNS вашего провайдера явно: `-dns-servers <ip_dns>`.
|
||||
- **Проблемы с MTU:** Если соединение обрывается или не грузятся тяжелые сайты, попробуйте снизить MTU в вашем VPN-клиенте (WireGuard/Amnezia) до `1280` или даже до `1120`. Из-за накладных расходов DTLS + TURN пакеты стандартного размера (1420) часто фрагментируются и отбрасываются.
|
||||
- **Соединение нестабильное:** попробуйте уменьшить потоки `-n` (например, `-n 6`). Слишком большое число потоков может привести к бану.
|
||||
- **VK часто требует captcha:** запустите с `-manual-captcha`, решите капчу в браузере, затем возвращайтесь к обычному запуску.
|
||||
- **Клиент висит на получении TURN-данных:** ссылка VK Calls умерла / звонок завершён для всех.
|
||||
- **Часто требуется captcha:** запустите с `-manual-captcha`, решите капчу в браузере, затем возвращайтесь к обычному запуску.
|
||||
- **Клиент висит на получении TURN-данных:** ссылка на звонок умерла / звонок завершён для всех.
|
||||
- **Docker bridge mode:** `CONNECT_ADDR=127.0.0.1:...` указывает внутрь контейнера. Используйте `--network host` или IP хоста.
|
||||
- **`-obf-profile != none`:** профиль и ключ должны совпадать на клиенте и сервере.
|
||||
+15
-12
@@ -5,7 +5,7 @@
|
||||
одноимённые флаги клиента, чтобы не вводить IP, режим, ключ и т.д. вручную.
|
||||
|
||||
> [!NOTE]
|
||||
> Ссылка на звонок VK (`-link`) **не входит** в URI - она уникальна для каждого клиента.
|
||||
> Ссылка на звонок (`-link`) **не входит** в URI - она уникальна для каждого клиента.
|
||||
|
||||
## Формат
|
||||
|
||||
@@ -22,39 +22,42 @@ Payload - JSON-объект, закодированный `base64url` (без pa
|
||||
| Ключ | Флаг | Описание |
|
||||
|------|------|----------|
|
||||
| `v` | - | версия формата (сейчас `1`). |
|
||||
| `provider` | `-provider` | источник TURN-creds (например `vk`). |
|
||||
| `provider` | `-provider` | источник потоков: `vk` \| `direct`. |
|
||||
| `peer` | `-peer` | адрес сервера на VPS (`ip:port`). |
|
||||
| `transport` | `-transport` | транспорт до TURN-реле: `tcp` \| `udp`. |
|
||||
| `mode` | `-mode` | режим туннеля: `udp` \| `tcp`. |
|
||||
| `bond` | `-bond` | bonding TCP (`true`), только с `mode=tcp`. |
|
||||
| `obf` | `-obf-profile` | профиль обфускации (`rtpopus` \| `rtpopus2`); `none` опускается. |
|
||||
| `mode` | `-mode` | режим туннеля: `udp` \| `tcp`; отсутствует - `udp`. |
|
||||
| `bond` | `-bond` | объединять сессии для одного TCP-соединения (`true`); только `mode: tcp`, по умолчанию выключено. |
|
||||
| `obf` | `-obf-profile` | профиль обфускации (`rtpopus` \| `rtpopus2` \| `rtpopus3`); `none` опускается. |
|
||||
| `key` | `-obf-key` | ключ обфускации (hex), только при заданном `obf`. |
|
||||
| `n` | `-n` | число TURN-потоков. |
|
||||
| `spc` | `-streams-per-cred` | потоков на один кеш VK-учёток. |
|
||||
| `n` | `-n` | число TURN-потоков; отсутствует - дефолт клиента. |
|
||||
| `spc` | `-streams-per-cred` | потоков на один кеш учёток; отсутствует - дефолт клиента. |
|
||||
| `cid` | `-client-id` | Client ID гостя; owner добавляет его в allowlist (`clients.json`). |
|
||||
| `listen` | `-listen` | локальный `ip:port` для WireGuard/Xray. |
|
||||
| `dns` | `-dns-mode` | резолвер клиента: `plain` \| `doh` \| `auto`. |
|
||||
| `dnss` | `-dns-servers` | свои DNS через запятую. |
|
||||
| `mcap` | `-manual-captcha` | ручная VK captcha (`true`). |
|
||||
| `mcap` | `-manual-captcha` | ручная captcha (`true`). |
|
||||
| `kcp` | `-kcp-*` | профиль ARQ для `mode: tcp` (`nodelay`, `interval`, `resend`, `nc`, `sndwnd`, `rcvwnd`, `mtu`, `acknodelay`); отсутствует - дефолт. |
|
||||
| `name` | - | имя клиента / комментарий. |
|
||||
|
||||
> [!NOTE]
|
||||
> **Про `cid`:** на каждую ссылку генерируется **свежий** Client ID, который owner
|
||||
> добавляет в `clients.json` (комментарий = `name`). Без `cid` в allowlist клиент не
|
||||
> авторизуется. В ссылку входят все параметры подключения - не входит только `-link`
|
||||
> (звонок VK, уникален для каждого клиента).
|
||||
> (звонок уникален для каждого клиента).
|
||||
|
||||
### Пример (декодированный payload)
|
||||
|
||||
```json
|
||||
{"v":1,"provider":"vk","peer":"1.2.3.4:56000","transport":"tcp","mode":"udp",
|
||||
"obf":"rtpopus","key":"d823fa...","n":10,"cid":"a1b2c3...","name":"RU-Server"}
|
||||
"obf":"rtpopus3","key":"d823fa...","n":10,"cid":"a1b2c3...","name":"RU-Server"}
|
||||
```
|
||||
|
||||
## Пример использования
|
||||
|
||||
```bash
|
||||
./client "freeturn://eyJ2IjoxLCJwcm92aWRlciI6InZrIiwicGVlciI6..." -link "https://vk.ru/call/join/..."
|
||||
./client "freeturn://eyJ2IjoxLCJwcm92aWRlciI6InZrIiwicGVlciI6..." -link "<call-link>"
|
||||
```
|
||||
|
||||
Параметры из URI переопределяют базовые флаги (`-peer`, `-mode`, `-obf-key`, `-n` ...).
|
||||
Параметры из URI переопределяют базовые флаги (`-peer`, `-transport`, `-mode`, `-obf-key`, `-n` ...).
|
||||
|
||||
Версия ссылки не меняется: `mode` и `kcp` - опциональные поля, старый клиент их игнорирует и остаётся на `udp` с дефолтным ARQ.
|
||||
|
||||
@@ -1,49 +1,51 @@
|
||||
module github.com/samosvalishe/free-turn-proxy
|
||||
|
||||
go 1.26.4
|
||||
go 1.26.7
|
||||
|
||||
require (
|
||||
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814
|
||||
github.com/bogdanfinn/fhttp v0.6.8
|
||||
github.com/bogdanfinn/tls-client v1.15.1
|
||||
github.com/cbeuw/connutil v1.0.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/pion/dtls/v3 v3.1.2
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/pion/dtls/v3 v3.1.5
|
||||
github.com/pion/logging v0.2.4
|
||||
github.com/pion/transport/v4 v4.0.1
|
||||
github.com/pion/turn/v5 v5.0.4
|
||||
github.com/pion/stun/v3 v3.1.7
|
||||
github.com/pion/transport/v4 v4.1.0
|
||||
github.com/pion/turn/v5 v5.0.13
|
||||
github.com/xtaci/kcp-go/v5 v5.6.72
|
||||
github.com/xtaci/smux v1.5.57
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260824205408-89f4e9bb5b38
|
||||
golang.org/x/net v0.58.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/bdandy/go-errors v1.2.2 // indirect
|
||||
github.com/bdandy/go-socks4 v1.2.3 // indirect
|
||||
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
|
||||
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
|
||||
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/reedsolomon v1.14.0 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/klauspost/reedsolomon v1.14.2 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/stun/v3 v3.1.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.46.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
)
|
||||
|
||||
tool golang.org/x/mobile/cmd/gobind
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814 h1:l2AhBD+sFycU8Im81n/bZORMxW7fWtlZJEuJ4Hh0+z0=
|
||||
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
||||
@@ -21,8 +23,6 @@ github.com/cbeuw/connutil v1.0.1/go.mod h1:lKofNtrW7Atmosgp1eNnTt2j2NjA2IkifapgL
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
@@ -37,6 +37,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
@@ -45,35 +47,33 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/reedsolomon v1.14.0 h1:5YSZeclzSYg5nl349+GDG/agDtQ6MZiwUYXvVKN1Jx0=
|
||||
github.com/klauspost/reedsolomon v1.14.0/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
|
||||
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/klauspost/reedsolomon v1.14.2 h1:SafJYwpBBQBI6amHUygcjxZjXeN2HpiENHQDwuPWCCQ=
|
||||
github.com/klauspost/reedsolomon v1.14.2/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
|
||||
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
|
||||
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||
github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
|
||||
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY=
|
||||
github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pion/turn/v5 v5.0.4 h1:xKAnP1b5eCnjFPd55OgxkqIVoyzbHKZa06SxZ3fopXQ=
|
||||
github.com/pion/turn/v5 v5.0.4/go.mod h1:zbPsMp+fIVhKt5uWu2jcjk88FcoRbGMBSzVsaTuhcmM=
|
||||
github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
|
||||
github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8=
|
||||
github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
|
||||
github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pion/turn/v5 v5.0.13 h1:erHOsJyxuV6QK54+PjWJhe8u1O7BM3a/US0zYJJsnx4=
|
||||
github.com/pion/turn/v5 v5.0.13/go.mod h1:btdOovUYdYc8iBnvt87JHN4Pa1XV5UiLaCYe4ay3o9A=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
|
||||
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
|
||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
|
||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
@@ -88,23 +88,25 @@ github.com/xtaci/smux v1.5.57 h1:N72VbGoSYxgcm6mPOYX0QzEZNVD3UI/JlVvAtXF+WrY=
|
||||
github.com/xtaci/smux v1.5.57/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96 h1:O1tFPDuiV6lk+c24ew9UqcKgmhgDF6aPXwSvYGxVR2A=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260824205408-89f4e9bb5b38 h1:KOTVVE9EBLJYMOieQRB5IdYazO/s7TMqVqRNQdO7CGk=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260824205408-89f4e9bb5b38/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 h1:zoM1gIKhVkcQNm43kad8OHLgPNoJ12xIqmxHtKr8Mug=
|
||||
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2/go.mod h1:QGMqsqLn6orFQ/ksqYMf+Fa33Soa1vPoHEd0Pj7N+lQ=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 h1:D/wiQ6AoTYjDtSD0HMPhU8O40NUP8EF0UmDhIYCnG4I=
|
||||
golang.org/x/mobile v0.0.0-20260821190718-4776eadac327/go.mod h1:D9q8rgXu13Q3uuM+Vuy6F/DG1WF/giTPLtqQ9on5B1M=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -112,28 +114,28 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -141,13 +143,15 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
@@ -164,5 +168,7 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 h1:ze1vwAdliUAr68RQ5NtufWaXaOg8WUO2OACzEV+TNdE=
|
||||
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -17,15 +17,12 @@ func sharedDohResolver() *DohResolver {
|
||||
return dohResolverInstance
|
||||
}
|
||||
|
||||
// AppDialer возвращает net.Dialer для tls-client и других HTTP-вызывающих.
|
||||
// DNS-транспорт выбирается по mode (udp | doh | auto).
|
||||
// AppDialer возвращает net.Dialer с DNS-транспортом по mode (udp | doh | auto).
|
||||
func AppDialer(mode string) net.Dialer {
|
||||
return buildDialer(mode, sharedDohResolver())
|
||||
}
|
||||
|
||||
// InstallGlobalResolver выставляет net.DefaultResolver на тот же DNS-транспорт,
|
||||
// что и AppDialer - чтобы сторонние библиотеки, собирающие свой http.Client
|
||||
// без нашего Dialer, тоже шли через DoH / auto-fallback, а не OS resolver.
|
||||
// InstallGlobalResolver устанавливает net.DefaultResolver для библиотек со стандартным http.Client.
|
||||
func InstallGlobalResolver(mode string) {
|
||||
d := AppDialer(mode)
|
||||
if d.Resolver != nil {
|
||||
|
||||
+73
-100
@@ -1,6 +1,4 @@
|
||||
// Package dnsdial владеет DNS-резолвингом и net.Dialer'ом, прокинутым во все
|
||||
// outbound HTTP/TLS клиенты. По Mode выбирает UDP/53, DNS-over-HTTPS или auto
|
||||
// (UDP-probe → sticky DoH fallback).
|
||||
// Package dnsdial управляет DNS-резолвингом и dialer для outbound HTTP/TLS (UDP/53, DoH, auto fallback).
|
||||
package dnsdial
|
||||
|
||||
import (
|
||||
@@ -21,21 +19,21 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/logx"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netctl"
|
||||
|
||||
// встроенные Mozilla CA roots для CGO_ENABLED=0 сборок (Android).
|
||||
// Mozilla CA roots для сборок без CGO.
|
||||
_ "golang.org/x/crypto/x509roots/fallback"
|
||||
)
|
||||
|
||||
// Log - пакетный логгер. По умолчанию no-op; main устанавливает через SetLogger.
|
||||
var Log logx.Logger = logx.Nop()
|
||||
var logHolder logx.Holder
|
||||
|
||||
// SetLogger ставит логгер пакета.
|
||||
func SetLogger(l logx.Logger) { Log = logx.OrNop(l) }
|
||||
func SetLogger(l logx.Logger) { logHolder.Set(l) }
|
||||
|
||||
func Log() logx.Logger { return logHolder.Get() }
|
||||
|
||||
const (
|
||||
dohQueryTimeout = 6 * time.Second
|
||||
// общий бюджет для всех попыток endpoint'ов в forwardRaw. Кратно
|
||||
// dohQueryTimeout - чтобы каждому fallback хватило времени.
|
||||
// dohForwardBudget кратен dohQueryTimeout для прохода по всем fallback-эндпоинтам.
|
||||
dohForwardBudget = 25 * time.Second
|
||||
dohMaxResponseBytes = 64 * 1024
|
||||
dohContentType = "application/dns-message"
|
||||
@@ -51,31 +49,26 @@ const (
|
||||
autoUDPBudget = 1500 * time.Millisecond
|
||||
)
|
||||
|
||||
// DohEndpoint - один DNS-over-HTTPS сервер с bootstrap-IP, чтобы резолв самого
|
||||
// hostname не требовал DNS.
|
||||
// DohEndpoint задаёт DNS-over-HTTPS сервер со статическими bootstrap-IP.
|
||||
type DohEndpoint struct {
|
||||
URL string
|
||||
Hostname string
|
||||
BootstrapIPs []string
|
||||
}
|
||||
|
||||
// Yandex - первый, т.к. остаётся доступен у RU мобильных операторов даже когда
|
||||
// международные резолверы блокируются; Google и Cloudflare - fallback.
|
||||
// Yandex в начале списка из-за доступности у RU-операторов; остальные - fallback.
|
||||
var defaultDohEndpoints = []DohEndpoint{
|
||||
{"https://common.dot.dns.yandex.net/dns-query", "common.dot.dns.yandex.net", []string{"77.88.8.8", "77.88.8.1"}},
|
||||
{"https://secure.dot.dns.yandex.net/dns-query", "secure.dot.dns.yandex.net", []string{"77.88.8.88", "77.88.8.2"}},
|
||||
{"https://family.dot.dns.yandex.net/dns-query", "family.dot.dns.yandex.net", []string{"77.88.8.7", "77.88.8.3"}},
|
||||
}
|
||||
|
||||
// DohResolver делает POST с DNS-wire запросом к одному из DoH endpoint'ов.
|
||||
type DohResolver struct {
|
||||
endpoints []DohEndpoint
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewDohResolver конструирует резолвер; если endpoints=nil, берёт
|
||||
// defaultDohEndpoints. Имена endpoint'ов диалятся по BootstrapIPs - DoH-транспорт
|
||||
// не зависит от системного резолвера.
|
||||
// NewDohResolver создаёт DoH резолвер с прямым bootstrap IP dialer без системного DNS.
|
||||
func NewDohResolver(endpoints []DohEndpoint) *DohResolver {
|
||||
if len(endpoints) == 0 {
|
||||
endpoints = defaultDohEndpoints
|
||||
@@ -86,19 +79,16 @@ func NewDohResolver(endpoints []DohEndpoint) *DohResolver {
|
||||
}
|
||||
}
|
||||
|
||||
// newDohResolverWithClient - тестовый hook, минующий bootstrap-транспорт.
|
||||
func newDohResolverWithClient(endpoints []DohEndpoint, client *http.Client) *DohResolver {
|
||||
return &DohResolver{endpoints: endpoints, client: client}
|
||||
}
|
||||
|
||||
// newBootstrapTransport возвращает http.Transport, чей DialContext знает только
|
||||
// заданные hostname'ы DoH endpoint'ов и резолвит их в BootstrapIPs.
|
||||
func newBootstrapTransport(endpoints []DohEndpoint) *http.Transport {
|
||||
bootstrap := make(map[string][]string, len(endpoints))
|
||||
for _, ep := range endpoints {
|
||||
bootstrap[ep.Hostname] = ep.BootstrapIPs
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: dohDialerTimeout, KeepAlive: dohDialerKeepAlive}
|
||||
dialer := &net.Dialer{Timeout: dohDialerTimeout, KeepAlive: dohDialerKeepAlive, Control: netctl.Apply}
|
||||
|
||||
return &http.Transport{
|
||||
MaxIdleConns: 8,
|
||||
@@ -125,9 +115,7 @@ func newBootstrapTransport(endpoints []DohEndpoint) *http.Transport {
|
||||
}
|
||||
return nil, lastErr
|
||||
},
|
||||
// явный DialTLSContext гарантирует SNI = hostname, даже если TCP-dial
|
||||
// идёт на bootstrap-IP. Без этого некоторые HTTP/2 пути сливают
|
||||
// литерал IP как ServerName и TLS падает.
|
||||
// Явный SNI гарантирует передачу hostname при TCP-dial по bootstrap-IP в HTTP/2.
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
@@ -161,14 +149,7 @@ func newBootstrapTransport(endpoints []DohEndpoint) *http.Transport {
|
||||
}
|
||||
}
|
||||
|
||||
// forwardRaw делает POST opaque DNS-wire запроса к настроенным DoH endpoint'ам
|
||||
// по порядку и возвращает первый успешный raw-ответ вместе с endpoint'ом.
|
||||
// Без парсинга - удобно для локального форвардера, который пропускает что бы
|
||||
// upstream ни ответил (RESINFO/HTTPS/SVCB/EDNS options/…).
|
||||
//
|
||||
// Каждому endpoint'у - свой per-attempt deadline (dohQueryTimeout), чтобы
|
||||
// медленный первый не сожрал весь бюджет и не зарезал fallback'и. Parent ctx
|
||||
// всё ещё ограничивает общее ожидание через cancel chain.
|
||||
// forwardRaw отправляет DNS-wire запрос к эндпоинтам по очереди с таймаутом на попытку.
|
||||
func (r *DohResolver) forwardRaw(ctx context.Context, query []byte) ([]byte, error) {
|
||||
if len(r.endpoints) == 0 {
|
||||
return nil, errors.New("doh: no endpoints configured")
|
||||
@@ -182,7 +163,7 @@ func (r *DohResolver) forwardRaw(ctx context.Context, query []byte) ([]byte, err
|
||||
body, err := r.postWire(epCtx, ep, query)
|
||||
cancel()
|
||||
if err != nil {
|
||||
Log.Warnf("[DoH] %s: %v", ep.Hostname, err)
|
||||
Log().Warnf("[DoH] %s: %v", ep.Hostname, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
@@ -191,7 +172,6 @@ func (r *DohResolver) forwardRaw(ctx context.Context, query []byte) ([]byte, err
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// postWire делает один application/dns-message POST к одному endpoint'у.
|
||||
func (r *DohResolver) postWire(ctx context.Context, ep DohEndpoint, query []byte) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(query))
|
||||
if err != nil {
|
||||
@@ -217,11 +197,7 @@ func (r *DohResolver) postWire(ctx context.Context, ep DohEndpoint, query []byte
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// net.Resolver Go дозванивается до этого stub'а как до обычного nameserver'а -
|
||||
// обходит уйму edge-case'ов fake-net.Conn (RESINFO probes, EDNS handshakes,
|
||||
// truncation, …). Что бы он ни прочитал на UDP/TCP - уходит дословно в DoH
|
||||
// endpoint, ответ отдаётся клиенту.
|
||||
|
||||
// dohForwarder - локальный stub DNS сервер, проксирующий raw DNS-пакеты в DoH.
|
||||
type dohForwarder struct {
|
||||
udpAddr string
|
||||
tcpAddr string
|
||||
@@ -231,19 +207,21 @@ var (
|
||||
dohForwarderOnce sync.Once
|
||||
dohForwarderInst *dohForwarder
|
||||
dohForwarderErr error
|
||||
// Резолвер подменяется на лету: ядро перезапускается в том же процессе (mobile), а Once держит листенеры.
|
||||
dohForwarderRes atomic.Pointer[DohResolver]
|
||||
)
|
||||
|
||||
// sharedDohForwarder лениво запускает process-wide форвардер, привязанный к
|
||||
// заданному resolver. Побеждает первый caller; следующие переиспользуют тот же
|
||||
// форвардер независимо от того, что передали.
|
||||
func sharedDohForwarder(r *DohResolver) (*dohForwarder, error) {
|
||||
dohForwarderRes.Store(r)
|
||||
dohForwarderOnce.Do(func() {
|
||||
dohForwarderInst, dohForwarderErr = startDohForwarder(r)
|
||||
dohForwarderInst, dohForwarderErr = startDohForwarder()
|
||||
})
|
||||
return dohForwarderInst, dohForwarderErr
|
||||
}
|
||||
|
||||
func startDohForwarder(r *DohResolver) (_ *dohForwarder, err error) {
|
||||
func currentDohResolver() *DohResolver { return dohForwarderRes.Load() }
|
||||
|
||||
func startDohForwarder() (_ *dohForwarder, err error) {
|
||||
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("doh forwarder: listen UDP: %w", err)
|
||||
@@ -267,51 +245,51 @@ func startDohForwarder(r *DohResolver) (_ *dohForwarder, err error) {
|
||||
udpAddr: udpConn.LocalAddr().String(),
|
||||
tcpAddr: tcpLn.Addr().String(),
|
||||
}
|
||||
Log.Infof("[DoH] forwarder listening udp=%s tcp=%s", fwd.udpAddr, fwd.tcpAddr)
|
||||
Log().Infof("[DoH] forwarder listening udp=%s tcp=%s", fwd.udpAddr, fwd.tcpAddr)
|
||||
|
||||
go fwd.serveUDP(udpConn, r)
|
||||
go fwd.serveTCP(tcpLn, r)
|
||||
go fwd.serveUDP(udpConn)
|
||||
go fwd.serveTCP(tcpLn)
|
||||
return fwd, nil
|
||||
}
|
||||
|
||||
func (*dohForwarder) serveUDP(conn *net.UDPConn, r *DohResolver) {
|
||||
func (*dohForwarder) serveUDP(conn *net.UDPConn) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
buf := make([]byte, forwarderUDPBufSize)
|
||||
for {
|
||||
n, client, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
Log.Errorf("[DoH] udp read: %v", err)
|
||||
Log().Errorf("[DoH] udp read: %v", err)
|
||||
return
|
||||
}
|
||||
query := append([]byte(nil), buf[:n]...)
|
||||
go func(q []byte, c *net.UDPAddr) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dohForwardBudget)
|
||||
defer cancel()
|
||||
resp, err := r.forwardRaw(ctx, q)
|
||||
resp, err := currentDohResolver().forwardRaw(ctx, q)
|
||||
if err != nil {
|
||||
Log.Warnf("[DoH] udp forward failed: %v", err)
|
||||
Log().Warnf("[DoH] udp forward failed: %v", err)
|
||||
return
|
||||
}
|
||||
if _, err := conn.WriteToUDP(resp, c); err != nil {
|
||||
Log.Warnf("[DoH] udp write: %v", err)
|
||||
Log().Warnf("[DoH] udp write: %v", err)
|
||||
}
|
||||
}(query, client)
|
||||
}
|
||||
}
|
||||
|
||||
func (*dohForwarder) serveTCP(ln *net.TCPListener, r *DohResolver) {
|
||||
func (*dohForwarder) serveTCP(ln *net.TCPListener) {
|
||||
defer func() { _ = ln.Close() }()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
Log.Errorf("[DoH] tcp accept: %v", err)
|
||||
Log().Errorf("[DoH] tcp accept: %v", err)
|
||||
return
|
||||
}
|
||||
go handleDohForwarderTCP(conn, r)
|
||||
go handleDohForwarderTCP(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDohForwarderTCP(conn net.Conn, r *DohResolver) {
|
||||
func handleDohForwarderTCP(conn net.Conn) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(forwarderTCPReadDL)) //nolint:errcheck
|
||||
@@ -328,15 +306,15 @@ func handleDohForwarderTCP(conn net.Conn, r *DohResolver) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dohQueryTimeout)
|
||||
resp, err := r.forwardRaw(ctx, query)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dohForwardBudget)
|
||||
resp, err := currentDohResolver().forwardRaw(ctx, query)
|
||||
cancel()
|
||||
if err != nil {
|
||||
Log.Warnf("[DoH] tcp forward failed: %v", err)
|
||||
Log().Warnf("[DoH] tcp forward failed: %v", err)
|
||||
return
|
||||
}
|
||||
if len(resp) > 0xFFFF {
|
||||
Log.Warnf("[DoH] response too large for TCP framing: %d", len(resp))
|
||||
Log().Warnf("[DoH] response too large for TCP framing: %d", len(resp))
|
||||
return
|
||||
}
|
||||
out := make([]byte, 2+len(resp))
|
||||
@@ -350,8 +328,6 @@ func handleDohForwarderTCP(conn net.Conn, r *DohResolver) {
|
||||
}
|
||||
}
|
||||
|
||||
// dohForwarderDial возвращает Resolver.Dial, подключающийся к локальному DoH
|
||||
// форвардеру по UDP или TCP (что запросил резолвер).
|
||||
func dohForwarderDial(r *DohResolver) dialFunc {
|
||||
return func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
fwd, err := sharedDohForwarder(r) //nolint:contextcheck
|
||||
@@ -376,7 +352,9 @@ const (
|
||||
|
||||
var udpDNSServersPtr atomic.Pointer[[]string]
|
||||
|
||||
func init() {
|
||||
func init() { ResetUDPDNSServers() }
|
||||
|
||||
func ResetUDPDNSServers() {
|
||||
def := []string{
|
||||
"77.88.8.8:53", "77.88.8.1:53",
|
||||
"8.8.8.8:53", "8.8.4.4:53",
|
||||
@@ -387,13 +365,7 @@ func init() {
|
||||
|
||||
func udpDNSServers() []string { return *udpDNSServersPtr.Load() }
|
||||
|
||||
// SetUDPDNSServers заменяет дефолтный список UDP/53 серверов. Каждый элемент -
|
||||
// "ip" или "ip:port"; голые IP получают :53. Пустой список оставляет дефолт.
|
||||
// На Android используется для подсовывания резолверов оператора из
|
||||
// LinkProperties.dnsServers - часто работают там, где публичный DoH/DoT нет.
|
||||
//
|
||||
// Безопасно вызывать конкурентно с использованием резолвера - указатель списка
|
||||
// меняется атомарно. Уже идущие dial видят свой захваченный снимок.
|
||||
// SetUDPDNSServers атомарно обновляет список UDP/53 серверов (IP или IP:Port).
|
||||
func SetUDPDNSServers(servers []string) {
|
||||
if len(servers) == 0 {
|
||||
return
|
||||
@@ -417,9 +389,6 @@ func SetUDPDNSServers(servers []string) {
|
||||
|
||||
type dialFunc = func(context.Context, string, string) (net.Conn, error)
|
||||
|
||||
// buildDialer возвращает net.Dialer, чей внутренний Go-резолвер использует
|
||||
// выбранный DNS-транспорт. В режиме "auto" первый полный отказ UDP/53
|
||||
// залипает процесс на DoH до конца его жизни.
|
||||
func buildDialer(mode string, r *DohResolver) net.Dialer {
|
||||
switch mode {
|
||||
case DNSModePlain:
|
||||
@@ -433,20 +402,18 @@ func buildDialer(mode string, r *DohResolver) net.Dialer {
|
||||
}
|
||||
}
|
||||
|
||||
// newAppDialer оборачивает Resolver.Dial таймаутами, используемыми везде в
|
||||
// приложении для outbound TCP/HTTP.
|
||||
func newAppDialer(dial dialFunc) net.Dialer {
|
||||
return net.Dialer{
|
||||
Timeout: appDialerTimeout,
|
||||
KeepAlive: appDialerKeepAlive,
|
||||
Resolver: &net.Resolver{PreferGo: true, Dial: dial},
|
||||
Control: netctl.Apply,
|
||||
}
|
||||
}
|
||||
|
||||
// udpDNSDial берёт первый достижимый UDP/53 резолвер из udpDNSServers.
|
||||
func udpDNSDial(ctx context.Context, _ string, _ string) (net.Conn, error) {
|
||||
var (
|
||||
d net.Dialer
|
||||
d = net.Dialer{Control: netctl.Apply}
|
||||
lastErr error
|
||||
)
|
||||
for _, s := range udpDNSServers() {
|
||||
@@ -462,37 +429,34 @@ func udpDNSDial(ctx context.Context, _ string, _ string) (net.Conn, error) {
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// autoDial возвращает Dial, который один раз пробит UDP/53 реальным DNS
|
||||
// round-trip'ом; при провале залипает на DoH до конца процесса. Сделано под
|
||||
// Android, где сеть скачет между Wi-Fi (UDP/53 работает) и мобилкой (блок).
|
||||
//
|
||||
// Просто dial-timeout не годится: UDP "dial" безсоединительный и всегда успешен
|
||||
// мгновенно. Единственный способ узнать - реально отправить запрос и ждать ответ.
|
||||
func autoDial(r *DohResolver) dialFunc {
|
||||
var (
|
||||
probed sync.Once
|
||||
useDoH atomic.Bool
|
||||
mu sync.Mutex
|
||||
probed *[]string
|
||||
useDoH bool
|
||||
doh = dohForwarderDial(r)
|
||||
)
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
probed.Do(func() {
|
||||
if udpProbe(autoUDPBudget) {
|
||||
Log.Infof("[DNS] UDP/53 probe OK, using UDP")
|
||||
mu.Lock()
|
||||
if servers := udpDNSServersPtr.Load(); servers != probed {
|
||||
probed = servers
|
||||
useDoH = !udpProbe(autoUDPBudget)
|
||||
if useDoH {
|
||||
Log().Warnf("[DNS] UDP/53 unreachable; switching to DoH until network change")
|
||||
} else {
|
||||
Log.Warnf("[DNS] UDP/53 unreachable; sticky-switching to DoH")
|
||||
useDoH.Store(true)
|
||||
Log().Infof("[DNS] UDP/53 probe OK, using UDP")
|
||||
}
|
||||
})
|
||||
if useDoH.Load() {
|
||||
}
|
||||
viaDoH := useDoH
|
||||
mu.Unlock()
|
||||
if viaDoH {
|
||||
return doh(ctx, network, addr)
|
||||
}
|
||||
return udpDNSDial(ctx, network, addr)
|
||||
}
|
||||
}
|
||||
|
||||
// udpProbe шлёт реальный DNS A-запрос к known-домену по UDP и проверяет, придёт
|
||||
// ли ответ до дедлайна. Пробуем первые два сервера из udpDNSServers под общим
|
||||
// deadline - если ни один не ответил, UDP/53 заблокирован.
|
||||
// udpProbe проверяет доступность UDP/53 реальным DNS-запросом к первым серверам.
|
||||
func udpProbe(timeout time.Duration) bool {
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("dns.google.", dns.TypeA)
|
||||
@@ -506,22 +470,31 @@ func udpProbe(timeout time.Duration) bool {
|
||||
buf := make([]byte, 512)
|
||||
servers := udpDNSServers()
|
||||
limit := min(len(servers), 2)
|
||||
for _, server := range servers[:limit] {
|
||||
for i, server := range servers[:limit] {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
conn, err := net.DialTimeout("udp", server, remaining) //nolint:noctx
|
||||
// Остаток бюджета делится поровну: молчащий сервер не съедает попытку следующего.
|
||||
attempt := remaining / time.Duration(limit-i)
|
||||
// netctl.Apply обязателен для исключения сокета пробы из VPN-туннеля.
|
||||
d := net.Dialer{Timeout: attempt, Control: netctl.Apply}
|
||||
conn, err := d.Dial("udp", server) //nolint:noctx
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = conn.SetDeadline(deadline) //nolint:errcheck
|
||||
_ = conn.SetDeadline(time.Now().Add(attempt)) //nolint:errcheck
|
||||
_, _ = conn.Write(wire)
|
||||
n, err := conn.Read(buf)
|
||||
_ = conn.Close()
|
||||
if err == nil && n > 12 {
|
||||
if err == nil && isReply(m, buf[:n]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isReply(query *dns.Msg, raw []byte) bool {
|
||||
var resp dns.Msg
|
||||
return resp.Unpack(raw) == nil && resp.Response && resp.Id == query.Id
|
||||
}
|
||||
|
||||
@@ -12,35 +12,22 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// dohAnswer builds a wire-format DNS reply for a single question with one
|
||||
// answer of the matching type (A or AAAA). TTL is returned as-is.
|
||||
func dohAnswer(t *testing.T, query []byte, ip net.IP, ttl uint32) []byte {
|
||||
func dnsAnswer(t *testing.T, query []byte) []byte {
|
||||
t.Helper()
|
||||
req := new(dns.Msg)
|
||||
if err := req.Unpack(query); err != nil {
|
||||
t.Fatalf("unpack query: %v", err)
|
||||
}
|
||||
reply := new(dns.Msg)
|
||||
reply.SetReply(req)
|
||||
if len(req.Question) != 1 {
|
||||
t.Fatalf("expected 1 question, got %d", len(req.Question))
|
||||
}
|
||||
q := req.Question[0]
|
||||
switch q.Qtype {
|
||||
case dns.TypeA:
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
reply.Answer = append(reply.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
|
||||
A: v4,
|
||||
})
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
if ip.To4() == nil {
|
||||
reply.Answer = append(reply.Answer, &dns.AAAA{
|
||||
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl},
|
||||
AAAA: ip,
|
||||
})
|
||||
}
|
||||
reply := new(dns.Msg)
|
||||
reply.SetReply(req)
|
||||
if q := req.Question[0]; q.Qtype == dns.TypeA {
|
||||
reply.Answer = append(reply.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
||||
A: net.IPv4(9, 9, 9, 9),
|
||||
})
|
||||
}
|
||||
out, err := reply.Pack()
|
||||
if err != nil {
|
||||
@@ -49,55 +36,97 @@ func dohAnswer(t *testing.T, query []byte, ip net.IP, ttl uint32) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
func readWire(t *testing.T, r io.Reader) []byte {
|
||||
func mockDoH(t *testing.T) *DohResolver {
|
||||
t.Helper()
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestAutoDial_StickyAfterUDPFailure(t *testing.T) {
|
||||
// DoH backend: always responds with a valid wire-format reply.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := readWire(t, r.Body)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read body: %v", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/dns-message")
|
||||
_, _ = w.Write(dohAnswer(t, body, net.ParseIP("9.9.9.9"), 300)) //nolint:errcheck
|
||||
_, _ = w.Write(dnsAnswer(t, body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resolver := newDohResolverWithClient(
|
||||
t.Cleanup(srv.Close)
|
||||
return newDohResolverWithClient(
|
||||
[]DohEndpoint{{URL: srv.URL, Hostname: "mock", BootstrapIPs: []string{"127.0.0.1"}}},
|
||||
srv.Client(),
|
||||
)
|
||||
|
||||
dial := autoDial(resolver)
|
||||
|
||||
// Poison udpDNSServers so that udpProbe (real DNS round-trip) fails
|
||||
// immediately — net.DialTimeout rejects the malformed address.
|
||||
old := udpDNSServersPtr.Load()
|
||||
bad := []string{"not-a-valid-host-port"}
|
||||
udpDNSServersPtr.Store(&bad)
|
||||
defer func() { udpDNSServersPtr.Store(old) }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn1, err := dial(ctx, "udp", "unused")
|
||||
if err != nil {
|
||||
t.Fatalf("first dial: %v", err)
|
||||
}
|
||||
_ = conn1.Close()
|
||||
|
||||
// Second call must skip UDP entirely. We assert this by poisoning
|
||||
// udpDNSServers with a value that would fail parsing — if the dialer
|
||||
// touches UDP again the call errors loudly.
|
||||
bad2 := []string{"still-not-a-valid-host-port"}
|
||||
udpDNSServersPtr.Store(&bad2)
|
||||
conn2, err := dial(ctx, "udp", "unused")
|
||||
if err != nil {
|
||||
t.Fatalf("second dial: %v", err)
|
||||
}
|
||||
_ = conn2.Close()
|
||||
}
|
||||
|
||||
// udpDNSServer поднимает UDP/53-стенд; reply == nil - сервер молчит.
|
||||
func udpDNSServer(t *testing.T, reply func(query []byte) []byte) string {
|
||||
t.Helper()
|
||||
pc, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = pc.Close() })
|
||||
go func() {
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
n, addr, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if reply != nil {
|
||||
_, _ = pc.WriteTo(reply(buf[:n]), addr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return pc.LocalAddr().String()
|
||||
}
|
||||
|
||||
func useUDPServers(t *testing.T, servers []string) {
|
||||
t.Helper()
|
||||
old := udpDNSServersPtr.Load()
|
||||
udpDNSServersPtr.Store(&servers)
|
||||
t.Cleanup(func() { udpDNSServersPtr.Store(old) })
|
||||
}
|
||||
|
||||
func TestAutoDial_DoHStickyUntilServersChange(t *testing.T) {
|
||||
dial := autoDial(mockDoH(t))
|
||||
// Невалидный адрес валит UDP-пробу сразу.
|
||||
servers := []string{"not-a-valid-host-port"}
|
||||
useUDPServers(t, servers)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
dialTo := func() string {
|
||||
t.Helper()
|
||||
conn, err := dial(ctx, "udp", "unused")
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
return conn.RemoteAddr().String()
|
||||
}
|
||||
dialTo()
|
||||
|
||||
good := udpDNSServer(t, func(q []byte) []byte { return dnsAnswer(t, q) })
|
||||
servers[0] = good
|
||||
if dialTo() == good {
|
||||
t.Fatal("re-probed UDP without servers change")
|
||||
}
|
||||
|
||||
SetUDPDNSServers([]string{good})
|
||||
if got := dialTo(); got != good {
|
||||
t.Fatalf("after servers change dial went to %s, want UDP %s", got, good)
|
||||
}
|
||||
}
|
||||
|
||||
// Молчащий первый сервер не должен съедать бюджет второго.
|
||||
func TestUDPProbe_SilentFirstServer(t *testing.T) {
|
||||
useUDPServers(t, []string{udpDNSServer(t, nil), udpDNSServer(t, func(q []byte) []byte { return dnsAnswer(t, q) })})
|
||||
if !udpProbe(300 * time.Millisecond) {
|
||||
t.Fatal("udpProbe = false, want true via second server")
|
||||
}
|
||||
}
|
||||
|
||||
// Эхо запроса - не DNS-ответ, UDP/53 такой сервер не обслуживает.
|
||||
func TestUDPProbe_RejectsNonResponse(t *testing.T) {
|
||||
useUDPServers(t, []string{udpDNSServer(t, func(q []byte) []byte { return append([]byte(nil), q...) })})
|
||||
if udpProbe(300 * time.Millisecond) {
|
||||
t.Fatal("udpProbe = true on echoed query, want false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ type listener struct {
|
||||
fd int
|
||||
}
|
||||
|
||||
// WrapListener подменяет стандартный net.Listener на legacy-syscall listener,
|
||||
// заточенный под iSH-симулятор на iOS, где нет современного `accept4`.
|
||||
// WrapListener подменяет net.Listener на legacy socketcall для работы в iSH-эмуляторе (нет accept4).
|
||||
func WrapListener(ln net.Listener) (net.Listener, error) {
|
||||
tl, ok := ln.(*net.TCPListener)
|
||||
if !ok {
|
||||
@@ -29,14 +28,12 @@ func WrapListener(ln net.Listener) (net.Listener, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// держим ссылку на *os.File, чтобы GC не закрыл FD.
|
||||
// Ссылка на *os.File предотвращает закрытие FD сборщиком мусора.
|
||||
return &listener{Listener: ln, f: f, fd: int(f.Fd())}, nil
|
||||
}
|
||||
|
||||
func (l *listener) Accept() (net.Conn, error) {
|
||||
// ставим listener-сокет в blocking. Go по умолчанию делает его non-blocking.
|
||||
// Это избавляет от time.Sleep в spin-loop, который триггерит futex_time64 SIGSYS
|
||||
// в современном Go на iSH.
|
||||
// Блокирующий сокет предотвращает spin-loop со sleep, триггерящий futex_time64 SIGSYS в iSH.
|
||||
if err := syscall.SetNonblock(l.fd, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -45,11 +42,9 @@ func (l *listener) Accept() (net.Conn, error) {
|
||||
addr := make([]byte, 128)
|
||||
addrlen := uintptr(128)
|
||||
|
||||
// i386 сетевые syscall'ы мультиплексируются через socketcall (102).
|
||||
// SYS_ACCEPT - subcall 5.
|
||||
// i386: socketcall (102), SYS_ACCEPT (5).
|
||||
args := [3]uintptr{uintptr(l.fd), uintptr(unsafe.Pointer(&addr[0])), uintptr(unsafe.Pointer(&addrlen))}
|
||||
|
||||
// Syscall6 - чтобы хватило регистров аргументов на этой платформе.
|
||||
r1, _, errno := syscall.Syscall6(102, 5, uintptr(unsafe.Pointer(&args)), 0, 0, 0, 0)
|
||||
if errno != 0 {
|
||||
if errno == syscall.EINTR {
|
||||
@@ -63,15 +58,13 @@ func (l *listener) Accept() (net.Conn, error) {
|
||||
_ = syscall.SetsockoptInt(nfd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, 256*1024)
|
||||
_ = syscall.SetsockoptInt(nfd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, 256*1024)
|
||||
|
||||
// избегаем net.FileConn - она регистрирует fd в Go epoll poller'е, что в
|
||||
// iSH стабильно падает с EEXIST. Возвращаем кастомный blocking net.Conn.
|
||||
// net.FileConn падает с EEXIST в epoll poller iSH; используется блокирующий raw FD conn.
|
||||
conn := &ishConn{fd: nfd}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *listener) Close() error {
|
||||
// закрываем и дублированный FD, и оригинальный listener.
|
||||
err1 := l.f.Close()
|
||||
err2 := l.Listener.Close()
|
||||
if err1 != nil {
|
||||
@@ -80,7 +73,6 @@ func (l *listener) Close() error {
|
||||
return err2
|
||||
}
|
||||
|
||||
// ishConn обходит сетевой poller Go, чтобы не словить EEXIST в iSH.
|
||||
type ishConn struct {
|
||||
fd int
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
// Package ish - TCP listener shim для iSH (https://ish.app), Linux user-mode
|
||||
// эмулятор на iOS. iSH таргетит linux/386 и не имеет современного accept4 /
|
||||
// epoll-poller Go, поэтому там нужен sandbox-aware accept loop. На любом
|
||||
// другом GOOS/GOARCH WrapListener - no-op passthrough.
|
||||
// Package ish предоставляет адаптер TCP listener для эмулятора iSH на iOS.
|
||||
package ish
|
||||
|
||||
import "net"
|
||||
|
||||
// WrapListener возвращает ln без изменений на не-iSH платформах.
|
||||
func WrapListener(ln net.Listener) (net.Listener, error) {
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Package clientid управляет персистентным ID клиента (allowlist сервера, seed отпечатка персоны).
|
||||
package clientid
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/statedir"
|
||||
)
|
||||
|
||||
const fileName = "client_config.json"
|
||||
|
||||
type fileFormat struct {
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
// Resolve возвращает существующий или сгенерированный ID. persisted=false при ошибке записи на диск.
|
||||
func Resolve(id string, paths []string) (resolved string, persisted bool, err error) {
|
||||
if id != "" {
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
for _, b := range statedir.ReadEach(paths) {
|
||||
var f fileFormat
|
||||
if json.Unmarshal(b, &f) == nil && f.ClientID != "" {
|
||||
return f.ClientID, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
buf := make([]byte, 16)
|
||||
if _, rerr := rand.Read(buf); rerr != nil {
|
||||
return "", false, fmt.Errorf("generate client id: %w", rerr)
|
||||
}
|
||||
newID := hex.EncodeToString(buf)
|
||||
|
||||
b, _ := json.MarshalIndent(fileFormat{ClientID: newID}, "", " ")
|
||||
return newID, statedir.WriteFirst(paths, b), nil
|
||||
}
|
||||
|
||||
func DefaultPaths() []string { return statedir.Paths(fileName) }
|
||||
@@ -0,0 +1,116 @@
|
||||
package clientid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveKeepsExplicitID(t *testing.T) {
|
||||
id, persisted, err := Resolve("explicit", nil)
|
||||
if err != nil || id != "explicit" || !persisted {
|
||||
t.Fatalf("Resolve() = %q, %v, %v", id, persisted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReadsFirstValidFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
broken := filepath.Join(dir, "broken.json")
|
||||
good := filepath.Join(dir, fileName)
|
||||
if err := os.WriteFile(broken, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(good, []byte(`{"client_id":"stored"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, persisted, err := Resolve("", []string{broken, good})
|
||||
if err != nil || id != "stored" || !persisted {
|
||||
t.Fatalf("Resolve() = %q, %v, %v", id, persisted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOverwritesFileWithEmptyID(t *testing.T) {
|
||||
empty := filepath.Join(t.TempDir(), fileName)
|
||||
if err := os.WriteFile(empty, []byte(`{"client_id":""}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, persisted, err := Resolve("", []string{empty})
|
||||
if err != nil || !persisted {
|
||||
t.Fatalf("Resolve() = %q, %v, %v", id, persisted, err)
|
||||
}
|
||||
if len(id) != 32 {
|
||||
t.Fatalf("generated id = %q, want 32 hex chars", id)
|
||||
}
|
||||
assertStoredID(t, empty, id)
|
||||
}
|
||||
|
||||
func TestResolveCreatesMissingDirectory(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "nested", "dir", fileName)
|
||||
|
||||
id, persisted, err := Resolve("", []string{target})
|
||||
if err != nil || !persisted {
|
||||
t.Fatalf("Resolve() = %q, %v, %v", id, persisted, err)
|
||||
}
|
||||
assertStoredID(t, target, id)
|
||||
}
|
||||
|
||||
func assertStoredID(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("id not persisted: %v", err)
|
||||
}
|
||||
var f fileFormat
|
||||
if json.Unmarshal(b, &f) != nil || f.ClientID != want {
|
||||
t.Fatalf("persisted file = %s, want client_id %q", b, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGeneratedIDIsStableAcrossCalls(t *testing.T) {
|
||||
paths := []string{filepath.Join(t.TempDir(), fileName)}
|
||||
|
||||
first, _, err := Resolve("", paths)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _, err := Resolve("", paths)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("id rotated between calls: %q != %q", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportsUnpersistedID(t *testing.T) {
|
||||
id, persisted, err := Resolve("", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persisted {
|
||||
t.Fatal("persisted = true without any writable path")
|
||||
}
|
||||
if len(id) != 32 {
|
||||
t.Fatalf("generated id = %q, want 32 hex chars", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPathsAreUniqueAndNamed(t *testing.T) {
|
||||
paths := DefaultPaths()
|
||||
if len(paths) == 0 {
|
||||
t.Fatal("DefaultPaths() is empty")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, p := range paths {
|
||||
if filepath.Base(p) != fileName {
|
||||
t.Fatalf("path %q does not end with %q", p, fileName)
|
||||
}
|
||||
if seen[p] {
|
||||
t.Fatalf("duplicate path %q", p)
|
||||
}
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
+159
-38
@@ -1,26 +1,28 @@
|
||||
package clientsdb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClientInfo содержит метаданные о клиенте
|
||||
type ClientInfo struct {
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// Data структура JSON-файла
|
||||
type Data struct {
|
||||
Clients map[string]ClientInfo `json:"clients"`
|
||||
}
|
||||
|
||||
// DB - база данных клиентов с поддержкой hot-reload
|
||||
type DB struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
@@ -28,7 +30,6 @@ type DB struct {
|
||||
lastModified time.Time
|
||||
}
|
||||
|
||||
// New создает новую базу данных и загружает ее из файла
|
||||
func New(path string) (*DB, error) {
|
||||
db := &DB{
|
||||
path: path,
|
||||
@@ -36,7 +37,6 @@ func New(path string) (*DB, error) {
|
||||
}
|
||||
|
||||
if err := db.load(); err != nil {
|
||||
// Если файла нет, это не ошибка, просто пустая база
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -45,18 +45,21 @@ func New(path string) (*DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// StartHotReload запускает горутину, которая периодически проверяет файл на изменения
|
||||
func (db *DB) StartHotReload(interval time.Duration) {
|
||||
func (db *DB) StartHotReload(ctx context.Context, interval time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
db.loadIfModified()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
db.loadIfModified()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// IsAuthorized проверяет, разрешен ли клиент
|
||||
func (db *DB) IsAuthorized(clientID string) bool {
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
@@ -64,25 +67,24 @@ func (db *DB) IsAuthorized(clientID string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// Add добавляет или обновляет клиента (и сразу сохраняет на диск)
|
||||
func (db *DB) Add(clientID, comment string) error {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
|
||||
db.data.Clients[clientID] = ClientInfo{Comment: comment}
|
||||
return db.save()
|
||||
next := Data{Clients: maps.Clone(db.data.Clients)}
|
||||
next.Clients[clientID] = ClientInfo{Comment: comment}
|
||||
return db.save(next)
|
||||
}
|
||||
|
||||
// Remove удаляет клиента
|
||||
func (db *DB) Remove(clientID string) error {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
|
||||
delete(db.data.Clients, clientID)
|
||||
return db.save()
|
||||
next := Data{Clients: maps.Clone(db.data.Clients)}
|
||||
delete(next.Clients, clientID)
|
||||
return db.save(next)
|
||||
}
|
||||
|
||||
// List возвращает всех клиентов
|
||||
func (db *DB) List() map[string]ClientInfo {
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
@@ -136,18 +138,23 @@ func (db *DB) loadIfModified() {
|
||||
}
|
||||
}
|
||||
|
||||
func (db *DB) save() error {
|
||||
b, err := json.MarshalIndent(db.data, "", " ")
|
||||
func (db *DB) save(next Data) error {
|
||||
b, err := json.MarshalIndent(next, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpFile := db.path + ".tmp"
|
||||
err = os.WriteFile(tmpFile, b, 0o600) // содержит Client ID (auth-токены) - не world-readable
|
||||
err = writeSync(tmpFile, b)
|
||||
if err == nil {
|
||||
err = os.Rename(tmpFile, db.path)
|
||||
}
|
||||
if err == nil {
|
||||
syncDir(filepath.Dir(db.path))
|
||||
}
|
||||
if err == nil {
|
||||
// Авторизация меняется только после успешной замены файла.
|
||||
db.data = next
|
||||
stat, _ := os.Stat(db.path)
|
||||
if stat != nil {
|
||||
db.lastModified = stat.ModTime()
|
||||
@@ -156,36 +163,150 @@ func (db *DB) save() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteClientID отправляет Client ID (1 байт длины + строка) в соединение
|
||||
func WriteClientID(conn net.Conn, clientID string) error {
|
||||
b := []byte(clientID)
|
||||
if len(b) > 255 {
|
||||
b = b[:255] // Усекаем до 255 байт
|
||||
func writeSync(path string, b []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) // 0o600: файл содержит Client ID токены авторизации
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = f.Write(b); err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
if closeErr := f.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
buf := make([]byte, 1+len(b))
|
||||
buf[0] = byte(len(b)) //nolint:gosec // len(b) усечён до ≤255 выше
|
||||
copy(buf[1:], b)
|
||||
_, err := conn.Write(buf)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadClientID читает Client ID из соединения. Учитывает DTLS record-orientated поведение.
|
||||
func ReadClientID(conn net.Conn) (string, error) {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
func syncDir(dir string) {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = d.Sync()
|
||||
_ = d.Close()
|
||||
}
|
||||
|
||||
const (
|
||||
ModeUnset byte = 0
|
||||
ModeUDP byte = 1
|
||||
ModeTCP byte = 2
|
||||
ModeTCPBond byte = 3
|
||||
|
||||
// idVersionAck - клиент ждёт подтверждения ID.
|
||||
idVersionAck byte = 2
|
||||
idAck byte = 0x06
|
||||
|
||||
idReadTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// ErrNoIDAck - сервер не подтвердил Client ID: он старше протокола подтверждения или
|
||||
// канал теряет всё подряд.
|
||||
var ErrNoIDAck = errors.New("clientsdb: server did not acknowledge client ID (server outdated?)")
|
||||
|
||||
// idRetransmit - таймеры повтора ID (RFC 6347 §4.2.4: старт 1 с, удвоение).
|
||||
var idRetransmit = []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}
|
||||
|
||||
func idRecord(clientID string, mode byte) []byte {
|
||||
b := []byte(clientID)
|
||||
if len(b) > 255 {
|
||||
b = b[:255]
|
||||
}
|
||||
buf := make([]byte, 0, len(b)+3)
|
||||
buf = append(buf, byte(len(b))) //nolint:gosec // len(b) усечён до ≤255 выше
|
||||
buf = append(buf, b...)
|
||||
return append(buf, mode, idVersionAck)
|
||||
}
|
||||
|
||||
func WriteClientID(ctx context.Context, conn net.Conn, clientID string, mode byte) error {
|
||||
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
|
||||
rec := idRecord(clientID, mode)
|
||||
var buf [16]byte
|
||||
for _, wait := range idRetransmit {
|
||||
if _, err := conn.Write(rec); err != nil {
|
||||
return fmt.Errorf("send client ID: %w", err)
|
||||
}
|
||||
if err := conn.SetReadDeadline(time.Now().Add(wait)); err != nil {
|
||||
return fmt.Errorf("client ID deadline: %w", err)
|
||||
}
|
||||
for {
|
||||
n, err := conn.Read(buf[:])
|
||||
if err == nil && n == 1 && buf[0] == idAck {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if !errors.As(err, &ne) || !ne.Timeout() {
|
||||
return fmt.Errorf("await client ID ack: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return ErrNoIDAck
|
||||
}
|
||||
|
||||
func readClientID(conn net.Conn) (string, byte, byte, error) {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(idReadTimeout))
|
||||
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
|
||||
|
||||
buf := make([]byte, 256)
|
||||
buf := make([]byte, 258)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", ModeUnset, 0, err
|
||||
}
|
||||
if n == 0 {
|
||||
return "", nil
|
||||
return "", ModeUnset, 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
l := int(buf[0])
|
||||
if n < 1+l {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
return "", ModeUnset, 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
var mode, ver byte
|
||||
if n > 1+l {
|
||||
mode = buf[1+l]
|
||||
}
|
||||
if n > 2+l {
|
||||
ver = buf[2+l]
|
||||
}
|
||||
return string(buf[1 : 1+l]), mode, ver, nil
|
||||
}
|
||||
|
||||
func AcceptClientID(conn net.Conn, authorize func(id string, mode byte) error) (string, net.Conn, error) {
|
||||
id, mode, ver, err := readClientID(conn)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := authorize(id, mode); err != nil {
|
||||
return id, nil, err
|
||||
}
|
||||
if ver < idVersionAck {
|
||||
return id, conn, nil
|
||||
}
|
||||
if _, err := conn.Write([]byte{idAck}); err != nil {
|
||||
return id, nil, fmt.Errorf("ack client ID: %w", err)
|
||||
}
|
||||
return id, &ackedConn{Conn: conn, rec: idRecord(id, mode)}, nil
|
||||
}
|
||||
|
||||
type ackedConn struct {
|
||||
net.Conn
|
||||
rec []byte
|
||||
}
|
||||
|
||||
func (c *ackedConn) Read(b []byte) (int, error) {
|
||||
for {
|
||||
n, err := c.Conn.Read(b)
|
||||
if err != nil || !bytes.Equal(b[:n], c.rec) {
|
||||
return n, err
|
||||
}
|
||||
if _, err := c.Write([]byte{idAck}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return string(buf[1 : 1+l]), nil
|
||||
}
|
||||
|
||||
+182
-35
@@ -1,12 +1,59 @@
|
||||
package clientsdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cbeuw/connutil"
|
||||
)
|
||||
|
||||
func TestFailedSavePreservesAuthorization(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "clients.json")
|
||||
db, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Add("existing", "original"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Каталог вместо временного файла воспроизводит отказ записи без прав root.
|
||||
if err = os.Mkdir(path+".tmp", 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
operations := []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{"add", func() error { return db.Add("new", "new") }},
|
||||
{"update", func() error { return db.Add("existing", "changed") }},
|
||||
{"remove", func() error { return db.Remove("existing") }},
|
||||
}
|
||||
for _, op := range operations {
|
||||
t.Run(op.name, func(t *testing.T) {
|
||||
if runErr := op.run(); runErr == nil {
|
||||
t.Fatal("save unexpectedly succeeded")
|
||||
}
|
||||
if !db.IsAuthorized("existing") || db.IsAuthorized("new") {
|
||||
t.Fatal("failed save changed authorization")
|
||||
}
|
||||
if got := db.List(); len(got) != 1 || got["existing"].Comment != "original" {
|
||||
t.Fatalf("failed save changed clients: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
loaded, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loaded.List(); len(got) != 1 || got["existing"].Comment != "original" {
|
||||
t.Fatalf("disk changed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientsDB(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "clients.json")
|
||||
@@ -36,7 +83,6 @@ func TestClientsDB(t *testing.T) {
|
||||
t.Errorf("Expected client-123 to be removed")
|
||||
}
|
||||
|
||||
// Test persistence
|
||||
_ = db.Add("client-789", "Test Persistence")
|
||||
|
||||
db2, err := New(dbPath)
|
||||
@@ -48,7 +94,6 @@ func TestClientsDB(t *testing.T) {
|
||||
t.Errorf("Expected client-789 to be persisted")
|
||||
}
|
||||
|
||||
// Test hot reload manually
|
||||
db2.mu.Lock()
|
||||
db2.lastModified = db2.lastModified.Add(-1 * time.Second)
|
||||
db2.mu.Unlock()
|
||||
@@ -61,40 +106,142 @@ func TestClientsDB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// dropWrites теряет первые n записей - модель потерь датаграмм.
|
||||
type dropWrites struct {
|
||||
net.Conn
|
||||
n int
|
||||
}
|
||||
|
||||
func (d *dropWrites) Write(b []byte) (int, error) {
|
||||
if d.n > 0 {
|
||||
d.n--
|
||||
return len(b), nil
|
||||
}
|
||||
return d.Conn.Write(b)
|
||||
}
|
||||
|
||||
const testID = "e8030ba252344e536664d96f4544d64d"
|
||||
|
||||
func accept(t *testing.T, conn net.Conn) (string, byte, net.Conn, error) {
|
||||
t.Helper()
|
||||
var gotMode byte
|
||||
id, data, err := AcceptClientID(conn, func(_ string, mode byte) error {
|
||||
gotMode = mode
|
||||
return nil
|
||||
})
|
||||
return id, gotMode, data, err
|
||||
}
|
||||
|
||||
func TestClientIDRoundTrip(t *testing.T) {
|
||||
addr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}
|
||||
serverConn, err := net.ListenUDP("udp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("ListenUDP: %v", err)
|
||||
client, server := pipe()
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- WriteClientID(t.Context(), client, testID, ModeTCP) }()
|
||||
|
||||
id, mode, _, err := accept(t, server)
|
||||
if err != nil || id != testID || mode != ModeTCP {
|
||||
t.Fatalf("accept = %q/%d/%v, want %q/%d", id, mode, err, testID, ModeTCP)
|
||||
}
|
||||
defer func() { _ = serverConn.Close() }()
|
||||
|
||||
udpAddr, ok := serverConn.LocalAddr().(*net.UDPAddr)
|
||||
if !ok {
|
||||
t.Fatalf("LocalAddr is not *net.UDPAddr")
|
||||
}
|
||||
clientConn, err := net.DialUDP("udp", nil, udpAddr)
|
||||
if err != nil {
|
||||
t.Fatalf("DialUDP: %v", err)
|
||||
}
|
||||
defer func() { _ = clientConn.Close() }()
|
||||
|
||||
expectedID := "client-test-uuid-123"
|
||||
|
||||
// Client writes
|
||||
go func() {
|
||||
if werr := WriteClientID(clientConn, expectedID); werr != nil {
|
||||
t.Errorf("WriteClientID failed: %v", werr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Server reads
|
||||
readID, err := ReadClientID(serverConn)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadClientID: %v", err)
|
||||
}
|
||||
|
||||
if readID != expectedID {
|
||||
t.Errorf("expected %q, got %q", expectedID, readID)
|
||||
if err := <-errc; err != nil {
|
||||
t.Fatalf("WriteClientID: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Потеря первой ID-записи: клиент повторяет по таймеру, сессия поднимается.
|
||||
func TestClientIDSurvivesLostRecord(t *testing.T) {
|
||||
client, server := pipe()
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- WriteClientID(t.Context(), &dropWrites{Conn: client, n: 1}, testID, ModeUDP) }()
|
||||
|
||||
if id, _, _, err := accept(t, server); err != nil || id != testID {
|
||||
t.Fatalf("accept = %q/%v", id, err)
|
||||
}
|
||||
if err := <-errc; err != nil {
|
||||
t.Fatalf("WriteClientID: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIDSurvivesLostAck(t *testing.T) {
|
||||
client, server := pipe()
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
if err := WriteClientID(t.Context(), client, testID, ModeUDP); err != nil {
|
||||
errc <- err
|
||||
return
|
||||
}
|
||||
_, err := client.Write([]byte("payload"))
|
||||
errc <- err
|
||||
}()
|
||||
|
||||
_, _, data, err := accept(t, &dropWrites{Conn: server, n: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
buf := make([]byte, 64)
|
||||
n, err := data.Read(buf)
|
||||
if err != nil || string(buf[:n]) != "payload" {
|
||||
t.Fatalf("data = %q/%v, want payload", buf[:n], err)
|
||||
}
|
||||
if err := <-errc; err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Клиенты старше подтверждения: без режима ([len][id]) и с режимом ([len][id][mode]).
|
||||
// Им сервер не отвечает и отдаёт соединение как есть - поведение до подтверждения.
|
||||
func TestAcceptLegacyRecordNoAck(t *testing.T) {
|
||||
head := append([]byte{byte(len(testID))}, testID...)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rec []byte
|
||||
mode byte
|
||||
}{
|
||||
{"no mode", head, ModeUnset},
|
||||
{"with mode", append(append([]byte{}, head...), ModeUDP), ModeUDP},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client, server := pipe()
|
||||
if _, err := client.Write(tc.rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, mode, data, err := accept(t, server)
|
||||
if err != nil || id != testID || mode != tc.mode || data != server {
|
||||
t.Fatalf("accept = %q/%d/%v, conn untouched=%v", id, mode, err, data == server)
|
||||
}
|
||||
_ = client.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||
if n, err := client.Read(make([]byte, 4)); err == nil {
|
||||
t.Fatalf("legacy client got %d bytes of ack", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptRejectsWithoutAck(t *testing.T) {
|
||||
client, server := pipe()
|
||||
if _, err := client.Write(idRecord(testID, ModeUDP)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := errors.New("denied")
|
||||
if _, _, err := AcceptClientID(server, func(string, byte) error { return denied }); !errors.Is(err, denied) {
|
||||
t.Fatalf("err = %v, want denied", err)
|
||||
}
|
||||
_ = client.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||
if n, err := client.Read(make([]byte, 4)); err == nil {
|
||||
t.Fatalf("rejected client got %d bytes of ack", n)
|
||||
}
|
||||
}
|
||||
|
||||
// pipe - датаграммная пара; таймаут отдаётся как у DTLS (net.Error), а не своей ошибкой connutil.
|
||||
func pipe() (net.Conn, net.Conn) {
|
||||
a, b := connutil.AsyncPacketPipe()
|
||||
return &netTimeout{a}, &netTimeout{b}
|
||||
}
|
||||
|
||||
type netTimeout struct{ net.Conn }
|
||||
|
||||
func (c *netTimeout) Read(b []byte) (int, error) {
|
||||
n, err := c.Conn.Read(b)
|
||||
if errors.Is(err, connutil.ErrTimeout) {
|
||||
err = os.ErrDeadlineExceeded
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClientArgs восстанавливает срез CLI-аргументов из Client структуры.
|
||||
func ClientArgs(c *Client) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
def := Defaults()
|
||||
var args []string
|
||||
addIf := func(cond bool, flag string, value ...string) {
|
||||
if cond {
|
||||
args = append(args, flag)
|
||||
args = append(args, value...)
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, "-peer", c.Proxy.Peer)
|
||||
addIf(len(c.VK.Links) > 0, "-links", strings.Join(c.VK.Links, ","))
|
||||
addIf(c.Provider.Name != def.Provider.Name, "-provider", c.Provider.Name)
|
||||
addIf(c.Proxy.Listen != def.Proxy.Listen, "-listen", c.Proxy.Listen)
|
||||
addIf(c.TURN.Host != "", "-turn", c.TURN.Host)
|
||||
addIf(c.TURN.Port != "", "-port", c.TURN.Port)
|
||||
addIf(c.TURN.N != def.TURN.N, "-n", strconv.Itoa(c.TURN.N))
|
||||
addIf(c.VK.StreamsPerCred != def.VK.StreamsPerCred, "-streams-per-cred", strconv.Itoa(c.VK.StreamsPerCred))
|
||||
addIf(c.TURN.TransportUDP != def.TURN.TransportUDP, "-transport", TransportUDP)
|
||||
addIf(c.Proxy.Mode != def.Proxy.Mode, "-mode", string(c.Proxy.Mode))
|
||||
addIf(c.Proxy.Bond, "-bond")
|
||||
args = append(args, kcpArgs(c.KCP.Profile, def.KCP.Profile)...)
|
||||
addIf(c.Obf.Enabled(), "-obf-profile", string(c.Obf.Profile))
|
||||
addIf(c.Obf.Enabled(), "-obf-key", hex.EncodeToString(c.Obf.Key))
|
||||
addIf(c.Obf.Timing > 0, "-obf-timing", c.Obf.Timing.String())
|
||||
addIf(c.VK.ManualCaptcha, "-manual-captcha")
|
||||
addIf(c.VK.Platform != def.VK.Platform, "-platform", string(c.VK.Platform))
|
||||
addIf(c.DNS.Mode != def.DNS.Mode, "-dns-mode", c.DNS.Mode)
|
||||
addIf(len(c.DNS.Servers) > 0, "-dns-servers", strings.Join(c.DNS.Servers, ","))
|
||||
addIf(c.ClientID != "", "-client-id", c.ClientID)
|
||||
addIf(c.SubURL != "", "-sub", c.SubURL)
|
||||
addIf(c.Log.Debug, "-debug")
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Главное свойство ClientArgs: строка, показанная пользователю, разбирается
|
||||
// обратно в тот же конфиг. Иначе экран "какая команда работает" начинает врать.
|
||||
func TestClientArgsRoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"minimal", validClientArgs()},
|
||||
{"udp transport", append(validClientArgs(), "-transport", "udp")},
|
||||
{"tcp mode", append(validClientArgs(), "-mode", "tcp")},
|
||||
{"tcp mode kcp", append(validClientArgs(), "-mode", "tcp", "-kcp-mtu", "900", "-kcp-interval", "40", "-kcp-acknodelay=false")},
|
||||
{"obf", append(validClientArgs(), "-obf-profile", "rtpopus3", "-obf-key", testObfKey)},
|
||||
{"obf timing", append(validClientArgs(), "-obf-profile", "rtpopus", "-obf-key", testObfKey, "-obf-timing", "20ms")},
|
||||
{"dns", append(validClientArgs(), "-dns-mode", "doh", "-dns-servers", "1.1.1.1,8.8.8.8:53")},
|
||||
{"turn override", append(validClientArgs(), "-turn", "5.5.5.5", "-port", "3478")},
|
||||
{"tuning", append(validClientArgs(), "-n", "3", "-streams-per-cred", "4")},
|
||||
{"vk extras", append(validClientArgs(), "-manual-captcha", "-platform", "mobile")},
|
||||
{"multi links", []string{"-peer", "1.2.3.4:5000", "-links", "https://vk.ru/call/join/A,https://vk.ru/call/join/B"}},
|
||||
{"misc", append(validClientArgs(), "-client-id", "deadbeef", "-listen", "127.0.0.1:1080", "-debug")},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
want, err := ParseClient(tc.args, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClient(%v) error = %v", tc.args, err)
|
||||
}
|
||||
|
||||
rebuilt := ClientArgs(want)
|
||||
got, err := ParseClient(rebuilt, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClient(%v) error = %v", rebuilt, err)
|
||||
}
|
||||
if !reflect.DeepEqual(*got, *want) {
|
||||
t.Errorf("round-trip changed config\nargs %v\n got %+v\nwant %+v", rebuilt, *got, *want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Дефолтные значения в строку не попадают: она должна читаться человеком.
|
||||
func TestClientArgsOmitsDefaults(t *testing.T) {
|
||||
c, err := ParseClient(validClientArgs(), io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.Join(ClientArgs(c), " ")
|
||||
want := "-peer 1.2.3.4:5000 -links abcdef"
|
||||
if got != want {
|
||||
t.Errorf("ClientArgs() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientArgsNil(t *testing.T) {
|
||||
if got := ClientArgs(nil); got != nil {
|
||||
t.Errorf("ClientArgs(nil) = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/uri"
|
||||
)
|
||||
|
||||
func TestBondConfigRoundTrip(t *testing.T) {
|
||||
args := []string{"-provider", "direct", "-peer", "127.0.0.1:56000", "-mode", "tcp", "-bond", "-n", "3"}
|
||||
c, err := ParseClient(args, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
round, err := ParseClient(ClientArgs(c), io.Discard)
|
||||
if err != nil || !round.Proxy.Bond {
|
||||
t.Fatalf("args round trip: %v", err)
|
||||
}
|
||||
link := (&uri.Config{Provider: "direct", Peer: "127.0.0.1:56000", Mode: "tcp", Bond: true, N: 3}).String()
|
||||
parsed, err := uri.Parse(link)
|
||||
if err != nil || !parsed.Bond {
|
||||
t.Fatalf("URI round trip: %v", err)
|
||||
}
|
||||
c, err = ParseClient([]string{link}, io.Discard)
|
||||
if err != nil || !c.Proxy.Bond {
|
||||
t.Fatalf("URI to CLI: %v", err)
|
||||
}
|
||||
c, err = ParseClientJSON([]byte(`{"provider":"direct","peer":"127.0.0.1:56000","proxy":{"mode":"tcp","bond":true}}`), "")
|
||||
if err != nil || !c.Proxy.Bond {
|
||||
t.Fatalf("JSON: %v", err)
|
||||
}
|
||||
c, err = ParseClientJSON([]byte(`{}`), link)
|
||||
if err != nil || !c.Proxy.Bond {
|
||||
t.Fatalf("URI overlay: %v", err)
|
||||
}
|
||||
if strings.Contains(DefaultClientJSON(), `"bond"`) {
|
||||
t.Fatal("default JSON changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBondValidation(t *testing.T) {
|
||||
for _, extra := range [][]string{{}, {"-mode", "tcp", "-n", "257"}} {
|
||||
args := append([]string{"-provider", "direct", "-peer", "127.0.0.1:56000", "-bond"}, extra...)
|
||||
if _, err := ParseClient(args, io.Discard); !errors.Is(err, ErrBondConfig) {
|
||||
t.Fatalf("accepted %v: %v", args, err)
|
||||
}
|
||||
}
|
||||
if _, err := ParseClientJSON([]byte(`{"provider":"direct","peer":"127.0.0.1:1","proxy":{"mode":"tcp","bonds":true}}`), ""); err == nil {
|
||||
t.Fatal("unknown JSON field accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/uri"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/wire/rtpopus"
|
||||
)
|
||||
|
||||
const uriScheme = "freeturn://"
|
||||
|
||||
// PeekSubURL извлекает значение флага -sub из сырых аргументов без полного парсинга.
|
||||
func PeekSubURL(args []string) string {
|
||||
for i := range args {
|
||||
a := args[i]
|
||||
if v, ok := strings.CutPrefix(a, "-sub="); ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := strings.CutPrefix(a, "--sub="); ok {
|
||||
return v
|
||||
}
|
||||
if (a == "-sub" || a == "--sub") && i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParseClient парсит аргументы командной строки в Client.
|
||||
func ParseClient(args []string, errOut io.Writer) (*Client, error) {
|
||||
r := defaultRaw()
|
||||
|
||||
fs := flag.NewFlagSet("client", flag.ContinueOnError)
|
||||
if errOut != nil {
|
||||
fs.SetOutput(errOut)
|
||||
}
|
||||
fs.StringVar(&r.Turn, "turn", r.Turn, "IP TURN-сервера; override creds провайдера")
|
||||
fs.StringVar(&r.Port, "port", r.Port, "порт TURN-сервера; override creds провайдера")
|
||||
fs.StringVar(&r.Listen, "listen", r.Listen, "локальный ip:port для WireGuard/Xray клиента")
|
||||
fs.StringVar(&r.Provider, "provider", r.Provider, "источник потоков: vk (TURN-реле VK Calls) | direct (напрямую на -peer)")
|
||||
fs.StringVar(&r.Link, "link", r.Link, "(устарел) одна ссылка VK Calls, используйте -links")
|
||||
fs.StringVar(&r.Links, "links", r.Links, "ссылки VK Calls через запятую: https://vk.ru/call/join/...,https://vk.ru/call/join/...")
|
||||
fs.StringVar(&r.Peer, "peer", r.Peer, "адрес сервера на VPS, host:port; обязательно")
|
||||
fs.IntVar(&r.N, "n", r.N, "число параллельных потоков; 0 - по провайдеру (vk 12, direct 1)")
|
||||
fs.StringVar(&r.Transport, "transport", r.Transport, "транспорт до TURN-реле: tcp | udp")
|
||||
fs.StringVar(&r.Mode, "mode", r.Mode, "режим туннеля: udp (WireGuard) | tcp (Xray/sing-box)")
|
||||
fs.BoolVar(&r.Bond, "bond", r.Bond, "объединять сессии для одного TCP-соединения; только -mode tcp")
|
||||
fs.StringVar(&r.ObfProfile, "obf-profile", r.ObfProfile, "wire-профиль обфускации: none | rtpopus | rtpopus2 | rtpopus3; должен совпадать с сервером")
|
||||
fs.StringVar(&r.ObfKey, "obf-key", r.ObfKey, "ключ для -obf-profile != none: 32 байта hex (64 символа)")
|
||||
fs.BoolVar(&r.GenObfKey, "gen-obf-key", r.GenObfKey, "напечатать новый -obf-key и выйти")
|
||||
fs.DurationVar(&r.ObfTiming, "obf-timing", r.ObfTiming, "межпакетная задержка для RTP-мимикрии (напр. 20ms); 0=выкл")
|
||||
fs.IntVar(&r.StreamsPerCred, "streams-per-cred", r.StreamsPerCred, "TURN-потоков на один кеш VK-creds; только -provider vk")
|
||||
fs.BoolVar(&r.Debug, "debug", r.Debug, "подробные debug-логи")
|
||||
fs.BoolVar(&r.ManualCaptcha, "manual-captcha", r.ManualCaptcha, "ручная VK captcha в браузере вместо авто; только -provider vk")
|
||||
fs.StringVar(&r.Platform, "platform", r.Platform, "класс устройства персоны VK-auth: desktop | mobile; только -provider vk")
|
||||
fs.StringVar(&r.DNSMode, "dns-mode", r.DNSMode, "резолвер клиента: plain | doh | auto")
|
||||
fs.StringVar(&r.DNSServers, "dns-servers", r.DNSServers, "свои UDP/53 DNS через запятую: ip[:port][,ip[:port]...]")
|
||||
fs.StringVar(&r.ClientID, "client-id", r.ClientID, "уникальный ID клиента (автогенерация если не задан)")
|
||||
fs.StringVar(&r.SubURL, "sub", r.SubURL, "URL подписки (sub.md) для получения списка серверов")
|
||||
fs.BoolVar(&r.Routes, "routes", r.Routes, "автоматическое управление маршрутами к TURN-серверам; требует прав администратора")
|
||||
kcp := registerKCPFlags(fs, r.KCP)
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if fs.NArg() > 0 && strings.HasPrefix(fs.Arg(0), uriScheme) {
|
||||
u, err := uri.Parse(fs.Arg(0))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse freeturn:// URI: %w", err)
|
||||
}
|
||||
r.applyURI(u)
|
||||
}
|
||||
r.KCP = kcp.profile()
|
||||
|
||||
c, err := assemble(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Obf.GenKey {
|
||||
return c, nil
|
||||
}
|
||||
if err := Validate(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ParseServer парсит аргументы командной строки в Server.
|
||||
func ParseServer(args []string, errOut io.Writer) (*Server, error) {
|
||||
def := ServerDefaults()
|
||||
|
||||
fs := flag.NewFlagSet("server", flag.ContinueOnError)
|
||||
if errOut != nil {
|
||||
fs.SetOutput(errOut)
|
||||
}
|
||||
listen := fs.String("listen", def.Proxy.Listen, "локальный адрес прослушивания ip:port")
|
||||
connect := fs.String("connect", "", "локальный бэкенд host:port; обязательно: WG 127.0.0.1:51820 | Xray 127.0.0.1:443")
|
||||
mode := fs.String("mode", string(def.Proxy.Mode), "режим туннеля: udp (WireGuard) | tcp (Xray/sing-box)")
|
||||
obfProfile := fs.String("obf-profile", string(def.Obf.Profile), "wire-профиль обфускации: none | rtpopus | rtpopus2 | rtpopus3; должен совпадать с клиентом")
|
||||
obfKey := fs.String("obf-key", "", "ключ для -obf-profile != none: 32 байта hex (64 символа)")
|
||||
genObfKey := fs.Bool("gen-obf-key", false, "напечатать новый -obf-key и выйти")
|
||||
obfTiming := fs.Duration("obf-timing", 0, "межпакетная задержка для RTP-мимикрии (напр. 10ms); 0=выкл")
|
||||
debug := fs.Bool("debug", false, "подробные debug-логи")
|
||||
clientsFile := fs.String("clients-file", "", "путь к файлу clients.json для авторизации по Client ID")
|
||||
kcp := registerKCPFlags(fs, def.KCP.Profile)
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch *mode {
|
||||
case ModeUDP, ModeTCP:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid -mode value %q: must be %s | %s", *mode, ModeUDP, ModeTCP)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
Obf: ObfOpts{
|
||||
Profile: ObfProfile(*obfProfile),
|
||||
GenKey: *genObfKey,
|
||||
Timing: *obfTiming,
|
||||
},
|
||||
Proxy: ProxyOpts{
|
||||
Mode: ProxyMode(*mode),
|
||||
Listen: *listen,
|
||||
Connect: *connect,
|
||||
},
|
||||
Log: LogOpts{Debug: *debug},
|
||||
KCP: KCPOpts{Profile: kcp.profile()},
|
||||
ClientsFile: *clientsFile,
|
||||
}
|
||||
|
||||
if s.Obf.GenKey {
|
||||
return s, nil
|
||||
}
|
||||
if err := ValidateServer(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := rtpopus.DecodeKey(s.Obf.Enabled(), *obfKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Obf.Key = key
|
||||
|
||||
return s, nil
|
||||
}
|
||||
+49
-431
@@ -1,128 +1,95 @@
|
||||
// Package config парсит CLI-флаги клиента и сервера.
|
||||
//
|
||||
// Функции Parse* без побочных эффектов: валидируют ввод и декодируют wrap-ключ,
|
||||
// но не трогают сеть, DNS и состояние процесса. Подключение этих эффектов -
|
||||
// ответственность main() после возврата Parse*.
|
||||
//
|
||||
// Опции сгруппированы по доменам (TURN, Obf, Proxy, VK, DNS, Log) - структура
|
||||
// зеркалит концептуальные слои прокси.
|
||||
// Package config описывает структуры конфигурации клиента и сервера.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcptun"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/uri"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/wire/rtpopus"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
|
||||
)
|
||||
|
||||
const (
|
||||
dnsModePlain = "plain"
|
||||
dnsModeDoH = "doh"
|
||||
dnsModeAuto = "auto"
|
||||
defaultStreamsPerCache = 10
|
||||
)
|
||||
|
||||
// ProxyMode выбирает payload прикладного уровня, который идёт через TURN-туннель.
|
||||
// На клиенте доступны все три; на сервере только UDP / TCPFwd
|
||||
// (bond определяется автоматически per-stream по magic-префиксу).
|
||||
type ProxyMode string
|
||||
|
||||
const (
|
||||
ProxyModeUDP ProxyMode = "udp" // -mode udp (default): UDP-релей пакетов (WireGuard)
|
||||
ProxyModeTCPFwd ProxyMode = "tcpfwd" // -mode tcp: TCP-форвардер через smux
|
||||
ProxyModeTCPFwdBond ProxyMode = "tcpfwd-bond" // -mode tcp -bond: bond TCP по N smux-сессиям
|
||||
)
|
||||
|
||||
// TURNOpts - опции TURN-сервера (куда и как подключаться).
|
||||
type TURNOpts struct {
|
||||
Host string // -turn: переопределить IP/host TURN-сервера
|
||||
Port string // -port: переопределить порт TURN
|
||||
TransportUDP bool // -transport udp: подключение к TURN по UDP (по умолчанию TCP/TLS)
|
||||
N int // -n: число TURN-потоков (только клиент)
|
||||
Host string
|
||||
Port string
|
||||
TransportUDP bool
|
||||
N int
|
||||
}
|
||||
|
||||
// ObfProfile выбирает wire-профиль обфускации TURN-payload.
|
||||
// Профили живут в internal/wire/<profile>/ - сейчас только rtpopus,
|
||||
// под добавление новых (rtph264, vp8 и т.д.).
|
||||
// ObfProfile - wire-профиль обфускации TURN-payload (internal/wire/<profile>/).
|
||||
type ObfProfile string
|
||||
|
||||
const (
|
||||
ObfProfileNone ObfProfile = "none" // обфускация отключена
|
||||
ObfProfileNone ObfProfile = "none"
|
||||
ObfProfileRTPOpus ObfProfile = "rtpopus" // RTP/opus + ChaCha20-Poly1305 AEAD
|
||||
ObfProfileRTPOpus2 ObfProfile = "rtpopus2" // rtpopus + RTP header extension (мимикрия под современный WebRTC)
|
||||
ObfProfileRTPOpus2 ObfProfile = "rtpopus2" // rtpopus + RTP header extension (мимикрия под WebRTC)
|
||||
ObfProfileRTPOpus3 ObfProfile = "rtpopus3" // rtpopus2 + abs-send-time + VAD + loss simulation + variable ts
|
||||
)
|
||||
|
||||
// ObfOpts - опции обфускации TURN-payload.
|
||||
type ObfOpts struct {
|
||||
Profile ObfProfile // -obf-profile: none (default) | rtpopus | rtpopus2
|
||||
Key []byte // -obf-key (декодированный): 32-байтовый общий ключ; nil если Profile=none
|
||||
GenKey bool // -gen-obf-key: напечатать новый ключ и выйти
|
||||
Timing time.Duration // -obf-timing: межпакетная задержка (RTP-мимикрия); 0=выкл
|
||||
Profile ObfProfile
|
||||
Key []byte
|
||||
GenKey bool
|
||||
Timing time.Duration
|
||||
}
|
||||
|
||||
// Enabled возвращает true когда выбран реальный профиль обфускации.
|
||||
func (o ObfOpts) Enabled() bool { return o.Profile != ObfProfileNone }
|
||||
|
||||
// ProxyOpts - опции прокси прикладного уровня.
|
||||
// ProxyMode задаёт тип прикладного трафика в TURN-туннеле.
|
||||
type ProxyMode string
|
||||
|
||||
const (
|
||||
ProxyModeUDP ProxyMode = "udp" // UDP-релей датаграмм (WireGuard)
|
||||
ProxyModeTCP ProxyMode = "tcp" // TCP-форвардер через KCP+smux (Xray/sing-box)
|
||||
)
|
||||
|
||||
type ProxyOpts struct {
|
||||
Mode ProxyMode // udp | tcpfwd | tcpfwd-bond (сервер: udp | tcpfwd)
|
||||
Listen string // -listen: локальный bind (клиент: WG/TCP entry; сервер: TURN entry)
|
||||
Connect string // -connect: backend (только сервер)
|
||||
Peer string // -peer: адрес серверного прокси, куда дозванивается клиент (только клиент)
|
||||
Bond bool
|
||||
Mode ProxyMode
|
||||
Listen string
|
||||
Connect string
|
||||
Peer string
|
||||
}
|
||||
|
||||
// Browser выбирает браузерный профиль для control-plane запросов VK-провайдера.
|
||||
type Browser string
|
||||
type Platform string
|
||||
|
||||
const (
|
||||
BrowserChrome Browser = "chrome"
|
||||
BrowserFirefox Browser = "firefox"
|
||||
BrowserSafari Browser = "safari"
|
||||
PlatformDesktop Platform = "desktop"
|
||||
PlatformMobile Platform = "mobile"
|
||||
)
|
||||
|
||||
// VKOpts - опции VK-учёток и captcha (только клиент, провайдер "vk").
|
||||
type VKOpts struct {
|
||||
Links []string // -links (нормализованные join-коды); несколько = больше стримов
|
||||
StreamsPerCred int // -streams-per-cred
|
||||
ManualCaptcha bool // -manual-captcha
|
||||
Browser Browser // -browser: chrome | firefox | safari
|
||||
Links []string
|
||||
StreamsPerCred int
|
||||
ManualCaptcha bool
|
||||
Platform Platform
|
||||
}
|
||||
|
||||
// ProviderOpts выбирает реализацию provider.Provider.
|
||||
type ProviderOpts struct {
|
||||
Name string // -provider: vk (default)
|
||||
Name string
|
||||
}
|
||||
|
||||
// Известные имена провайдеров.
|
||||
const (
|
||||
ProviderVK = "vk"
|
||||
ProviderVK = "vk"
|
||||
ProviderDirect = "direct"
|
||||
)
|
||||
|
||||
// DNSOpts - опции DNS-резолвинга (только клиент).
|
||||
type DNSOpts struct {
|
||||
Mode string // -dns-mode: plain | doh | auto
|
||||
Servers []string // -dns-servers (через запятую); nil если флаг пуст
|
||||
Mode string
|
||||
Servers []string
|
||||
}
|
||||
|
||||
// LogOpts - опции логирования.
|
||||
type LogOpts struct {
|
||||
Debug bool // -debug
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// KCPOpts - параметры KCP-туннеля, хардкодятся из DefaultProfile/FEC{}.
|
||||
type KCPOpts struct {
|
||||
Profile kcptun.Profile
|
||||
FEC kcptun.FEC
|
||||
// TunnelOpts - параметры встроенного WireGuard/AmneziaWG userspace-туннеля.
|
||||
type TunnelOpts struct {
|
||||
Mode tunnel.Mode
|
||||
Config string
|
||||
MTU int
|
||||
}
|
||||
|
||||
// Client - разобранные и провалидированные CLI-опции клиента.
|
||||
func (t TunnelOpts) Enabled() bool { return t.Mode != "" && t.Mode != tunnel.ModeNone }
|
||||
|
||||
type Client struct {
|
||||
TURN TURNOpts
|
||||
Obf ObfOpts
|
||||
@@ -132,365 +99,16 @@ type Client struct {
|
||||
DNS DNSOpts
|
||||
Log LogOpts
|
||||
KCP KCPOpts
|
||||
Tunnel TunnelOpts
|
||||
ClientID string
|
||||
SubURL string
|
||||
Routes bool
|
||||
}
|
||||
|
||||
// Server - разобранные и провалидированные CLI-опции сервера.
|
||||
type Server struct {
|
||||
Obf ObfOpts
|
||||
Proxy ProxyOpts
|
||||
Log LogOpts
|
||||
KCP KCPOpts
|
||||
ClientsFile string // -clients-file
|
||||
}
|
||||
|
||||
// PeekSubURL вытаскивает значение -sub из сырых args без полного парсинга.
|
||||
// Нужно до ParseClient: подписка отдаёт peer, без которого ParseClient падает
|
||||
// на валидации. Вызывающий тянет подписку и подсовывает URI ноды позиционным
|
||||
// аргументом - дальше применение идёт общим путём в ParseClient.
|
||||
func PeekSubURL(args []string) string {
|
||||
for i := range args {
|
||||
a := args[i]
|
||||
if v, ok := strings.CutPrefix(a, "-sub="); ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := strings.CutPrefix(a, "--sub="); ok {
|
||||
return v
|
||||
}
|
||||
if (a == "-sub" || a == "--sub") && i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParseClient разбирает args (без имени программы) в Client.
|
||||
// При flag.ErrHelp возвращает (nil, flag.ErrHelp) - вызывающий выходит штатно.
|
||||
func ParseClient(args []string, errOut io.Writer) (*Client, error) {
|
||||
fs := flag.NewFlagSet("client", flag.ContinueOnError)
|
||||
if errOut != nil {
|
||||
fs.SetOutput(errOut)
|
||||
}
|
||||
|
||||
turn := fs.String("turn", "", "IP TURN-сервера; override creds провайдера")
|
||||
port := fs.String("port", "", "порт TURN-сервера; override creds провайдера")
|
||||
listen := fs.String("listen", "127.0.0.1:9000", "локальный ip:port для WireGuard/Xray клиента")
|
||||
provider := fs.String("provider", ProviderVK, "источник TURN-creds: vk")
|
||||
link := fs.String("link", "", "(устарел) одна ссылка VK Calls, используйте -links")
|
||||
links := fs.String("links", "", "ссылки VK Calls через запятую: https://vk.ru/call/join/...,https://vk.ru/call/join/...")
|
||||
peer := fs.String("peer", "", "адрес сервера на VPS, host:port; обязательно")
|
||||
n := fs.Int("n", 10, "число параллельных TURN-потоков")
|
||||
transport := fs.String("transport", "tcp", "транспорт до TURN-реле: tcp | udp")
|
||||
mode := fs.String("mode", "udp", "режим туннеля: udp (WireGuard) | tcp (Xray/sing-box)")
|
||||
bond := fs.Bool("bond", false, "страйпинг TCP по smux-сессиям; только с -mode tcp")
|
||||
obfProfile := fs.String("obf-profile", string(ObfProfileNone), "wire-профиль обфускации: none | rtpopus | rtpopus2 | rtpopus3; должен совпадать с сервером")
|
||||
obfKey := fs.String("obf-key", "", "ключ для -obf-profile != none: 32 байта hex (64 символа)")
|
||||
genObfKey := fs.Bool("gen-obf-key", false, "напечатать новый -obf-key и выйти")
|
||||
obfTiming := fs.Duration("obf-timing", 0, "межпакетная задержка для RTP-мимикрии (напр. 20ms); 0=выкл")
|
||||
streamsPerCred := fs.Int("streams-per-cred", defaultStreamsPerCache, "TURN-потоков на один кеш VK-creds; только -provider vk")
|
||||
debug := fs.Bool("debug", false, "подробные debug-логи")
|
||||
manualCaptcha := fs.Bool("manual-captcha", false, "ручная VK captcha в браузере вместо авто; только -provider vk")
|
||||
browser := fs.String("browser", string(BrowserFirefox), "браузерный профиль VK-auth: chrome | firefox | safari; только -provider vk")
|
||||
dnsMode := fs.String("dns-mode", dnsModeAuto, "резолвер клиента: plain | doh | auto")
|
||||
dnsServers := fs.String("dns-servers", "", "свои UDP/53 DNS через запятую: ip[:port][,ip[:port]...]")
|
||||
clientID := fs.String("client-id", "", "уникальный ID клиента (автогенерация если не задан)")
|
||||
subURL := fs.String("sub", "", "URL подписки (sub.md) для получения списка серверов")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
TURN: TURNOpts{
|
||||
Host: *turn,
|
||||
Port: *port,
|
||||
TransportUDP: *transport == "udp",
|
||||
N: *n,
|
||||
},
|
||||
Obf: ObfOpts{
|
||||
Profile: ObfProfile(*obfProfile),
|
||||
GenKey: *genObfKey,
|
||||
Timing: *obfTiming,
|
||||
},
|
||||
Proxy: ProxyOpts{
|
||||
Mode: ClientProxyMode(*mode, *bond),
|
||||
Listen: *listen,
|
||||
Peer: *peer,
|
||||
},
|
||||
Provider: ProviderOpts{
|
||||
Name: *provider,
|
||||
},
|
||||
VK: VKOpts{
|
||||
StreamsPerCred: *streamsPerCred,
|
||||
ManualCaptcha: *manualCaptcha,
|
||||
Browser: Browser(*browser),
|
||||
},
|
||||
DNS: DNSOpts{
|
||||
Mode: *dnsMode,
|
||||
},
|
||||
Log: LogOpts{
|
||||
Debug: *debug,
|
||||
},
|
||||
KCP: KCPOpts{
|
||||
Profile: kcptun.DefaultProfile(),
|
||||
FEC: kcptun.FEC{},
|
||||
},
|
||||
ClientID: *clientID,
|
||||
SubURL: *subURL,
|
||||
}
|
||||
|
||||
// Обработка позиционного аргумента URI
|
||||
if fs.NArg() > 0 {
|
||||
arg := fs.Arg(0)
|
||||
if strings.HasPrefix(arg, "freeturn://") {
|
||||
ucfg, err := uri.Parse(arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse freeturn:// URI: %w", err)
|
||||
}
|
||||
if ucfg.Provider != "" {
|
||||
c.Provider.Name = ucfg.Provider
|
||||
}
|
||||
if ucfg.Transport != "" {
|
||||
*transport = ucfg.Transport
|
||||
}
|
||||
if ucfg.Mode != "" {
|
||||
*mode = ucfg.Mode
|
||||
}
|
||||
if ucfg.Bond {
|
||||
*bond = true
|
||||
}
|
||||
if ucfg.N > 0 {
|
||||
c.TURN.N = ucfg.N
|
||||
}
|
||||
if ucfg.StreamsPerCred > 0 {
|
||||
c.VK.StreamsPerCred = ucfg.StreamsPerCred
|
||||
}
|
||||
if ucfg.ObfProfile != "" {
|
||||
c.Obf.Profile = ObfProfile(ucfg.ObfProfile)
|
||||
}
|
||||
if ucfg.ObfKey != "" {
|
||||
*obfKey = ucfg.ObfKey
|
||||
}
|
||||
if ucfg.Peer != "" {
|
||||
c.Proxy.Peer = ucfg.Peer
|
||||
}
|
||||
if ucfg.ClientID != "" {
|
||||
c.ClientID = ucfg.ClientID
|
||||
}
|
||||
if ucfg.Listen != "" {
|
||||
c.Proxy.Listen = ucfg.Listen
|
||||
}
|
||||
if ucfg.DNSMode != "" {
|
||||
c.DNS.Mode = ucfg.DNSMode
|
||||
}
|
||||
if ucfg.DNSServers != "" {
|
||||
*dnsServers = ucfg.DNSServers
|
||||
}
|
||||
if ucfg.ManualCaptcha {
|
||||
c.VK.ManualCaptcha = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пересчитываем после возможного изменения из URI
|
||||
c.Proxy.Mode = ClientProxyMode(*mode, *bond)
|
||||
c.TURN.TransportUDP = *transport == "udp"
|
||||
|
||||
switch *transport {
|
||||
case "tcp", "udp":
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid -transport value %q: must be tcp | udp", *transport)
|
||||
}
|
||||
switch *mode {
|
||||
case "udp", "tcp":
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid -mode value %q: must be udp | tcp", *mode)
|
||||
}
|
||||
if *bond && *mode != "tcp" {
|
||||
return nil, fmt.Errorf("-bond requires -mode tcp")
|
||||
}
|
||||
switch c.DNS.Mode {
|
||||
case dnsModePlain, dnsModeDoH, dnsModeAuto:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid -dns-mode value %q: must be plain | doh | auto", c.DNS.Mode)
|
||||
}
|
||||
if *dnsServers != "" {
|
||||
c.DNS.Servers = strings.Split(*dnsServers, ",")
|
||||
}
|
||||
|
||||
if c.Obf.GenKey {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
if c.Proxy.Peer == "" {
|
||||
return nil, errors.New("need peer address")
|
||||
}
|
||||
switch c.Provider.Name {
|
||||
case ProviderVK:
|
||||
if *links == "" && *link == "" {
|
||||
return nil, errors.New("need -links (или -link) (required for -provider vk)")
|
||||
}
|
||||
if c.VK.StreamsPerCred <= 0 {
|
||||
return nil, fmt.Errorf("-streams-per-cred must be positive")
|
||||
}
|
||||
switch c.VK.Browser {
|
||||
case BrowserChrome, BrowserFirefox, BrowserSafari:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid -browser value %q: must be %s | %s | %s", c.VK.Browser, BrowserChrome, BrowserFirefox, BrowserSafari)
|
||||
}
|
||||
rawLinks := strings.Split(*links, ",")
|
||||
if len(rawLinks) == 1 && rawLinks[0] == "" {
|
||||
// -links не задан, используем -link (backward compat)
|
||||
rawLinks = []string{*link}
|
||||
}
|
||||
for _, raw := range rawLinks {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(raw, "join/")
|
||||
normalized := parts[len(parts)-1]
|
||||
if idx := strings.IndexAny(normalized, "/?#"); idx != -1 {
|
||||
normalized = normalized[:idx]
|
||||
}
|
||||
if normalized != "" {
|
||||
c.VK.Links = append(c.VK.Links, normalized)
|
||||
}
|
||||
}
|
||||
if len(c.VK.Links) == 0 {
|
||||
return nil, errors.New("need at least one valid VK link")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid -provider value %q: must be %s", c.Provider.Name, ProviderVK)
|
||||
}
|
||||
if err := validateObfProfile(c.Obf.Profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := rtpopus.DecodeKey(c.Obf.Enabled(), *obfKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Obf.Key = key
|
||||
if err := validateObfTiming(c.Obf, c.Proxy.Mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.TURN.N <= 0 {
|
||||
c.TURN.N = 10
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ParseServer разбирает args (без имени программы) в Server.
|
||||
func ParseServer(args []string, errOut io.Writer) (*Server, error) {
|
||||
fs := flag.NewFlagSet("server", flag.ContinueOnError)
|
||||
if errOut != nil {
|
||||
fs.SetOutput(errOut)
|
||||
}
|
||||
|
||||
listen := fs.String("listen", "0.0.0.0:56000", "локальный адрес прослушивания ip:port")
|
||||
connect := fs.String("connect", "", "локальный бэкенд host:port; обязательно: WG 127.0.0.1:51820 | Xray 127.0.0.1:443")
|
||||
mode := fs.String("mode", "udp", "режим туннеля: udp (WireGuard) | tcp (Xray/sing-box; bond авто)")
|
||||
obfProfile := fs.String("obf-profile", string(ObfProfileNone), "wire-профиль обфускации: none | rtpopus | rtpopus2 | rtpopus3; должен совпадать с клиентом")
|
||||
obfKey := fs.String("obf-key", "", "ключ для -obf-profile != none: 32 байта hex (64 символа)")
|
||||
genObfKey := fs.Bool("gen-obf-key", false, "напечатать новый -obf-key и выйти")
|
||||
obfTiming := fs.Duration("obf-timing", 0, "межпакетная задержка для RTP-мимикрии (напр. 10ms); 0=выкл")
|
||||
debug := fs.Bool("debug", false, "подробные debug-логи")
|
||||
clientsFile := fs.String("clients-file", "", "путь к файлу clients.json для авторизации по Client ID")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
Obf: ObfOpts{
|
||||
Profile: ObfProfile(*obfProfile),
|
||||
GenKey: *genObfKey,
|
||||
Timing: *obfTiming,
|
||||
},
|
||||
Proxy: ProxyOpts{
|
||||
Mode: serverProxyMode(*mode),
|
||||
Listen: *listen,
|
||||
Connect: *connect,
|
||||
},
|
||||
Log: LogOpts{
|
||||
Debug: *debug,
|
||||
},
|
||||
KCP: KCPOpts{
|
||||
Profile: kcptun.DefaultProfile(),
|
||||
FEC: kcptun.FEC{},
|
||||
},
|
||||
ClientsFile: *clientsFile,
|
||||
}
|
||||
|
||||
switch *mode {
|
||||
case "udp", "tcp":
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid -mode value %q: must be udp | tcp", *mode)
|
||||
}
|
||||
|
||||
if s.Obf.GenKey {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
if s.Proxy.Connect == "" {
|
||||
return nil, fmt.Errorf("server address is required")
|
||||
}
|
||||
if err := validateObfProfile(s.Obf.Profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := rtpopus.DecodeKey(s.Obf.Enabled(), *obfKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Obf.Key = key
|
||||
if err := validateObfTiming(s.Obf, s.Proxy.Mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// validateObfTiming ограничивает -obf-timing UDP-релеем с включённой обфускацией:
|
||||
// без RTP-профиля паковать нечего, а в tcp-режиме pacing ломает RTT/конгешн KCP.
|
||||
func validateObfTiming(o ObfOpts, mode ProxyMode) error {
|
||||
if o.Timing <= 0 {
|
||||
return nil
|
||||
}
|
||||
if !o.Enabled() {
|
||||
return errors.New("-obf-timing requires -obf-profile != none")
|
||||
}
|
||||
if mode != ProxyModeUDP {
|
||||
return errors.New("-obf-timing supported only with -mode udp")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateObfProfile проверяет что -obf-profile содержит известное значение.
|
||||
func validateObfProfile(p ObfProfile) error {
|
||||
switch p {
|
||||
case ObfProfileNone, ObfProfileRTPOpus, ObfProfileRTPOpus2, ObfProfileRTPOpus3:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid -obf-profile value %q: must be %s | %s | %s | %s", p, ObfProfileNone, ObfProfileRTPOpus, ObfProfileRTPOpus2, ObfProfileRTPOpus3)
|
||||
}
|
||||
}
|
||||
|
||||
func ClientProxyMode(mode string, bond bool) ProxyMode {
|
||||
switch {
|
||||
case mode == "tcp" && bond:
|
||||
return ProxyModeTCPFwdBond
|
||||
case mode == "tcp":
|
||||
return ProxyModeTCPFwd
|
||||
default:
|
||||
return ProxyModeUDP
|
||||
}
|
||||
}
|
||||
|
||||
func serverProxyMode(mode string) ProxyMode {
|
||||
if mode == "tcp" {
|
||||
return ProxyModeTCPFwd
|
||||
}
|
||||
return ProxyModeUDP
|
||||
ClientsFile string
|
||||
}
|
||||
|
||||
+124
-70
@@ -30,13 +30,13 @@ func TestParseClient_Defaults(t *testing.T) {
|
||||
if c.Proxy.Listen != "127.0.0.1:9000" {
|
||||
t.Errorf("Proxy.Listen default: %q", c.Proxy.Listen)
|
||||
}
|
||||
if c.TURN.N != 10 {
|
||||
if c.TURN.N != DefaultStreams {
|
||||
t.Errorf("TURN.N default: %d", c.TURN.N)
|
||||
}
|
||||
if c.DNS.Mode != "auto" {
|
||||
t.Errorf("DNS.Mode default: %q", c.DNS.Mode)
|
||||
}
|
||||
if c.VK.StreamsPerCred != defaultStreamsPerCache {
|
||||
if c.VK.StreamsPerCred != DefaultStreamsPerCred {
|
||||
t.Errorf("VK.StreamsPerCred default: %d", c.VK.StreamsPerCred)
|
||||
}
|
||||
if len(c.VK.Links) != 1 || c.VK.Links[0] != "abcdef" {
|
||||
@@ -51,9 +51,6 @@ func TestParseClient_Defaults(t *testing.T) {
|
||||
if c.Obf.Enabled() {
|
||||
t.Errorf("Obf.Enabled() should be false when profile=none")
|
||||
}
|
||||
if c.Proxy.Mode != ProxyModeUDP {
|
||||
t.Errorf("Proxy.Mode default: %q (expected udp)", c.Proxy.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_VKLinkStrip(t *testing.T) {
|
||||
@@ -115,30 +112,32 @@ func TestParseClient_InvalidDNS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_BrowserSafari(t *testing.T) {
|
||||
args := append(validClientArgs(), "-browser", "safari")
|
||||
func TestParseClient_PlatformDefaultDesktop(t *testing.T) {
|
||||
c, err := ParseClient(validClientArgs(), io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.VK.Platform != PlatformDesktop {
|
||||
t.Errorf("VK.Platform = %q, want %q", c.VK.Platform, PlatformDesktop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_PlatformMobile(t *testing.T) {
|
||||
args := append(validClientArgs(), "-platform", "mobile")
|
||||
c, err := ParseClient(args, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.VK.Browser != BrowserSafari {
|
||||
t.Errorf("VK.Browser = %q, want %q", c.VK.Browser, BrowserSafari)
|
||||
if c.VK.Platform != PlatformMobile {
|
||||
t.Errorf("VK.Platform = %q, want %q", c.VK.Platform, PlatformMobile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_BrowserInvalid(t *testing.T) {
|
||||
args := append(validClientArgs(), "-browser", "opera")
|
||||
func TestParseClient_PlatformInvalid(t *testing.T) {
|
||||
args := append(validClientArgs(), "-platform", "tablet")
|
||||
_, err := ParseClient(args, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid -browser") {
|
||||
t.Errorf("expected browser error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_BondWithoutTCPMode(t *testing.T) {
|
||||
args := append(validClientArgs(), "-bond")
|
||||
_, err := ParseClient(args, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "-bond requires -mode tcp") {
|
||||
t.Errorf("expected bond error, got %v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid -platform") {
|
||||
t.Errorf("expected platform error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,14 +182,6 @@ func TestParseClient_ObfTimingRequiresObf(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_ObfTimingRejectsTCP(t *testing.T) {
|
||||
args := append(validClientArgs(), "-mode", "tcp", "-obf-profile", "rtpopus3", "-obf-key", testObfKey, "-obf-timing", "20ms")
|
||||
_, err := ParseClient(args, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "-obf-timing") {
|
||||
t.Errorf("expected obf-timing/mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_ObfTimingValid(t *testing.T) {
|
||||
args := append(validClientArgs(), "-obf-profile", "rtpopus3", "-obf-key", testObfKey, "-obf-timing", "20ms")
|
||||
c, err := ParseClient(args, io.Discard)
|
||||
@@ -202,14 +193,6 @@ func TestParseClient_ObfTimingValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServer_ObfTimingRejectsTCP(t *testing.T) {
|
||||
args := []string{"-connect", "127.0.0.1:51820", "-mode", "tcp", "-obf-profile", "rtpopus3", "-obf-key", testObfKey, "-obf-timing", "10ms"}
|
||||
_, err := ParseServer(args, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "-obf-timing") {
|
||||
t.Errorf("expected obf-timing/mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_StreamsPerCredNonPositive(t *testing.T) {
|
||||
args := append(validClientArgs(), "-streams-per-cred", "0")
|
||||
_, err := ParseClient(args, io.Discard)
|
||||
@@ -218,14 +201,14 @@ func TestParseClient_StreamsPerCredNonPositive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_NClampedToTen(t *testing.T) {
|
||||
func TestParseClient_NClampedToDefault(t *testing.T) {
|
||||
args := append(validClientArgs(), "-n", "-5")
|
||||
c, err := ParseClient(args, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.TURN.N != 10 {
|
||||
t.Errorf("TURN.N: %d (expected 10)", c.TURN.N)
|
||||
if c.TURN.N != DefaultStreams {
|
||||
t.Errorf("TURN.N: %d (expected %d)", c.TURN.N, DefaultStreams)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,31 +249,6 @@ func TestParseClient_HelpReturnsErrHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_ProxyMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want ProxyMode
|
||||
}{
|
||||
{"default-udp", nil, ProxyModeUDP},
|
||||
{"tcp", []string{"-mode", "tcp"}, ProxyModeTCPFwd},
|
||||
{"tcp-bond", []string{"-mode", "tcp", "-bond"}, ProxyModeTCPFwdBond},
|
||||
{"default-udp-no-bond", nil, ProxyModeUDP},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := append(validClientArgs(), tc.args...)
|
||||
c, err := ParseClient(args, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Proxy.Mode != tc.want {
|
||||
t.Errorf("Proxy.Mode = %q, want %q", c.Proxy.Mode, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServer_Defaults(t *testing.T) {
|
||||
s, err := ParseServer([]string{"-connect", "backend:1234"}, io.Discard)
|
||||
if err != nil {
|
||||
@@ -302,9 +260,6 @@ func TestParseServer_Defaults(t *testing.T) {
|
||||
if s.Proxy.Connect != "backend:1234" {
|
||||
t.Errorf("Proxy.Connect: %q", s.Proxy.Connect)
|
||||
}
|
||||
if s.Proxy.Mode != ProxyModeUDP {
|
||||
t.Errorf("Proxy.Mode default: %q", s.Proxy.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServer_MissingConnect(t *testing.T) {
|
||||
@@ -363,7 +318,106 @@ func TestParseServer_ProxyMode(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Proxy.Mode != ProxyModeTCPFwd {
|
||||
t.Errorf("Proxy.Mode = %q, want tcpfwd", s.Proxy.Mode)
|
||||
if s.Proxy.Mode != ProxyModeTCP {
|
||||
t.Errorf("Proxy.Mode = %q, want %q", s.Proxy.Mode, ProxyModeTCP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServer_InvalidMode(t *testing.T) {
|
||||
_, err := ParseServer([]string{"-connect", "x:1", "-mode", "quic"}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid -mode") {
|
||||
t.Errorf("expected mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServer_KCPFlagsRequireTCPMode(t *testing.T) {
|
||||
_, err := ParseServer([]string{"-connect", "x:1", "-kcp-mtu", "900"}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "-kcp-*") {
|
||||
t.Errorf("expected kcp/mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServer_KCPProfileApplied(t *testing.T) {
|
||||
s, err := ParseServer([]string{"-connect", "x:1", "-mode", "tcp", "-kcp-mtu", "900", "-kcp-interval", "40"}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.KCP.Profile.MTU != 900 || s.KCP.Profile.Interval != 40 {
|
||||
t.Errorf("KCP profile = %+v", s.KCP.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServer_KCPMTUOutOfRange(t *testing.T) {
|
||||
_, err := ParseServer([]string{"-connect", "x:1", "-mode", "tcp", "-kcp-mtu", "2000"}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "-kcp-mtu") {
|
||||
t.Errorf("expected kcp-mtu error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_ProxyMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want ProxyMode
|
||||
}{
|
||||
{"default-udp", nil, ProxyModeUDP},
|
||||
{"tcp", []string{"-mode", "tcp"}, ProxyModeTCP},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, err := ParseClient(append(validClientArgs(), tc.args...), io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Proxy.Mode != tc.want {
|
||||
t.Errorf("Proxy.Mode = %q, want %q", c.Proxy.Mode, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_InvalidMode(t *testing.T) {
|
||||
_, err := ParseClient(append(validClientArgs(), "-mode", "quic"), io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid -mode") {
|
||||
t.Errorf("expected mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_ObfTimingAllowedInTCP(t *testing.T) {
|
||||
args := append(validClientArgs(), "-mode", "tcp", "-obf-profile", "rtpopus3", "-obf-key", testObfKey, "-obf-timing", "20ms")
|
||||
c, err := ParseClient(args, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClient() error = %v", err)
|
||||
}
|
||||
if c.Obf.Timing != 20*time.Millisecond {
|
||||
t.Errorf("Obf.Timing = %v, want 20ms", c.Obf.Timing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_ObfTimingRequiresProfile(t *testing.T) {
|
||||
_, err := ParseClient(append(validClientArgs(), "-obf-timing", "20ms"), io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "-obf-timing") {
|
||||
t.Errorf("expected obf-timing/profile error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_KCPFlagsRequireTCPMode(t *testing.T) {
|
||||
_, err := ParseClient(append(validClientArgs(), "-kcp-sndwnd", "128"), io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "-kcp-*") {
|
||||
t.Errorf("expected kcp/mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_ProviderDirect(t *testing.T) {
|
||||
c, err := ParseClient([]string{"-peer", "1.2.3.4:5000", "-provider", "direct"}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("direct without links: %v", err)
|
||||
}
|
||||
if c.TURN.N != DefaultDirectStreams {
|
||||
t.Errorf("TURN.N = %d, want %d", c.TURN.N, DefaultDirectStreams)
|
||||
}
|
||||
c, err = ParseClient([]string{"-peer", "1.2.3.4:5000", "-provider", "direct", "-n", "5"}, io.Discard)
|
||||
if err != nil || c.TURN.N != 5 {
|
||||
t.Fatalf("explicit -n: %v, n=%d", err, c.TURN.N)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/client/dnsdial"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcpmux"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
|
||||
)
|
||||
|
||||
const (
|
||||
TransportTCP = "tcp"
|
||||
TransportUDP = "udp"
|
||||
|
||||
ModeUDP = "udp"
|
||||
ModeTCP = "tcp"
|
||||
)
|
||||
|
||||
const (
|
||||
DNSModePlain = dnsdial.DNSModePlain
|
||||
DNSModeDoH = dnsdial.DNSModeDoH
|
||||
DNSModeAuto = dnsdial.DNSModeAuto
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultListen = "127.0.0.1:9000"
|
||||
DefaultStreams = 12
|
||||
DefaultDirectStreams = 1
|
||||
DefaultStreamsPerCred = 12
|
||||
DefaultTransport = TransportTCP
|
||||
DefaultMode = ModeUDP
|
||||
DefaultDNSMode = DNSModeAuto
|
||||
DefaultProvider = ProviderVK
|
||||
DefaultPlatform = PlatformDesktop
|
||||
DefaultObfProfile = ObfProfileNone
|
||||
|
||||
DefaultServerListen = "0.0.0.0:56000"
|
||||
)
|
||||
|
||||
func defaultRaw() raw {
|
||||
return raw{
|
||||
Listen: DefaultListen,
|
||||
Provider: DefaultProvider,
|
||||
StreamsPerCred: DefaultStreamsPerCred,
|
||||
Transport: DefaultTransport,
|
||||
Mode: DefaultMode,
|
||||
ObfProfile: string(DefaultObfProfile),
|
||||
Platform: string(DefaultPlatform),
|
||||
DNSMode: DefaultDNSMode,
|
||||
Routes: false,
|
||||
TunnelMode: string(tunnel.ModeNone),
|
||||
TunnelMTU: tunnel.DefaultMTU,
|
||||
KCP: kcpmux.DefaultProfile(),
|
||||
}
|
||||
}
|
||||
|
||||
// Defaults возвращает конфигурацию клиента с дефолтными значениями (без peer/links).
|
||||
func Defaults() Client {
|
||||
c, err := assemble(defaultRaw())
|
||||
if err != nil {
|
||||
panic("config: defaults must assemble: " + err.Error())
|
||||
}
|
||||
return *c
|
||||
}
|
||||
|
||||
// ServerDefaults возвращает конфигурацию сервера с дефолтными значениями.
|
||||
func ServerDefaults() Server {
|
||||
return Server{
|
||||
Obf: ObfOpts{Profile: DefaultObfProfile},
|
||||
Proxy: ProxyOpts{Mode: ProxyModeUDP, Listen: DefaultServerListen},
|
||||
KCP: KCPOpts{Profile: kcpmux.DefaultProfile()},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultsAssemble(t *testing.T) {
|
||||
d := Defaults()
|
||||
if d.Proxy.Listen != DefaultListen {
|
||||
t.Errorf("Listen = %q, want %q", d.Proxy.Listen, DefaultListen)
|
||||
}
|
||||
if d.TURN.N != DefaultStreams || d.VK.StreamsPerCred != DefaultStreamsPerCred {
|
||||
t.Errorf("N = %d, StreamsPerCred = %d", d.TURN.N, d.VK.StreamsPerCred)
|
||||
}
|
||||
if d.TURN.TransportUDP {
|
||||
t.Error("TransportUDP must be false for default -transport tcp")
|
||||
}
|
||||
if d.DNS.Mode != DefaultDNSMode || d.VK.Platform != DefaultPlatform {
|
||||
t.Errorf("DNS.Mode = %q, Platform = %q", d.DNS.Mode, d.VK.Platform)
|
||||
}
|
||||
if d.Obf.Enabled() || d.Obf.Key != nil {
|
||||
t.Errorf("Obf must be off by default: %+v", d.Obf)
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка совпадения дефолтов флагов и структуры Defaults().
|
||||
func TestFlagDefaultsMatchDefaults(t *testing.T) {
|
||||
parsed, err := ParseClient(validClientArgs(), io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := Defaults()
|
||||
want.Proxy.Peer = parsed.Proxy.Peer
|
||||
want.VK.Links = parsed.VK.Links
|
||||
|
||||
if !reflect.DeepEqual(*parsed, want) {
|
||||
t.Errorf("ParseClient defaults differ from Defaults():\n got %+v\nwant %+v", *parsed, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDefaults(t *testing.T) {
|
||||
d := ServerDefaults()
|
||||
if d.Proxy.Listen != DefaultServerListen {
|
||||
t.Errorf("Listen = %q, want %q", d.Proxy.Listen, DefaultServerListen)
|
||||
}
|
||||
if d.Obf.Enabled() {
|
||||
t.Errorf("unexpected server defaults: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServerDefaultsMatchServerDefaults(t *testing.T) {
|
||||
parsed, err := ParseServer([]string{"-connect", "127.0.0.1:51820"}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := ServerDefaults()
|
||||
want.Proxy.Connect = parsed.Proxy.Connect
|
||||
|
||||
if !reflect.DeepEqual(*parsed, want) {
|
||||
t.Errorf("ParseServer defaults differ from ServerDefaults():\n got %+v\nwant %+v", *parsed, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/uri"
|
||||
)
|
||||
|
||||
// ClientJSON - JSON-схема конфигурации клиента (gomobile).
|
||||
type ClientJSON struct {
|
||||
Peer string `json:"peer"`
|
||||
ClientID string `json:"clientId"`
|
||||
SubURL string `json:"subUrl"`
|
||||
Provider string `json:"provider"`
|
||||
Routes bool `json:"routes"`
|
||||
|
||||
TURN turnJSON `json:"turn"`
|
||||
Proxy proxyJSON `json:"proxy"`
|
||||
VK vkJSON `json:"vk"`
|
||||
Obf obfJSON `json:"obf"`
|
||||
DNS dnsJSON `json:"dns"`
|
||||
Log logJSON `json:"log"`
|
||||
KCP kcpJSON `json:"kcp"`
|
||||
Tunnel tunnelJSON `json:"tunnel"`
|
||||
}
|
||||
|
||||
// tunnelJSON - параметры встроенного wg-quick туннеля.
|
||||
type tunnelJSON struct {
|
||||
Mode string `json:"mode"`
|
||||
Config string `json:"config"`
|
||||
MTU int `json:"mtu"`
|
||||
}
|
||||
|
||||
type turnJSON struct {
|
||||
N int `json:"n"`
|
||||
Transport string `json:"transport"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
}
|
||||
|
||||
type proxyJSON struct {
|
||||
Bond bool `json:"bond,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
Listen string `json:"listen"`
|
||||
}
|
||||
|
||||
type vkJSON struct {
|
||||
Links []string `json:"links"`
|
||||
StreamsPerCred int `json:"streamsPerCred"`
|
||||
ManualCaptcha bool `json:"manualCaptcha"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
|
||||
type obfJSON struct {
|
||||
Profile string `json:"profile"`
|
||||
Key string `json:"key"`
|
||||
TimingMs int `json:"timingMs"`
|
||||
}
|
||||
|
||||
type dnsJSON struct {
|
||||
Mode string `json:"mode"`
|
||||
Servers []string `json:"servers"`
|
||||
}
|
||||
|
||||
type logJSON struct {
|
||||
Debug bool `json:"debug"`
|
||||
}
|
||||
|
||||
// DefaultClientJSON возвращает дефолтную конфигурацию клиента в формате JSON.
|
||||
func DefaultClientJSON() string {
|
||||
b, err := json.MarshalIndent(defaultClientJSON(), "", " ")
|
||||
if err != nil {
|
||||
panic("config: default JSON must marshal: " + err.Error())
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// PeekSubURLJSON извлекает subUrl из сырого JSON без полной валидации.
|
||||
func PeekSubURLJSON(data []byte) string {
|
||||
var peek struct {
|
||||
SubURL string `json:"subUrl"`
|
||||
}
|
||||
if json.Unmarshal(data, &peek) != nil {
|
||||
return ""
|
||||
}
|
||||
return peek.SubURL
|
||||
}
|
||||
|
||||
// ParseClientJSON парсит JSON-конфигурацию клиента с опциональным overlayURI подписки.
|
||||
func ParseClientJSON(data []byte, overlayURI string) (*Client, error) {
|
||||
dto := defaultClientJSON()
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&dto); err != nil {
|
||||
return nil, fmt.Errorf("config json: %w", err)
|
||||
}
|
||||
|
||||
r := dto.toRaw()
|
||||
if overlayURI != "" {
|
||||
u, err := uri.Parse(overlayURI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse freeturn:// URI: %w", err)
|
||||
}
|
||||
r.applyURI(u)
|
||||
}
|
||||
|
||||
c, err := assemble(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := Validate(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func defaultClientJSON() ClientJSON {
|
||||
r := defaultRaw()
|
||||
return ClientJSON{
|
||||
Peer: r.Peer,
|
||||
ClientID: r.ClientID,
|
||||
SubURL: r.SubURL,
|
||||
Provider: r.Provider,
|
||||
Routes: r.Routes,
|
||||
TURN: turnJSON{
|
||||
N: r.N,
|
||||
Transport: r.Transport,
|
||||
Host: r.Turn,
|
||||
Port: r.Port,
|
||||
},
|
||||
Proxy: proxyJSON{Mode: r.Mode, Listen: r.Listen},
|
||||
KCP: kcpJSONFrom(r.KCP),
|
||||
VK: vkJSON{
|
||||
StreamsPerCred: r.StreamsPerCred,
|
||||
ManualCaptcha: r.ManualCaptcha,
|
||||
Platform: r.Platform,
|
||||
},
|
||||
Obf: obfJSON{
|
||||
Profile: r.ObfProfile,
|
||||
Key: r.ObfKey,
|
||||
TimingMs: int(r.ObfTiming / time.Millisecond),
|
||||
},
|
||||
DNS: dnsJSON{Mode: r.DNSMode},
|
||||
Log: logJSON{Debug: r.Debug},
|
||||
Tunnel: tunnelJSON{
|
||||
Mode: r.TunnelMode,
|
||||
Config: r.TunnelConfig,
|
||||
MTU: r.TunnelMTU,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (j ClientJSON) toRaw() raw {
|
||||
return raw{
|
||||
Turn: j.TURN.Host,
|
||||
Port: j.TURN.Port,
|
||||
Listen: j.Proxy.Listen,
|
||||
Peer: j.Peer,
|
||||
|
||||
Provider: j.Provider,
|
||||
Links: strings.Join(j.VK.Links, ","),
|
||||
|
||||
N: j.TURN.N,
|
||||
StreamsPerCred: j.VK.StreamsPerCred,
|
||||
Transport: j.TURN.Transport,
|
||||
Mode: j.Proxy.Mode,
|
||||
Bond: j.Proxy.Bond,
|
||||
|
||||
ObfProfile: j.Obf.Profile,
|
||||
ObfKey: j.Obf.Key,
|
||||
ObfTiming: time.Duration(j.Obf.TimingMs) * time.Millisecond,
|
||||
|
||||
ManualCaptcha: j.VK.ManualCaptcha,
|
||||
Platform: j.VK.Platform,
|
||||
|
||||
DNSMode: j.DNS.Mode,
|
||||
DNSServers: strings.Join(j.DNS.Servers, ","),
|
||||
|
||||
Debug: j.Log.Debug,
|
||||
ClientID: j.ClientID,
|
||||
SubURL: j.SubURL,
|
||||
Routes: j.Routes,
|
||||
|
||||
TunnelMode: j.Tunnel.Mode,
|
||||
TunnelConfig: j.Tunnel.Config,
|
||||
TunnelMTU: j.Tunnel.MTU,
|
||||
|
||||
KCP: j.KCP.profile(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcpmux"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/uri"
|
||||
)
|
||||
|
||||
const minimalJSON = `{"peer":"1.2.3.4:5000","vk":{"links":["https://vk.ru/call/join/CODE"]}}`
|
||||
|
||||
func TestParseClientJSONFillsDefaults(t *testing.T) {
|
||||
c, err := ParseClientJSON([]byte(minimalJSON), "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClientJSON() error = %v", err)
|
||||
}
|
||||
|
||||
want := Defaults()
|
||||
want.Proxy.Peer = "1.2.3.4:5000"
|
||||
want.VK.Links = []string{"CODE"}
|
||||
|
||||
if !reflect.DeepEqual(*c, want) {
|
||||
t.Errorf("parsed config differs from defaults:\n got %+v\nwant %+v", *c, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClientJSONAppliesFields(t *testing.T) {
|
||||
in := `{
|
||||
"peer": "5.6.7.8:56000",
|
||||
"clientId": "abc",
|
||||
"turn": {"n": 3, "transport": "udp"},
|
||||
"proxy": {"listen": "127.0.0.1:1080"},
|
||||
"vk": {"links": ["A", "B"], "streamsPerCred": 4, "manualCaptcha": true, "platform": "mobile"},
|
||||
"obf": {"profile": "rtpopus3", "key": "` + testObfKey + `"},
|
||||
"dns": {"mode": "doh", "servers": ["1.1.1.1", "8.8.8.8"]},
|
||||
"log": {"debug": true}
|
||||
}`
|
||||
c, err := ParseClientJSON([]byte(in), "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClientJSON() error = %v", err)
|
||||
}
|
||||
|
||||
if c.TURN.N != 3 || !c.TURN.TransportUDP {
|
||||
t.Errorf("TURN = %+v", c.TURN)
|
||||
}
|
||||
if c.Proxy.Listen != "127.0.0.1:1080" {
|
||||
t.Errorf("Proxy = %+v", c.Proxy)
|
||||
}
|
||||
if len(c.VK.Links) != 2 || c.VK.Platform != PlatformMobile || !c.VK.ManualCaptcha {
|
||||
t.Errorf("VK = %+v", c.VK)
|
||||
}
|
||||
if c.Obf.Profile != ObfProfileRTPOpus3 || len(c.Obf.Key) != 32 {
|
||||
t.Errorf("Obf = %+v", c.Obf)
|
||||
}
|
||||
if c.DNS.Mode != DNSModeDoH || len(c.DNS.Servers) != 2 {
|
||||
t.Errorf("DNS = %+v", c.DNS)
|
||||
}
|
||||
if !c.Log.Debug || c.ClientID != "abc" {
|
||||
t.Errorf("Log/ClientID = %+v / %q", c.Log, c.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClientJSONRunsValidate(t *testing.T) {
|
||||
in := `{"peer":"1.2.3.4:5000","vk":{"links":["A"]},` +
|
||||
`"obf":{"profile":"none","timingMs":20}}`
|
||||
_, err := ParseClientJSON([]byte(in), "")
|
||||
if err == nil || !strings.Contains(err.Error(), "-obf-timing") {
|
||||
t.Fatalf("ParseClientJSON() error = %v, want obf-timing rule", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClientJSONRejectsUnknownField(t *testing.T) {
|
||||
in := `{"peer":"1.2.3.4:5000","vk":{"links":["A"]},"treads":12}`
|
||||
_, err := ParseClientJSON([]byte(in), "")
|
||||
if err == nil || !strings.Contains(err.Error(), "treads") {
|
||||
t.Fatalf("ParseClientJSON() error = %v, want unknown field error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClientJSONRejectsMalformed(t *testing.T) {
|
||||
if _, err := ParseClientJSON([]byte("{"), ""); err == nil {
|
||||
t.Fatal("ParseClientJSON() error = nil for malformed input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClientJSONOverlayURI(t *testing.T) {
|
||||
node := &uri.Config{Version: 1, Provider: ProviderVK, Peer: "9.9.9.9:56000", N: 5}
|
||||
|
||||
c, err := ParseClientJSON([]byte(minimalJSON), node.String())
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClientJSON() error = %v", err)
|
||||
}
|
||||
if c.Proxy.Peer != "9.9.9.9:56000" || c.TURN.N != 5 {
|
||||
t.Errorf("overlay not applied: peer %q, n %d", c.Proxy.Peer, c.TURN.N)
|
||||
}
|
||||
if len(c.VK.Links) != 1 || c.VK.Links[0] != "CODE" {
|
||||
t.Errorf("links overwritten by overlay: %v", c.VK.Links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClientJSONRejectsBadOverlay(t *testing.T) {
|
||||
if _, err := ParseClientJSON([]byte(minimalJSON), "freeturn://not-base64!!"); err == nil {
|
||||
t.Fatal("ParseClientJSON() error = nil for broken URI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeekSubURLJSON(t *testing.T) {
|
||||
if got := PeekSubURLJSON([]byte(`{"subUrl":"https://example.org/sub"}`)); got != "https://example.org/sub" {
|
||||
t.Errorf("PeekSubURLJSON() = %q", got)
|
||||
}
|
||||
if got := PeekSubURLJSON([]byte(minimalJSON)); got != "" {
|
||||
t.Errorf("PeekSubURLJSON() = %q, want empty", got)
|
||||
}
|
||||
if got := PeekSubURLJSON([]byte("{")); got != "" {
|
||||
t.Errorf("PeekSubURLJSON() on malformed = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultClientJSONRoundTrip(t *testing.T) {
|
||||
var dto ClientJSON
|
||||
if err := json.Unmarshal([]byte(DefaultClientJSON()), &dto); err != nil {
|
||||
t.Fatalf("DefaultClientJSON() is not valid JSON: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(dto, defaultClientJSON()) {
|
||||
t.Errorf("round-trip changed defaults:\n got %+v\nwant %+v", dto, defaultClientJSON())
|
||||
}
|
||||
|
||||
dto.Peer = "1.2.3.4:5000"
|
||||
dto.VK.Links = []string{"CODE"}
|
||||
b, err := json.Marshal(dto)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := ParseClientJSON(b, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClientJSON(DefaultClientJSON + required) error = %v", err)
|
||||
}
|
||||
if c.Obf.Timing != 0 || c.TURN.N != DefaultStreams {
|
||||
t.Errorf("unexpected config from defaults: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientJSONTimingMsMapsToDuration(t *testing.T) {
|
||||
in := `{"peer":"1.2.3.4:5000","vk":{"links":["A"]},` +
|
||||
`"obf":{"profile":"rtpopus","key":"` + testObfKey + `","timingMs":25}}`
|
||||
c, err := ParseClientJSON([]byte(in), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Obf.Timing != 25*time.Millisecond {
|
||||
t.Errorf("Obf.Timing = %v, want 25ms", c.Obf.Timing)
|
||||
}
|
||||
}
|
||||
|
||||
// Старый JSON без секций mode/kcp обязан читаться: поля optional, дефолт - udp.
|
||||
func TestParseClientJSONLegacyWithoutMode(t *testing.T) {
|
||||
data := `{"peer":"1.2.3.4:5000","vk":{"links":["abcdef"]}}`
|
||||
c, err := ParseClientJSON([]byte(data), "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClientJSON() error = %v", err)
|
||||
}
|
||||
if c.Proxy.Mode != ProxyModeUDP {
|
||||
t.Errorf("Proxy.Mode = %q, want udp", c.Proxy.Mode)
|
||||
}
|
||||
if c.KCP.Profile != kcpmux.DefaultProfile() {
|
||||
t.Errorf("KCP profile = %+v, want default", c.KCP.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClientJSONTCPMode(t *testing.T) {
|
||||
data := `{"peer":"1.2.3.4:5000","vk":{"links":["abcdef"]},"proxy":{"mode":"tcp"}}`
|
||||
c, err := ParseClientJSON([]byte(data), "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClientJSON() error = %v", err)
|
||||
}
|
||||
if c.Proxy.Mode != ProxyModeTCP {
|
||||
t.Errorf("Proxy.Mode = %q, want tcp", c.Proxy.Mode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcpmux"
|
||||
)
|
||||
|
||||
// KCPOpts - параметры ARQ-слоя tcp-режима; в udp-режиме не используются.
|
||||
type KCPOpts struct {
|
||||
Profile kcpmux.Profile
|
||||
}
|
||||
|
||||
// kcpFlags держит указатели на -kcp-*; флаги регистрируются одинаково у клиента и
|
||||
// сервера, потому что окна и MTU настраиваются на каждой стороне отдельно.
|
||||
type kcpFlags struct {
|
||||
noDelay *int
|
||||
interval *int
|
||||
resend *int
|
||||
nc *int
|
||||
sndWnd *int
|
||||
rcvWnd *int
|
||||
mtu *int
|
||||
ackNoDelay *bool
|
||||
}
|
||||
|
||||
func registerKCPFlags(fs *flag.FlagSet, def kcpmux.Profile) *kcpFlags {
|
||||
return &kcpFlags{
|
||||
noDelay: fs.Int("kcp-nodelay", def.NoDelay, "KCP nodelay: 0 (обычный) | 1 (быстрый)"),
|
||||
interval: fs.Int("kcp-interval", def.Interval, "интервал внутреннего цикла KCP, мс; больше = меньше служебного трафика"),
|
||||
resend: fs.Int("kcp-resend", def.Resend, "быстрая повторная отправка после N дубликатов ACK; 0=выкл"),
|
||||
nc: fs.Int("kcp-nc", def.NC, "KCP congestion control: 0 (вкл) | 1 (выкл)"),
|
||||
sndWnd: fs.Int("kcp-sndwnd", def.SndWnd, "окно отправки KCP в пакетах"),
|
||||
rcvWnd: fs.Int("kcp-rcvwnd", def.RcvWnd, "окно приёма KCP в пакетах"),
|
||||
mtu: fs.Int("kcp-mtu", def.MTU, "MTU сегмента KCP в байтах"),
|
||||
ackNoDelay: fs.Bool("kcp-acknodelay", def.ACKNoDelay, "отправлять ACK сразу; ниже задержка, выше служебный трафик. Все -kcp-* работают только с -mode tcp"),
|
||||
}
|
||||
}
|
||||
|
||||
func (k *kcpFlags) profile() kcpmux.Profile {
|
||||
return kcpmux.Profile{
|
||||
NoDelay: *k.noDelay,
|
||||
Interval: *k.interval,
|
||||
Resend: *k.resend,
|
||||
NC: *k.nc,
|
||||
SndWnd: *k.sndWnd,
|
||||
RcvWnd: *k.rcvWnd,
|
||||
MTU: *k.mtu,
|
||||
ACKNoDelay: *k.ackNoDelay,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
minKCPMTU = 300
|
||||
// Сегмент KCP едет внутри DTLS-записи (~37 B), obf-обёртки (до 40 B), TURN
|
||||
// ChannelData/Send (до 36 B) и IP/UDP (28 B): выше 1350 пакет режется по пути.
|
||||
maxKCPMTU = 1350
|
||||
)
|
||||
|
||||
func validateKCP(p kcpmux.Profile) error {
|
||||
if p.NoDelay != 0 && p.NoDelay != 1 {
|
||||
return fmt.Errorf("invalid -kcp-nodelay value %d: must be 0 | 1", p.NoDelay)
|
||||
}
|
||||
if p.NC != 0 && p.NC != 1 {
|
||||
return fmt.Errorf("invalid -kcp-nc value %d: must be 0 | 1", p.NC)
|
||||
}
|
||||
if p.Interval <= 0 {
|
||||
return fmt.Errorf("invalid -kcp-interval value %d: must be positive", p.Interval)
|
||||
}
|
||||
if p.Resend < 0 {
|
||||
return fmt.Errorf("invalid -kcp-resend value %d: must be non-negative", p.Resend)
|
||||
}
|
||||
if p.SndWnd <= 0 || p.RcvWnd <= 0 {
|
||||
return fmt.Errorf("invalid KCP window %d/%d: -kcp-sndwnd and -kcp-rcvwnd must be positive", p.SndWnd, p.RcvWnd)
|
||||
}
|
||||
if p.MTU < minKCPMTU || p.MTU > maxKCPMTU {
|
||||
return fmt.Errorf("invalid -kcp-mtu value %d: must be %d..%d", p.MTU, minKCPMTU, maxKCPMTU)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// kcpJSON - секция "kcp" клиентского JSON; в udp-режиме игнорируется.
|
||||
type kcpJSON struct {
|
||||
NoDelay int `json:"noDelay"`
|
||||
Interval int `json:"interval"`
|
||||
Resend int `json:"resend"`
|
||||
NC int `json:"nc"`
|
||||
SndWnd int `json:"sndWnd"`
|
||||
RcvWnd int `json:"rcvWnd"`
|
||||
MTU int `json:"mtu"`
|
||||
ACKNoDelay bool `json:"ackNoDelay"`
|
||||
}
|
||||
|
||||
func kcpJSONFrom(p kcpmux.Profile) kcpJSON {
|
||||
return kcpJSON{
|
||||
NoDelay: p.NoDelay,
|
||||
Interval: p.Interval,
|
||||
Resend: p.Resend,
|
||||
NC: p.NC,
|
||||
SndWnd: p.SndWnd,
|
||||
RcvWnd: p.RcvWnd,
|
||||
MTU: p.MTU,
|
||||
ACKNoDelay: p.ACKNoDelay,
|
||||
}
|
||||
}
|
||||
|
||||
func (k kcpJSON) profile() kcpmux.Profile {
|
||||
return kcpmux.Profile{
|
||||
NoDelay: k.NoDelay,
|
||||
Interval: k.Interval,
|
||||
Resend: k.Resend,
|
||||
NC: k.NC,
|
||||
SndWnd: k.SndWnd,
|
||||
RcvWnd: k.RcvWnd,
|
||||
MTU: k.MTU,
|
||||
ACKNoDelay: k.ACKNoDelay,
|
||||
}
|
||||
}
|
||||
|
||||
// kcpArgs выводит только отличия от дефолта: -kcp-* валидны лишь в tcp-режиме.
|
||||
func kcpArgs(p, def kcpmux.Profile) []string {
|
||||
var args []string
|
||||
addInt := func(flag string, v, d int) {
|
||||
if v != d {
|
||||
args = append(args, flag, strconv.Itoa(v))
|
||||
}
|
||||
}
|
||||
addInt("-kcp-nodelay", p.NoDelay, def.NoDelay)
|
||||
addInt("-kcp-interval", p.Interval, def.Interval)
|
||||
addInt("-kcp-resend", p.Resend, def.Resend)
|
||||
addInt("-kcp-nc", p.NC, def.NC)
|
||||
addInt("-kcp-sndwnd", p.SndWnd, def.SndWnd)
|
||||
addInt("-kcp-rcvwnd", p.RcvWnd, def.RcvWnd)
|
||||
addInt("-kcp-mtu", p.MTU, def.MTU)
|
||||
if p.ACKNoDelay != def.ACKNoDelay {
|
||||
args = append(args, "-kcp-acknodelay="+strconv.FormatBool(p.ACKNoDelay))
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcpmux"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/uri"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/wire/rtpopus"
|
||||
)
|
||||
|
||||
// raw хранит неразобранные строковые опции до этапа assemble.
|
||||
type raw struct {
|
||||
Turn string
|
||||
Port string
|
||||
Listen string
|
||||
Peer string
|
||||
|
||||
Provider string
|
||||
Link string
|
||||
Links string
|
||||
|
||||
N int
|
||||
StreamsPerCred int
|
||||
Transport string
|
||||
Mode string
|
||||
Bond bool
|
||||
|
||||
ObfProfile string
|
||||
ObfKey string
|
||||
ObfTiming time.Duration
|
||||
GenObfKey bool
|
||||
|
||||
ManualCaptcha bool
|
||||
Platform string
|
||||
|
||||
DNSMode string
|
||||
DNSServers string
|
||||
|
||||
Debug bool
|
||||
ClientID string
|
||||
SubURL string
|
||||
Routes bool
|
||||
|
||||
TunnelMode string
|
||||
TunnelConfig string
|
||||
TunnelMTU int
|
||||
|
||||
KCP kcpmux.Profile
|
||||
}
|
||||
|
||||
// applyURI применяет параметры freeturn:// поверх raw-опций.
|
||||
func (r *raw) applyURI(u *uri.Config) {
|
||||
override(&r.Provider, u.Provider)
|
||||
override(&r.Transport, u.Transport)
|
||||
override(&r.Mode, u.Mode)
|
||||
r.Bond = r.Bond || u.Bond
|
||||
if u.KCP != nil {
|
||||
r.KCP = kcpmux.Profile{
|
||||
NoDelay: u.KCP.NoDelay,
|
||||
Interval: u.KCP.Interval,
|
||||
Resend: u.KCP.Resend,
|
||||
NC: u.KCP.NC,
|
||||
SndWnd: u.KCP.SndWnd,
|
||||
RcvWnd: u.KCP.RcvWnd,
|
||||
MTU: u.KCP.MTU,
|
||||
ACKNoDelay: u.KCP.ACKNoDelay,
|
||||
}
|
||||
}
|
||||
if u.N > 0 {
|
||||
r.N = u.N
|
||||
}
|
||||
if u.StreamsPerCred > 0 {
|
||||
r.StreamsPerCred = u.StreamsPerCred
|
||||
}
|
||||
override(&r.ObfProfile, u.ObfProfile)
|
||||
override(&r.ObfKey, u.ObfKey)
|
||||
|
||||
if u.ObfTimingMs > 0 && u.ObfProfile != "" && u.ObfProfile != string(ObfProfileNone) {
|
||||
r.ObfTiming = time.Duration(u.ObfTimingMs) * time.Millisecond
|
||||
}
|
||||
|
||||
if u.VKLink != "" && r.Links == "" && r.Link == "" {
|
||||
r.Links = u.VKLink
|
||||
}
|
||||
override(&r.Peer, u.Peer)
|
||||
override(&r.ClientID, u.ClientID)
|
||||
override(&r.Listen, u.Listen)
|
||||
override(&r.DNSMode, u.DNSMode)
|
||||
override(&r.DNSServers, u.DNSServers)
|
||||
r.ManualCaptcha = r.ManualCaptcha || u.ManualCaptcha
|
||||
if u.WGConf != "" {
|
||||
r.TunnelMode = "awg"
|
||||
r.TunnelConfig = u.WGConf
|
||||
}
|
||||
}
|
||||
|
||||
func override[T comparable](dst *T, v T) {
|
||||
var zero T
|
||||
if v != zero {
|
||||
*dst = v
|
||||
}
|
||||
}
|
||||
|
||||
func assemble(r raw) (*Client, error) {
|
||||
switch r.Transport {
|
||||
case TransportTCP, TransportUDP:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid -transport value %q: must be %s | %s", r.Transport, TransportTCP, TransportUDP)
|
||||
}
|
||||
switch r.Mode {
|
||||
case ModeUDP, ModeTCP:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid -mode value %q: must be %s | %s", r.Mode, ModeUDP, ModeTCP)
|
||||
}
|
||||
n := r.N
|
||||
if n <= 0 {
|
||||
n = DefaultStreams
|
||||
if r.Provider == ProviderDirect {
|
||||
n = DefaultDirectStreams
|
||||
}
|
||||
}
|
||||
platform := Platform(r.Platform)
|
||||
if platform == "" {
|
||||
platform = DefaultPlatform
|
||||
}
|
||||
tunnelMTU := r.TunnelMTU
|
||||
if tunnelMTU <= 0 {
|
||||
tunnelMTU = tunnel.DefaultMTU
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
TURN: TURNOpts{
|
||||
Host: r.Turn,
|
||||
Port: r.Port,
|
||||
TransportUDP: r.Transport == TransportUDP,
|
||||
N: n,
|
||||
},
|
||||
Obf: ObfOpts{
|
||||
Profile: ObfProfile(r.ObfProfile),
|
||||
GenKey: r.GenObfKey,
|
||||
Timing: r.ObfTiming,
|
||||
},
|
||||
Proxy: ProxyOpts{
|
||||
Bond: r.Bond,
|
||||
Mode: ProxyMode(r.Mode),
|
||||
Listen: r.Listen,
|
||||
Peer: r.Peer,
|
||||
},
|
||||
Provider: ProviderOpts{Name: r.Provider},
|
||||
VK: VKOpts{
|
||||
Links: normalizeVKLinks(r.Links, r.Link),
|
||||
StreamsPerCred: r.StreamsPerCred,
|
||||
ManualCaptcha: r.ManualCaptcha,
|
||||
Platform: platform,
|
||||
},
|
||||
DNS: DNSOpts{Mode: r.DNSMode},
|
||||
Log: LogOpts{Debug: r.Debug},
|
||||
KCP: KCPOpts{Profile: r.KCP},
|
||||
Tunnel: TunnelOpts{
|
||||
Mode: tunnel.Mode(r.TunnelMode),
|
||||
Config: r.TunnelConfig,
|
||||
MTU: tunnelMTU,
|
||||
},
|
||||
ClientID: r.ClientID,
|
||||
SubURL: r.SubURL,
|
||||
Routes: r.Routes,
|
||||
}
|
||||
|
||||
if r.DNSServers != "" {
|
||||
c.DNS.Servers = strings.Split(r.DNSServers, ",")
|
||||
}
|
||||
|
||||
if c.Obf.GenKey {
|
||||
return c, nil
|
||||
}
|
||||
if err := validateObfProfile(c.Obf.Profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := rtpopus.DecodeKey(c.Obf.Enabled(), r.ObfKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Obf.Key = key
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// normalizeVKLinks извлекает join-код звонка из URL или строки кода.
|
||||
func normalizeVKLinks(links, link string) []string {
|
||||
items := strings.Split(links, ",")
|
||||
if len(items) == 1 && items[0] == "" {
|
||||
items = []string{link}
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(item, "join/")
|
||||
code := parts[len(parts)-1]
|
||||
if idx := strings.IndexAny(code, "/?#"); idx != -1 {
|
||||
code = code[:idx]
|
||||
}
|
||||
if code != "" {
|
||||
out = append(out, code)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/uri"
|
||||
)
|
||||
|
||||
func TestNormalizeVKLinks(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
links string
|
||||
link string
|
||||
want []string
|
||||
}{
|
||||
{"empty", "", "", nil},
|
||||
{"legacy link only", "", "https://vk.ru/call/join/CODE", []string{"CODE"}},
|
||||
{"links win over legacy", "https://vk.ru/call/join/NEW", "https://vk.ru/call/join/OLD", []string{"NEW"}},
|
||||
{"query stripped", "", "https://vk.ru/call/join/CODE?utm=1", []string{"CODE"}},
|
||||
{"path stripped", "", "https://vk.ru/call/join/CODE/extra", []string{"CODE"}},
|
||||
{"fragment stripped", "", "https://vk.ru/call/join/CODE#x", []string{"CODE"}},
|
||||
{"bare code", "", "CODE", []string{"CODE"}},
|
||||
{"spaces and blanks", " A , ,B ", "", []string{"A", "B"}},
|
||||
{"only separators", ",,", "", nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := normalizeVKLinks(tc.links, tc.link); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("normalizeVKLinks(%q, %q) = %v, want %v", tc.links, tc.link, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleRejectsRawEnums(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*raw)
|
||||
want string
|
||||
}{
|
||||
{"transport", func(r *raw) { r.Transport = "sctp" }, "invalid -transport"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := defaultRaw()
|
||||
tc.mutate(&r)
|
||||
_, err := assemble(r)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("assemble() error = %v, want mention of %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleGenKeySkipsKeyDecode(t *testing.T) {
|
||||
r := defaultRaw()
|
||||
r.GenObfKey = true
|
||||
r.ObfProfile = "bogus"
|
||||
r.ObfKey = "not-hex"
|
||||
|
||||
c, err := assemble(r)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble() error = %v, want nil for -gen-obf-key", err)
|
||||
}
|
||||
if c.Obf.Key != nil {
|
||||
t.Errorf("Obf.Key = %v, want nil", c.Obf.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyURIOverridesOnlyPresentFields(t *testing.T) {
|
||||
r := defaultRaw()
|
||||
r.Peer = "flag-peer:1"
|
||||
r.ClientID = "flag-id"
|
||||
r.Links = "https://vk.ru/call/join/FROMFLAG"
|
||||
|
||||
r.applyURI(&uri.Config{
|
||||
Peer: "uri-peer:2",
|
||||
Transport: TransportUDP,
|
||||
N: 7,
|
||||
})
|
||||
|
||||
if r.Peer != "uri-peer:2" {
|
||||
t.Errorf("Peer = %q, want URI value", r.Peer)
|
||||
}
|
||||
if r.Transport != TransportUDP || r.N != 7 {
|
||||
t.Errorf("URI fields not applied: %+v", r)
|
||||
}
|
||||
if r.ClientID != "flag-id" || r.Links != "https://vk.ru/call/join/FROMFLAG" {
|
||||
t.Errorf("URI wiped fields it does not carry: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyURIVKLinkIsFallback(t *testing.T) {
|
||||
r := defaultRaw()
|
||||
r.applyURI(&uri.Config{VKLink: "https://vk.ru/call/join/OWNER"})
|
||||
if r.Links != "https://vk.ru/call/join/OWNER" {
|
||||
t.Errorf("Links = %q, want owner link", r.Links)
|
||||
}
|
||||
|
||||
r = defaultRaw()
|
||||
r.Links = "https://vk.ru/call/join/MINE"
|
||||
r.applyURI(&uri.Config{VKLink: "https://vk.ru/call/join/OWNER"})
|
||||
if r.Links != "https://vk.ru/call/join/MINE" {
|
||||
t.Errorf("Links = %q, own link must win", r.Links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyURITimingNeedsObf(t *testing.T) {
|
||||
r := defaultRaw()
|
||||
r.applyURI(&uri.Config{ObfTimingMs: 20})
|
||||
if r.ObfTiming != 0 {
|
||||
t.Errorf("ObfTiming = %s without obf profile, want 0", r.ObfTiming)
|
||||
}
|
||||
|
||||
r.applyURI(&uri.Config{ObfProfile: "rtpopus", ObfKey: "00", ObfTimingMs: 20})
|
||||
if r.ObfTiming != 20*time.Millisecond {
|
||||
t.Errorf("ObfTiming = %s, want 20ms", r.ObfTiming)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyURIIgnoresZeroValues(t *testing.T) {
|
||||
r := defaultRaw()
|
||||
r.N = 5
|
||||
r.StreamsPerCred = 3
|
||||
|
||||
r.applyURI(&uri.Config{})
|
||||
|
||||
if r.N != 5 || r.StreamsPerCred != 3 {
|
||||
t.Errorf("empty URI must not reset fields: %+v", r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/proxy/bond"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcpmux"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
|
||||
)
|
||||
|
||||
// Validate проверяет валидность конфигурации Client.
|
||||
func Validate(c *Client) error {
|
||||
if c == nil {
|
||||
return errors.New("nil client config")
|
||||
}
|
||||
if c.Proxy.Peer == "" {
|
||||
return errors.New("need peer address")
|
||||
}
|
||||
|
||||
if err := validateProvider(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDNSMode(c.DNS.Mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateObfProfile(c.Obf.Profile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateProxyMode(c.Proxy.Mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateBond(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTunnel(c.Tunnel, c.Proxy.Mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateKCPFor(c.KCP, c.Proxy.Mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateObfTiming(c.Obf)
|
||||
}
|
||||
|
||||
var ErrBondConfig = errors.New("invalid bond configuration")
|
||||
|
||||
func validateBond(c *Client) error {
|
||||
if !c.Proxy.Bond {
|
||||
return nil
|
||||
}
|
||||
groups := 1
|
||||
if c.Provider.Name == ProviderVK {
|
||||
groups = max(len(c.VK.Links), 1)
|
||||
}
|
||||
if c.Proxy.Mode != ProxyModeTCP || c.TURN.N > bond.MaxLanes/groups {
|
||||
return fmt.Errorf("%w: -bond requires -mode tcp and at most %d sessions in total", ErrBondConfig, bond.MaxLanes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProvider(c *Client) error {
|
||||
switch c.Provider.Name {
|
||||
case ProviderDirect:
|
||||
return nil
|
||||
case ProviderVK:
|
||||
default:
|
||||
return fmt.Errorf("invalid -provider value %q: must be %s | %s", c.Provider.Name, ProviderVK, ProviderDirect)
|
||||
}
|
||||
if len(c.VK.Links) == 0 {
|
||||
return errors.New("vk: need at least one link (-links / -link)")
|
||||
}
|
||||
if c.VK.StreamsPerCred <= 0 {
|
||||
return errors.New("-streams-per-cred must be positive")
|
||||
}
|
||||
switch c.VK.Platform {
|
||||
case PlatformDesktop, PlatformMobile:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid -platform value %q: must be %s | %s", c.VK.Platform, PlatformDesktop, PlatformMobile)
|
||||
}
|
||||
}
|
||||
|
||||
func validateDNSMode(m string) error {
|
||||
switch m {
|
||||
case DNSModePlain, DNSModeDoH, DNSModeAuto:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid -dns-mode value %q: must be %s | %s | %s", m, DNSModePlain, DNSModeDoH, DNSModeAuto)
|
||||
}
|
||||
}
|
||||
|
||||
func validateProxyMode(m ProxyMode) error {
|
||||
switch m {
|
||||
case ProxyModeUDP, ProxyModeTCP:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid -mode value %q: must be %s | %s", m, ProxyModeUDP, ProxyModeTCP)
|
||||
}
|
||||
}
|
||||
|
||||
// validateKCPFor: -kcp-* настраивают ARQ, который живёт только в tcp-режиме.
|
||||
func validateKCPFor(k KCPOpts, mode ProxyMode) error {
|
||||
if mode != ProxyModeTCP {
|
||||
if k.Profile != kcpmux.DefaultProfile() {
|
||||
return errors.New("-kcp-* supported only with -mode tcp")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return validateKCP(k.Profile)
|
||||
}
|
||||
|
||||
// validateTunnel: встроенный WG гонит датаграммы, tcp-режим их не переносит.
|
||||
func validateTunnel(t TunnelOpts, mode ProxyMode) error {
|
||||
if t.Mode != "" && !t.Mode.Valid() {
|
||||
return fmt.Errorf("invalid tunnel mode %q: must be %s | %s | %s",
|
||||
t.Mode, tunnel.ModeNone, tunnel.ModeWG, tunnel.ModeAWG)
|
||||
}
|
||||
if !t.Enabled() {
|
||||
return nil
|
||||
}
|
||||
if mode != ProxyModeUDP {
|
||||
return errors.New("tunnel requires -mode udp")
|
||||
}
|
||||
if strings.TrimSpace(t.Config) == "" {
|
||||
return errors.New("tunnel config is required")
|
||||
}
|
||||
if t.MTU <= 0 {
|
||||
return errors.New("tunnel MTU must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateServer(s *Server) error {
|
||||
if s == nil {
|
||||
return errors.New("nil server config")
|
||||
}
|
||||
if s.Proxy.Connect == "" {
|
||||
return errors.New("server address is required")
|
||||
}
|
||||
if err := validateObfProfile(s.Obf.Profile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateProxyMode(s.Proxy.Mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateKCPFor(s.KCP, s.Proxy.Mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateObfTiming(s.Obf)
|
||||
}
|
||||
|
||||
func validateObfTiming(o ObfOpts) error {
|
||||
if o.Timing <= 0 {
|
||||
return nil
|
||||
}
|
||||
if !o.Enabled() {
|
||||
return errors.New("-obf-timing requires -obf-profile != none")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateObfProfile(p ObfProfile) error {
|
||||
switch p {
|
||||
case ObfProfileNone, ObfProfileRTPOpus, ObfProfileRTPOpus2, ObfProfileRTPOpus3:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid -obf-profile value %q: must be %s | %s | %s | %s", p, ObfProfileNone, ObfProfileRTPOpus, ObfProfileRTPOpus2, ObfProfileRTPOpus3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/tunnel"
|
||||
)
|
||||
|
||||
func validClient() *Client {
|
||||
c := Defaults()
|
||||
c.Proxy.Peer = "1.2.3.4:5000"
|
||||
c.VK.Links = []string{"abcdef"}
|
||||
return &c
|
||||
}
|
||||
|
||||
func TestValidateAcceptsDefaultsPlusRequired(t *testing.T) {
|
||||
if err := Validate(validClient()); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejects(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Client)
|
||||
want string
|
||||
}{
|
||||
{"nil peer", func(c *Client) { c.Proxy.Peer = "" }, "peer"},
|
||||
{"no links", func(c *Client) { c.VK.Links = nil }, "link"},
|
||||
{"streams per cred", func(c *Client) { c.VK.StreamsPerCred = 0 }, "streams-per-cred"},
|
||||
{"platform", func(c *Client) { c.VK.Platform = "watch" }, "invalid -platform"},
|
||||
{"provider", func(c *Client) { c.Provider.Name = "bogus" }, "invalid -provider"},
|
||||
{"dns mode", func(c *Client) { c.DNS.Mode = "bogus" }, "invalid -dns-mode"},
|
||||
{"obf profile", func(c *Client) { c.Obf.Profile = "bogus" }, "invalid -obf-profile"},
|
||||
{"timing without obf", func(c *Client) { c.Obf.Timing = time.Second }, "-obf-timing"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := validClient()
|
||||
tc.mutate(c)
|
||||
err := Validate(c)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Validate() error = %v, want mention of %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsNil(t *testing.T) {
|
||||
if err := Validate(nil); err == nil {
|
||||
t.Fatal("Validate(nil) error = nil")
|
||||
}
|
||||
if err := ValidateServer(nil); err == nil {
|
||||
t.Fatal("ValidateServer(nil) error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateServer(t *testing.T) {
|
||||
s := ServerDefaults()
|
||||
if err := ValidateServer(&s); err == nil {
|
||||
t.Fatal("ValidateServer() must require -connect")
|
||||
}
|
||||
s.Proxy.Connect = "127.0.0.1:51820"
|
||||
if err := ValidateServer(&s); err != nil {
|
||||
t.Fatalf("ValidateServer() error = %v", err)
|
||||
}
|
||||
s.Obf.Profile = "bogus"
|
||||
if err := ValidateServer(&s); err == nil {
|
||||
t.Fatal("ValidateServer() must reject unknown obf profile")
|
||||
}
|
||||
}
|
||||
|
||||
// Встроенный WG гонит датаграммы - в tcp-режиме такой конфиг молча не работал бы.
|
||||
func TestValidateTunnelRejectsTCPMode(t *testing.T) {
|
||||
c := validClient()
|
||||
c.Proxy.Mode = ProxyModeTCP
|
||||
c.Tunnel = TunnelOpts{Mode: tunnel.ModeWG, Config: "[Interface]", MTU: 1280}
|
||||
if err := Validate(c); err == nil || !strings.Contains(err.Error(), "tunnel requires") {
|
||||
t.Errorf("expected tunnel/mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Маршруты к TURN от режима туннеля не зависят.
|
||||
func TestValidateRoutesAllowedInTCPMode(t *testing.T) {
|
||||
c := validClient()
|
||||
c.Proxy.Mode = ProxyModeTCP
|
||||
c.Routes = true
|
||||
if err := Validate(c); err != nil {
|
||||
t.Errorf("routes must be allowed in tcp mode: %v", err)
|
||||
}
|
||||
}
|
||||
+25
-12
@@ -1,20 +1,18 @@
|
||||
// Package logx - минимальный уровневый логгер поверх stdlib log.
|
||||
// Вызывающий получает Logger и зовёт Debugf/Infof/Warnf/Errorf;
|
||||
// Debugf управляется debug-флагом, остальные уровни всегда пишут.
|
||||
// Package logx предоставляет уровневый логгер поверх stdlib log.
|
||||
package logx
|
||||
|
||||
import "log"
|
||||
import (
|
||||
"log"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Logger - интерфейс уровневого логирования.
|
||||
// Debugf гейтится debug-флагом конструктора; остальные уровни печатают всегда.
|
||||
type Logger interface {
|
||||
Debugf(format string, v ...any)
|
||||
Infof(format string, v ...any)
|
||||
Warnf(format string, v ...any)
|
||||
Errorf(format string, v ...any)
|
||||
// DebugEnabled сообщает, будет ли Debugf писать вывод. Hot-path
|
||||
// (счётчики статистики, условные ветки) используют это, чтобы не
|
||||
// делать работу, результат которой логгер всё равно отбросит.
|
||||
// DebugEnabled сообщает, включен ли уровень отладки.
|
||||
DebugEnabled() bool
|
||||
}
|
||||
|
||||
@@ -22,12 +20,10 @@ type stdLogger struct {
|
||||
debug bool
|
||||
}
|
||||
|
||||
// New возвращает Logger поверх stdlib log. При debug=false Debugf - no-op.
|
||||
func New(debug bool) Logger {
|
||||
return &stdLogger{debug: debug}
|
||||
}
|
||||
|
||||
// Nop возвращает Logger, отбрасывающий весь вывод. Полезно в тестах.
|
||||
func Nop() Logger { return nopLogger{} }
|
||||
|
||||
func (l *stdLogger) Debugf(format string, v ...any) {
|
||||
@@ -40,8 +36,7 @@ func (*stdLogger) Warnf(format string, v ...any) { log.Printf("[WARN] "+format,
|
||||
func (*stdLogger) Errorf(format string, v ...any) { log.Printf("[ERROR] "+format, v...) }
|
||||
func (l *stdLogger) DebugEnabled() bool { return l.debug }
|
||||
|
||||
// OrNop возвращает l, если он не nil, иначе Nop. Используется в конструкторах
|
||||
// пакетов, принимающих nullable Logger.
|
||||
// OrNop возвращает l или Nop, если l == nil.
|
||||
func OrNop(l Logger) Logger {
|
||||
if l == nil {
|
||||
return Nop()
|
||||
@@ -56,3 +51,21 @@ func (nopLogger) Infof(string, ...any) {}
|
||||
func (nopLogger) Warnf(string, ...any) {}
|
||||
func (nopLogger) Errorf(string, ...any) {}
|
||||
func (nopLogger) DebugEnabled() bool { return false }
|
||||
|
||||
// Holder хранит пакетный логгер, подменяемый на лету: ядро перезапускается в том же
|
||||
// процессе (mobile), поэтому Set гонится с пишущими горутинами.
|
||||
type Holder struct {
|
||||
ptr atomic.Pointer[Logger]
|
||||
}
|
||||
|
||||
func (h *Holder) Set(l Logger) {
|
||||
v := OrNop(l)
|
||||
h.ptr.Store(&v)
|
||||
}
|
||||
|
||||
func (h *Holder) Get() Logger {
|
||||
if p := h.ptr.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return Nop()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package logx
|
||||
|
||||
import "strings"
|
||||
|
||||
type prefixedLogger struct {
|
||||
Logger
|
||||
prefix string
|
||||
}
|
||||
|
||||
// WithPrefix привязывает источник к логгеру, не к изменяемому глобальному состоянию.
|
||||
func WithPrefix(logger Logger, prefix string) Logger {
|
||||
return &prefixedLogger{Logger: OrNop(logger), prefix: strings.ReplaceAll(prefix, "%", "%%")}
|
||||
}
|
||||
|
||||
func (l *prefixedLogger) Debugf(format string, v ...any) {
|
||||
if l.DebugEnabled() {
|
||||
l.Logger.Debugf(l.prefix+format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *prefixedLogger) Infof(format string, v ...any) { l.Logger.Infof(l.prefix+format, v...) }
|
||||
func (l *prefixedLogger) Warnf(format string, v ...any) { l.Logger.Warnf(l.prefix+format, v...) }
|
||||
func (l *prefixedLogger) Errorf(format string, v ...any) { l.Logger.Errorf(l.prefix+format, v...) }
|
||||
@@ -0,0 +1,37 @@
|
||||
package logx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type captureLogger struct {
|
||||
nopLogger
|
||||
lines []string
|
||||
}
|
||||
|
||||
func (l *captureLogger) Infof(format string, args ...any) {
|
||||
l.lines = append(l.lines, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func TestPrefixRemainsBoundToLogger(t *testing.T) {
|
||||
sink := &captureLogger{}
|
||||
old := WithPrefix(sink, "[old 100%] ")
|
||||
next := WithPrefix(sink, "[next] ")
|
||||
next.Infof("ready %d", 2)
|
||||
old.Infof("closed %d", 1)
|
||||
if sink.lines[0] != "[next] ready 2" || sink.lines[1] != "[old 100%] closed 1" {
|
||||
t.Fatalf("messages = %q", sink.lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixNilLogger(t *testing.T) {
|
||||
l := WithPrefix(nil, "test ")
|
||||
l.Debugf("%d", 1)
|
||||
l.Infof("%d", 1)
|
||||
l.Warnf("%d", 1)
|
||||
l.Errorf("%d", 1)
|
||||
if l.DebugEnabled() {
|
||||
t.Fatal("nil logger enabled debug")
|
||||
}
|
||||
}
|
||||
@@ -8,27 +8,16 @@ import (
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/randx"
|
||||
)
|
||||
|
||||
// MultiSplitWriteConn оборачивает TCP net.Conn и разбивает первый Write на
|
||||
// несколько сегментов так, чтобы SNI из TLS ClientHello попадал на границу.
|
||||
// DPI без TCP-реассемблинга не собирает hostname и не инжектит SNI-based RST.
|
||||
//
|
||||
// В отличие от SplitFirstWriteConn (один рез на фиксированном offset - рвёт
|
||||
// только TLS record header, SNI остаётся целым) парсит ClientHello и режет
|
||||
// внутри host_name. Если первый Write не ClientHello или SNI не найден -
|
||||
// фоллбэк на одиночный рез FallbackSplitAt (поведение SplitFirstWriteConn).
|
||||
// MultiSplitWriteConn разбивает первый Write ClientHello на несколько TCP-сегментов, разрезая SNI.
|
||||
// DPI без TCP-реассемблинга не может извлечь hostname для фильтрации.
|
||||
type MultiSplitWriteConn struct {
|
||||
net.Conn
|
||||
// Delay - базовая пауза между сегментами.
|
||||
Delay time.Duration
|
||||
// Jitter - верхняя граница случайной добавки к Delay (антифингерпринт тайминга).
|
||||
Jitter time.Duration
|
||||
// FallbackSplitAt - offset одиночного реза, когда SNI не найден. 0 - без реза.
|
||||
Delay time.Duration
|
||||
Jitter time.Duration
|
||||
FallbackSplitAt int
|
||||
done atomic.Bool
|
||||
}
|
||||
|
||||
// Write на первом вызове дробит ClientHello по границам внутри host_name (SNI),
|
||||
// далее проксирует напрямую.
|
||||
func (s *MultiSplitWriteConn) Write(b []byte) (int, error) {
|
||||
if !s.done.CompareAndSwap(false, true) {
|
||||
return s.Conn.Write(b)
|
||||
@@ -61,8 +50,7 @@ func (s *MultiSplitWriteConn) Write(b []byte) (int, error) {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// splitOffsets возвращает отсортированные точки реза первого сегмента. Сначала
|
||||
// пробует SNI (рез внутри host_name), иначе одиночный FallbackSplitAt.
|
||||
// splitOffsets возвращает смещения для разбиения первого сегмента (по SNI или FallbackSplitAt).
|
||||
func (s *MultiSplitWriteConn) splitOffsets(b []byte) []int {
|
||||
if start, end, ok := clientHelloSNIRange(b); ok {
|
||||
if offs := sniSplitOffsets(start, end); len(offs) > 0 {
|
||||
@@ -75,10 +63,7 @@ func (s *MultiSplitWriteConn) splitOffsets(b []byte) []int {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sniSplitOffsets делит host_name [start,end) на ~трети с джиттером. Гарантирует:
|
||||
// registrable-домен (vk.ru и т.п.) пересекает хотя бы одну границу независимо от
|
||||
// раскладки лейблов. Короткий hostname (len 2) - один рез посередине; len<2 -
|
||||
// без реза (nil, уходит на фоллбэк).
|
||||
// sniSplitOffsets вычисляет точки разреза внутри диапазона SNI host_name.
|
||||
func sniSplitOffsets(start, end int) []int {
|
||||
l := end - start
|
||||
switch {
|
||||
@@ -97,9 +82,7 @@ func sniSplitOffsets(start, end int) []int {
|
||||
}
|
||||
}
|
||||
|
||||
// clientHelloSNIRange парсит TLS ClientHello (b начинается с TLS record header)
|
||||
// и возвращает байтовый диапазон [start,end) первого host_name в SNI-расширении.
|
||||
// ok=false если b не ClientHello-record либо SNI/host_name отсутствует.
|
||||
// clientHelloSNIRange парсит TLS ClientHello и возвращает диапазон байт [start,end) первого SNI host_name.
|
||||
func clientHelloSNIRange(b []byte) (int, int, bool) {
|
||||
u16 := func(i int) int { return int(b[i])<<8 | int(b[i+1]) }
|
||||
|
||||
@@ -149,8 +132,7 @@ func clientHelloSNIRange(b []byte) (int, int, bool) {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// parseSNIExtension ищет первый host_name (name_type 0) в server_name_list
|
||||
// внутри [p,end). Возвращает абсолютный диапазон host_name в b.
|
||||
// parseSNIExtension извлекает границы первого host_name из server_name_list.
|
||||
func parseSNIExtension(b []byte, p, end int) (int, int, bool) {
|
||||
u16 := func(i int) int { return int(b[i])<<8 | int(b[i+1]) }
|
||||
if p+2 > end {
|
||||
|
||||
@@ -5,27 +5,21 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// u8 - узкое преобразование длины в байт для тестового билдера ClientHello.
|
||||
// Длины здесь малы и ограничены, переполнение невозможно.
|
||||
//
|
||||
//nolint:gosec // G115: тестовые длины малы и ограничены
|
||||
func u8(v int) byte { return byte(v) }
|
||||
func u8(v int) byte { return byte(v & 0xFF) }
|
||||
|
||||
// buildClientHello собирает минимальный валидный TLS ClientHello-record с
|
||||
// заданным SNI host_name. Достаточно для проверки парсера и сегментации.
|
||||
func buildClientHello(host string) []byte {
|
||||
hn := []byte(host)
|
||||
entry := append([]byte{0x00, u8(len(hn) >> 8), u8(len(hn))}, hn...) // name_type=host_name + len + host
|
||||
sni := append([]byte{u8(len(entry) >> 8), u8(len(entry))}, entry...) // server_name_list len + entry
|
||||
ext := append([]byte{0x00, 0x00, u8(len(sni) >> 8), u8(len(sni))}, sni...) // ext type 0 + len + body
|
||||
entry := append([]byte{0x00, u8(len(hn) >> 8), u8(len(hn))}, hn...)
|
||||
sni := append([]byte{u8(len(entry) >> 8), u8(len(entry))}, entry...)
|
||||
ext := append([]byte{0x00, 0x00, u8(len(sni) >> 8), u8(len(sni))}, sni...)
|
||||
|
||||
body := make([]byte, 0, 64)
|
||||
body = append(body, 0x03, 0x03) // client_version
|
||||
body = append(body, make([]byte, 32)...) // random
|
||||
body = append(body, 0x00) // session_id len 0
|
||||
body = append(body, 0x00, 0x02, 0x13, 0x01) // cipher_suites len 2 + TLS_AES_128_GCM_SHA256
|
||||
body = append(body, 0x01, 0x00) // compression methods len 1 + null
|
||||
body = append(body, u8(len(ext)>>8), u8(len(ext))) // extensions length
|
||||
body = append(body, 0x03, 0x03)
|
||||
body = append(body, make([]byte, 32)...)
|
||||
body = append(body, 0x00)
|
||||
body = append(body, 0x00, 0x02, 0x13, 0x01)
|
||||
body = append(body, 0x01, 0x00)
|
||||
body = append(body, u8(len(ext)>>8), u8(len(ext)))
|
||||
body = append(body, ext...)
|
||||
|
||||
hs := append([]byte{0x01, u8(len(body) >> 16), u8(len(body) >> 8), u8(len(body))}, body...)
|
||||
|
||||
+69
-24
@@ -1,7 +1,4 @@
|
||||
// Package netconn - мелкие net.Conn / transport.Net адаптеры для клиента
|
||||
// и сервера: passthrough transport.Net (для pion turn), ConnectedUDPConn
|
||||
// (WriteTo поверх dialed UDPConn) и SplitFirstWriteConn (обход DPI через
|
||||
// разбиение первого сегмента).
|
||||
// Package netconn содержит сетевые адаптеры для net.Conn и transport.Net.
|
||||
package netconn
|
||||
|
||||
import (
|
||||
@@ -12,12 +9,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/pion/transport/v4"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netctl"
|
||||
)
|
||||
|
||||
// DirectNet реализует transport.Net через стандартный net.
|
||||
type DirectNet struct{}
|
||||
|
||||
// New возвращает transport.Net поверх стандартного net.
|
||||
func New() transport.Net {
|
||||
return DirectNet{}
|
||||
}
|
||||
@@ -34,32 +30,81 @@ type directTCPListener struct {
|
||||
*net.TCPListener
|
||||
}
|
||||
|
||||
// Все сокеты создаются с Control: netctl.Apply для защиты от попадания в VPN-туннель.
|
||||
func (DirectNet) ListenPacket(network string, address string) (net.PacketConn, error) {
|
||||
return net.ListenPacket(network, address) //nolint:noctx
|
||||
return (&net.ListenConfig{Control: netctl.Apply}).ListenPacket(context.Background(), network, address)
|
||||
}
|
||||
|
||||
func (DirectNet) ListenUDP(network string, locAddr *net.UDPAddr) (transport.UDPConn, error) {
|
||||
return net.ListenUDP(network, locAddr)
|
||||
}
|
||||
|
||||
func (DirectNet) ListenTCP(network string, laddr *net.TCPAddr) (transport.TCPListener, error) {
|
||||
listener, err := net.ListenTCP(network, laddr)
|
||||
addr := ""
|
||||
if locAddr != nil {
|
||||
addr = locAddr.String()
|
||||
}
|
||||
pc, err := (&net.ListenConfig{Control: netctl.Apply}).ListenPacket(context.Background(), network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return directTCPListener{listener}, nil
|
||||
udp, ok := pc.(*net.UDPConn)
|
||||
if !ok {
|
||||
_ = pc.Close()
|
||||
return nil, fmt.Errorf("netconn: expected *net.UDPConn, got %T", pc)
|
||||
}
|
||||
return udp, nil
|
||||
}
|
||||
|
||||
func (DirectNet) ListenTCP(network string, laddr *net.TCPAddr) (transport.TCPListener, error) {
|
||||
addr := ""
|
||||
if laddr != nil {
|
||||
addr = laddr.String()
|
||||
}
|
||||
l, err := (&net.ListenConfig{Control: netctl.Apply}).Listen(context.Background(), network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tl, ok := l.(*net.TCPListener)
|
||||
if !ok {
|
||||
_ = l.Close()
|
||||
return nil, fmt.Errorf("netconn: expected *net.TCPListener, got %T", l)
|
||||
}
|
||||
return directTCPListener{tl}, nil
|
||||
}
|
||||
|
||||
func (DirectNet) Dial(network, address string) (net.Conn, error) {
|
||||
return net.Dial(network, address) //nolint:noctx
|
||||
return (&net.Dialer{Control: netctl.Apply}).Dial(network, address)
|
||||
}
|
||||
|
||||
func (DirectNet) DialUDP(network string, laddr, raddr *net.UDPAddr) (transport.UDPConn, error) {
|
||||
return net.DialUDP(network, laddr, raddr)
|
||||
d := &net.Dialer{Control: netctl.Apply}
|
||||
if laddr != nil {
|
||||
d.LocalAddr = laddr
|
||||
}
|
||||
c, err := d.Dial(network, raddr.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
udp, ok := c.(*net.UDPConn)
|
||||
if !ok {
|
||||
_ = c.Close()
|
||||
return nil, fmt.Errorf("netconn: expected *net.UDPConn, got %T", c)
|
||||
}
|
||||
return udp, nil
|
||||
}
|
||||
|
||||
func (DirectNet) DialTCP(network string, laddr, raddr *net.TCPAddr) (transport.TCPConn, error) {
|
||||
return net.DialTCP(network, laddr, raddr)
|
||||
d := &net.Dialer{Control: netctl.Apply}
|
||||
if laddr != nil {
|
||||
d.LocalAddr = laddr
|
||||
}
|
||||
c, err := d.Dial(network, raddr.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tcp, ok := c.(*net.TCPConn)
|
||||
if !ok {
|
||||
_ = c.Close()
|
||||
return nil, fmt.Errorf("netconn: expected *net.TCPConn, got %T", c)
|
||||
}
|
||||
return tcp, nil
|
||||
}
|
||||
|
||||
func (DirectNet) ResolveIPAddr(network, address string) (*net.IPAddr, error) {
|
||||
@@ -87,10 +132,16 @@ func (DirectNet) InterfaceByName(name string) (*transport.Interface, error) {
|
||||
}
|
||||
|
||||
func (DirectNet) CreateDialer(dialer *net.Dialer) transport.Dialer {
|
||||
if dialer.Control == nil {
|
||||
dialer.Control = netctl.Apply
|
||||
}
|
||||
return directDialer{Dialer: dialer}
|
||||
}
|
||||
|
||||
func (DirectNet) CreateListenConfig(listenerConfig *net.ListenConfig) transport.ListenConfig {
|
||||
if listenerConfig.Control == nil {
|
||||
listenerConfig.Control = netctl.Apply
|
||||
}
|
||||
return directListenConfig{ListenConfig: listenerConfig}
|
||||
}
|
||||
|
||||
@@ -110,21 +161,16 @@ func (l directTCPListener) AcceptTCP() (transport.TCPConn, error) {
|
||||
return l.TCPListener.AcceptTCP()
|
||||
}
|
||||
|
||||
// ConnectedUDPConn адаптирует dialed (connected) *net.UDPConn к семантике
|
||||
// net.PacketConn: WriteTo игнорирует адрес, т.к. ядро уже знает его из connect().
|
||||
// ConnectedUDPConn адаптирует dialed *net.UDPConn к net.PacketConn (WriteTo транслируется в Write).
|
||||
type ConnectedUDPConn struct {
|
||||
*net.UDPConn
|
||||
}
|
||||
|
||||
// WriteTo игнорирует addr (UDP уже connected) и пишет p.
|
||||
func (c *ConnectedUDPConn) WriteTo(p []byte, _ net.Addr) (int, error) {
|
||||
return c.Write(p)
|
||||
}
|
||||
|
||||
// SplitFirstWriteConn оборачивает TCP net.Conn и разбивает самый первый Write
|
||||
// на два сегмента (SplitAt байт + остаток) с опциональной паузой Delay между ними.
|
||||
// Ломает DPI-правила, матчащие фиксированный offset в первом сегменте без
|
||||
// TCP-реассемблинга (например STUN magic cookie на offset 4-7).
|
||||
// SplitFirstWriteConn разбивает первый Write на два TCP-сегмента для обхода DPI без реассемблинга.
|
||||
type SplitFirstWriteConn struct {
|
||||
net.Conn
|
||||
SplitAt int
|
||||
@@ -132,7 +178,6 @@ type SplitFirstWriteConn struct {
|
||||
done atomic.Bool
|
||||
}
|
||||
|
||||
// Write делает one-shot разбиение при первом вызове, далее проксирует напрямую.
|
||||
func (s *SplitFirstWriteConn) Write(b []byte) (int, error) {
|
||||
if s.done.CompareAndSwap(false, true) && len(b) > s.SplitAt {
|
||||
n1, err := s.Conn.Write(b[:s.SplitAt])
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// pipeRecorder is a net.Conn that records the slice handed to each Write.
|
||||
type pipeRecorder struct {
|
||||
net.Conn
|
||||
mu sync.Mutex
|
||||
|
||||
+16
-15
@@ -8,20 +8,21 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// BiCopy запускает io.Copy в обе стороны между c1 и c2; возвращает
|
||||
// (bytesC1FromC2, bytesC2FromC1). Как только одна копия завершилась
|
||||
// (EOF/ошибка/cancel) - обоим концам выставляется deadline=time.Now(),
|
||||
// чтобы разблокировать парную горутину. Ошибки уходят в errf, если он не nil.
|
||||
// BiCopy закрывает оба conn, как только копирование в любую сторону завершится.
|
||||
func BiCopy(ctx context.Context, c1, c2 net.Conn, errf func(format string, v ...any)) (int64, int64) {
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
context.AfterFunc(ctx2, func() {
|
||||
now := time.Now()
|
||||
if err := c1.SetDeadline(now); err != nil && errf != nil {
|
||||
errf("BiCopy: c1 SetDeadline: %v", err)
|
||||
setDeadline := func(t time.Time, what string) {
|
||||
if err := c1.SetDeadline(t); err != nil && errf != nil {
|
||||
errf("BiCopy: c1 %s: %v", what, err)
|
||||
}
|
||||
if err := c2.SetDeadline(now); err != nil && errf != nil {
|
||||
errf("BiCopy: c2 SetDeadline: %v", err)
|
||||
if err := c2.SetDeadline(t); err != nil && errf != nil {
|
||||
errf("BiCopy: c2 %s: %v", what, err)
|
||||
}
|
||||
}
|
||||
hookDone := make(chan struct{})
|
||||
stopOnCancel := context.AfterFunc(ctx2, func() {
|
||||
defer close(hookDone)
|
||||
setDeadline(time.Now(), "SetDeadline")
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -44,11 +45,11 @@ func BiCopy(ctx context.Context, c1, c2 net.Conn, errf func(format string, v ...
|
||||
})
|
||||
wg.Wait()
|
||||
|
||||
if err := c1.SetDeadline(time.Time{}); err != nil && errf != nil {
|
||||
errf("BiCopy: c1 clear deadline: %v", err)
|
||||
}
|
||||
if err := c2.SetDeadline(time.Time{}); err != nil && errf != nil {
|
||||
errf("BiCopy: c2 clear deadline: %v", err)
|
||||
// Дедлайны сбрасываются только после того, как хук отменён или доработал - иначе
|
||||
// он взвёлся бы следом и оставил conn'ы просроченными навсегда.
|
||||
if !stopOnCancel() {
|
||||
<-hookDone
|
||||
}
|
||||
setDeadline(time.Time{}, "clear deadline")
|
||||
return c1FromC2, c2FromC1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package netconn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBiCopy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a1, a2 := net.Pipe()
|
||||
b1, b2 := net.Pipe()
|
||||
defer func() { _ = a1.Close() }()
|
||||
defer func() { _ = b1.Close() }()
|
||||
|
||||
done := make(chan [2]int64, 1)
|
||||
go func() {
|
||||
n1, n2 := BiCopy(context.Background(), a2, b2, nil)
|
||||
done <- [2]int64{n1, n2}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_, _ = a1.Write([]byte("ping"))
|
||||
_ = a1.Close()
|
||||
}()
|
||||
|
||||
got := make([]byte, 4)
|
||||
if _, err := io.ReadFull(b1, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "ping" {
|
||||
t.Fatalf("got %q, want %q", got, "ping")
|
||||
}
|
||||
_ = b1.Close()
|
||||
|
||||
counts := <-done
|
||||
if counts[1] != 4 {
|
||||
t.Errorf("a2->b2 = %d, want 4", counts[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package netconn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrPacketTooLarge возвращается, если размер датаграммы превышает MTU пайпа.
|
||||
var ErrPacketTooLarge = errors.New("netconn: packet exceeds pipe MTU")
|
||||
|
||||
const (
|
||||
defaultPipeMTU = 2048
|
||||
defaultPipeQueue = 256
|
||||
)
|
||||
|
||||
type pipeAddr string
|
||||
|
||||
func (pipeAddr) Network() string { return "pipe" }
|
||||
func (a pipeAddr) String() string { return string(a) }
|
||||
|
||||
// PacketPipe возвращает пару связанных net.PacketConn в памяти.
|
||||
func PacketPipe(mtu, queue int) (net.PacketConn, net.PacketConn) {
|
||||
if mtu <= 0 {
|
||||
mtu = defaultPipeMTU
|
||||
}
|
||||
if queue <= 0 {
|
||||
queue = defaultPipeQueue
|
||||
}
|
||||
|
||||
// sync.Pool хранит *[]byte для избежания аллокаций при передаче в канал.
|
||||
pool := &sync.Pool{New: func() any { b := make([]byte, mtu); return &b }}
|
||||
toA := make(chan *[]byte, queue)
|
||||
toB := make(chan *[]byte, queue)
|
||||
|
||||
a := &packetPipe{local: pipeAddr("pipe:a"), remote: pipeAddr("pipe:b"), rx: toA, tx: toB, mtu: mtu, pool: pool, done: make(chan struct{})}
|
||||
b := &packetPipe{local: pipeAddr("pipe:b"), remote: pipeAddr("pipe:a"), rx: toB, tx: toA, mtu: mtu, pool: pool, done: make(chan struct{})}
|
||||
a.peer, b.peer = b, a
|
||||
return a, b
|
||||
}
|
||||
|
||||
type packetPipe struct {
|
||||
local net.Addr
|
||||
remote net.Addr
|
||||
rx <-chan *[]byte
|
||||
tx chan<- *[]byte
|
||||
peer *packetPipe
|
||||
mtu int
|
||||
pool *sync.Pool
|
||||
|
||||
// deadline хранит дедлайн и канал оповещения для немедленного пробуждения читателя.
|
||||
deadline struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
closeOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (p *packetPipe) ReadFrom(b []byte) (int, net.Addr, error) {
|
||||
var timer *time.Timer
|
||||
defer func() {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
at, changed := p.readDeadline()
|
||||
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
timer = nil
|
||||
}
|
||||
var timeout <-chan time.Time
|
||||
if !at.IsZero() {
|
||||
timer = time.NewTimer(time.Until(at))
|
||||
timeout = timer.C
|
||||
}
|
||||
|
||||
select {
|
||||
case <-p.done:
|
||||
return 0, nil, net.ErrClosed
|
||||
case <-p.peer.done:
|
||||
if n, ok := p.drain(b); ok {
|
||||
return n, p.remote, nil
|
||||
}
|
||||
return 0, nil, net.ErrClosed
|
||||
case <-changed:
|
||||
continue
|
||||
case <-timeout:
|
||||
return 0, nil, os.ErrDeadlineExceeded
|
||||
case buf, ok := <-p.rx:
|
||||
if !ok {
|
||||
return 0, nil, net.ErrClosed
|
||||
}
|
||||
n := copy(b, *buf)
|
||||
p.pool.Put(buf)
|
||||
return n, p.remote, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *packetPipe) drain(b []byte) (int, bool) {
|
||||
select {
|
||||
case buf, ok := <-p.rx:
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
n := copy(b, *buf)
|
||||
p.pool.Put(buf)
|
||||
return n, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func (p *packetPipe) readDeadline() (time.Time, <-chan struct{}) {
|
||||
p.deadline.mu.Lock()
|
||||
defer p.deadline.mu.Unlock()
|
||||
if p.deadline.changed == nil {
|
||||
p.deadline.changed = make(chan struct{})
|
||||
}
|
||||
return p.deadline.at, p.deadline.changed
|
||||
}
|
||||
|
||||
func (p *packetPipe) WriteTo(b []byte, _ net.Addr) (int, error) {
|
||||
if len(b) > p.mtu {
|
||||
return 0, ErrPacketTooLarge
|
||||
}
|
||||
|
||||
select {
|
||||
case <-p.done:
|
||||
return 0, net.ErrClosed
|
||||
case <-p.peer.done:
|
||||
return 0, net.ErrClosed
|
||||
default:
|
||||
}
|
||||
|
||||
buf, _ := p.pool.Get().(*[]byte)
|
||||
*buf = (*buf)[:len(b)]
|
||||
copy(*buf, b)
|
||||
|
||||
select {
|
||||
case p.tx <- buf:
|
||||
default:
|
||||
p.pool.Put(buf)
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (p *packetPipe) Close() error {
|
||||
p.closeOnce.Do(func() { close(p.done) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *packetPipe) LocalAddr() net.Addr { return p.local }
|
||||
|
||||
func (p *packetPipe) SetDeadline(t time.Time) error {
|
||||
return p.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
func (p *packetPipe) SetReadDeadline(t time.Time) error {
|
||||
p.deadline.mu.Lock()
|
||||
defer p.deadline.mu.Unlock()
|
||||
p.deadline.at = t
|
||||
if p.deadline.changed != nil {
|
||||
close(p.deadline.changed)
|
||||
}
|
||||
p.deadline.changed = make(chan struct{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetWriteDeadline - no-op: неблокирующая запись дропает пакет при переполнении очереди.
|
||||
func (*packetPipe) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
// datagramConn подаёт packetPipe как net.Conn: границы датаграмм сохраняются, поэтому
|
||||
// поверх такой пары можно поднимать KCP без реального сокета.
|
||||
type datagramConn struct {
|
||||
net.PacketConn
|
||||
remote net.Addr
|
||||
}
|
||||
|
||||
func (c *datagramConn) Read(b []byte) (int, error) {
|
||||
n, _, err := c.ReadFrom(b)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *datagramConn) Write(b []byte) (int, error) { return c.WriteTo(b, c.remote) }
|
||||
func (c *datagramConn) RemoteAddr() net.Addr { return c.remote }
|
||||
|
||||
// DatagramPipe - пара связанных net.Conn в памяти с датаграммной семантикой.
|
||||
func DatagramPipe(mtu, queue int) (net.Conn, net.Conn) {
|
||||
a, b := PacketPipe(mtu, queue)
|
||||
return &datagramConn{PacketConn: a, remote: b.LocalAddr()}, &datagramConn{PacketConn: b, remote: a.LocalAddr()}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package netconn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPacketPipeRoundTrip(t *testing.T) {
|
||||
a, b := PacketPipe(0, 0)
|
||||
defer func() { _ = a.Close() }()
|
||||
defer func() { _ = b.Close() }()
|
||||
|
||||
want := []byte("hello turn")
|
||||
if _, err := a.WriteTo(want, nil); err != nil {
|
||||
t.Fatalf("WriteTo() error = %v", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 64)
|
||||
n, addr, err := b.ReadFrom(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFrom() error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(buf[:n], want) {
|
||||
t.Errorf("read %q, want %q", buf[:n], want)
|
||||
}
|
||||
if addr == nil || addr.String() != a.LocalAddr().String() {
|
||||
t.Errorf("addr = %v, want %v", addr, a.LocalAddr())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketPipeKeepsDatagramBoundaries(t *testing.T) {
|
||||
a, b := PacketPipe(0, 0)
|
||||
defer func() { _ = a.Close() }()
|
||||
defer func() { _ = b.Close() }()
|
||||
|
||||
for _, msg := range [][]byte{[]byte("one"), []byte("two")} {
|
||||
if _, err := a.WriteTo(msg, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
buf := make([]byte, 64)
|
||||
for _, want := range []string{"one", "two"} {
|
||||
n, _, err := b.ReadFrom(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(buf[:n]); got != want {
|
||||
t.Fatalf("read %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketPipeTruncatesToCallerBuffer(t *testing.T) {
|
||||
a, b := PacketPipe(0, 0)
|
||||
defer func() { _ = a.Close() }()
|
||||
defer func() { _ = b.Close() }()
|
||||
|
||||
if _, err := a.WriteTo([]byte("0123456789"), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.WriteTo([]byte("next"), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
small := make([]byte, 4)
|
||||
n, _, err := b.ReadFrom(small)
|
||||
if err != nil || n != 4 || string(small) != "0123" {
|
||||
t.Fatalf("ReadFrom() = %d, %q, %v", n, small[:n], err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 64)
|
||||
n, _, err = b.ReadFrom(buf)
|
||||
if err != nil || string(buf[:n]) != "next" {
|
||||
t.Fatalf("second ReadFrom() = %q, %v", buf[:n], err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketPipeRejectsOversized(t *testing.T) {
|
||||
a, b := PacketPipe(16, 0)
|
||||
defer func() { _ = a.Close() }()
|
||||
defer func() { _ = b.Close() }()
|
||||
|
||||
if _, err := a.WriteTo(make([]byte, 17), nil); !errors.Is(err, ErrPacketTooLarge) {
|
||||
t.Fatalf("WriteTo() error = %v, want ErrPacketTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketPipeDropsWhenQueueFull(t *testing.T) {
|
||||
a, b := PacketPipe(64, 2)
|
||||
defer func() { _ = a.Close() }()
|
||||
defer func() { _ = b.Close() }()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for range 10 {
|
||||
if _, err := a.WriteTo([]byte("x"), nil); err != nil {
|
||||
t.Errorf("WriteTo() error = %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("WriteTo blocked on full queue")
|
||||
}
|
||||
|
||||
buf := make([]byte, 8)
|
||||
for range 2 {
|
||||
if _, _, err := b.ReadFrom(buf); err != nil {
|
||||
t.Fatalf("ReadFrom() error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := b.SetReadDeadline(time.Now().Add(50 * time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := b.ReadFrom(buf); !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
t.Fatalf("ReadFrom() error = %v, want deadline", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketPipeReadDeadline(t *testing.T) {
|
||||
a, b := PacketPipe(0, 0)
|
||||
defer func() { _ = a.Close() }()
|
||||
defer func() { _ = b.Close() }()
|
||||
|
||||
if err := b.SetDeadline(time.Now().Add(20 * time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, _, err := b.ReadFrom(make([]byte, 8)); !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
t.Fatalf("ReadFrom() error = %v, want deadline", err)
|
||||
}
|
||||
if time.Since(start) > time.Second {
|
||||
t.Error("ReadFrom() ignored deadline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketPipeClose(t *testing.T) {
|
||||
a, b := PacketPipe(0, 0)
|
||||
|
||||
if err := b.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if _, _, err := b.ReadFrom(make([]byte, 8)); !errors.Is(err, net.ErrClosed) {
|
||||
t.Errorf("ReadFrom() after Close error = %v, want net.ErrClosed", err)
|
||||
}
|
||||
if _, err := a.WriteTo([]byte("x"), nil); !errors.Is(err, net.ErrClosed) {
|
||||
t.Errorf("WriteTo() to closed peer error = %v, want net.ErrClosed", err)
|
||||
}
|
||||
if err := b.Close(); err != nil {
|
||||
t.Errorf("second Close() error = %v", err)
|
||||
}
|
||||
_ = a.Close()
|
||||
}
|
||||
|
||||
var _ net.PacketConn = func() net.PacketConn { a, _ := PacketPipe(0, 0); return a }()
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package netctl предоставляет глобальный Control-хук для сокетов (VpnService.protect).
|
||||
package netctl
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type ControlFunc func(network, address string, c syscall.RawConn) error
|
||||
|
||||
var control atomic.Pointer[ControlFunc]
|
||||
|
||||
// SetControl регистрирует функцию защиты сокетов хоста (nil - no-op).
|
||||
func SetControl(fn ControlFunc) {
|
||||
if fn == nil {
|
||||
control.Store(nil)
|
||||
return
|
||||
}
|
||||
control.Store(&fn)
|
||||
}
|
||||
|
||||
// Apply вызывается из net.Dialer и net.ListenConfig для защиты создаваемых сокетов.
|
||||
func Apply(network, address string, c syscall.RawConn) error {
|
||||
if fn := control.Load(); fn != nil {
|
||||
return (*fn)(network, address, c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
// Package multi объединяет несколько provider.Provider в один, распределяя
|
||||
// streamID по вложенным провайдерам round-robin. Применение: несколько
|
||||
// VK-звонков как единый пул TURN-стримов (больше параллельных аллокаций).
|
||||
// Package multi объединяет несколько provider.Provider с балансировкой round-robin.
|
||||
package multi
|
||||
|
||||
import (
|
||||
@@ -10,9 +8,7 @@ import (
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider"
|
||||
)
|
||||
|
||||
// Provider распределяет 1-based streamID round-robin: провайдер = (streamID-1) % n,
|
||||
// внутренний streamID = ((streamID-1) / n) + 1 (биекция, без коллизий).
|
||||
// Без изменяемого состояния - thread-safe.
|
||||
// Provider распределяет запросы учетных данных по пулу провайдеров.
|
||||
type Provider struct {
|
||||
providers []provider.Provider
|
||||
n int
|
||||
@@ -36,7 +32,6 @@ func (m *Provider) GetCredentials(ctx context.Context, streamID int) (provider.C
|
||||
return p.GetCredentials(ctx, innerID)
|
||||
}
|
||||
|
||||
// IsAuthError делегирует первому провайдеру: все вложенные однотипны (VK).
|
||||
func (m *Provider) IsAuthError(err error) bool {
|
||||
return m.providers[0].IsAuthError(err)
|
||||
}
|
||||
@@ -51,7 +46,11 @@ func (m *Provider) ResetErrors(streamID int) {
|
||||
p.ResetErrors(innerID)
|
||||
}
|
||||
|
||||
// BackoffUntilUnix - максимум среди провайдеров (один в блокировке -> пауза для всех).
|
||||
func (m *Provider) DropCredentials(streamID int) {
|
||||
p, innerID := m.providerFor(streamID)
|
||||
p.DropCredentials(innerID)
|
||||
}
|
||||
|
||||
func (m *Provider) BackoffUntilUnix() int64 {
|
||||
var until int64
|
||||
for _, p := range m.providers {
|
||||
|
||||
@@ -17,6 +17,7 @@ type fakeProvider struct {
|
||||
gotCreds []int
|
||||
gotHandle []int
|
||||
gotReset []int
|
||||
gotDrop []int
|
||||
authResult bool
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ func (f *fakeProvider) GetCredentials(_ context.Context, streamID int) (provider
|
||||
func (f *fakeProvider) IsAuthError(error) bool { return f.authResult }
|
||||
func (f *fakeProvider) HandleAuthError(s int) bool { f.gotHandle = append(f.gotHandle, s); return true }
|
||||
func (f *fakeProvider) ResetErrors(s int) { f.gotReset = append(f.gotReset, s) }
|
||||
func (f *fakeProvider) DropCredentials(s int) { f.gotDrop = append(f.gotDrop, s) }
|
||||
func (f *fakeProvider) BackoffUntilUnix() int64 { return f.backoff }
|
||||
func (f *fakeProvider) Name() string { return fmt.Sprintf("fake%d", f.idx) }
|
||||
|
||||
@@ -93,6 +95,10 @@ func TestHandleAndResetRouting(t *testing.T) {
|
||||
if len(fs[0].gotReset) != 1 || fs[0].gotReset[0] != 3 {
|
||||
t.Fatalf("ResetErrors routed wrong: %v", fs[0].gotReset)
|
||||
}
|
||||
m.DropCredentials(5)
|
||||
if len(fs[0].gotDrop) != 1 || fs[0].gotDrop[0] != 3 {
|
||||
t.Fatalf("DropCredentials routed wrong: %v", fs[0].gotDrop)
|
||||
}
|
||||
if len(fs[1].gotHandle) != 0 {
|
||||
t.Fatalf("provider 1 should not be touched: %v", fs[1].gotHandle)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
// Package provider определяет абстракцию источника TURN-реквизитов.
|
||||
//
|
||||
// Реализации (internal/provider/vk, ...) поставляют
|
||||
// короткоживущие user/pass/addr для TURN-allocate. Pipeline (proxy/udprelay,
|
||||
// proxy/tcpfwd) работает только через этот интерфейс - никаких VK-specific
|
||||
// типов в общем коде.
|
||||
//
|
||||
// Sentinel-ошибки описывают provider-нейтральные состояния, которые pipeline
|
||||
// обрабатывает однотипно: ErrBackoffActive - провайдер просит подождать,
|
||||
// ErrFatalNoStreams - фатально, дальше пробовать бессмысленно.
|
||||
// Package provider определяет интерфейс источника учетных данных TURN-серверов.
|
||||
package provider
|
||||
|
||||
import (
|
||||
@@ -16,56 +7,26 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Credentials - выданные провайдером TURN-реквизиты для одного стрима.
|
||||
// ServerAddrs - кандидаты host:port в порядке предпочтения (первый -
|
||||
// предпочтительный для этого streamID). Pipeline пробует их по очереди при
|
||||
// allocate: если первый не проходит (DPI-дроп/RST), берёт следующий.
|
||||
// Credentials содержит реквизиты авторизации и адреса TURN-серверов.
|
||||
type Credentials struct {
|
||||
User string
|
||||
Pass string
|
||||
ServerAddrs []string // host:port, primary-first
|
||||
ExpiresAt time.Time // 0 если провайдер не сообщает дедлайн
|
||||
ServerAddrs []string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Provider - источник TURN-реквизитов для клиентского pipeline.
|
||||
// Реализации обязаны быть thread-safe: pipeline вызывает GetCredentials и
|
||||
// HandleAuthError из N разных горутин одновременно (по числу стримов).
|
||||
// Provider предоставляет доступ к учетным записям TURN.
|
||||
type Provider interface {
|
||||
// GetCredentials блокирующе возвращает реквизиты для streamID.
|
||||
// Реализация отвечает за throttling, кеширование, ретраи к upstream-API.
|
||||
GetCredentials(ctx context.Context, streamID int) (Credentials, error)
|
||||
|
||||
// IsAuthError - провайдер сам решает, какие ошибки от TURN-allocate
|
||||
// (401/403/stale-nonce/etc.) считать auth-related и приводить к
|
||||
// инвалидации кеша через HandleAuthError.
|
||||
IsAuthError(err error) bool
|
||||
|
||||
// HandleAuthError увеличивает счётчик auth-ошибок для streamID.
|
||||
// Возвращает true, если порог достигнут и кеш для streamID инвалидирован.
|
||||
HandleAuthError(streamID int) bool
|
||||
|
||||
// ResetErrors обнуляет счётчик ошибок (вызывать после успешного allocate).
|
||||
ResetErrors(streamID int)
|
||||
|
||||
// BackoffUntilUnix - unix-секунда, до которой провайдер просит pipeline
|
||||
// не звать GetCredentials. 0 - нет активного бэк-оффа.
|
||||
// Используется только при возврате ошибки, обёрнутой ErrBackoffActive.
|
||||
DropCredentials(streamID int)
|
||||
BackoffUntilUnix() int64
|
||||
|
||||
// Name - короткое имя провайдера для логов ("vk", ...).
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Sentinel-ошибки, которые провайдер оборачивает (через errors.Join или
|
||||
// fmt.Errorf("%w: ...", ErrXxx, inner)) для сигнализации pipeline.
|
||||
var (
|
||||
// ErrBackoffActive - провайдер просит подождать. Pipeline вызывает
|
||||
// BackoffUntilUnix() для получения точного дедлайна; если 0 - sleep по
|
||||
// дефолтному backoff (60s).
|
||||
ErrBackoffActive = errors.New("provider backoff active")
|
||||
|
||||
// ErrFatalNoStreams - провайдер не может выдать креды и ни один стрим
|
||||
// ещё не поднят. Pipeline возвращает фатальную ошибку наружу
|
||||
// (хост-процесс делает os.Exit без вмешательства pipeline).
|
||||
ErrBackoffActive = errors.New("provider backoff active")
|
||||
ErrFatalNoStreams = errors.New("provider fatal: no streams alive")
|
||||
)
|
||||
|
||||
@@ -1,133 +1,261 @@
|
||||
package browserprofile
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
// Kind - семейство браузера.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
Chrome Kind = "chrome"
|
||||
Firefox Kind = "firefox"
|
||||
Safari Kind = "safari"
|
||||
chromeSecChUa = `"Google Chrome";v="146", "Chromium";v="146", "Not)A;Brand";v="24"`
|
||||
|
||||
uaWindows = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
uaMac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
uaAndroid = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36"
|
||||
|
||||
timezoneMoscow = "Europe/Moscow"
|
||||
touchPointsMobile = 5
|
||||
)
|
||||
|
||||
// KindFromString мапит строку флага -browser в Kind. Пустое/неизвестное -
|
||||
// Firefox (текущий дефолт продукта).
|
||||
func KindFromString(s string) Kind {
|
||||
switch s {
|
||||
case string(Chrome):
|
||||
return Chrome
|
||||
case string(Safari):
|
||||
return Safari
|
||||
type Platform string
|
||||
|
||||
const (
|
||||
Desktop Platform = "desktop"
|
||||
Mobile Platform = "mobile"
|
||||
)
|
||||
|
||||
// PlatformFromString преобразует строку в Platform (по умолчанию Desktop).
|
||||
func PlatformFromString(s string) Platform {
|
||||
if s == string(Mobile) {
|
||||
return Mobile
|
||||
}
|
||||
return Firefox
|
||||
return Desktop
|
||||
}
|
||||
|
||||
// Profile содержит браузерные параметры и отпечатки (UA, client hints, device fingerprint).
|
||||
type Profile struct {
|
||||
Platform Platform
|
||||
UserAgent string
|
||||
SecChUa string // пусто для non-Chromium профилей (Chromium-only client hint)
|
||||
SecChUa string
|
||||
SecChUaMobile string
|
||||
SecChUaPlatform string
|
||||
AcceptLanguage string
|
||||
DeviceJSON string
|
||||
VisitorID string
|
||||
|
||||
cores int
|
||||
memGB int
|
||||
webdriver bool
|
||||
dev device
|
||||
}
|
||||
|
||||
// ForKind возвращает канонический профиль для браузера.
|
||||
func ForKind(k Kind) Profile {
|
||||
switch k {
|
||||
case Safari:
|
||||
return Profile{
|
||||
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15",
|
||||
AcceptLanguage: "ru-RU,ru;q=0.9",
|
||||
}
|
||||
case Firefox:
|
||||
return Profile{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0",
|
||||
AcceptLanguage: "ru-RU,ru;q=0.9",
|
||||
}
|
||||
func (p Profile) IsMobile() bool { return p.Platform == Mobile }
|
||||
|
||||
func (p Profile) HardwareConcurrency() int { return p.cores }
|
||||
|
||||
func (p Profile) Webdriver() bool { return p.webdriver }
|
||||
|
||||
func (p Profile) DeviceMemory() *int {
|
||||
if p.memGB == 0 {
|
||||
return nil
|
||||
}
|
||||
return Profile{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
||||
SecChUa: `"Google Chrome";v="146", "Chromium";v="146", "Not)A;Brand";v="24"`,
|
||||
return &p.memGB
|
||||
}
|
||||
|
||||
func (p Profile) Languages() []string { return p.dev.Languages }
|
||||
|
||||
func (p Profile) DevicePixelRatio() float64 { return p.dev.DevicePixelRatio }
|
||||
|
||||
func (Profile) Timezone() string { return timezoneMoscow }
|
||||
|
||||
func (p Profile) MaxTouchPoints() int {
|
||||
if p.IsMobile() {
|
||||
return touchPointsMobile
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (p Profile) Orientation() string {
|
||||
if p.IsMobile() {
|
||||
return "portrait-primary"
|
||||
}
|
||||
return "landscape-primary"
|
||||
}
|
||||
|
||||
func (p Profile) PlatformName() string { return strings.Trim(p.SecChUaPlatform, `"`) }
|
||||
|
||||
type Brand struct {
|
||||
Brand string `json:"brand"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func (p Profile) Brands() []Brand {
|
||||
out := make([]Brand, 0, 3)
|
||||
for _, part := range strings.Split(p.SecChUa, ", ") {
|
||||
name, ver, ok := strings.Cut(part, ";v=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, Brand{Brand: strings.Trim(name, `"`), Version: strings.Trim(ver, `"`)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type device struct {
|
||||
ScreenWidth int `json:"screenWidth"`
|
||||
ScreenHeight int `json:"screenHeight"`
|
||||
ScreenAvailWidth int `json:"screenAvailWidth"`
|
||||
ScreenAvailHeight int `json:"screenAvailHeight"`
|
||||
InnerWidth int `json:"innerWidth"`
|
||||
InnerHeight int `json:"innerHeight"`
|
||||
DevicePixelRatio float64 `json:"devicePixelRatio"`
|
||||
Language string `json:"language"`
|
||||
Languages []string `json:"languages"`
|
||||
Webdriver bool `json:"webdriver"`
|
||||
HardwareConcurrency int `json:"hardwareConcurrency"`
|
||||
DeviceMemory *int `json:"deviceMemory,omitempty"`
|
||||
ConnectionEffectiveType string `json:"connectionEffectiveType,omitempty"`
|
||||
NotificationsPermission string `json:"notificationsPermission"`
|
||||
}
|
||||
|
||||
var chromeHeaderOrder = []string{
|
||||
"content-length",
|
||||
"sec-ch-ua-platform",
|
||||
"user-agent",
|
||||
"sec-ch-ua",
|
||||
"content-type",
|
||||
"sec-ch-ua-mobile",
|
||||
"upgrade-insecure-requests",
|
||||
"accept",
|
||||
"origin",
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-user",
|
||||
"sec-fetch-dest",
|
||||
"referer",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"cookie",
|
||||
"priority",
|
||||
}
|
||||
|
||||
type spec struct {
|
||||
userAgent string
|
||||
chPlatform string
|
||||
dev device
|
||||
}
|
||||
|
||||
var desktopSpecs = []spec{
|
||||
{userAgent: uaWindows, chPlatform: `"Windows"`, dev: newDevice(1920, 1080, 1032, 1147, 945, 1, 16, 8)},
|
||||
{userAgent: uaWindows, chPlatform: `"Windows"`, dev: newDevice(1536, 864, 816, 1229, 738, 1.25, 8, 8)},
|
||||
{userAgent: uaWindows, chPlatform: `"Windows"`, dev: newDevice(2560, 1440, 1392, 1512, 1237, 1, 12, 8)},
|
||||
{userAgent: uaMac, chPlatform: `"macOS"`, dev: newDevice(1512, 982, 944, 1147, 870, 2, 10, 8)},
|
||||
}
|
||||
|
||||
var mobileSpecs = []spec{
|
||||
{userAgent: uaAndroid, chPlatform: `"Android"`, dev: newDevice(364, 793, 793, 363, 671, 3.5, 8, 8)},
|
||||
{userAgent: uaAndroid, chPlatform: `"Android"`, dev: newDevice(393, 852, 852, 393, 659, 3, 8, 8)},
|
||||
{userAgent: uaAndroid, chPlatform: `"Android"`, dev: newDevice(412, 915, 915, 412, 724, 2.625, 8, 4)},
|
||||
{userAgent: uaAndroid, chPlatform: `"Android"`, dev: newDevice(360, 800, 800, 360, 612, 3, 8, 4)},
|
||||
{userAgent: uaAndroid, chPlatform: `"Android"`, dev: newDevice(384, 832, 832, 384, 644, 2.75, 8, 8)},
|
||||
}
|
||||
|
||||
// Identity идентифицирует браузерную личность и её поколение.
|
||||
type Identity struct {
|
||||
Seed string
|
||||
Gen int
|
||||
}
|
||||
|
||||
func (id Identity) String() string { return id.Seed + "|" + strconv.Itoa(id.Gen) }
|
||||
|
||||
// For генерирует профиль браузера для указанной платформы и идентичности.
|
||||
func For(p Platform, id Identity) Profile {
|
||||
specs := desktopSpecs
|
||||
if p == Mobile {
|
||||
specs = mobileSpecs
|
||||
} else {
|
||||
p = Desktop
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(id.String()))
|
||||
s := specs[binary.BigEndian.Uint64(sum[:8])%uint64(len(specs))]
|
||||
|
||||
profile := Profile{
|
||||
Platform: p,
|
||||
SecChUa: chromeSecChUa,
|
||||
SecChUaMobile: "?0",
|
||||
SecChUaPlatform: `"Windows"`,
|
||||
AcceptLanguage: "ru-RU,ru;q=0.9",
|
||||
SecChUaPlatform: s.chPlatform,
|
||||
AcceptLanguage: "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
UserAgent: s.userAgent,
|
||||
}
|
||||
if p == Mobile {
|
||||
profile.SecChUaMobile = "?1"
|
||||
}
|
||||
|
||||
profile = withDevice(profile, s.dev)
|
||||
profile.VisitorID = visitorID(id, profile)
|
||||
return profile
|
||||
}
|
||||
|
||||
func newDevice(screenW, screenH, availH, innerW, innerH int, dpr float64, cores, memGB int) device {
|
||||
memory := memGB
|
||||
return device{
|
||||
ScreenWidth: screenW, ScreenHeight: screenH,
|
||||
ScreenAvailWidth: screenW, ScreenAvailHeight: availH,
|
||||
InnerWidth: innerW, InnerHeight: innerH,
|
||||
DevicePixelRatio: dpr,
|
||||
Language: "ru-RU",
|
||||
Languages: []string{"ru-RU", "en-US"},
|
||||
HardwareConcurrency: cores,
|
||||
DeviceMemory: &memory,
|
||||
ConnectionEffectiveType: "4g",
|
||||
NotificationsPermission: "denied",
|
||||
}
|
||||
}
|
||||
|
||||
func Family(p Profile) Kind {
|
||||
ua := strings.ToLower(p.UserAgent)
|
||||
switch {
|
||||
case strings.Contains(ua, "firefox/"):
|
||||
return Firefox
|
||||
case strings.Contains(ua, "version/") && strings.Contains(ua, "safari/") && !strings.Contains(ua, "chrome/") && !strings.Contains(ua, "chromium/"):
|
||||
return Safari
|
||||
case strings.Contains(ua, "chrome/") || strings.Contains(ua, "chromium/"):
|
||||
return Chrome
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
func visitorID(id Identity, p Profile) string {
|
||||
sum := sha256.Sum256([]byte(id.String() + "|" + p.UserAgent + "|" + p.DeviceJSON))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func IsMobile(p Profile) bool {
|
||||
ua := strings.ToLower(p.UserAgent)
|
||||
return strings.Contains(ua, " mobile ") ||
|
||||
strings.Contains(ua, " mobile/") ||
|
||||
strings.Contains(ua, "android") ||
|
||||
strings.Contains(strings.ToLower(p.SecChUaMobile), "?1")
|
||||
}
|
||||
|
||||
type Saved struct {
|
||||
Profile
|
||||
DeviceJSON string
|
||||
BrowserFp string
|
||||
}
|
||||
|
||||
const profileFile = "vk_profile.json"
|
||||
|
||||
// profilePath возвращает путь к сохранённому профилю.
|
||||
func profilePath() string {
|
||||
if p := strings.TrimSpace(os.Getenv("VK_PROFILE_PATH")); p != "" {
|
||||
func withDevice(p Profile, d device) Profile {
|
||||
data, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
return profileFile
|
||||
p.DeviceJSON = string(data)
|
||||
p.dev = d
|
||||
p.cores, p.webdriver = d.HardwareConcurrency, d.Webdriver
|
||||
if d.DeviceMemory != nil {
|
||||
p.memGB = *d.DeviceMemory
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func Load() (*Saved, error) {
|
||||
data, err := os.ReadFile(profilePath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sp Saved
|
||||
if err := json.Unmarshal(data, &sp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sp, nil
|
||||
}
|
||||
|
||||
func Save(sp Saved) error {
|
||||
data, err := json.MarshalIndent(sp, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(profilePath(), data, 0600)
|
||||
func (Profile) ClientProfile() profiles.ClientProfile {
|
||||
return profiles.Chrome_146
|
||||
}
|
||||
|
||||
func ApplyFhttp(req *fhttp.Request, profile Profile) {
|
||||
req.Header.Set("User-Agent", profile.UserAgent)
|
||||
if profile.SecChUa != "" {
|
||||
req.Header.Set("sec-ch-ua", profile.SecChUa)
|
||||
req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile)
|
||||
req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform)
|
||||
req.Header.Set("sec-ch-ua", profile.SecChUa)
|
||||
req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile)
|
||||
req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform)
|
||||
req.Header.Set("Accept-Language", profile.AcceptLanguage)
|
||||
if req.Header.Get("Sec-Fetch-Dest") == "document" {
|
||||
req.Header.Set("Priority", "u=0, i")
|
||||
} else {
|
||||
req.Header.Set("Priority", "u=1, i")
|
||||
}
|
||||
acceptLang := profile.AcceptLanguage
|
||||
if acceptLang == "" {
|
||||
acceptLang = "ru-RU,ru;q=0.9"
|
||||
}
|
||||
req.Header.Set("Accept-Language", acceptLang)
|
||||
req.Header[fhttp.HeaderOrderKey] = chromeHeaderOrder
|
||||
}
|
||||
|
||||
func ApplyProxyFhttp(req *fhttp.Request) {
|
||||
req.Header[fhttp.HeaderOrderKey] = chromeHeaderOrder
|
||||
}
|
||||
|
||||
@@ -1,97 +1,277 @@
|
||||
package browserprofile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
func TestKindFromString(t *testing.T) {
|
||||
const testSeed = "persona-test"
|
||||
|
||||
// personas возвращает по персоне на каждый spec платформы: инварианты обязаны
|
||||
// держаться на всех, а не только на той, что выпала дефолтной identity.
|
||||
func personas(t *testing.T, plat Platform) []Profile {
|
||||
t.Helper()
|
||||
specs := desktopSpecs
|
||||
if plat == Mobile {
|
||||
specs = mobileSpecs
|
||||
}
|
||||
seen := make(map[string]Profile, len(specs))
|
||||
for gen := 0; gen < 200 && len(seen) < len(specs); gen++ {
|
||||
p := For(plat, Identity{Seed: testSeed, Gen: gen})
|
||||
seen[p.UserAgent+p.DeviceJSON] = p
|
||||
}
|
||||
if len(seen) != len(specs) {
|
||||
t.Fatalf("%s: covered %d/%d specs", plat, len(seen), len(specs))
|
||||
}
|
||||
out := make([]Profile, 0, len(seen))
|
||||
for _, p := range seen {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deviceOf(t *testing.T, p Profile) device {
|
||||
t.Helper()
|
||||
var dev device
|
||||
if err := json.Unmarshal([]byte(p.DeviceJSON), &dev); err != nil {
|
||||
t.Fatalf("DeviceJSON: %v", err)
|
||||
}
|
||||
return dev
|
||||
}
|
||||
|
||||
func TestPlatformFromString(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want Kind
|
||||
want Platform
|
||||
}{
|
||||
{"chrome", Chrome},
|
||||
{"firefox", Firefox},
|
||||
{"safari", Safari},
|
||||
{"", Firefox},
|
||||
{"unknown", Firefox},
|
||||
{"mobile", Mobile},
|
||||
{"desktop", Desktop},
|
||||
{"", Desktop},
|
||||
{"unknown", Desktop},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := KindFromString(tc.in); got != tc.want {
|
||||
t.Fatalf("KindFromString(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
if got := PlatformFromString(tc.in); got != tc.want {
|
||||
t.Fatalf("PlatformFromString(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilePathHonorsEnv(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "vk_profile_chrome_42.json")
|
||||
t.Setenv("VK_PROFILE_PATH", path)
|
||||
|
||||
want := Saved{Profile: ForKind(Chrome), DeviceJSON: "{}", BrowserFp: "abc123"}
|
||||
if err := Save(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("profile not written to VK_PROFILE_PATH %s: %v", path, err)
|
||||
}
|
||||
|
||||
got, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.BrowserFp != want.BrowserFp || got.UserAgent != want.UserAgent {
|
||||
t.Fatalf("loaded = %+v, want fp=%s ua=%s", got, want.BrowserFp, want.UserAgent)
|
||||
func TestEveryPersonaIsChrome146(t *testing.T) {
|
||||
for _, plat := range []Platform{Desktop, Mobile} {
|
||||
for _, p := range personas(t, plat) {
|
||||
if !strings.Contains(p.UserAgent, "Chrome/146.0.0.0") {
|
||||
t.Fatalf("%s UA not chrome 146: %q", plat, p.UserAgent)
|
||||
}
|
||||
if p.SecChUa != chromeSecChUa {
|
||||
t.Fatalf("%s sec-ch-ua = %q", plat, p.SecChUa)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForKindChrome146(t *testing.T) {
|
||||
p := ForKind(Chrome)
|
||||
if p.UserAgent != "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" {
|
||||
t.Fatalf("unexpected chrome UA: %q", p.UserAgent)
|
||||
}
|
||||
if p.SecChUa != `"Google Chrome";v="146", "Chromium";v="146", "Not)A;Brand";v="24"` {
|
||||
t.Fatalf("unexpected chrome sec-ch-ua: %q", p.SecChUa)
|
||||
// Одна identity - одна личность: иначе visitorId прыгал бы между запросами
|
||||
// одной сессии.
|
||||
func TestForIsDeterministic(t *testing.T) {
|
||||
for _, plat := range []Platform{Desktop, Mobile} {
|
||||
id := Identity{Seed: testSeed, Gen: 3}
|
||||
first, again := For(plat, id), For(plat, id)
|
||||
if !reflect.DeepEqual(first, again) {
|
||||
t.Fatalf("%s: For not deterministic", plat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFhttpOmitsClientHintsForFirefoxAndSafari(t *testing.T) {
|
||||
for _, kind := range []Kind{Firefox, Safari} {
|
||||
req, err := fhttp.NewRequest(fhttp.MethodGet, "https://example.com/", nil)
|
||||
// Сжигание персоны обязано менять и устройство, и visitorId: иначе отвергнутый
|
||||
// отпечаток переиспользуется.
|
||||
func TestBurnChangesIdentity(t *testing.T) {
|
||||
for _, plat := range []Platform{Desktop, Mobile} {
|
||||
prev := For(plat, Identity{Seed: testSeed})
|
||||
changed := false
|
||||
for gen := 1; gen <= 4; gen++ {
|
||||
next := For(plat, Identity{Seed: testSeed, Gen: gen})
|
||||
if next.VisitorID == prev.VisitorID {
|
||||
t.Fatalf("%s gen=%d reused visitorId %q", plat, gen, next.VisitorID)
|
||||
}
|
||||
if next.DeviceJSON != prev.DeviceJSON {
|
||||
changed = true
|
||||
}
|
||||
prev = next
|
||||
}
|
||||
if !changed {
|
||||
t.Fatalf("%s: burning never changed the device", plat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Разные установки - разные личности, иначе visitorId общий на всех.
|
||||
func TestSeedSeparatesInstalls(t *testing.T) {
|
||||
a := For(Desktop, Identity{Seed: "install-a"})
|
||||
b := For(Desktop, Identity{Seed: "install-b"})
|
||||
if a.VisitorID == b.VisitorID {
|
||||
t.Fatal("different seeds share visitorId")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisitorIDFormat(t *testing.T) {
|
||||
re := regexp.MustCompile(`^[0-9a-f]{32}$`)
|
||||
for _, plat := range []Platform{Desktop, Mobile} {
|
||||
for _, p := range personas(t, plat) {
|
||||
if !re.MatchString(p.VisitorID) {
|
||||
t.Fatalf("%s visitorId = %q", plat, p.VisitorID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformSet(t *testing.T) {
|
||||
for _, plat := range []Platform{Desktop, Mobile} {
|
||||
p := For(plat, Identity{Seed: testSeed})
|
||||
if p.Platform != plat {
|
||||
t.Fatalf("For(%s).Platform = %q", plat, p.Platform)
|
||||
}
|
||||
if p.IsMobile() != (plat == Mobile) {
|
||||
t.Fatalf("For(%s).IsMobile() = %v", plat, p.IsMobile())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Окно не бывает больше экрана, а рабочая область - больше самого экрана.
|
||||
func TestWindowFitsScreen(t *testing.T) {
|
||||
for _, plat := range []Platform{Desktop, Mobile} {
|
||||
for _, p := range personas(t, plat) {
|
||||
dev := deviceOf(t, p)
|
||||
if dev.InnerWidth > dev.ScreenAvailWidth || dev.InnerHeight > dev.ScreenAvailHeight {
|
||||
t.Fatalf("%s window %dx%d exceeds avail %dx%d", plat,
|
||||
dev.InnerWidth, dev.InnerHeight, dev.ScreenAvailWidth, dev.ScreenAvailHeight)
|
||||
}
|
||||
if dev.ScreenAvailWidth > dev.ScreenWidth || dev.ScreenAvailHeight > dev.ScreenHeight {
|
||||
t.Fatalf("%s avail %dx%d exceeds screen %dx%d", plat,
|
||||
dev.ScreenAvailWidth, dev.ScreenAvailHeight, dev.ScreenWidth, dev.ScreenHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chrome квантует navigator.deviceMemory по степеням двойки и режет потолок до 8:
|
||||
// любое другое значение живым браузером быть не может.
|
||||
func TestDeviceMemoryIsChromeQuantized(t *testing.T) {
|
||||
allowed := []int{1, 2, 4, 8}
|
||||
for _, plat := range []Platform{Desktop, Mobile} {
|
||||
for _, p := range personas(t, plat) {
|
||||
dev := deviceOf(t, p)
|
||||
if dev.DeviceMemory == nil || !slices.Contains(allowed, *dev.DeviceMemory) {
|
||||
t.Fatalf("%s deviceMemory = %v, want one of %v", plat, dev.DeviceMemory, allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Порядок заголовков обязан быть задан всегда, иначе на проводе останется
|
||||
// случайный порядок Go-мапы.
|
||||
func TestApplyFhttpSetsChromeHeaders(t *testing.T) {
|
||||
for _, plat := range []Platform{Desktop, Mobile} {
|
||||
req, err := fhttp.NewRequest(fhttp.MethodPost, "https://example.com/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ApplyFhttp(req, ForKind(kind))
|
||||
if req.Header.Get("sec-ch-ua") != "" {
|
||||
t.Fatalf("%s sec-ch-ua = %q, want empty", kind, req.Header.Get("sec-ch-ua"))
|
||||
p := For(plat, Identity{Seed: testSeed})
|
||||
ApplyFhttp(req, p)
|
||||
|
||||
if req.Header.Get("sec-ch-ua") != p.SecChUa {
|
||||
t.Fatalf("For(%s) sec-ch-ua = %q", plat, req.Header.Get("sec-ch-ua"))
|
||||
}
|
||||
if req.Header.Get("DNT") != "" {
|
||||
t.Fatalf("%s DNT = %q, want empty", kind, req.Header.Get("DNT"))
|
||||
if got := req.Header.Get("Priority"); got != "u=1, i" {
|
||||
t.Fatalf("For(%s) priority = %q", plat, got)
|
||||
}
|
||||
if len(req.Header[fhttp.HeaderOrderKey]) == 0 {
|
||||
t.Fatalf("For(%s) header order not set", plat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFamily(t *testing.T) {
|
||||
for _, kind := range []Kind{Chrome, Firefox, Safari} {
|
||||
if got := Family(ForKind(kind)); got != kind {
|
||||
t.Fatalf("Family(%s profile) = %q, want %q", kind, got, kind)
|
||||
// Персона обязана быть самосогласованной: мобильная платформа -> мобильный UA,
|
||||
// мобильный sec-ch-ua и непустой device для captcha.
|
||||
func TestPersonaSelfConsistent(t *testing.T) {
|
||||
for _, p := range personas(t, Mobile) {
|
||||
if !strings.Contains(strings.ToLower(p.UserAgent), "mobile") {
|
||||
t.Fatalf("mobile UA not mobile: %q", p.UserAgent)
|
||||
}
|
||||
if p.DeviceJSON == "" {
|
||||
t.Fatal("mobile persona has empty DeviceJSON")
|
||||
}
|
||||
if p.SecChUaMobile != "?1" {
|
||||
t.Fatalf("mobile sec-ch-ua-mobile = %q, want ?1", p.SecChUaMobile)
|
||||
}
|
||||
}
|
||||
if got := Family(Profile{UserAgent: "curl/8.0"}); got != "" {
|
||||
t.Fatalf("Family(unknown) = %q, want empty", got)
|
||||
for _, p := range personas(t, Desktop) {
|
||||
if strings.Contains(strings.ToLower(p.UserAgent), "mobile") {
|
||||
t.Fatalf("desktop UA is mobile: %q", p.UserAgent)
|
||||
}
|
||||
if p.SecChUaMobile != "?0" {
|
||||
t.Fatalf("desktop sec-ch-ua-mobile = %q, want ?0", p.SecChUaMobile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMobile(t *testing.T) {
|
||||
if IsMobile(ForKind(Chrome)) {
|
||||
t.Fatal("desktop chrome detected as mobile")
|
||||
// sec-ch-ua-platform и UA читает один и тот же анти-бот: заявленная в hints ОС
|
||||
// обязана совпадать с ОС в User-Agent.
|
||||
func TestPersonaPlatformMatchesUA(t *testing.T) {
|
||||
marker := map[string]string{
|
||||
`"Windows"`: "Windows NT",
|
||||
`"macOS"`: "Macintosh",
|
||||
`"Android"`: "Android",
|
||||
}
|
||||
if !IsMobile(Profile{UserAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Mobile Safari/537.36"}) {
|
||||
t.Fatal("android chrome was not detected as mobile")
|
||||
}
|
||||
if !IsMobile(Profile{UserAgent: ForKind(Chrome).UserAgent, SecChUaMobile: "?1"}) {
|
||||
t.Fatal("sec-ch-ua-mobile ?1 was not detected as mobile")
|
||||
for _, plat := range []Platform{Desktop, Mobile} {
|
||||
for _, p := range personas(t, plat) {
|
||||
want, ok := marker[p.SecChUaPlatform]
|
||||
if !ok {
|
||||
t.Fatalf("unknown sec-ch-ua-platform %q", p.SecChUaPlatform)
|
||||
}
|
||||
if !strings.Contains(p.UserAgent, want) {
|
||||
t.Fatalf("sec-ch-ua-platform %s but UA %q", p.SecChUaPlatform, p.UserAgent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accept-Language и navigator.languages читает один и тот же анти-бот: тег,
|
||||
// объявленный в JS, но отсутствующий в заголовке - несоответствие персоны.
|
||||
func TestPersonaLanguagesMatchHeader(t *testing.T) {
|
||||
for _, plat := range []Platform{Desktop, Mobile} {
|
||||
for _, p := range personas(t, plat) {
|
||||
dev := deviceOf(t, p)
|
||||
tags := strings.Split(p.AcceptLanguage, ",")
|
||||
for i, tag := range tags {
|
||||
tags[i], _, _ = strings.Cut(tag, ";")
|
||||
}
|
||||
if len(dev.Languages) == 0 || dev.Languages[0] != dev.Language {
|
||||
t.Fatalf("%s languages[0]=%v, want language %q", plat, dev.Languages, dev.Language)
|
||||
}
|
||||
for _, want := range dev.Languages {
|
||||
if !slices.Contains(tags, want) {
|
||||
t.Fatalf("%s language %q missing from Accept-Language %q", plat, want, p.AcceptLanguage)
|
||||
}
|
||||
}
|
||||
if tags[0] != dev.Language {
|
||||
t.Fatalf("%s Accept-Language starts with %q, want %q", plat, tags[0], dev.Language)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chrome использует один TLS-стек на всех платформах.
|
||||
func TestClientProfileIsChrome146(t *testing.T) {
|
||||
for _, plat := range []Platform{Desktop, Mobile} {
|
||||
got := For(plat, Identity{Seed: testSeed}).ClientProfile()
|
||||
if got.GetClientHelloStr() != profiles.Chrome_146.GetClientHelloStr() {
|
||||
t.Fatalf("For(%s).ClientProfile() = %s", plat, got.GetClientHelloStr())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
neturl "net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
assetsMaxParallel = 6
|
||||
assetsMaxCount = 40
|
||||
)
|
||||
|
||||
var (
|
||||
reAssetScript = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
|
||||
reAssetLink = regexp.MustCompile(`<link[^>]+>`)
|
||||
reAssetHref = regexp.MustCompile(`href="([^"]+)"`)
|
||||
reAssetRel = regexp.MustCompile(`rel="([^"]+)"`)
|
||||
reAssetAs = regexp.MustCompile(`as="([^"]+)"`)
|
||||
reAssetImg = regexp.MustCompile(`<img[^>]+src="([^"]+)"`)
|
||||
)
|
||||
|
||||
type asset struct {
|
||||
URL string
|
||||
Dest string
|
||||
}
|
||||
|
||||
func parsePageAssets(html string) []asset {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]asset, 0, assetsMaxCount)
|
||||
|
||||
add := func(raw, dest string) {
|
||||
url := absoluteAssetURL(raw)
|
||||
if url == "" || len(out) >= assetsMaxCount {
|
||||
return
|
||||
}
|
||||
if _, dup := seen[url]; dup {
|
||||
return
|
||||
}
|
||||
seen[url] = struct{}{}
|
||||
out = append(out, asset{URL: url, Dest: dest})
|
||||
}
|
||||
|
||||
for _, m := range reAssetScript.FindAllStringSubmatch(html, -1) {
|
||||
add(m[1], "script")
|
||||
}
|
||||
for _, tag := range reAssetLink.FindAllString(html, -1) {
|
||||
href := reAssetHref.FindStringSubmatch(tag)
|
||||
if len(href) < 2 {
|
||||
continue
|
||||
}
|
||||
add(href[1], linkDest(tag))
|
||||
}
|
||||
for _, m := range reAssetImg.FindAllStringSubmatch(html, -1) {
|
||||
add(m[1], "image")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func linkDest(tag string) string {
|
||||
if as := reAssetAs.FindStringSubmatch(tag); len(as) > 1 {
|
||||
return as[1]
|
||||
}
|
||||
rel := ""
|
||||
if m := reAssetRel.FindStringSubmatch(tag); len(m) > 1 {
|
||||
rel = m[1]
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(rel, "stylesheet"):
|
||||
return "style"
|
||||
case strings.Contains(rel, "icon"):
|
||||
return "image"
|
||||
default:
|
||||
return "empty"
|
||||
}
|
||||
}
|
||||
|
||||
// Рекламные хосты сюда не входят: manual их блокирует и всё равно проходит.
|
||||
var assetHostSuffixes = []string{".vk.com", ".vk.ru", ".userapi.com", ".okcdn.ru", ".mycdn.me"}
|
||||
|
||||
// Относительные пути пропускаются: статика раздаётся с CDN.
|
||||
func absoluteAssetURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(raw, "https://"):
|
||||
case strings.HasPrefix(raw, "//"):
|
||||
raw = "https:" + raw
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
u, err := neturl.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := u.Hostname()
|
||||
for _, suffix := range assetHostSuffixes {
|
||||
if strings.HasSuffix(host, suffix) || host == suffix[1:] {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func assetAccept(dest string) string {
|
||||
switch dest {
|
||||
case "style":
|
||||
return "text/css,*/*;q=0.1"
|
||||
case "image":
|
||||
return "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
|
||||
default:
|
||||
return "*/*"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *captchaSession) loadAssets(assets []asset) {
|
||||
if len(assets) == 0 {
|
||||
return
|
||||
}
|
||||
sem := make(chan struct{}, assetsMaxParallel)
|
||||
var wg sync.WaitGroup
|
||||
var loaded, failed atomic.Int32
|
||||
|
||||
for _, a := range assets {
|
||||
wg.Go(func() {
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
// Шрифт браузер тянет в cors, остальное - no-cors.
|
||||
mode := "no-cors"
|
||||
if a.Dest == "font" {
|
||||
mode = "cors"
|
||||
}
|
||||
_, err := s.doRaw(fhttp.MethodGet, a.URL, nil, map[string]string{
|
||||
"Accept": assetAccept(a.Dest),
|
||||
"Sec-Fetch-Dest": a.Dest,
|
||||
"Sec-Fetch-Mode": mode,
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"Referer": s.pageOrigin + "/",
|
||||
})
|
||||
if err != nil {
|
||||
failed.Add(1)
|
||||
} else {
|
||||
loaded.Add(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
s.logger().Debugf("[Captcha] assets loaded=%d failed=%d", loaded.Load(), failed.Load())
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const assetsPageHTML = `<html><head>
|
||||
<link rel="stylesheet" href="https://static.vk.ru/vkid/1.1.1391/main.css">
|
||||
<link rel="preload" as="font" href="https://static.vk.ru/fonts/vk.woff2">
|
||||
<link rel="icon" href="//static.vk.ru/favicon.ico">
|
||||
<script src="https://static.vk.ru/vkid/1.1.1391/not_robot_captcha.js"></script>
|
||||
<script src="https://static.vk.ru/vkid/1.1.1391/chunk.js"></script>
|
||||
<script src="/local/inline.js"></script>
|
||||
</head><body>
|
||||
<img src="https://static.vk.ru/img/logo.png">
|
||||
<img src="https://static.vk.ru/img/logo.png">
|
||||
</body></html>`
|
||||
|
||||
func TestParsePageAssetsClassifiesDest(t *testing.T) {
|
||||
got := parsePageAssets(assetsPageHTML)
|
||||
|
||||
want := map[string]string{
|
||||
"https://static.vk.ru/vkid/1.1.1391/main.css": "style",
|
||||
"https://static.vk.ru/fonts/vk.woff2": "font",
|
||||
"https://static.vk.ru/favicon.ico": "image",
|
||||
"https://static.vk.ru/vkid/1.1.1391/not_robot_captcha.js": "script",
|
||||
"https://static.vk.ru/vkid/1.1.1391/chunk.js": "script",
|
||||
"https://static.vk.ru/img/logo.png": "image",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("assets = %d, want %d: %+v", len(got), len(want), got)
|
||||
}
|
||||
for _, a := range got {
|
||||
if want[a.URL] != a.Dest {
|
||||
t.Fatalf("%s dest = %q, want %q", a.URL, a.Dest, want[a.URL])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Относительные пути виджета обслуживает не CDN, и абсолютного URL для них нет.
|
||||
func TestParsePageAssetsDropsRelative(t *testing.T) {
|
||||
for _, a := range parsePageAssets(assetsPageHTML) {
|
||||
if a.URL == "/local/inline.js" {
|
||||
t.Fatal("relative asset must be dropped")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Рекламу manual блокирует и всё равно проходит - авто-путь не должен её тянуть.
|
||||
func TestParsePageAssetsDropsForeignHosts(t *testing.T) {
|
||||
html := `<script src="https://ad.mail.ru/static/sync-loader.js"></script>` +
|
||||
`<script src="https://static.vk.ru/vkid/app.js"></script>`
|
||||
got := parsePageAssets(html)
|
||||
if len(got) != 1 || got[0].URL != "https://static.vk.ru/vkid/app.js" {
|
||||
t.Fatalf("assets = %+v, want only the vk.ru script", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePageAssetsCaps(t *testing.T) {
|
||||
var html strings.Builder
|
||||
for i := range assetsMaxCount + 20 {
|
||||
html.WriteString(`<script src="https://static.vk.ru/` + string(rune('a'+i%26)) + string(rune('a'+i/26)) + `.js"></script>`)
|
||||
}
|
||||
if got := len(parsePageAssets(html.String())); got > assetsMaxCount {
|
||||
t.Fatalf("assets = %d, want <= %d", got, assetsMaxCount)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,25 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
)
|
||||
|
||||
// Значения query в лог не уезжают - в них session_token.
|
||||
func TestSafeURL(t *testing.T) {
|
||||
tests := []struct{ raw, want string }{
|
||||
{"", "-"},
|
||||
{"https://api.vk.ru/not_robot_captcha?variant=popup&domain=vk.com&session_token=jwt", "api.vk.ru/not_robot_captcha?domain,session_token,variant"},
|
||||
{"https://id.vk.ru/", "id.vk.ru/"},
|
||||
{"https://api.vk.ru", "api.vk.ru/"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := SafeURL(tt.raw); got != tt.want {
|
||||
t.Errorf("SafeURL(%q) = %q, want %q", tt.raw, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaInitSettingContentRefPrefersSettingsKey(t *testing.T) {
|
||||
setting := captchaInitSetting{
|
||||
Type: "slider",
|
||||
@@ -34,46 +45,34 @@ func TestCaptchaInitSettingContentRefLegacySettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCaptchaPageSettingsKey(t *testing.T) {
|
||||
html := `<html><head><script src="https://static.vk.ru/vkid/1.1.1359/not_robot_captcha.js"></script></head><body>
|
||||
<script>
|
||||
window.init = {"data":{"show_captcha_type":"slider","captcha_settings":[{"type":"slider","settings_key":"abc123"}]}};
|
||||
const powInput = "input";
|
||||
const difficulty = 2;
|
||||
</script>
|
||||
</body></html>`
|
||||
|
||||
page, err := parseCaptchaPage(html)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
func TestParseCaptchaInitSession(t *testing.T) {
|
||||
raw := map[string]any{"response": map[string]any{
|
||||
"show_captcha_type": "slider",
|
||||
"captcha_id": "cid",
|
||||
"content_settings": []any{
|
||||
map[string]any{"type": "slider", "settings_key": "sliderkey"},
|
||||
map[string]any{"type": "sound", "settings_key": "soundkey"},
|
||||
},
|
||||
}}
|
||||
showType, content := parseCaptchaInitSession(raw)
|
||||
if showType != "slider" {
|
||||
t.Fatalf("show_type = %q, want slider", showType)
|
||||
}
|
||||
if page.Init == nil || len(page.Init.Data.CaptchaSettings) != 1 {
|
||||
t.Fatalf("captcha settings missing: %+v", page.Init)
|
||||
}
|
||||
got := page.Init.Data.CaptchaSettings[0].contentRef()
|
||||
if got.Source != "settings_key" || got.Value != "abc123" {
|
||||
t.Fatalf("contentRef = %+v, want settings_key/abc123", got)
|
||||
if content.Value != "sliderkey" || content.Source != "settings_key" {
|
||||
t.Fatalf("content = %+v, want sliderkey/settings_key", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeCaptchaPoW(t *testing.T) {
|
||||
got := encodeCaptchaPoW("00abcdef", 147)
|
||||
if !strings.HasPrefix(got, "v2.") {
|
||||
t.Fatalf("pow = %q, want v2 prefix", got)
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(got, "v2."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded struct {
|
||||
Hash string `json:"hash"`
|
||||
Nonce int `json:"nonce"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Hash != "00abcdef" || decoded.Nonce != 147 {
|
||||
t.Fatalf("decoded = %+v, want hash/nonce", decoded)
|
||||
func TestParseCaptchaInitSessionCheckbox(t *testing.T) {
|
||||
raw := map[string]any{"response": map[string]any{
|
||||
"show_captcha_type": "checkbox",
|
||||
"content_settings": []any{
|
||||
map[string]any{"type": "slider", "settings_key": "k"},
|
||||
},
|
||||
}}
|
||||
showType, _ := parseCaptchaInitSession(raw)
|
||||
if showType != "checkbox" {
|
||||
t.Fatalf("show_type = %q, want checkbox", showType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,56 +113,183 @@ func TestCaptchaDomainFromRedirectURI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaConnectionFieldsGatedByFamily(t *testing.T) {
|
||||
for _, family := range []browserprofile.Kind{browserprofile.Firefox, browserprofile.Safari} {
|
||||
if got := captchaConnectionFields(family, true); got != nil {
|
||||
t.Fatalf("%s must omit NetworkInformation telemetry, got %v", family, got)
|
||||
}
|
||||
func TestPickSliderAttempts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
indexes []int
|
||||
limit int
|
||||
want []int
|
||||
}{
|
||||
{
|
||||
name: "skips neighbours of the top guess",
|
||||
indexes: []int{20, 19, 21, 18, 40, 5},
|
||||
limit: 2,
|
||||
want: []int{20, 40},
|
||||
},
|
||||
{
|
||||
name: "falls back to neighbours when nothing is far enough",
|
||||
indexes: []int{20, 19, 21},
|
||||
limit: 3,
|
||||
want: []int{20, 19, 21},
|
||||
},
|
||||
{
|
||||
name: "limit above candidate count",
|
||||
indexes: []int{7},
|
||||
limit: 4,
|
||||
want: []int{7},
|
||||
},
|
||||
}
|
||||
got := captchaConnectionFields(browserprofile.Chrome, true)
|
||||
if len(got) != 2 || got[0][0] != "connectionRtt" || got[1][0] != "connectionDownlink" {
|
||||
t.Fatalf("chrome connection fields = %v, want rtt+downlink", got)
|
||||
}
|
||||
// Реальный браузер шлёт фиксированную выборку, не привязанную к курсору.
|
||||
var rtt []int
|
||||
if err := json.Unmarshal([]byte(got[0][1]), &rtt); err != nil || len(rtt) != captchaConnectionSamples {
|
||||
t.Fatalf("connectionRtt = %s, want %d samples", got[0][1], captchaConnectionSamples)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
guesses := make([]sliderGuess, 0, len(tt.indexes))
|
||||
for _, idx := range tt.indexes {
|
||||
guesses = append(guesses, sliderGuess{Index: idx})
|
||||
}
|
||||
got := pickSliderAttempts(guesses, tt.limit)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len = %d, want %d", len(got), len(tt.want))
|
||||
}
|
||||
for i := range tt.want {
|
||||
if got[i].Index != tt.want[i] {
|
||||
t.Fatalf("attempts = %v, want %v", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaMobileAccelerometerIsGravity(t *testing.T) {
|
||||
var pts []struct{ X, Y, Z float64 }
|
||||
if err := json.Unmarshal([]byte(captchaMobileAccelerometer()), &pts); err != nil {
|
||||
t.Fatal(err)
|
||||
// VK раздаёт страницу то с id.vk.ru, то с api.vk.ru: Origin от одного хоста при
|
||||
// странице на другом браузер выдать не может.
|
||||
func TestAPIRequestHeadersFollowPageOrigin(t *testing.T) {
|
||||
const pageQuery = "/not_robot_captcha?domain=vk.com&session_token=x&variant=popup"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
page string
|
||||
apiHost string
|
||||
wantSite string
|
||||
wantRef string
|
||||
wantOrig string
|
||||
}{
|
||||
{
|
||||
name: "cross-origin page",
|
||||
page: "https://id.vk.ru" + pageQuery,
|
||||
apiHost: "api.vk.ru",
|
||||
wantSite: "same-site",
|
||||
wantRef: "https://id.vk.ru/",
|
||||
wantOrig: "https://id.vk.ru",
|
||||
},
|
||||
{
|
||||
name: "same-origin page keeps full url in referer",
|
||||
page: "https://api.vk.ru" + pageQuery,
|
||||
apiHost: "api.vk.ru",
|
||||
wantSite: "same-origin",
|
||||
wantRef: "https://api.vk.ru" + pageQuery,
|
||||
wantOrig: "https://api.vk.ru",
|
||||
},
|
||||
}
|
||||
if len(pts) != 3 {
|
||||
t.Fatalf("samples = %d, want 3", len(pts))
|
||||
}
|
||||
// Магнитуда каждого сэмпла ~ g (покоящийся телефон).
|
||||
for _, p := range pts {
|
||||
mag := p.X*p.X + p.Y*p.Y + p.Z*p.Z
|
||||
if mag < 64 || mag > 144 { // |a| в [8, 12]
|
||||
t.Fatalf("accelerometer magnitude^2 = %.1f, not gravity-like", mag)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := &captchaSession{apiHost: tc.apiHost}
|
||||
s.setPageURL(tc.page)
|
||||
got := s.apiRequestHeaders()
|
||||
if got["Sec-Fetch-Site"] != tc.wantSite {
|
||||
t.Fatalf("Sec-Fetch-Site = %q, want %q", got["Sec-Fetch-Site"], tc.wantSite)
|
||||
}
|
||||
if got["Referer"] != tc.wantRef {
|
||||
t.Fatalf("Referer = %q, want %q", got["Referer"], tc.wantRef)
|
||||
}
|
||||
if got["Origin"] != tc.wantOrig {
|
||||
t.Fatalf("Origin = %q, want %q", got["Origin"], tc.wantOrig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseSwapPairs(t *testing.T) {
|
||||
got := reverseSwapPairs([]int{1, 2, 3, 4, 5, 6})
|
||||
want := []int{5, 6, 3, 4, 1, 2}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len = %d, want %d", len(got), len(want))
|
||||
// Пока challenge есть в window.init, виджет не ходит в initSession - и мы тоже.
|
||||
func TestParseCaptchaInitGlobal(t *testing.T) {
|
||||
html := `<script>window.init = {"hosts":{"api":"api.vk.ru"},` +
|
||||
`"data":{"show_captcha_type":"slider","captcha_settings":[{"type":"slider","settings_key":"a2V5"}]},` +
|
||||
`"tail":{"brace":"}"}};</script>`
|
||||
got := parseCaptchaInitGlobal(html)
|
||||
if !got.Found || got.APIHost != "api.vk.ru" || got.ShowType != "slider" {
|
||||
t.Fatalf("init = %+v", got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("reverseSwapPairs = %v, want %v", got, want)
|
||||
}
|
||||
if got.Content.Value != "a2V5" || got.Content.Source != "settings_key" {
|
||||
t.Fatalf("content = %+v", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSliderCursorIsBrowserLike(t *testing.T) {
|
||||
cursor := buildSliderCursor(5, 25)
|
||||
if got := captchaCursorPointCount(cursor); got < 70 {
|
||||
t.Fatalf("cursor point count = %d, want at least 70", got)
|
||||
// Без window.init виджет запрашивает initSession сам - и Found обязан быть false.
|
||||
func TestParseCaptchaInitGlobalAbsent(t *testing.T) {
|
||||
if got := parseCaptchaInitGlobal(`<script>var x = 1;</script>`); got.Found || got.APIHost != "" {
|
||||
t.Fatalf("init = %+v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Битый redirect_uri не должен оставлять Origin пустым.
|
||||
func TestSetPageURLFallsBackToWidgetOrigin(t *testing.T) {
|
||||
s := &captchaSession{apiHost: "api.vk.ru"}
|
||||
s.setPageURL("not-a-url")
|
||||
if s.pageOrigin != captchaAPIOrigin || s.pageURL != captchaAPIOrigin+"/" {
|
||||
t.Fatalf("page = %q / %q", s.pageOrigin, s.pageURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCaptchaDebugInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, html, want string
|
||||
}{
|
||||
{
|
||||
name: "renamed key",
|
||||
html: `<script>window.vk = {stDomain: "https://st.vk.ru", qqqqqqqq: "273cc83f-426f-4d98-9ce5-92490107e3a6", id: 0};</script>`,
|
||||
want: "273cc83f-426f-4d98-9ce5-92490107e3a6",
|
||||
},
|
||||
{
|
||||
name: "quoted keys of nested json are not candidates",
|
||||
html: `<script>window.vk = {statsMeta: {"hash":"X84u4GhF","uuid":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}, k: "273cc83f-426f-4d98-9ce5-92490107e3a6"};</script>`,
|
||||
want: "273cc83f-426f-4d98-9ce5-92490107e3a6",
|
||||
},
|
||||
{name: "no window.vk", html: `<script>var x = 1;</script>`},
|
||||
{name: "no uuid in block", html: `<script>window.vk = {id: 0, logoutUrl: ""};</script>`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := parseCaptchaDebugInfo(tt.html); got != tt.want {
|
||||
t.Fatalf("debug_info = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCaptchaDebugInfoAmbiguous(t *testing.T) {
|
||||
html := `<script>window.vk = {a: "273cc83f-426f-4d98-9ce5-92490107e3a6", b: "ec772ebb-0d69-4fa0-b974-904549c8a7d1"};</script>`
|
||||
if got := parseCaptchaDebugInfo(html); got != "273cc83f-426f-4d98-9ce5-92490107e3a6" {
|
||||
t.Fatalf("debug_info = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCaptchaPageRejectsNonCaptchaHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseCaptchaPage("<html><head><title>429 Too Many Requests</title></head><body></body></html>")
|
||||
if !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("err = %v, want ErrUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Смена обфускации - не аутаж: ошибка обязана дойти до эскалации на другой решатель.
|
||||
// Переименованный конверт PoW - именно этот случай, страница настоящая.
|
||||
func TestParseCaptchaPageBrokenParserIsNotUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, html := range map[string]string{
|
||||
"pow renamed": `<script>window.vk = {}; window.zzz = "v2." + solve();</script>`,
|
||||
"debug absent": `<script>window.init = {};</script>`,
|
||||
} {
|
||||
_, err := parseCaptchaPage(html)
|
||||
if err == nil || errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("%s: err = %v, want plain parse error", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ type Error struct {
|
||||
ErrorCode int
|
||||
ErrorMsg string
|
||||
CaptchaSid string
|
||||
CaptchaImg string
|
||||
RedirectURI string
|
||||
IsSoundCaptchaAvailable bool
|
||||
SessionToken string
|
||||
@@ -24,14 +23,14 @@ type Error struct {
|
||||
func ParseError(errData map[string]any) *Error {
|
||||
codeFloat, ok := errData["error_code"].(float64)
|
||||
if !ok {
|
||||
Log.Warnf("[Captcha] missing error_code in error data")
|
||||
Log().Warnf("[Captcha] missing error_code in error data")
|
||||
return nil
|
||||
}
|
||||
code := int(codeFloat)
|
||||
|
||||
redirectURI, ok := errData["redirect_uri"].(string)
|
||||
if !ok {
|
||||
Log.Warnf("[Captcha] missing redirect_uri in error data")
|
||||
Log().Warnf("[Captcha] missing redirect_uri in error data")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -42,11 +41,9 @@ func ParseError(errData map[string]any) *Error {
|
||||
}
|
||||
}
|
||||
|
||||
captchaImg, _ := errData["captcha_img"].(string)
|
||||
|
||||
errorMsg, ok := errData["error_msg"].(string)
|
||||
if !ok {
|
||||
Log.Warnf("[Captcha] missing error_msg in error data")
|
||||
Log().Warnf("[Captcha] missing error_msg in error data")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -55,7 +52,7 @@ func ParseError(errData map[string]any) *Error {
|
||||
if parsed, err := neturl.Parse(redirectURI); err == nil {
|
||||
sessionToken = parsed.Query().Get("session_token")
|
||||
} else {
|
||||
Log.Warnf("[Captcha] failed to parse redirect_uri: %v", err)
|
||||
Log().Warnf("[Captcha] failed to parse redirect_uri: %v", err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -88,7 +85,6 @@ func ParseError(errData map[string]any) *Error {
|
||||
ErrorCode: code,
|
||||
ErrorMsg: errorMsg,
|
||||
CaptchaSid: captchaSid,
|
||||
CaptchaImg: captchaImg,
|
||||
RedirectURI: redirectURI,
|
||||
IsSoundCaptchaAvailable: isSound,
|
||||
SessionToken: sessionToken,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const { local: LOCAL, upstream: UPSTREAM } = window.__ftpCaptcha ?? {};
|
||||
const PROXY = '/generic_proxy?proxy_url=';
|
||||
const RESULT = '/local-captcha-result';
|
||||
const CHECK = 'captchaNotRobot.check';
|
||||
|
||||
function rewriteUrl(url) {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
if (url.startsWith(LOCAL)) return url;
|
||||
if (url.startsWith(UPSTREAM)) return LOCAL + url.slice(UPSTREAM.length);
|
||||
if (url.startsWith('//')) return PROXY + encodeURIComponent(location.protocol + url);
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return PROXY + encodeURIComponent(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
function rewriteAttr(el, attr) {
|
||||
const value = el.getAttribute?.(attr);
|
||||
if (!value) return;
|
||||
const rewritten = rewriteUrl(value);
|
||||
if (rewritten !== value) el.setAttribute(attr, rewritten);
|
||||
}
|
||||
|
||||
function rewriteTree(root) {
|
||||
if (!root.querySelectorAll) return;
|
||||
for (const attr of ['href', 'src', 'action']) {
|
||||
root.querySelectorAll(`[${attr}]`).forEach((el) => rewriteAttr(el, attr));
|
||||
}
|
||||
}
|
||||
|
||||
const extractToken = (data) => data?.response?.success_token;
|
||||
|
||||
function sendToken(token) {
|
||||
if (!token) return;
|
||||
const body = `token=${encodeURIComponent(token)}`;
|
||||
|
||||
// sendBeacon переживает уход со страницы (mobile Safari)
|
||||
if (navigator.sendBeacon?.(RESULT, new Blob([body], { type: 'application/x-www-form-urlencoded' }))) {
|
||||
showDone();
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(RESULT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
}).then(showDone).catch(() => {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = RESULT;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'token';
|
||||
input.value = token;
|
||||
form.append(input);
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
function showPending() {
|
||||
document.body.style.background = '#000';
|
||||
const banner = document.createElement('div');
|
||||
banner.textContent = 'free turn proxy - captcha';
|
||||
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;padding:12px;text-align:center;font:13.5px ui-monospace,monospace;color:#e1e1e1;background:#000';
|
||||
document.body.append(banner);
|
||||
}
|
||||
|
||||
function showDone() {
|
||||
document.body.style.background = '#000';
|
||||
document.body.innerHTML =
|
||||
'<div style="min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;font-family:ui-monospace,monospace;text-align:center">' +
|
||||
'<div style="font-size:16.5px;font-weight:700;color:#e1e1e1">free turn proxy</div>' +
|
||||
'<div style="font-size:13.5px;color:#818181">gg</div></div>';
|
||||
setTimeout(() => window.close(), 1000);
|
||||
}
|
||||
|
||||
const xhrOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (...args) {
|
||||
if (typeof args[1] === 'string') {
|
||||
this._origUrl = args[1];
|
||||
args[1] = rewriteUrl(args[1]);
|
||||
}
|
||||
return xhrOpen.apply(this, args);
|
||||
};
|
||||
|
||||
const xhrSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.send = function (...args) {
|
||||
if (this._origUrl?.includes(CHECK)) {
|
||||
this.addEventListener('load', () => {
|
||||
try { sendToken(extractToken(JSON.parse(this.responseText))); } catch {}
|
||||
});
|
||||
}
|
||||
return xhrSend.apply(this, args);
|
||||
};
|
||||
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = function (...args) {
|
||||
const urlStr = typeof args[0] === 'object' ? args[0]?.url : args[0];
|
||||
if (typeof args[0] === 'string') args[0] = rewriteUrl(args[0]);
|
||||
|
||||
const promise = origFetch.apply(this, args);
|
||||
if (typeof urlStr === 'string' && urlStr.includes(CHECK)) {
|
||||
promise
|
||||
.then((res) => res.clone().json())
|
||||
.then((data) => sendToken(extractToken(data)))
|
||||
.catch(() => {});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
document.addEventListener('submit', (event) => {
|
||||
if (event.target?.action) event.target.action = rewriteUrl(event.target.action);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const link = event.target?.closest?.('a[href]');
|
||||
if (link?.href) link.href = rewriteUrl(link.href);
|
||||
}, true);
|
||||
|
||||
const formSubmit = HTMLFormElement.prototype.submit;
|
||||
HTMLFormElement.prototype.submit = function (...args) {
|
||||
if (this.action) this.action = rewriteUrl(this.action);
|
||||
return formSubmit.apply(this, args);
|
||||
};
|
||||
|
||||
const windowOpen = window.open;
|
||||
window.open = function (...args) {
|
||||
if (typeof args[0] === 'string') args[0] = rewriteUrl(args[0]);
|
||||
return windowOpen.apply(this, args);
|
||||
};
|
||||
|
||||
rewriteTree(document);
|
||||
new MutationObserver((mutations) => {
|
||||
for (const { type, target, attributeName, addedNodes } of mutations) {
|
||||
if (type === 'attributes') {
|
||||
rewriteAttr(target, attributeName);
|
||||
continue;
|
||||
}
|
||||
for (const node of addedNodes) {
|
||||
if (node.nodeType === 1) rewriteTree(node);
|
||||
}
|
||||
}
|
||||
}).observe(document.documentElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['href', 'src', 'action'],
|
||||
});
|
||||
|
||||
if (document.body) showPending();
|
||||
else document.addEventListener('DOMContentLoaded', showPending);
|
||||
})();
|
||||
@@ -1,18 +1,13 @@
|
||||
// Package manual реализует ручное решение CAPTCHA - fallback, когда автосолвер
|
||||
// (internal/provider/vk/internal/captcha) не справился или пользователь дал -manual-captcha.
|
||||
// Поднимает локальный HTTP-сервер 127.0.0.1:8765, проксирующий страницу VK
|
||||
// CAPTCHA (с переписыванием абсолютных URL и gzip), открывает её в браузере и
|
||||
// ждёт токен/ключ после решения пользователем.
|
||||
package manual
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -22,26 +17,39 @@ import (
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/client/ish"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/logx"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/personanet"
|
||||
)
|
||||
|
||||
// Debug включает подробное логирование request/response проксируемого
|
||||
// браузерного трафика. Ставится из main после разбора cfg.
|
||||
var Debug bool
|
||||
// debugFlag включает логирование проксируемого браузерного трафика.
|
||||
var debugFlag atomic.Bool
|
||||
|
||||
// Log - пакетный логгер; по умолчанию no-op. main устанавливает его через
|
||||
// SetLogger, чтобы вывод подчинялся -debug.
|
||||
var Log logx.Logger = logx.Nop()
|
||||
func SetDebug(v bool) { debugFlag.Store(v) }
|
||||
|
||||
// SetLogger ставит логгер пакета.
|
||||
func SetLogger(l logx.Logger) { Log = logx.OrNop(l) }
|
||||
var logHolder logx.Holder
|
||||
|
||||
func SetLogger(l logx.Logger) { logHolder.Set(l) }
|
||||
|
||||
func Log() logx.Logger { return logHolder.Get() }
|
||||
|
||||
const captchaListenPort = "8765"
|
||||
|
||||
//go:embed inject.js
|
||||
var injectJS string
|
||||
|
||||
// injectScriptTag: конфиг отдельной строкой (json.Marshal экранирует, включая "<"),
|
||||
// сам inject.js статичный, без плейсхолдеров.
|
||||
func injectScriptTag(localOrigin, upstreamOrigin string) string {
|
||||
cfg, _ := json.Marshal(map[string]string{"local": localOrigin, "upstream": upstreamOrigin})
|
||||
return "\n<script>window.__ftpCaptcha=" + string(cfg) + ";</script>\n<script>\n" + injectJS + "</script>\n"
|
||||
}
|
||||
|
||||
type browserCommand struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -66,7 +74,22 @@ func localCaptchaHosts() []string {
|
||||
}
|
||||
}
|
||||
|
||||
// blockedProxyHosts - реклама и внешняя телеметрия. Всё, что не в allowed, и так
|
||||
// не проксируется; список нужен для хостов под доменами VK, которые иначе прошли
|
||||
// бы по суффиксу. Captcha без них решается: adFp тогда пустой, ровно как у
|
||||
// браузера с блокировщиком.
|
||||
var blockedProxyHosts = []string{
|
||||
"ads.vk.com", "ads.vk.ru",
|
||||
"top-fwz1.mail.ru", "r0.mradx.net",
|
||||
"sdk-api.apptracer.ru", "stats.vk-portal.net",
|
||||
}
|
||||
|
||||
func isAllowedProxyHost(hostname string) bool {
|
||||
for _, blocked := range blockedProxyHosts {
|
||||
if strings.EqualFold(hostname, blocked) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
allowed := []string{
|
||||
".vk.com", ".vk.ru", ".vkontakte.ru",
|
||||
".userapi.com", ".okcdn.ru", ".mycdn.me",
|
||||
@@ -296,199 +319,7 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
|
||||
// сразу при парсинге HTML - раньше любых инжектированных JS-перехватов.
|
||||
html = rewriteHTMLAttrsServerSide(html, targetURL)
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
<script>
|
||||
(function() {
|
||||
var localOrigin = %q;
|
||||
var upstreamOrigin = %q;
|
||||
|
||||
function rewriteUrl(urlStr) {
|
||||
if (!urlStr || typeof urlStr !== 'string') return urlStr;
|
||||
if (urlStr.indexOf(localOrigin) === 0) return urlStr;
|
||||
if (urlStr.indexOf(upstreamOrigin) === 0) return localOrigin + urlStr.slice(upstreamOrigin.length);
|
||||
if (urlStr.indexOf('//') === 0) {
|
||||
return '/generic_proxy?proxy_url=' + encodeURIComponent(window.location.protocol + urlStr);
|
||||
}
|
||||
if (urlStr.indexOf('http://') === 0 || urlStr.indexOf('https://') === 0) {
|
||||
return '/generic_proxy?proxy_url=' + encodeURIComponent(urlStr);
|
||||
}
|
||||
return urlStr;
|
||||
}
|
||||
|
||||
function rewriteElementAttr(el, attr) {
|
||||
if (!el || !el.getAttribute) return;
|
||||
var value = el.getAttribute(attr);
|
||||
if (!value) return;
|
||||
var rewritten = rewriteUrl(value);
|
||||
if (rewritten !== value) {
|
||||
el.setAttribute(attr, rewritten);
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteDocument(root) {
|
||||
if (!root || !root.querySelectorAll) return;
|
||||
root.querySelectorAll('[href]').forEach(function(el) { rewriteElementAttr(el, 'href'); });
|
||||
root.querySelectorAll('[src]').forEach(function(el) { rewriteElementAttr(el, 'src'); });
|
||||
root.querySelectorAll('form[action]').forEach(function(el) { rewriteElementAttr(el, 'action'); });
|
||||
}
|
||||
|
||||
function handleSuccessToken(token) {
|
||||
if (!token) return;
|
||||
console.log('Captcha solved, sending token to proxy...');
|
||||
var body = 'token=' + encodeURIComponent(token);
|
||||
|
||||
// sendBeacon is the most reliable on mobile Safari:
|
||||
// it's fire-and-forget and works even if the page navigates away.
|
||||
if (navigator && navigator.sendBeacon) {
|
||||
var blob = new Blob([body], {type: 'application/x-www-form-urlencoded'});
|
||||
var sent = navigator.sendBeacon('/local-captcha-result', blob);
|
||||
if (sent) {
|
||||
console.log('Token sent via sendBeacon');
|
||||
showDone();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: fetch
|
||||
fetch('/local-captcha-result', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: body
|
||||
}).then(function(r) {
|
||||
console.log('Proxy acknowledged token');
|
||||
showDone();
|
||||
}).catch(function(e) {
|
||||
console.error('Fetch failed, trying form submit...', e);
|
||||
// Last resort: form POST (navigates the page)
|
||||
var form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/local-captcha-result';
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'token';
|
||||
input.value = token;
|
||||
form.appendChild(input);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
function showDone() {
|
||||
document.body.innerHTML = '<div style="text-align:center;margin-top:20vh;font-family:sans-serif">' +
|
||||
'<h2 style="color:#4caf50">✔ Done!</h2>' +
|
||||
'<p>Captcha solved successfully. You can close this tab now.</p>' +
|
||||
'</div>';
|
||||
// On iOS, window.close() often doesn't work, so we just let the user know they are done.
|
||||
setTimeout(function() { window.close(); }, 1000);
|
||||
}
|
||||
|
||||
var origOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function() {
|
||||
if (arguments[1] && typeof arguments[1] === 'string') {
|
||||
this._origUrl = arguments[1];
|
||||
arguments[1] = rewriteUrl(arguments[1]);
|
||||
}
|
||||
return origOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
var origSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.send = function() {
|
||||
var xhr = this;
|
||||
if (this._origUrl && this._origUrl.indexOf('captchaNotRobot.check') !== -1) {
|
||||
xhr.addEventListener('load', function() {
|
||||
try {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
if (data.response && data.response.success_token) {
|
||||
handleSuccessToken(data.response.success_token);
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
return origSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
var origFetch = window.fetch;
|
||||
if (origFetch) {
|
||||
window.fetch = function() {
|
||||
var url = arguments[0];
|
||||
var isObj = (typeof url === 'object' && url && url.url);
|
||||
var urlStr = isObj ? url.url : url;
|
||||
var origUrlStr = urlStr;
|
||||
|
||||
if (typeof urlStr === 'string') {
|
||||
urlStr = rewriteUrl(urlStr);
|
||||
arguments[0] = urlStr;
|
||||
}
|
||||
|
||||
var p = origFetch.apply(this, arguments);
|
||||
if (typeof origUrlStr === 'string' && origUrlStr.indexOf('captchaNotRobot.check') !== -1) {
|
||||
p.then(function(response) {
|
||||
return response.clone().json();
|
||||
}).then(function(data) {
|
||||
if (data.response && data.response.success_token) {
|
||||
handleSuccessToken(data.response.success_token);
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
return p;
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('submit', function(event) {
|
||||
if (event.target && event.target.action) {
|
||||
event.target.action = rewriteUrl(event.target.action);
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
var target = event.target && event.target.closest ? event.target.closest('a[href]') : null;
|
||||
if (target && target.href) {
|
||||
target.href = rewriteUrl(target.href);
|
||||
}
|
||||
}, true);
|
||||
|
||||
var origFormSubmit = HTMLFormElement.prototype.submit;
|
||||
HTMLFormElement.prototype.submit = function() {
|
||||
if (this.action) {
|
||||
this.action = rewriteUrl(this.action);
|
||||
}
|
||||
return origFormSubmit.apply(this, arguments);
|
||||
};
|
||||
|
||||
var origWindowOpen = window.open;
|
||||
if (origWindowOpen) {
|
||||
window.open = function(url) {
|
||||
if (typeof url === 'string') {
|
||||
arguments[0] = rewriteUrl(url);
|
||||
}
|
||||
return origWindowOpen.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
rewriteDocument(document);
|
||||
if (document.documentElement && window.MutationObserver) {
|
||||
new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === 'attributes' && mutation.target) {
|
||||
rewriteElementAttr(mutation.target, mutation.attributeName);
|
||||
return;
|
||||
}
|
||||
mutation.addedNodes.forEach(function(node) {
|
||||
if (node.nodeType === 1) {
|
||||
rewriteDocument(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
}).observe(document.documentElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['href', 'src', 'action']
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
`, localOrigin, upstreamOrigin)
|
||||
script := injectScriptTag(localOrigin, upstreamOrigin)
|
||||
|
||||
// Шаг 3: инжектируем клиентский скрипт как можно раньше - после <head>,
|
||||
// чтобы XHR/fetch-перехваты были активны до любого inline <script> в <head>.
|
||||
@@ -504,18 +335,6 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
|
||||
}
|
||||
}
|
||||
|
||||
func newCaptchaProxyTransport(dialer net.Dialer) *http.Transport {
|
||||
return &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ForceAttemptHTTP2: false,
|
||||
DialContext: dialer.DialContext,
|
||||
}
|
||||
}
|
||||
|
||||
func startCaptchaServer(srv *http.Server, logPrefix string) error {
|
||||
var listenErrs []string
|
||||
var listening bool
|
||||
@@ -529,12 +348,12 @@ func startCaptchaServer(srv *http.Server, logPrefix string) error {
|
||||
listening = true
|
||||
wrappedListener, err := ish.WrapListener(listener)
|
||||
if err != nil {
|
||||
Log.Warnf("%s: failed to wrap listener for iSH: %v", logPrefix, err)
|
||||
Log().Warnf("%s: failed to wrap listener for iSH: %v", logPrefix, err)
|
||||
wrappedListener = listener
|
||||
}
|
||||
go func(listener net.Listener) {
|
||||
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
Log.Errorf("%s: %s", logPrefix, err)
|
||||
Log().Errorf("%s: %s", logPrefix, err)
|
||||
}
|
||||
}(wrappedListener)
|
||||
}
|
||||
@@ -561,16 +380,12 @@ func runCaptchaServerAndWait(ctx context.Context, handler http.Handler, captchaU
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer shutCancel()
|
||||
if err := srv.Shutdown(shutCtx); err != nil {
|
||||
Log.Warnf("%s: shutdown warning: %v", logPrefix, err)
|
||||
Log().Warnf("%s: shutdown warning: %v", logPrefix, err)
|
||||
}
|
||||
}()
|
||||
|
||||
fmt.Println("\n==============================================")
|
||||
fmt.Println("ACTION REQUIRED: MANUAL CAPTCHA SOLVING NEEDED")
|
||||
fmt.Println("If your browser didn't open automatically,")
|
||||
fmt.Println("manually open this URL: " + localCaptchaOrigin())
|
||||
fmt.Println("==============================================")
|
||||
fmt.Println()
|
||||
Log().Warnf("%s: ACTION REQUIRED: manual captcha solving needed", logPrefix)
|
||||
Log().Warnf("%s: if the browser did not open, go to %s", logPrefix, localCaptchaOrigin())
|
||||
|
||||
present(captchaURL)
|
||||
|
||||
@@ -592,41 +407,27 @@ func notifyKey(keyCh chan<- string, key string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SolveViaHTTP отдаёт минимальную HTML-страницу для решения картинки CAPTCHA,
|
||||
// открывает её в браузере и блокируется до прихода ключа.
|
||||
func SolveViaHTTP(ctx context.Context, captchaImg string) (string, error) {
|
||||
keyCh := make(chan string, 1)
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>body{font-family:sans-serif;text-align:center;padding:20px}
|
||||
img{max-width:100%%;margin:16px 0}
|
||||
input{font-size:24px;padding:12px;width:80%%;box-sizing:border-box}
|
||||
button{font-size:24px;padding:12px 32px;margin-top:12px;cursor:pointer}</style>
|
||||
</head><body>
|
||||
<h2>Solve the Captcha</h2>
|
||||
<img src="%s" alt="captcha"/>
|
||||
<form onsubmit="fetch('/solve?key='+encodeURIComponent(document.getElementById('k').value)).then(()=>{document.body.innerHTML='<h2>Done!</h2>';setTimeout(function(){window.close();}, 300);});return false;">
|
||||
<br><input id="k" type="text" autofocus placeholder="Text from image"/>
|
||||
<br><button type="submit">Submit</button>
|
||||
</form></body></html>`, captchaImg)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/solve", func(w http.ResponseWriter, r *http.Request) {
|
||||
notifyKey(keyCh, r.URL.Query().Get("key"))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = fmt.Fprint(w, `<!DOCTYPE html><html><body><h2>Done!</h2></body></html>`)
|
||||
})
|
||||
|
||||
return runCaptchaServerAndWait(ctx, mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error", openBrowser)
|
||||
}
|
||||
|
||||
type loggingTransport struct {
|
||||
rt http.RoundTripper
|
||||
// Открытие виджета: смещения от него дают человеческую раскладку пауз.
|
||||
started time.Time
|
||||
}
|
||||
|
||||
func (t *loggingTransport) elapsed() string {
|
||||
return "+" + time.Since(t.started).Truncate(time.Millisecond).String()
|
||||
}
|
||||
|
||||
// Конверт PoW живого браузера из тела check - эталон для телеметрии авторешателя.
|
||||
func browserPowEnvelope(body []byte) string {
|
||||
form, err := neturl.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
hash := form.Get("hash")
|
||||
if hash == "" {
|
||||
return ""
|
||||
}
|
||||
return captcha.DecodePowEnvelope(hash)
|
||||
}
|
||||
|
||||
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
@@ -635,66 +436,59 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
if isCaptchaRequest {
|
||||
b, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
Log.Warnf("[Captcha Proxy] failed to read request body: %v", err)
|
||||
Log().Warnf("[Captcha Proxy] failed to read request body: %v", err)
|
||||
b = nil
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(b))
|
||||
|
||||
if Debug {
|
||||
Log.Debugf("[Captcha Proxy] real browser sent %s data: %s", req.URL.Path, string(b))
|
||||
if debugFlag.Load() {
|
||||
Log().Debugf("[Captcha Proxy] real browser sent %s data: %s", req.URL.Path, string(b))
|
||||
if env := browserPowEnvelope(b); env != "" {
|
||||
Log().Debugf("[Captcha Proxy] real browser pow: %s", env)
|
||||
}
|
||||
for k, v := range req.Header {
|
||||
Log.Debugf("[Captcha Proxy] header (%s): %s = %s", req.URL.Path, k, strings.Join(v, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(req.URL.Path, "captchaNotRobot.componentDone") || strings.Contains(req.URL.Path, "captchaNotRobot.check") {
|
||||
parsedBody, err := neturl.ParseQuery(string(b))
|
||||
if err != nil {
|
||||
Log.Warnf("[Captcha Proxy] failed to parse request body: %v", err)
|
||||
}
|
||||
device := parsedBody.Get("device")
|
||||
browserFp := parsedBody.Get("browser_fp")
|
||||
|
||||
// сохраняем только если есть device. componentDone обычно его содержит.
|
||||
if device != "" && browserFp != "" {
|
||||
sp := browserprofile.Saved{
|
||||
Profile: browserprofile.Profile{
|
||||
UserAgent: req.Header.Get("User-Agent"),
|
||||
SecChUa: req.Header.Get("Sec-Ch-Ua"),
|
||||
SecChUaMobile: req.Header.Get("Sec-Ch-Ua-Mobile"),
|
||||
SecChUaPlatform: req.Header.Get("Sec-Ch-Ua-Platform"),
|
||||
AcceptLanguage: req.Header.Get("Accept-Language"),
|
||||
},
|
||||
DeviceJSON: device,
|
||||
BrowserFp: browserFp,
|
||||
}
|
||||
if err := browserprofile.Save(sp); err != nil {
|
||||
Log.Warnf("[Captcha Proxy] failed to save browser profile: %v", err)
|
||||
} else {
|
||||
Log.Infof("[Captcha Proxy] saved real browser profile")
|
||||
}
|
||||
Log().Debugf("[Captcha Proxy] header (%s): %s = %s", req.URL.Path, k, strings.Join(v, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
return t.rt.RoundTrip(req)
|
||||
|
||||
start := time.Now()
|
||||
resp, err := t.rt.RoundTrip(req)
|
||||
// Весь трафик виджета, а не только check/componentDone: иначе не видно, зовёт
|
||||
// ли живой браузер то, чего не зовём мы, и с какими паузами.
|
||||
if err != nil {
|
||||
Log().Debugf("[Captcha Proxy] http %s %s failed t=%s after=%s %s err=%v",
|
||||
req.Method, captcha.SafeURL(req.URL.String()), t.elapsed(), time.Since(start).Truncate(time.Millisecond), navSummary(req), err)
|
||||
return nil, err
|
||||
}
|
||||
Log().Debugf("[Captcha Proxy] http %s %s status=%d t=%s after=%s %s",
|
||||
req.Method, captcha.SafeURL(req.URL.String()), resp.StatusCode, t.elapsed(), time.Since(start).Truncate(time.Millisecond), navSummary(req))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func navSummary(req *http.Request) string {
|
||||
return captcha.NavSummary(
|
||||
req.Header.Get("Sec-Fetch-Dest"),
|
||||
req.Header.Get("Sec-Fetch-Site"),
|
||||
req.Header.Get("Referer"))
|
||||
}
|
||||
|
||||
// SolveViaProxy проксирует VK redirect_uri через локальный HTTP-сервер,
|
||||
// переписывая абсолютные URL так, чтобы браузер всё время оставался на
|
||||
// 127.0.0.1:8765; возвращает результирующий auth-токен.
|
||||
func SolveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer) (string, error) {
|
||||
return solveViaProxy(ctx, redirectURI, dialer, openBrowser)
|
||||
func SolveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, profile browserprofile.Profile) (string, error) {
|
||||
return solveViaProxy(ctx, redirectURI, dialer, profile, openBrowser)
|
||||
}
|
||||
|
||||
// SolveViaProxyWithPresenter передаёт URL вызывающему после запуска сервера.
|
||||
func SolveViaProxyWithPresenter(ctx context.Context, redirectURI string, dialer net.Dialer, present func(string)) (string, error) {
|
||||
func SolveViaProxyWithPresenter(ctx context.Context, redirectURI string, dialer net.Dialer, profile browserprofile.Profile, present func(string)) (string, error) {
|
||||
if present == nil {
|
||||
present = func(string) {}
|
||||
}
|
||||
return solveViaProxy(ctx, redirectURI, dialer, present)
|
||||
return solveViaProxy(ctx, redirectURI, dialer, profile, present)
|
||||
}
|
||||
|
||||
func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, present func(string)) (string, error) {
|
||||
func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, profile browserprofile.Profile, present func(string)) (string, error) {
|
||||
keyCh := make(chan string, 1)
|
||||
|
||||
targetURL, err := neturl.Parse(redirectURI)
|
||||
@@ -702,7 +496,13 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
|
||||
return "", fmt.Errorf("invalid redirect URI: %v", err)
|
||||
}
|
||||
|
||||
transport := &loggingTransport{rt: newCaptchaProxyTransport(dialer)}
|
||||
// Апстрим уходит с TLS-отпечатком персоны: браузерный UA поверх Go-JA3 сам по
|
||||
// себе выдавал бы автоматизацию.
|
||||
client, err := personanet.NewClient(profile, dialer, nil, personanet.NoFollowRedirects())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("captcha proxy client: %w", err)
|
||||
}
|
||||
transport := &loggingTransport{rt: personanet.ProxyRoundTripper(client), started: time.Now()}
|
||||
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Transport: transport,
|
||||
@@ -710,11 +510,8 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
|
||||
rewriteProxyRequest(req.Out, targetURL)
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
Log.Errorf("[Captcha Proxy] %s %s: %v", r.Method, r.URL.String(), err)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = fmt.Fprintf(w, `<!DOCTYPE html><html><body style="font-family:sans-serif;padding:20px"><h2>Captcha proxy error</h2><p>%s %s</p><p>%s</p></body></html>`,
|
||||
html.EscapeString(r.Method), html.EscapeString(r.URL.String()), html.EscapeString(err.Error()))
|
||||
Log().Errorf("[Captcha Proxy] %s %s: %v", r.Method, r.URL.String(), err)
|
||||
http.Error(w, "ошибка прокси капчи, детали в консоли клиента", http.StatusBadGateway)
|
||||
},
|
||||
ModifyResponse: func(res *http.Response) error {
|
||||
rewriteProxyCookies(res.Header)
|
||||
@@ -732,8 +529,8 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
|
||||
|
||||
contentType := res.Header.Get("Content-Type")
|
||||
contentEncoding := res.Header.Get("Content-Encoding")
|
||||
if Debug {
|
||||
Log.Debugf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding)
|
||||
if debugFlag.Load() {
|
||||
Log().Debugf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding)
|
||||
}
|
||||
|
||||
shouldInspectBody := strings.Contains(contentType, "text/html") ||
|
||||
@@ -751,7 +548,7 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
|
||||
reader = gzReader
|
||||
defer func() {
|
||||
if err := gzReader.Close(); err != nil {
|
||||
Log.Warnf("[Captcha Proxy] close gzip reader: %v", err)
|
||||
Log().Warnf("[Captcha Proxy] close gzip reader: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -786,9 +583,11 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
|
||||
}
|
||||
|
||||
bodyBytes = []byte(rewriteCaptchaHTML(string(bodyBytes), targetURL))
|
||||
res.Header.Del("Content-Encoding")
|
||||
}
|
||||
|
||||
// Тело отдаём разжатым независимо от типа: для JSON-ответа check
|
||||
// оставшийся Content-Encoding ломал декодирование в браузере.
|
||||
res.Header.Del("Content-Encoding")
|
||||
res.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
res.ContentLength = int64(len(bodyBytes))
|
||||
res.Header.Set("Content-Length", fmt.Sprint(len(bodyBytes)))
|
||||
@@ -801,10 +600,10 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
|
||||
mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.FormValue("token")
|
||||
if token != "" {
|
||||
Log.Infof("[Captcha] received success token from browser (%d bytes)", len(token))
|
||||
Log().Infof("[Captcha] received success token from browser (%d bytes)", len(token))
|
||||
notifyKey(keyCh, token)
|
||||
} else {
|
||||
Log.Warnf("[Captcha] received empty token from browser")
|
||||
Log().Warnf("[Captcha] received empty token from browser")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
@@ -867,7 +666,7 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
|
||||
})
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
Log.Debugf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.Path)
|
||||
Log().Debugf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.Path)
|
||||
if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" {
|
||||
// не логируем полный redirect URL - шум в консоли
|
||||
http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect)
|
||||
|
||||
@@ -95,3 +95,33 @@ func TestRewriteProxyRedirectLocation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedProxyHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
hostname string
|
||||
want bool
|
||||
}{
|
||||
{"id.vk.ru", true},
|
||||
{"api.vk.com", true},
|
||||
{"vk.com", true},
|
||||
{"sun9-1.userapi.com", true},
|
||||
{"ads.vk.com", false},
|
||||
{"ads.vk.ru", false},
|
||||
{"top-fwz1.mail.ru", false},
|
||||
{"sdk-api.apptracer.ru", false},
|
||||
{"stats.vk-portal.net", false},
|
||||
{"evil.com", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.hostname, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := isAllowedProxyHost(tc.hostname); got != tc.want {
|
||||
t.Fatalf("isAllowedProxyHost(%q) = %v, want %v", tc.hostname, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
)
|
||||
|
||||
const powResultGlobal = "captchaPowResult"
|
||||
|
||||
var (
|
||||
rePowArgs = regexp.MustCompile(`\}\(\s*["']([A-Za-z0-9_-]{8,})["']\s*,\s*(\d+)\s*,\s*["'][^"']*["']\s*(?:,|\)\s*\))`)
|
||||
rePowPrefix = regexp.MustCompile(powResultGlobal + `["'\]]{0,3}\s*=\s*["']([A-Za-z0-9._-]{0,8})["']\s*\+`)
|
||||
)
|
||||
|
||||
type powParams struct {
|
||||
Input string
|
||||
Difficulty int
|
||||
Prefix string
|
||||
}
|
||||
|
||||
// powResult - конверт window.captchaPowResult: виджету уходит не голый хэш.
|
||||
// Порядок полей повторяет порядок ключей у JSON.stringify страницы.
|
||||
type powResult struct {
|
||||
Hash string `json:"hash"`
|
||||
Nonce int `json:"nonce"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Telemetry json.RawMessage `json:"telemetry"`
|
||||
TelHash string `json:"tel_hash"`
|
||||
}
|
||||
|
||||
func parsePowParams(html string) (powParams, error) {
|
||||
m := rePowArgs.FindStringSubmatch(html)
|
||||
if len(m) < 3 {
|
||||
return powParams{}, errors.New("captcha pow args not found")
|
||||
}
|
||||
difficulty, err := strconv.Atoi(m[2])
|
||||
if err != nil || difficulty <= 0 {
|
||||
return powParams{}, fmt.Errorf("invalid captcha difficulty %q", m[2])
|
||||
}
|
||||
prefix := rePowPrefix.FindStringSubmatch(html)
|
||||
if len(prefix) < 2 {
|
||||
return powParams{}, errors.New("captcha pow envelope not recognized")
|
||||
}
|
||||
return powParams{Input: m[1], Difficulty: difficulty, Prefix: prefix[1]}, nil
|
||||
}
|
||||
|
||||
func (s *captchaSession) powEnvelope(p powParams) (string, error) {
|
||||
hash, nonce := solvePoW(s.ctx, p.Input, p.Difficulty)
|
||||
if hash == "" {
|
||||
return "", errors.New("captcha pow failed")
|
||||
}
|
||||
telemetry, err := marshalJS(s.powTelemetry())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("captcha pow telemetry: %w", err)
|
||||
}
|
||||
telHash, err := telemetryHash(telemetry)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("captcha pow tel_hash: %w", err)
|
||||
}
|
||||
envelope, err := marshalJS(powResult{
|
||||
Hash: hash,
|
||||
Nonce: nonce,
|
||||
DurationMs: powDurationMs(nonce),
|
||||
Telemetry: telemetry,
|
||||
TelHash: telHash,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("captcha pow encode: %w", err)
|
||||
}
|
||||
// Печатаем до base64: диффать с живым браузером (см. DecodePowEnvelope) иначе нечем.
|
||||
s.logger().Debugf("[Captcha] pow envelope: %s", envelope)
|
||||
return p.Prefix + base64.StdEncoding.EncodeToString(envelope), nil
|
||||
}
|
||||
|
||||
// DecodePowEnvelope разворачивает значение window.captchaPowResult обратно в JSON.
|
||||
func DecodePowEnvelope(value string) string {
|
||||
payload := value
|
||||
if _, rest, ok := strings.Cut(value, "."); ok {
|
||||
payload = rest
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func solvePoW(ctx context.Context, input string, difficulty int) (string, int) {
|
||||
if input == "" || difficulty <= 0 {
|
||||
return "", 0
|
||||
}
|
||||
target := strings.Repeat("0", difficulty)
|
||||
buf := make([]byte, 0, len(input)+20)
|
||||
buf = append(buf, input...)
|
||||
for nonce := 0; nonce <= 10_000_000; nonce++ {
|
||||
if nonce&1023 == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", 0
|
||||
default:
|
||||
}
|
||||
}
|
||||
buf = strconv.AppendInt(buf[:len(input)], int64(nonce), 10)
|
||||
sum := sha256.Sum256(buf)
|
||||
hashHex := hex.EncodeToString(sum[:])
|
||||
if strings.HasPrefix(hashHex, target) {
|
||||
return hashHex, nonce
|
||||
}
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// Цикл в браузере синхронный: nonce 53 -> 1 мс, 267 -> 4 мс.
|
||||
func powDurationMs(nonce int) int64 {
|
||||
return max(1, int64(math.Round(float64(nonce+1)*0.015)))
|
||||
}
|
||||
|
||||
// telemetryHash повторяет канонизатор страницы: ключи по алфавиту (map в Go
|
||||
// сортирует их сам), undefined на нашей стороне не бывает.
|
||||
func telemetryHash(telemetry []byte) (string, error) {
|
||||
var v any
|
||||
if err := json.Unmarshal(telemetry, &v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
canonical, err := marshalJS(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(canonical)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// marshalJS - JSON без HTML-экранирования: JSON.stringify не трогает < > &.
|
||||
func marshalJS(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.TrimRight(buf.Bytes(), "\n"), nil
|
||||
}
|
||||
|
||||
// powTelemetryData - пробы PoW-скрипта; порядок полей = порядок проб, так их
|
||||
// пишет JSON.stringify.
|
||||
type powTelemetryData struct {
|
||||
Globals powProbe `json:"globals"`
|
||||
UA powProbe `json:"ua"`
|
||||
Frame powProbe `json:"frame"`
|
||||
MatchMedia powProbe `json:"match_media"`
|
||||
Plugins powProbe `json:"plugins"`
|
||||
NavTamper powProbe `json:"nav_tamper"`
|
||||
Referrer powProbe `json:"referrer"`
|
||||
DevTools powProbe `json:"devtools"`
|
||||
CSS powProbe `json:"css"`
|
||||
NativeIntegrity powProbe `json:"native_integrity"`
|
||||
CookieTest powProbe `json:"cookie_test"`
|
||||
AncestorOrigins powProbe `json:"ancestor_origins"`
|
||||
SandboxBehavior powProbe `json:"sandbox_behavior"`
|
||||
MaxTouchPoints powProbe `json:"max_touch_points"`
|
||||
TimezoneLocale powProbe `json:"timezone_locale"`
|
||||
DevicePixelRatio powProbe `json:"device_pixel_ratio"`
|
||||
}
|
||||
|
||||
type powProbe struct {
|
||||
OK bool `json:"ok"`
|
||||
Result any `json:"result"`
|
||||
}
|
||||
|
||||
func probe(result any) powProbe { return powProbe{OK: true, Result: result} }
|
||||
|
||||
type powGlobals struct {
|
||||
Doc bool `json:"doc"`
|
||||
Win bool `json:"win"`
|
||||
Nav bool `json:"nav"`
|
||||
Webdriver bool `json:"webdriver"`
|
||||
Subtle bool `json:"subtle"`
|
||||
Secure bool `json:"secure"`
|
||||
GCS bool `json:"gcs"`
|
||||
RAF bool `json:"raf"`
|
||||
Wasm bool `json:"wasm"`
|
||||
PluginsLen int `json:"plugins_len"`
|
||||
LanguagesLen int `json:"languages_len"`
|
||||
HW int `json:"hw"`
|
||||
Mem *int `json:"mem"`
|
||||
}
|
||||
|
||||
type powUA struct {
|
||||
UserAgent string `json:"userAgent"`
|
||||
UserAgentData *powUAData `json:"userAgentData"`
|
||||
}
|
||||
|
||||
type powUAData struct {
|
||||
Brands []browserprofile.Brand `json:"brands"`
|
||||
Platform string `json:"platform"`
|
||||
Mobile bool `json:"mobile"`
|
||||
Architecture *string `json:"architecture"`
|
||||
}
|
||||
|
||||
type powFrame struct {
|
||||
FrameElement *string `json:"frameElement"`
|
||||
AncestorOriginsLen int `json:"ancestorOriginsLen"`
|
||||
ParentAccessible bool `json:"parentAccessible"`
|
||||
}
|
||||
|
||||
type powMatchMedia struct {
|
||||
PrefersDark bool `json:"prefersDark"`
|
||||
PrefersLight bool `json:"prefersLight"`
|
||||
ReducedMotion bool `json:"reducedMotion"`
|
||||
PointerFine bool `json:"pointerFine"`
|
||||
}
|
||||
|
||||
type powPlugins struct {
|
||||
Length int `json:"length"`
|
||||
Names []string `json:"names"`
|
||||
Descriptions []string `json:"descriptions"`
|
||||
MimeTypes [][]string `json:"mimeTypes"`
|
||||
IsChrome bool `json:"isChrome"`
|
||||
}
|
||||
|
||||
type powNavTamper struct {
|
||||
Tampered bool `json:"tampered"`
|
||||
ElCtor string `json:"el_ctor"`
|
||||
StyleCtor string `json:"style_ctor"`
|
||||
NavCtor string `json:"nav_ctor"`
|
||||
AlertNative bool `json:"alert_native"`
|
||||
ToStringNative bool `json:"to_string_native"`
|
||||
}
|
||||
|
||||
type powReferrer struct {
|
||||
Referrer string `json:"referrer"`
|
||||
InIframe bool `json:"inIframe"`
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
|
||||
type powDevTools struct {
|
||||
Open bool `json:"open"`
|
||||
DelayMs int `json:"delay_ms"`
|
||||
}
|
||||
|
||||
type powCSS struct {
|
||||
ExpectedMissing int `json:"expectedMissing"`
|
||||
}
|
||||
|
||||
type powNativeIntegrity struct {
|
||||
ProtoMatch bool `json:"protoMatch"`
|
||||
XHRNative bool `json:"xhrNative"`
|
||||
XHRSendNative bool `json:"xhrSendNative"`
|
||||
AddEventListenerNative bool `json:"addEventListenerNative"`
|
||||
AlertNative bool `json:"alertNative"`
|
||||
ToStringNative bool `json:"toStringNative"`
|
||||
}
|
||||
|
||||
type powCookieTest struct {
|
||||
Write bool `json:"write"`
|
||||
}
|
||||
|
||||
type powAncestorOrigins struct {
|
||||
AncestorOrigin *string `json:"ancestorOrigin"`
|
||||
}
|
||||
|
||||
type powSandboxBehavior struct {
|
||||
OriginIsNull bool `json:"originIsNull"`
|
||||
LocalStorage bool `json:"localStorage"`
|
||||
SessionStorage bool `json:"sessionStorage"`
|
||||
}
|
||||
|
||||
type powMaxTouchPoints struct {
|
||||
MaxTouchPoints int `json:"maxTouchPoints"`
|
||||
}
|
||||
|
||||
type powTimezoneLocale struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Languages []string `json:"languages"`
|
||||
}
|
||||
|
||||
type powDevicePixelRatio struct {
|
||||
DPR float64 `json:"dpr"`
|
||||
Orientation string `json:"orientation"`
|
||||
OrientationAngle int `json:"orientationAngle"`
|
||||
}
|
||||
|
||||
var chromePDFPlugins = []string{
|
||||
"PDF Viewer",
|
||||
"Chrome PDF Viewer",
|
||||
"Chromium PDF Viewer",
|
||||
"Microsoft Edge PDF Viewer",
|
||||
"WebKit built-in PDF",
|
||||
}
|
||||
|
||||
const chromePDFDescription = "Portable Document Format"
|
||||
|
||||
var chromePDFMimeTypes = []string{"application/pdf", "text/pdf"}
|
||||
|
||||
func chromePlugins() powPlugins {
|
||||
out := powPlugins{
|
||||
Length: len(chromePDFPlugins),
|
||||
Names: chromePDFPlugins,
|
||||
Descriptions: make([]string, len(chromePDFPlugins)),
|
||||
MimeTypes: make([][]string, len(chromePDFPlugins)),
|
||||
IsChrome: true,
|
||||
}
|
||||
for i := range chromePDFPlugins {
|
||||
out.Descriptions[i] = chromePDFDescription
|
||||
out.MimeTypes[i] = chromePDFMimeTypes
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// powTelemetry описывает страницу такой, какой её видел бы браузер персоны:
|
||||
// страницу мы тянем верхнеуровневой навигацией (Sec-Fetch-Dest: document),
|
||||
// поэтому frame и referrer описывают top-level документ - врозь их менять нельзя.
|
||||
func (s *captchaSession) powTelemetry() powTelemetryData {
|
||||
p := s.profile
|
||||
// На Android нет встроенного PDF-вьювера, значит и списка плагинов.
|
||||
plugins := powPlugins{Names: []string{}, Descriptions: []string{}, MimeTypes: [][]string{}}
|
||||
if !p.IsMobile() {
|
||||
plugins = chromePlugins()
|
||||
}
|
||||
dark := prefersDark(p)
|
||||
return powTelemetryData{
|
||||
Globals: probe(powGlobals{
|
||||
Doc: true, Win: true, Nav: true,
|
||||
Webdriver: p.Webdriver(),
|
||||
Subtle: true,
|
||||
Secure: true,
|
||||
GCS: true,
|
||||
RAF: true,
|
||||
Wasm: true,
|
||||
PluginsLen: plugins.Length,
|
||||
LanguagesLen: len(p.Languages()),
|
||||
HW: p.HardwareConcurrency(),
|
||||
Mem: p.DeviceMemory(),
|
||||
}),
|
||||
UA: probe(powUA{
|
||||
UserAgent: p.UserAgent,
|
||||
UserAgentData: &powUAData{
|
||||
Brands: p.Brands(),
|
||||
Platform: p.PlatformName(),
|
||||
Mobile: p.IsMobile(),
|
||||
},
|
||||
}),
|
||||
Frame: probe(powFrame{ParentAccessible: true}),
|
||||
MatchMedia: probe(powMatchMedia{PrefersDark: dark, PrefersLight: !dark, PointerFine: !p.IsMobile()}),
|
||||
Plugins: probe(plugins),
|
||||
NavTamper: probe(powNavTamper{
|
||||
ElCtor: "HTMLDivElement", StyleCtor: "CSSStyleDeclaration", NavCtor: "Navigator",
|
||||
AlertNative: true, ToStringNative: true,
|
||||
}),
|
||||
Referrer: probe(powReferrer{
|
||||
Referrer: "https://" + s.domain + "/",
|
||||
Domain: strings.TrimPrefix(s.pageOrigin, "https://"),
|
||||
}),
|
||||
DevTools: probe(powDevTools{}),
|
||||
CSS: probe(powCSS{}),
|
||||
NativeIntegrity: probe(powNativeIntegrity{
|
||||
ProtoMatch: true, XHRNative: true, XHRSendNative: true,
|
||||
AddEventListenerNative: true, AlertNative: true, ToStringNative: true,
|
||||
}),
|
||||
CookieTest: probe(powCookieTest{Write: true}),
|
||||
AncestorOrigins: probe(powAncestorOrigins{}),
|
||||
SandboxBehavior: probe(powSandboxBehavior{LocalStorage: true, SessionStorage: true}),
|
||||
MaxTouchPoints: probe(powMaxTouchPoints{MaxTouchPoints: p.MaxTouchPoints()}),
|
||||
TimezoneLocale: probe(powTimezoneLocale{Timezone: p.Timezone(), Languages: p.Languages()}),
|
||||
DevicePixelRatio: probe(powDevicePixelRatio{
|
||||
DPR: p.DevicePixelRatio(),
|
||||
Orientation: p.Orientation(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// prefers-color-scheme у живого посетителя не скачет между сессиями - берём бит персоны.
|
||||
func prefersDark(p browserprofile.Profile) bool {
|
||||
return len(p.VisitorID) > 0 && p.VisitorID[0]%2 == 1
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
)
|
||||
|
||||
// Живая страница captcha, снятая целиком: PoW-скрипт обфусцирован, поэтому
|
||||
// контракт проверяется на настоящей разметке, а не на её пересказе. Обновлять
|
||||
// фикстуру - task captcha:page.
|
||||
func TestParseCaptchaPageFixture(t *testing.T) {
|
||||
html, err := os.ReadFile("testdata/not_robot_captcha.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
page, err := parseCaptchaPage(string(html))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Pow.Input != "ST9BEClStK0wSdo4" || page.Pow.Difficulty != 2 || page.Pow.Prefix != "v2." {
|
||||
t.Fatalf("pow = %+v", page.Pow)
|
||||
}
|
||||
if page.DebugInfo != "273cc83f-426f-4d98-9ce5-92490107e3a6" {
|
||||
t.Fatalf("debug_info = %q", page.DebugInfo)
|
||||
}
|
||||
if page.Init.Found {
|
||||
t.Fatalf("init = %+v", page.Init)
|
||||
}
|
||||
}
|
||||
|
||||
// Незнакомая разметка обязана ронять авторешение, а не уезжать в check заведомо
|
||||
// неверным hash.
|
||||
func TestParsePowParamsRejectsUnknownMarkup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
html string
|
||||
}{
|
||||
{name: "no pow script", html: `<html><body><div id="spa_root"></div></body></html>`},
|
||||
{
|
||||
name: "args without envelope",
|
||||
html: `<script>(function(a,b,c){})("fnZQN7lKKXvo37tH",2,"pow_timeout"));</script>`,
|
||||
},
|
||||
{
|
||||
name: "envelope without args",
|
||||
html: `<script>window['captchaPowResult']='v2.'+btoa(x);}(powInput,difficulty,"pow_timeout"));</script>`,
|
||||
},
|
||||
{
|
||||
name: "zero difficulty",
|
||||
html: `<script>window['captchaPowResult']='v2.'+btoa(x);}("fnZQN7lKKXvo37tH",0,"pow_timeout"));</script>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := parsePowParams(tt.html); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePowParamsAcceptsAdditionalArgs(t *testing.T) {
|
||||
html := `<script>window['captchaPowResult']='v2.'+btoa(x);}('fnZQN7lKKXvo37tH',2,'pow_timeout',["globals"]));</script>`
|
||||
|
||||
got, err := parsePowParams(html)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Input != "fnZQN7lKKXvo37tH" || got.Difficulty != 2 || got.Prefix != "v2." {
|
||||
t.Fatalf("pow = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolvePoW(t *testing.T) {
|
||||
got, nonce := solvePoW(context.Background(), "input", 1)
|
||||
if len(got) != 64 || !strings.HasPrefix(got, "0") {
|
||||
t.Fatalf("pow = %q, want 64-hex with leading zero", got)
|
||||
}
|
||||
sum := sha256.Sum256([]byte("input" + strconv.Itoa(nonce)))
|
||||
if hex.EncodeToString(sum[:]) != got {
|
||||
t.Fatalf("pow hash does not match sha256(input+nonce)")
|
||||
}
|
||||
}
|
||||
|
||||
// Эталон снят с персоны seed=fixture; tel_hash сверен с канонизатором самой
|
||||
// страницы. Любая правка значений телеметрии обязана осознанно двигать эталон.
|
||||
const (
|
||||
goldenTelemetry = `{"globals":{"ok":true,"result":{"doc":true,"win":true,"nav":true,"webdriver":false,"subtle":true,"secure":true,"gcs":true,"raf":true,"wasm":true,"plugins_len":5,"languages_len":2,"hw":12,"mem":8}},` +
|
||||
`"ua":{"ok":true,"result":{"userAgent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36","userAgentData":{"brands":[{"brand":"Google Chrome","version":"146"},{"brand":"Chromium","version":"146"},{"brand":"Not)A;Brand","version":"24"}],"platform":"Windows","mobile":false,"architecture":null}}},` +
|
||||
`"frame":{"ok":true,"result":{"frameElement":null,"ancestorOriginsLen":0,"parentAccessible":true}},` +
|
||||
`"match_media":{"ok":true,"result":{"prefersDark":true,"prefersLight":false,"reducedMotion":false,"pointerFine":true}},` +
|
||||
`"plugins":{"ok":true,"result":{"length":5,"names":["PDF Viewer","Chrome PDF Viewer","Chromium PDF Viewer","Microsoft Edge PDF Viewer","WebKit built-in PDF"],"descriptions":["Portable Document Format","Portable Document Format","Portable Document Format","Portable Document Format","Portable Document Format"],"mimeTypes":[["application/pdf","text/pdf"],["application/pdf","text/pdf"],["application/pdf","text/pdf"],["application/pdf","text/pdf"],["application/pdf","text/pdf"]],"isChrome":true}},` +
|
||||
`"nav_tamper":{"ok":true,"result":{"tampered":false,"el_ctor":"HTMLDivElement","style_ctor":"CSSStyleDeclaration","nav_ctor":"Navigator","alert_native":true,"to_string_native":true}},` +
|
||||
`"referrer":{"ok":true,"result":{"referrer":"https://vk.com/","inIframe":false,"domain":"id.vk.ru"}},` +
|
||||
`"devtools":{"ok":true,"result":{"open":false,"delay_ms":0}},` +
|
||||
`"css":{"ok":true,"result":{"expectedMissing":0}},` +
|
||||
`"native_integrity":{"ok":true,"result":{"protoMatch":true,"xhrNative":true,"xhrSendNative":true,"addEventListenerNative":true,"alertNative":true,"toStringNative":true}},` +
|
||||
`"cookie_test":{"ok":true,"result":{"write":true}},` +
|
||||
`"ancestor_origins":{"ok":true,"result":{"ancestorOrigin":null}},` +
|
||||
`"sandbox_behavior":{"ok":true,"result":{"originIsNull":false,"localStorage":true,"sessionStorage":true}},` +
|
||||
`"max_touch_points":{"ok":true,"result":{"maxTouchPoints":0}},` +
|
||||
`"timezone_locale":{"ok":true,"result":{"timezone":"Europe/Moscow","languages":["ru-RU","en-US"]}},` +
|
||||
`"device_pixel_ratio":{"ok":true,"result":{"dpr":1,"orientation":"landscape-primary","orientationAngle":0}}}`
|
||||
|
||||
goldenTelHash = "838102524be656361f2f88dba3dd16d3baaa20b5b33251b472b365ff646b99e9"
|
||||
)
|
||||
|
||||
// Конверт целиком: порядок ключей, телеметрия персоны и её sha256 - всё, что VK
|
||||
// проверяет на check.
|
||||
func TestPowEnvelope(t *testing.T) {
|
||||
s := &captchaSession{
|
||||
ctx: context.Background(),
|
||||
profile: browserprofile.For(browserprofile.Desktop, browserprofile.Identity{Seed: "fixture"}),
|
||||
domain: "vk.com",
|
||||
pageOrigin: "https://id.vk.ru",
|
||||
}
|
||||
|
||||
got, err := s.powEnvelope(powParams{Input: "input", Difficulty: 1, Prefix: "v2."})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, ok := strings.CutPrefix(got, "v2.")
|
||||
if !ok {
|
||||
t.Fatalf("envelope = %q, want v2. prefix", got)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hash, nonce := solvePoW(context.Background(), "input", 1)
|
||||
want := `{"hash":"` + hash + `","nonce":` + strconv.Itoa(nonce) +
|
||||
`,"duration_ms":` + strconv.FormatInt(powDurationMs(nonce), 10) +
|
||||
`,"telemetry":` + goldenTelemetry + `,"tel_hash":"` + goldenTelHash + `"}`
|
||||
if string(raw) != want {
|
||||
t.Fatalf("envelope =\n%s\nwant\n%s", raw, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelemetryHashCanonicalizesKeys(t *testing.T) {
|
||||
// Тот же объект с переставленными ключами обязан дать тот же tel_hash.
|
||||
shuffled := `{"b":{"y":1,"x":[2,{"n":null,"m":"s"}]},"a":true}`
|
||||
ordered := `{"a":true,"b":{"x":[2,{"m":"s","n":null}],"y":1}}`
|
||||
|
||||
first, err := telemetryHash([]byte(shuffled))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := telemetryHash([]byte(ordered))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("tel_hash = %s vs %s", first, second)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(ordered))
|
||||
if first != hex.EncodeToString(sum[:]) {
|
||||
t.Fatalf("tel_hash = %s, want sha256 of canonical form", first)
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,11 @@ import (
|
||||
"image/color"
|
||||
_ "image/jpeg" // регистрация JPEG-декодера для image.Decode
|
||||
"math"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/randx"
|
||||
)
|
||||
|
||||
type sliderPuzzle struct {
|
||||
@@ -32,17 +30,13 @@ type sliderGuess struct {
|
||||
Score int64
|
||||
ScoreRGB int64
|
||||
ScoreLuma int64
|
||||
ScoreReverse int64
|
||||
ScoreText float64
|
||||
ConsensusRank int
|
||||
}
|
||||
|
||||
func (s *captchaSession) solveSliderCaptcha(
|
||||
sessionToken string,
|
||||
browserFP string,
|
||||
hash string,
|
||||
content captchaContentRef,
|
||||
debugInfo string,
|
||||
) (string, error) {
|
||||
if content.Value == "" {
|
||||
return "", errors.New("slider content settings missing")
|
||||
@@ -71,49 +65,32 @@ func (s *captchaSession) solveSliderCaptcha(
|
||||
return "", err
|
||||
}
|
||||
|
||||
limit := puzzle.Attempts
|
||||
if limit > len(guesses) {
|
||||
limit = len(guesses)
|
||||
}
|
||||
limit := min(puzzle.Attempts, len(guesses))
|
||||
if limit <= 0 {
|
||||
return "", errors.New("slider has no attempts available")
|
||||
}
|
||||
attempts := pickSliderAttempts(guesses, limit)
|
||||
s.logger().Debugf("[Captcha] slider guesses ranked: total=%d limit=%d", len(guesses), limit)
|
||||
|
||||
deviceJSON := captchaDeviceInfo
|
||||
deviceSource := "default"
|
||||
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
|
||||
deviceJSON = s.savedProfile.DeviceJSON
|
||||
deviceSource = "saved"
|
||||
}
|
||||
s.logger().Debugf("[Captcha] slider componentDone device_source=%s device_bytes=%d", deviceSource, len(deviceJSON))
|
||||
if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
|
||||
{"session_token", sessionToken},
|
||||
{"domain", s.domain},
|
||||
{"adFp", ""},
|
||||
{"access_token", ""},
|
||||
{"browser_fp", browserFP},
|
||||
{"device", deviceJSON},
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("captcha componentDone failed: %w", err)
|
||||
if err := s.sendComponentDone(sessionToken); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
s.logger().Debugf("[Captcha] slider attempt %d/%d (guess #%d)", i+1, limit, guesses[i].Index)
|
||||
for i, guess := range attempts {
|
||||
s.logger().Debugf("[Captcha] slider attempt %d/%d (guess #%d)", i+1, len(attempts), guess.Index)
|
||||
answerData, err := json.Marshal(struct {
|
||||
Value []int `json:"value"`
|
||||
}{Value: guesses[i].Swaps})
|
||||
}{Value: guess.Swaps})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
check, err := s.performCaptchaCheck(
|
||||
sessionToken,
|
||||
browserFP,
|
||||
hash,
|
||||
string(answerData),
|
||||
buildSliderCursor(guesses[i].Index, len(guesses)),
|
||||
debugInfo,
|
||||
)
|
||||
// Протяжка ручки: координаты никуда не уходят, но время сессии задаёт
|
||||
// длину массивов телеметрии.
|
||||
if dragErr := s.dwell(650, 1750); dragErr != nil {
|
||||
return "", dragErr
|
||||
}
|
||||
|
||||
check, err := s.performCaptchaCheck(sessionToken, string(answerData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -128,6 +105,10 @@ func (s *captchaSession) solveSliderCaptcha(
|
||||
if strings.EqualFold(check.Status, "error_limit") {
|
||||
return "", errCaptchaRateLimit
|
||||
}
|
||||
// Виджет показал ошибку - человеку нужно время, чтобы это осознать.
|
||||
if dwellErr := s.dwell(700, 1600); dwellErr != nil {
|
||||
return "", dwellErr
|
||||
}
|
||||
}
|
||||
return "", errors.New("slider guesses exhausted")
|
||||
}
|
||||
@@ -192,9 +173,12 @@ func splitSliderSteps(steps []int) (int, []int, int, error) {
|
||||
tail := append([]int(nil), steps[1:]...)
|
||||
attempts := 4
|
||||
if len(tail)%2 != 0 {
|
||||
// Штатный формат: [size, ...пары свапов, attempts].
|
||||
attempts = tail[len(tail)-1]
|
||||
tail = tail[:len(tail)-1]
|
||||
Log.Warnf("[Captcha] slider payload had odd-length tail; fallback attempts=%d", attempts)
|
||||
Log().Debugf("[Captcha] slider attempts from payload=%d", attempts)
|
||||
} else {
|
||||
Log().Debugf("[Captcha] slider payload without attempts counter; default=%d", attempts)
|
||||
}
|
||||
if attempts <= 0 {
|
||||
attempts = 4
|
||||
@@ -220,15 +204,6 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
|
||||
}
|
||||
guesses[idx-1] = sliderGuess{Index: idx, Swaps: active}
|
||||
guesses[idx-1].ScoreLuma = seamScoreLuma(img, gridSize, mapping)
|
||||
if len(active) > 2 {
|
||||
reverseMapping, err := applySliderSwaps(gridSize, reverseSwapPairs(active))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guesses[idx-1].ScoreReverse = seamScoreLuma(img, gridSize, reverseMapping)
|
||||
} else {
|
||||
guesses[idx-1].ScoreReverse = guesses[idx-1].ScoreLuma
|
||||
}
|
||||
}
|
||||
|
||||
lumaOrder := append([]sliderGuess(nil), guesses...)
|
||||
@@ -243,69 +218,25 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
|
||||
lumaRank[g.Index] = rank
|
||||
}
|
||||
|
||||
stage2Count := candidateCount
|
||||
stage2Set := make(map[int]struct{}, stage2Count)
|
||||
for i := 0; i < stage2Count; i++ {
|
||||
stage2Set[lumaOrder[i].Index] = struct{}{}
|
||||
}
|
||||
|
||||
type stage2Result struct {
|
||||
index int
|
||||
rgb int64
|
||||
text float64
|
||||
err error
|
||||
}
|
||||
jobs := make([]int, 0, stage2Count)
|
||||
for idx := range stage2Set {
|
||||
jobs = append(jobs, idx)
|
||||
}
|
||||
jobCh := make(chan int, len(jobs))
|
||||
resCh := make(chan stage2Result, len(jobs))
|
||||
|
||||
workers := runtime.NumCPU()
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
if workers > len(jobs) {
|
||||
workers = len(jobs)
|
||||
}
|
||||
// Второй проход тяжелее первого - считаем параллельно, каждый в свою ячейку.
|
||||
errs := make([]error, candidateCount)
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
for i := range guesses {
|
||||
wg.Go(func() {
|
||||
for index := range jobCh {
|
||||
mapping, err := applySliderSwaps(gridSize, guesses[index-1].Swaps)
|
||||
if err != nil {
|
||||
resCh <- stage2Result{index: index, err: err}
|
||||
continue
|
||||
}
|
||||
rgb, text := seamScoreRGBText(img, gridSize, mapping)
|
||||
resCh <- stage2Result{index: index, rgb: rgb, text: text}
|
||||
mapping, err := applySliderSwaps(gridSize, guesses[i].Swaps)
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
guesses[i].ScoreRGB, guesses[i].ScoreText = seamScoreRGBText(img, gridSize, mapping)
|
||||
})
|
||||
}
|
||||
for _, idx := range jobs {
|
||||
jobCh <- idx
|
||||
}
|
||||
close(jobCh)
|
||||
wg.Wait()
|
||||
close(resCh)
|
||||
for r := range resCh {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
g := &guesses[r.index-1]
|
||||
g.ScoreRGB = r.rgb
|
||||
g.ScoreText = r.text
|
||||
if err := errors.Join(errs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stage2 := make([]sliderGuess, 0, stage2Count)
|
||||
for _, g := range guesses {
|
||||
if _, ok := stage2Set[g.Index]; ok {
|
||||
stage2 = append(stage2, g)
|
||||
}
|
||||
}
|
||||
|
||||
rgbOrder := append([]sliderGuess(nil), stage2...)
|
||||
rgbOrder := append([]sliderGuess(nil), guesses...)
|
||||
sort.SliceStable(rgbOrder, func(i, j int) bool {
|
||||
if rgbOrder[i].ScoreRGB == rgbOrder[j].ScoreRGB {
|
||||
return rgbOrder[i].Index < rgbOrder[j].Index
|
||||
@@ -317,7 +248,7 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
|
||||
rgbRank[g.Index] = rank
|
||||
}
|
||||
|
||||
textOrder := append([]sliderGuess(nil), stage2...)
|
||||
textOrder := append([]sliderGuess(nil), guesses...)
|
||||
sort.SliceStable(textOrder, func(i, j int) bool {
|
||||
if textOrder[i].ScoreText == textOrder[j].ScoreText {
|
||||
return textOrder[i].Index < textOrder[j].Index
|
||||
@@ -329,26 +260,9 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
|
||||
textRank[g.Index] = rank
|
||||
}
|
||||
|
||||
reverseOrder := append([]sliderGuess(nil), guesses...)
|
||||
sort.SliceStable(reverseOrder, func(i, j int) bool {
|
||||
if reverseOrder[i].ScoreReverse == reverseOrder[j].ScoreReverse {
|
||||
return reverseOrder[i].Index < reverseOrder[j].Index
|
||||
}
|
||||
return reverseOrder[i].ScoreReverse < reverseOrder[j].ScoreReverse
|
||||
})
|
||||
reverseRank := make(map[int]int, len(reverseOrder))
|
||||
for rank, g := range reverseOrder {
|
||||
reverseRank[g.Index] = rank
|
||||
}
|
||||
|
||||
for i := range guesses {
|
||||
g := &guesses[i]
|
||||
g.ConsensusRank = lumaRank[g.Index] + reverseRank[g.Index]
|
||||
if _, ok := stage2Set[g.Index]; ok {
|
||||
g.ConsensusRank += rgbRank[g.Index] + textRank[g.Index]
|
||||
} else {
|
||||
g.ConsensusRank += candidateCount
|
||||
}
|
||||
g.ConsensusRank = lumaRank[g.Index] + rgbRank[g.Index] + textRank[g.Index]
|
||||
g.Score = int64(g.ConsensusRank)
|
||||
}
|
||||
|
||||
@@ -364,10 +278,25 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
|
||||
return guesses, nil
|
||||
}
|
||||
|
||||
func reverseSwapPairs(swaps []int) []int {
|
||||
out := make([]int, 0, len(swaps))
|
||||
for i := len(swaps) - 2; i >= 0; i -= 2 {
|
||||
out = append(out, swaps[i], swaps[i+1])
|
||||
// pickSliderAttempts разносит попытки по номеру кандидата: соседние отличаются
|
||||
// одним свапом и промахиваются вместе.
|
||||
func pickSliderAttempts(guesses []sliderGuess, limit int) []sliderGuess {
|
||||
const minGap = 3
|
||||
out := make([]sliderGuess, 0, limit)
|
||||
taken := func(index, gap int) bool {
|
||||
return slices.ContainsFunc(out, func(g sliderGuess) bool {
|
||||
return absInt(g.Index-index) < gap
|
||||
})
|
||||
}
|
||||
for _, gap := range []int{minGap, 1} {
|
||||
for _, g := range guesses {
|
||||
if len(out) == limit {
|
||||
return out
|
||||
}
|
||||
if !taken(g.Index, gap) {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -376,10 +305,7 @@ func activeSwapsForIndex(swaps []int, index int) []int {
|
||||
if index <= 0 {
|
||||
return []int{}
|
||||
}
|
||||
end := index * 2
|
||||
if end > len(swaps) {
|
||||
end = len(swaps)
|
||||
}
|
||||
end := min(index*2, len(swaps))
|
||||
return append([]int(nil), swaps[:end]...)
|
||||
}
|
||||
|
||||
@@ -409,19 +335,15 @@ func applySliderSwaps(gridSize int, swaps []int) ([]int, error) {
|
||||
func seamScoreLuma(img image.Image, gridSize int, mapping []int) int64 {
|
||||
bounds := img.Bounds()
|
||||
var score int64
|
||||
for row := 0; row < gridSize; row++ {
|
||||
for col := 0; col < gridSize-1; col++ {
|
||||
for row := range gridSize {
|
||||
for col := range gridSize - 1 {
|
||||
leftIdx := row*gridSize + col
|
||||
rightIdx := leftIdx + 1
|
||||
leftDst := sliderTileRect(bounds, gridSize, leftIdx)
|
||||
rightDst := sliderTileRect(bounds, gridSize, rightIdx)
|
||||
leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
|
||||
rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
|
||||
h := leftDst.Dy()
|
||||
if rightDst.Dy() < h {
|
||||
h = rightDst.Dy()
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
for y := range min(leftDst.Dy(), rightDst.Dy()) {
|
||||
yy := leftDst.Min.Y + y
|
||||
a := sampleLumaMapped(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
|
||||
b := sampleLumaMapped(img, rightDst, rightSrc, rightDst.Min.X, yy)
|
||||
@@ -429,19 +351,15 @@ func seamScoreLuma(img image.Image, gridSize int, mapping []int) int64 {
|
||||
}
|
||||
}
|
||||
}
|
||||
for row := 0; row < gridSize-1; row++ {
|
||||
for col := 0; col < gridSize; col++ {
|
||||
for row := range gridSize - 1 {
|
||||
for col := range gridSize {
|
||||
topIdx := row*gridSize + col
|
||||
bottomIdx := (row+1)*gridSize + col
|
||||
topDst := sliderTileRect(bounds, gridSize, topIdx)
|
||||
bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
|
||||
topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
|
||||
bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
|
||||
w := topDst.Dx()
|
||||
if bottomDst.Dx() < w {
|
||||
w = bottomDst.Dx()
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
for x := range min(topDst.Dx(), bottomDst.Dx()) {
|
||||
xx := topDst.Min.X + x
|
||||
a := sampleLumaMapped(img, topDst, topSrc, xx, topDst.Max.Y-1)
|
||||
b := sampleLumaMapped(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
|
||||
@@ -466,31 +384,24 @@ func seamScoreRGBText(img image.Image, gridSize int, mapping []int) (int64, floa
|
||||
}
|
||||
weight := func(y int) float64 {
|
||||
yf := float64(y)
|
||||
best := absFloat(yf - textCenters[0])
|
||||
for i := 1; i < len(textCenters); i++ {
|
||||
d := absFloat(yf - textCenters[i])
|
||||
if d < best {
|
||||
best = d
|
||||
}
|
||||
best := math.Abs(yf - textCenters[0])
|
||||
for _, center := range textCenters[1:] {
|
||||
best = min(best, math.Abs(yf-center))
|
||||
}
|
||||
return 1 + 3*math.Exp(-(best*best)/(2*sigma*sigma))
|
||||
}
|
||||
|
||||
var rgbScore int64
|
||||
var textScore float64
|
||||
for row := 0; row < gridSize; row++ {
|
||||
for col := 0; col < gridSize-1; col++ {
|
||||
for row := range gridSize {
|
||||
for col := range gridSize - 1 {
|
||||
leftIdx := row*gridSize + col
|
||||
rightIdx := leftIdx + 1
|
||||
leftDst := sliderTileRect(bounds, gridSize, leftIdx)
|
||||
rightDst := sliderTileRect(bounds, gridSize, rightIdx)
|
||||
leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
|
||||
rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
|
||||
h := leftDst.Dy()
|
||||
if rightDst.Dy() < h {
|
||||
h = rightDst.Dy()
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
for y := range min(leftDst.Dy(), rightDst.Dy()) {
|
||||
yy := leftDst.Min.Y + y
|
||||
l := sampleColorMapped(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
|
||||
r := sampleColorMapped(img, rightDst, rightSrc, rightDst.Min.X, yy)
|
||||
@@ -501,19 +412,15 @@ func seamScoreRGBText(img image.Image, gridSize int, mapping []int) (int64, floa
|
||||
}
|
||||
}
|
||||
}
|
||||
for row := 0; row < gridSize-1; row++ {
|
||||
for col := 0; col < gridSize; col++ {
|
||||
for row := range gridSize - 1 {
|
||||
for col := range gridSize {
|
||||
topIdx := row*gridSize + col
|
||||
bottomIdx := (row+1)*gridSize + col
|
||||
topDst := sliderTileRect(bounds, gridSize, topIdx)
|
||||
bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
|
||||
topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
|
||||
bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
|
||||
w := topDst.Dx()
|
||||
if bottomDst.Dx() < w {
|
||||
w = bottomDst.Dx()
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
for x := range min(topDst.Dx(), bottomDst.Dx()) {
|
||||
xx := topDst.Min.X + x
|
||||
t := sampleColorMapped(img, topDst, topSrc, xx, topDst.Max.Y-1)
|
||||
b := sampleColorMapped(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
|
||||
@@ -527,37 +434,21 @@ func seamScoreRGBText(img image.Image, gridSize int, mapping []int) (int64, floa
|
||||
return rgbScore, textScore
|
||||
}
|
||||
|
||||
func sampleColorMapped(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) color.Color {
|
||||
dx := dstRect.Dx()
|
||||
if dx < 1 {
|
||||
dx = 1
|
||||
}
|
||||
dy := dstRect.Dy()
|
||||
if dy < 1 {
|
||||
dy = 1
|
||||
}
|
||||
func sampleColorMapped(img image.Image, dstRect, srcRect image.Rectangle, dstX, dstY int) color.Color {
|
||||
dx := max(dstRect.Dx(), 1)
|
||||
dy := max(dstRect.Dy(), 1)
|
||||
sx := srcRect.Min.X + (dstX-dstRect.Min.X)*srcRect.Dx()/dx
|
||||
sy := srcRect.Min.Y + (dstY-dstRect.Min.Y)*srcRect.Dy()/dy
|
||||
return img.At(sx, sy)
|
||||
}
|
||||
|
||||
func sampleLumaMapped(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) uint8 {
|
||||
func sampleLumaMapped(img image.Image, dstRect, srcRect image.Rectangle, dstX, dstY int) uint8 {
|
||||
c := sampleColorMapped(img, dstRect, srcRect, dstX, dstY)
|
||||
r, g, b, _ := c.RGBA()
|
||||
y := (299*(r>>8) + 587*(g>>8) + 114*(b>>8)) / 1000
|
||||
if y > 255 {
|
||||
y = 255
|
||||
}
|
||||
y := min((299*(r>>8)+587*(g>>8)+114*(b>>8))/1000, 255)
|
||||
return uint8(y) //nolint:gosec // bounded above by 255
|
||||
}
|
||||
|
||||
func absFloat(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func absInt(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
@@ -565,7 +456,7 @@ func absInt(v int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
|
||||
func sliderTileRect(bounds image.Rectangle, gridSize, index int) image.Rectangle {
|
||||
row := index / gridSize
|
||||
col := index % gridSize
|
||||
x0 := bounds.Min.X + col*bounds.Dx()/gridSize
|
||||
@@ -587,96 +478,3 @@ func absDiff(left uint32, right uint32) int64 {
|
||||
}
|
||||
return int64(right - left)
|
||||
}
|
||||
|
||||
func buildSliderCursor(candidateIndex int, candidateCount int) string {
|
||||
if candidateCount <= 0 {
|
||||
return "[]"
|
||||
}
|
||||
if candidateIndex < 1 {
|
||||
candidateIndex = 1
|
||||
}
|
||||
if candidateIndex > candidateCount {
|
||||
candidateIndex = candidateCount
|
||||
}
|
||||
|
||||
type cursorPoint struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
startX := 570 + randx.Intn(40)
|
||||
startY := 875 + randx.Intn(30)
|
||||
|
||||
denom := candidateCount - 1
|
||||
if denom < 1 {
|
||||
denom = 1
|
||||
}
|
||||
baseTargetX := 734 + (937-734)*(candidateIndex-1)/denom
|
||||
targetX := baseTargetX + randx.Intn(10) - 5
|
||||
targetY := 655 + randx.Intn(14)
|
||||
|
||||
points := make([]cursorPoint, 0, 128)
|
||||
|
||||
for i := 0; i < 4+randx.Intn(8); i++ {
|
||||
points = append(points, cursorPoint{
|
||||
X: startX + randx.Intn(5) - 2,
|
||||
Y: startY + randx.Intn(5) - 2,
|
||||
})
|
||||
}
|
||||
|
||||
transitSteps := 32 + randx.Intn(26)
|
||||
arcOffX := randx.Intn(60) - 30
|
||||
arcOffY := -(randx.Intn(30) + 10)
|
||||
for i := 1; i <= transitSteps; i++ {
|
||||
t := easeInOut(float64(i) / float64(transitSteps+1))
|
||||
cx := float64(startX+targetX)/2 + float64(arcOffX)
|
||||
cy := float64(startY+targetY)/2 + float64(arcOffY)
|
||||
bx := (1-t)*(1-t)*float64(startX) + 2*t*(1-t)*cx + t*t*float64(targetX)
|
||||
by := (1-t)*(1-t)*float64(startY) + 2*t*(1-t)*cy + t*t*float64(targetY)
|
||||
jitter := int((1-t)*5) + 1
|
||||
points = append(points, cursorPoint{
|
||||
X: int(math.Round(bx)) + randx.Intn(jitter*2+1) - jitter,
|
||||
Y: int(math.Round(by)) + randx.Intn(jitter*2+1) - jitter,
|
||||
})
|
||||
if i%9 == 0 && randx.Intn(3) == 0 {
|
||||
last := points[len(points)-1]
|
||||
points = append(points, last)
|
||||
}
|
||||
}
|
||||
|
||||
approachSteps := 16 + randx.Intn(14)
|
||||
prev := points[len(points)-1]
|
||||
for i := 1; i <= approachSteps; i++ {
|
||||
t := easeOut(float64(i) / float64(approachSteps))
|
||||
ax := prev.X + int(math.Round(t*float64(targetX-prev.X))) + randx.Intn(5) - 2
|
||||
ay := prev.Y + int(math.Round(t*float64(targetY-prev.Y))) + randx.Intn(5) - 2
|
||||
points = append(points, cursorPoint{X: ax, Y: ay})
|
||||
}
|
||||
|
||||
settleCount := 18 + randx.Intn(24)
|
||||
for i := 0; i < settleCount; i++ {
|
||||
points = append(points, cursorPoint{
|
||||
X: targetX + randx.Intn(7) - 3,
|
||||
Y: targetY + randx.Intn(7) - 3,
|
||||
})
|
||||
}
|
||||
|
||||
data, err := json.Marshal(points)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func easeInOut(t float64) float64 {
|
||||
if t < 0.5 {
|
||||
return 2 * t * t
|
||||
}
|
||||
p := -2*t + 2
|
||||
return 1 - p*p/2
|
||||
}
|
||||
|
||||
func easeOut(t float64) float64 {
|
||||
p := 1 - t
|
||||
return 1 - p*p*p
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/randx"
|
||||
)
|
||||
|
||||
const (
|
||||
sensorDelayDefault = 100 * time.Millisecond
|
||||
sensorDelayFloor = 20 * time.Millisecond
|
||||
sensorDelayCeil = 2 * time.Second
|
||||
// Виджет режет аккумулятор по 900 КБ, до такого объёма сессии не доживают.
|
||||
sensorTicksMax = 600
|
||||
|
||||
emptyArray = "[]"
|
||||
)
|
||||
|
||||
// sensorConfig - период таймера телеметрии; приходит из captchaNotRobot.settings.
|
||||
type sensorConfig struct {
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func defaultSensorConfig() sensorConfig {
|
||||
return sensorConfig{delay: sensorDelayDefault}
|
||||
}
|
||||
|
||||
func parseSensorConfig(raw map[string]any) sensorConfig {
|
||||
cfg := defaultSensorConfig()
|
||||
resp, ok := raw["response"].(map[string]any)
|
||||
if !ok {
|
||||
return cfg
|
||||
}
|
||||
if ms, hasDelay := resp["sensors_delay"].(float64); hasDelay && ms > 0 {
|
||||
cfg.delay = min(max(time.Duration(ms)*time.Millisecond, sensorDelayFloor), sensorDelayCeil)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// analytics - аккумулятор виджета; наполняется только downlink.
|
||||
type analytics struct {
|
||||
connDownlink []float64
|
||||
}
|
||||
|
||||
// fields сохраняет порядок ключей виджета; ненаполняемые массивы уходят пустыми.
|
||||
func (a analytics) fields() [][2]string {
|
||||
return [][2]string{
|
||||
{"accelerometer", emptyArray},
|
||||
{"gyroscope", emptyArray},
|
||||
{"motion", emptyArray},
|
||||
{"cursor", emptyArray},
|
||||
{"taps", emptyArray},
|
||||
{"connectionRtt", emptyArray},
|
||||
{"connectionDownlink", downlinkArray(a.connDownlink)},
|
||||
}
|
||||
}
|
||||
|
||||
func downlinkArray(values []float64) string {
|
||||
if len(values) == 0 {
|
||||
return emptyArray
|
||||
}
|
||||
data, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return emptyArray
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// buildAnalytics: тиков столько, сколько таймер успел сделать за elapsed с
|
||||
// момента ответа settings.
|
||||
func buildAnalytics(cfg sensorConfig, downlink float64, elapsed time.Duration) analytics {
|
||||
ticks := ticksIn(elapsed, cfg.delay)
|
||||
if ticks == 0 {
|
||||
return analytics{}
|
||||
}
|
||||
return analytics{connDownlink: sampleDownlink(downlink, ticks)}
|
||||
}
|
||||
|
||||
func ticksIn(d time.Duration, delay time.Duration) int {
|
||||
if d <= 0 || delay <= 0 {
|
||||
return 0
|
||||
}
|
||||
return min(int(d/delay), sensorTicksMax)
|
||||
}
|
||||
|
||||
// Оценка Chrome квантована шагом 0.05, считаем в этих шагах.
|
||||
const (
|
||||
downlinkMinSteps = 160 // 8.0
|
||||
downlinkMaxSteps = 200 // 10.0
|
||||
)
|
||||
|
||||
// sessionDownlink: у живого виджета значение постоянно ([10,10,10]), но жёсткая
|
||||
// константа была бы отпечатком.
|
||||
func sessionDownlink() float64 {
|
||||
steps := downlinkMinSteps + randx.Intn(downlinkMaxSteps-downlinkMinSteps+1)
|
||||
// Делим, а не умножаем на 0.05: иначе выходит соседний double и JSON печатает 9.200000000000001.
|
||||
return float64(steps*5) / 100
|
||||
}
|
||||
|
||||
func sampleDownlink(value float64, ticks int) []float64 {
|
||||
if ticks <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]float64, ticks)
|
||||
for i := range out {
|
||||
out[i] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Эталон снят с успешного check живого браузера через manual-прокси: непустой
|
||||
// там ровно один ключ.
|
||||
func TestAnalyticsMatchesLiveBrowser(t *testing.T) {
|
||||
want := [][2]string{
|
||||
{"accelerometer", "[]"},
|
||||
{"gyroscope", "[]"},
|
||||
{"motion", "[]"},
|
||||
{"cursor", "[]"},
|
||||
{"taps", "[]"},
|
||||
{"connectionRtt", "[]"},
|
||||
{"connectionDownlink", "[10,10,10]"},
|
||||
}
|
||||
cfg := sensorConfig{delay: 200 * time.Millisecond}
|
||||
got := buildAnalytics(cfg, 10, 600*time.Millisecond).fields()
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%d fields, want %d", len(got), len(want))
|
||||
}
|
||||
for i, kv := range got {
|
||||
if kv != want[i] {
|
||||
t.Fatalf("field %d = %v, want %v", i, kv, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Число сэмплов задаёт таймер виджета: сколько тиков уместилось в реальное время
|
||||
// сессии, столько и точек.
|
||||
func TestAnalyticsSampleCountFollowsElapsed(t *testing.T) {
|
||||
cfg := sensorConfig{delay: 100 * time.Millisecond}
|
||||
elapsed := 5 * time.Second
|
||||
|
||||
if got := len(buildAnalytics(cfg, 10, elapsed).connDownlink); got != int(elapsed/cfg.delay) {
|
||||
t.Fatalf("downlink = %d, want %d", got, int(elapsed/cfg.delay))
|
||||
}
|
||||
}
|
||||
|
||||
// Сессия короче одного тика: массивы пустые, но ключи на месте.
|
||||
func TestAnalyticsWithoutTicksIsEmpty(t *testing.T) {
|
||||
cfg := sensorConfig{delay: 2 * time.Second}
|
||||
for _, kv := range buildAnalytics(cfg, 10, 500*time.Millisecond).fields() {
|
||||
if kv[1] != emptyArray {
|
||||
t.Fatalf("field %q = %s, want []", kv[0], kv[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSensorConfigFallsBackToDefaults(t *testing.T) {
|
||||
cfg := parseSensorConfig(map[string]any{"error": map[string]any{"error_code": float64(5)}})
|
||||
if cfg != defaultSensorConfig() {
|
||||
t.Fatalf("sensor config = %+v, want defaults", cfg)
|
||||
}
|
||||
clamped := parseSensorConfig(map[string]any{"response": map[string]any{"sensors_delay": float64(1)}})
|
||||
if clamped.delay != sensorDelayFloor {
|
||||
t.Fatalf("delay = %s, want clamp to %s", clamped.delay, sensorDelayFloor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSensorConfigReadsDelay(t *testing.T) {
|
||||
cfg := parseSensorConfig(map[string]any{"response": map[string]any{"sensors_delay": float64(250)}})
|
||||
if cfg.delay != 250*time.Millisecond {
|
||||
t.Fatalf("delay = %s, want 250ms", cfg.delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Оценка не меняется за время captcha: замеры дали [10,10,10] и [9.6,9.6,9.6].
|
||||
func TestDownlinkIsConstantWithinSession(t *testing.T) {
|
||||
for _, v := range sampleDownlink(9.6, 16) {
|
||||
if v != 9.6 {
|
||||
t.Fatalf("downlink = %v, want 9.6", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON.stringify печатает число кратчайшей записью; 9.200000000000001 в выдаче
|
||||
// живого браузера невозможен и сам был бы маркером.
|
||||
func TestSessionDownlinkSerializesShort(t *testing.T) {
|
||||
for range 500 {
|
||||
v := sessionDownlink()
|
||||
if v < 8 || v > 10 {
|
||||
t.Fatalf("downlink = %v, want [8, 10]", v)
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(raw) > 4 {
|
||||
t.Fatalf("downlink serialized as %s, want at most 4 chars", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownlinkArray(t *testing.T) {
|
||||
if got := downlinkArray(nil); got != emptyArray {
|
||||
t.Fatalf("downlinkArray(nil) = %q, want []", got)
|
||||
}
|
||||
if got := downlinkArray([]float64{10, 9.6}); got != "[10,9.6]" {
|
||||
t.Fatalf("downlinkArray = %s, want [10,9.6]", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
|
||||
package personanet
|
||||
|
||||
import (
|
||||
neturl "net/url"
|
||||
"time"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// cookieJar чинит удаление кук в tls-client: его фильтр смотрит только на
|
||||
// Max-Age (jar.go, проверка Expires закомментирована), а VK гасит куки прошедшим
|
||||
// Expires. Без этого remixir=DELETED уезжает в каждом запросе - живой браузер
|
||||
// такую куку удаляет и не шлёт никогда.
|
||||
type cookieJar struct {
|
||||
inner tlsclient.CookieJar
|
||||
}
|
||||
|
||||
func NewCookieJar() tlsclient.CookieJar {
|
||||
return &cookieJar{inner: tlsclient.NewCookieJar()}
|
||||
}
|
||||
|
||||
func (j *cookieJar) SetCookies(u *neturl.URL, cookies []*fhttp.Cookie) {
|
||||
now := time.Now()
|
||||
for _, c := range cookies {
|
||||
if c.MaxAge == 0 && !c.Expires.IsZero() && !c.Expires.After(now) {
|
||||
c.MaxAge = -1
|
||||
}
|
||||
}
|
||||
j.inner.SetCookies(u, cookies)
|
||||
}
|
||||
|
||||
func (j *cookieJar) Cookies(u *neturl.URL) []*fhttp.Cookie { return j.inner.Cookies(u) }
|
||||
|
||||
func (j *cookieJar) GetAllCookies() map[string][]*fhttp.Cookie { return j.inner.GetAllCookies() }
|
||||
@@ -0,0 +1,210 @@
|
||||
// Package personanet собирает HTTP-клиент, чей TLS-отпечаток и заголовки
|
||||
// принадлежат браузерной персоне. Один конструктор на всех потребителей:
|
||||
// расхождение JA3 между control-plane и captcha само по себе сигнал бота.
|
||||
package personanet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"golang.org/x/net/proxy"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netconn"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
)
|
||||
|
||||
const (
|
||||
// clientHelloSplitAt - фоллбэк-offset разбиения, когда SNI в ClientHello не
|
||||
// распарсился. Совпадает с TURN STUN-split.
|
||||
clientHelloSplitAt = 6
|
||||
// clientHelloSplitDelay / clientHelloSplitJitter - пауза между сегментами
|
||||
// ClientHello (base + случайная добавка [0,jitter)) для антифингерпринта тайминга.
|
||||
clientHelloSplitDelay = 20 * time.Millisecond
|
||||
clientHelloSplitJitter = 15 * time.Millisecond
|
||||
|
||||
clientTimeoutSeconds = 20
|
||||
)
|
||||
|
||||
// splitDialer дробит первый Write результирующего conn (TLS ClientHello) по
|
||||
// границам внутри SNI host_name для обхода SNI-based DPI RST. Реализует
|
||||
// proxy.ContextDialer - tls-client берёт его через WithProxyDialerFactory как
|
||||
// прямой (не прокси) дилер.
|
||||
type splitDialer struct {
|
||||
base net.Dialer
|
||||
}
|
||||
|
||||
func (d *splitDialer) Dial(network, addr string) (net.Conn, error) {
|
||||
return d.DialContext(context.Background(), network, addr)
|
||||
}
|
||||
|
||||
func (d *splitDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
c, err := d.base.DialContext(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &netconn.MultiSplitWriteConn{
|
||||
Conn: c,
|
||||
Delay: clientHelloSplitDelay,
|
||||
Jitter: clientHelloSplitJitter,
|
||||
FallbackSplitAt: clientHelloSplitAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Option func(*options)
|
||||
|
||||
type options struct {
|
||||
noFollowRedirects bool
|
||||
}
|
||||
|
||||
// NoFollowRedirects нужен прокси-режиму: по редиректам ходит браузер, иначе
|
||||
// rewrite Location мёртв, а Set-Cookie промежуточных хопов теряются.
|
||||
func NoFollowRedirects() Option {
|
||||
return func(o *options) { o.noFollowRedirects = true }
|
||||
}
|
||||
|
||||
// NewClient строит tls-client с ClientHello персоны поверх dialer. jar может быть
|
||||
// nil, если куки ведёт вызывающий.
|
||||
func NewClient(profile browserprofile.Profile, dialer net.Dialer, jar tlsclient.CookieJar, extra ...Option) (tlsclient.HttpClient, error) {
|
||||
var o options
|
||||
for _, apply := range extra {
|
||||
apply(&o)
|
||||
}
|
||||
opts := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(clientTimeoutSeconds),
|
||||
tlsclient.WithClientProfile(profile.ClientProfile()),
|
||||
tlsclient.WithProxyDialerFactory(func(_ string, timeout time.Duration, localAddr *net.TCPAddr, _ fhttp.Header, _ tlsclient.Logger) (proxy.ContextDialer, error) {
|
||||
base := dialer
|
||||
base.Timeout = timeout
|
||||
if localAddr != nil {
|
||||
base.LocalAddr = localAddr
|
||||
}
|
||||
return &splitDialer{base: base}, nil
|
||||
}),
|
||||
}
|
||||
if jar != nil {
|
||||
opts = append(opts, tlsclient.WithCookieJar(jar))
|
||||
}
|
||||
if o.noFollowRedirects {
|
||||
opts = append(opts, tlsclient.WithNotFollowRedirects())
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), opts...)
|
||||
}
|
||||
|
||||
// RoundTripper подаёт net/http-запросы (httputil.ReverseProxy) через клиент
|
||||
// персоны: иначе апстрим уходил бы с Go-отпечатком под браузерным User-Agent.
|
||||
func RoundTripper(client tlsclient.HttpClient, profile browserprofile.Profile) http.RoundTripper {
|
||||
return &personaRoundTripper{client: client, profile: profile}
|
||||
}
|
||||
|
||||
type personaRoundTripper struct {
|
||||
client tlsclient.HttpClient
|
||||
profile browserprofile.Profile
|
||||
}
|
||||
|
||||
func (rt *personaRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
var body io.Reader
|
||||
if req.Body != nil {
|
||||
body = req.Body
|
||||
}
|
||||
out, err := fhttp.NewRequestWithContext(req.Context(), req.Method, req.URL.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Host = req.Host
|
||||
for name, values := range req.Header {
|
||||
// Сжатие ведёт tls-client: он же и разожмёт ответ.
|
||||
if http.CanonicalHeaderKey(name) == "Accept-Encoding" {
|
||||
continue
|
||||
}
|
||||
for _, v := range values {
|
||||
out.Header.Add(name, v)
|
||||
}
|
||||
}
|
||||
browserprofile.ApplyFhttp(out, rt.profile)
|
||||
|
||||
resp, err := rt.client.Do(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header := make(http.Header, len(resp.Header))
|
||||
for name, values := range resp.Header {
|
||||
header[http.CanonicalHeaderKey(name)] = append([]string(nil), values...)
|
||||
}
|
||||
// По HTTP/2 fhttp разжимает тело, но заголовки сжатия не чистит (в отличие от
|
||||
// HTTP/1.1-пути) - иначе потребитель режет тело по сжатой длине.
|
||||
if resp.Uncompressed {
|
||||
header.Del("Content-Encoding")
|
||||
header.Del("Content-Length")
|
||||
}
|
||||
return &http.Response{
|
||||
Status: resp.Status,
|
||||
StatusCode: resp.StatusCode,
|
||||
Proto: resp.Proto,
|
||||
ProtoMajor: resp.ProtoMajor,
|
||||
ProtoMinor: resp.ProtoMinor,
|
||||
Header: header,
|
||||
Body: resp.Body,
|
||||
ContentLength: resp.ContentLength,
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ProxyRoundTripper(client tlsclient.HttpClient) http.RoundTripper {
|
||||
return &proxyRoundTripper{client: client}
|
||||
}
|
||||
|
||||
type proxyRoundTripper struct {
|
||||
client tlsclient.HttpClient
|
||||
}
|
||||
|
||||
func (rt *proxyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
var body io.Reader
|
||||
if req.Body != nil {
|
||||
body = req.Body
|
||||
}
|
||||
out, err := fhttp.NewRequestWithContext(req.Context(), req.Method, req.URL.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Host = req.Host
|
||||
for name, values := range req.Header {
|
||||
if http.CanonicalHeaderKey(name) == "Accept-Encoding" {
|
||||
continue
|
||||
}
|
||||
for _, v := range values {
|
||||
out.Header.Add(name, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Важное отличие: задаём только порядок, не трогаем User-Agent
|
||||
browserprofile.ApplyProxyFhttp(out)
|
||||
|
||||
resp, err := rt.client.Do(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header := make(http.Header, len(resp.Header))
|
||||
for name, values := range resp.Header {
|
||||
header[http.CanonicalHeaderKey(name)] = append([]string(nil), values...)
|
||||
}
|
||||
if resp.Uncompressed {
|
||||
header.Del("Content-Encoding")
|
||||
header.Del("Content-Length")
|
||||
}
|
||||
return &http.Response{
|
||||
Status: resp.Status,
|
||||
StatusCode: resp.StatusCode,
|
||||
Proto: resp.Proto,
|
||||
ProtoMajor: resp.ProtoMajor,
|
||||
ProtoMinor: resp.ProtoMinor,
|
||||
Header: header,
|
||||
Body: resp.Body,
|
||||
ContentLength: resp.ContentLength,
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package vkauth
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha"
|
||||
@@ -11,15 +10,15 @@ import (
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
func (c *Client) defaultAutoSolve(
|
||||
func (*Client) defaultAutoSolve(
|
||||
ctx context.Context,
|
||||
captchaErr *captcha.Error,
|
||||
streamID int,
|
||||
client tlsclient.HttpClient,
|
||||
profile browserprofile.Profile,
|
||||
) (string, error) {
|
||||
log := captcha.Log
|
||||
log.Infof("[STREAM %d] [Captcha] Solving captcha...", streamID)
|
||||
log := captcha.Log()
|
||||
log.Infof("[STREAM %d] [Captcha] Solving captcha (platform=%s)...", streamID, profile.Platform)
|
||||
|
||||
if captchaErr.SessionToken == "" {
|
||||
return "", fmt.Errorf("no session_token in redirect_uri for auto-solve")
|
||||
@@ -28,45 +27,10 @@ func (c *Client) defaultAutoSolve(
|
||||
return "", fmt.Errorf("no redirect_uri for auto-solve")
|
||||
}
|
||||
|
||||
var savedProfile *browserprofile.Saved
|
||||
switch sp, err := browserprofile.Load(); {
|
||||
case err == nil && savedProfileUsable(*sp):
|
||||
savedFamily := browserprofile.Family(sp.Profile)
|
||||
log.Infof("[STREAM %d] [Captcha] Using captured browser profile (authority) family=%s mobile=%t ua=%q device=%t browser_fp=%t",
|
||||
streamID, savedFamily, browserprofile.IsMobile(sp.Profile), sp.UserAgent, sp.DeviceJSON != "", sp.BrowserFp != "")
|
||||
savedProfile = sp
|
||||
profile = sp.Profile
|
||||
if savedFamily != "" && normalizeFamily(savedFamily) != normalizeFamily(c.browser) {
|
||||
if rebuilt, rebuildErr := c.newTLSClientForKind(savedFamily, tlsclient.NewCookieJar()); rebuildErr == nil {
|
||||
client = rebuilt
|
||||
log.Infof("[STREAM %d] [Captcha] Rebuilt TLS client to match captured family=%s", streamID, savedFamily)
|
||||
} else {
|
||||
log.Warnf("[STREAM %d] [Captcha] Failed to rebuild TLS for captured family=%s: %v; keeping -browser TLS", streamID, savedFamily, rebuildErr)
|
||||
}
|
||||
}
|
||||
case err == nil:
|
||||
log.Warnf("[STREAM %d] [Captcha] Captured profile unusable (empty UA); using -browser fallback", streamID)
|
||||
default:
|
||||
log.Debugf("[STREAM %d] [Captcha] No captured browser profile: %v", streamID, err)
|
||||
}
|
||||
|
||||
successToken, err := captcha.Solve(ctx, captchaErr, streamID, client, profile, savedProfile, log)
|
||||
successToken, err := captcha.Solve(ctx, captchaErr, streamID, client, profile, log)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Infof("[STREAM %d] [Captcha] solver succeeded", streamID)
|
||||
return successToken, nil
|
||||
}
|
||||
|
||||
func savedProfileUsable(saved browserprofile.Saved) bool {
|
||||
return strings.TrimSpace(saved.UserAgent) != ""
|
||||
}
|
||||
|
||||
func normalizeFamily(k browserprofile.Kind) browserprofile.Kind {
|
||||
switch k {
|
||||
case browserprofile.Firefox, browserprofile.Safari:
|
||||
return k
|
||||
default:
|
||||
return browserprofile.Chrome
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
package vkauth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pion/stun/v3"
|
||||
)
|
||||
|
||||
// StreamCredentialsCache хранит TURN-реквизиты для группы потоков
|
||||
// и счётчик ошибок для решений об инвалидации.
|
||||
type StreamCredentialsCache struct {
|
||||
creds TurnCredentials
|
||||
mutex sync.RWMutex
|
||||
fetchMu sync.Mutex
|
||||
errorCount atomic.Int32
|
||||
lastErrorTime atomic.Int64
|
||||
}
|
||||
|
||||
// Store отображает cache-id ((streamID-1) / streamsPerCache) -> StreamCredentialsCache.
|
||||
func (c *StreamCredentialsCache) lookup(link string, streamID int) (TurnCredentials, []string, bool) {
|
||||
c.mutex.RLock()
|
||||
defer c.mutex.RUnlock()
|
||||
if c.creds.Link != link || !time.Now().Before(c.creds.ExpiresAt) || len(c.creds.ServerAddrs) == 0 {
|
||||
return TurnCredentials{}, nil, false
|
||||
}
|
||||
return c.creds, orderAddrs(c.creds.ServerAddrs, streamID), true
|
||||
}
|
||||
|
||||
func (c *StreamCredentialsCache) store(creds TurnCredentials) {
|
||||
c.mutex.Lock()
|
||||
c.creds = creds
|
||||
c.mutex.Unlock()
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
caches map[int]*StreamCredentialsCache
|
||||
@@ -33,9 +49,8 @@ func NewStore(streamsPerCache int) *Store {
|
||||
}
|
||||
|
||||
// CacheID группирует потоки в блоки по streamsPerCache: потоки 1..streamsPerCache
|
||||
// делят один кэш реквизитов, streamsPerCache+1.. - следующий. streamID 1-based
|
||||
// (единый базис udprelay и tcpfwd); первый поток блока инициирует fetch к VK,
|
||||
// остальные переиспользуют тёплый кэш.
|
||||
// делят один кэш реквизитов, streamsPerCache+1.. - следующий. streamID 1-based;
|
||||
// первый поток блока инициирует fetch к VK, остальные переиспользуют тёплый кэш.
|
||||
func (s *Store) CacheID(streamID int) int {
|
||||
if streamID < 1 {
|
||||
return 0
|
||||
@@ -64,26 +79,31 @@ func (s *Store) Get(streamID int) *StreamCredentialsCache {
|
||||
return cache
|
||||
}
|
||||
|
||||
// Invalidate сбрасывает реквизиты кэша потока и обнуляет счётчик ошибок.
|
||||
func (c *StreamCredentialsCache) Invalidate() {
|
||||
func (c *StreamCredentialsCache) Invalidate() bool {
|
||||
c.mutex.Lock()
|
||||
had := c.creds.Username != ""
|
||||
c.creds = TurnCredentials{}
|
||||
c.mutex.Unlock()
|
||||
|
||||
c.errorCount.Store(0)
|
||||
c.lastErrorTime.Store(0)
|
||||
return had
|
||||
}
|
||||
|
||||
// IsAuthError проверяет ошибку по эвристике TURN-клиента:
|
||||
// auth/401/stale-nonce - признак инвалидации кэша.
|
||||
func IsAuthError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "401") ||
|
||||
strings.Contains(s, "Unauthorized") ||
|
||||
strings.Contains(s, "authentication") ||
|
||||
strings.Contains(s, "invalid credential") ||
|
||||
strings.Contains(s, "stale nonce")
|
||||
// Ответ TURN-сервера приходит типизированным - код берём из него, а не из текста.
|
||||
if turnErr, ok := errors.AsType[*stun.TurnError](err); ok {
|
||||
// 486 означает занятую квоту, а не невалидные реквизиты.
|
||||
switch turnErr.ErrorCodeAttr.Code {
|
||||
case stun.CodeUnauthorized, stun.CodeWrongCredentials, stun.CodeStaleNonce:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Is(err, ErrVKAuthFailed)
|
||||
}
|
||||
|
||||
@@ -5,59 +5,38 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/logx"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/personanet"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/randx"
|
||||
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// Config конфигурирует Client. Нулевые значения - безопасные дефолты,
|
||||
// кроме Dialer (должен быть задан явно для кастомного DNS).
|
||||
type Config struct {
|
||||
// Credentials для перебора по порядку. nil/empty -> DefaultCredentials.
|
||||
Credentials []VKCredentials
|
||||
|
||||
// Dialer для HTTP-транспорта запросов к VK API.
|
||||
Dialer net.Dialer
|
||||
|
||||
// ManualOnly форсирует ручной путь captcha с первой попытки.
|
||||
ManualOnly bool
|
||||
|
||||
// StreamsPerCache - делитель streamID -> cacheID. <=0 -> дефолт.
|
||||
Credentials []VKCredentials
|
||||
Dialer net.Dialer
|
||||
ManualOnly bool
|
||||
StreamsPerCache int
|
||||
|
||||
// StreamsAlive возвращает число подключённых потоков; используется для
|
||||
// решения, является ли исчерпанная captcha фатальной или только throttle.
|
||||
// nil -> 1.
|
||||
StreamsAlive func() int32
|
||||
|
||||
// AutoSolver / ManualSolver - подключаемые решалки captcha. nil отключает
|
||||
// соответствующий путь (поток переходит к следующей попытке).
|
||||
AutoSolver AutoSolveFunc
|
||||
ManualSolver ManualSolveFunc
|
||||
|
||||
// Browser - браузерный профиль (UA + JA3 + client hints) для control-plane.
|
||||
// Нулевое значение -> Chrome (исторический дефолт для прямых вызовов в тестах).
|
||||
Browser browserprofile.Kind
|
||||
|
||||
// Log - уровневый логгер. nil -> no-op.
|
||||
Log logx.Logger
|
||||
StreamsAlive func() int32
|
||||
AutoSolver AutoSolveFunc
|
||||
ManualSolver ManualSolveFunc
|
||||
Platform browserprofile.Platform
|
||||
FingerprintSeed string
|
||||
StatePaths []string
|
||||
Log logx.Logger
|
||||
}
|
||||
|
||||
// Client - фасад VK-аутентификации и кэша реквизитов. Владеет кэшем группы
|
||||
// потоков, глобальным throttle запросов, таймером блокировки captcha и счётчиком
|
||||
// ошибок аутентификации для инвалидации устаревших TURN-реквизитов.
|
||||
type Client struct {
|
||||
credentials []VKCredentials
|
||||
dialer net.Dialer
|
||||
manualOnly bool
|
||||
browser browserprofile.Kind
|
||||
platform browserprofile.Platform
|
||||
streamsFn func() int32
|
||||
autoSolver AutoSolveFunc
|
||||
manualSolve ManualSolveFunc
|
||||
@@ -67,14 +46,15 @@ type Client struct {
|
||||
|
||||
lockout atomic.Int64
|
||||
|
||||
fetchMu sync.Mutex
|
||||
lastFetchTime time.Time
|
||||
personaMu sync.RWMutex
|
||||
identity browserprofile.Identity
|
||||
persona browserprofile.Profile
|
||||
gens genStore
|
||||
|
||||
// tokenChain - 4-шаговый получатель токена для пары credentials.
|
||||
// В prod подключён (*Client).getTokenChain; тесты подменяют fake.
|
||||
tokenChain tokenChainFn
|
||||
|
||||
// minFetchIntervalFn ограничивает частоту запросов к VK. Тесты снижают.
|
||||
fetchMu sync.Mutex
|
||||
lastFetchTime time.Time
|
||||
captchaAttempt int
|
||||
tokenChain tokenChainFn
|
||||
minFetchIntervalFn func() time.Duration
|
||||
}
|
||||
|
||||
@@ -85,7 +65,7 @@ func New(cfg Config) *Client {
|
||||
credentials: cfg.Credentials,
|
||||
dialer: cfg.Dialer,
|
||||
manualOnly: cfg.ManualOnly,
|
||||
browser: cfg.Browser,
|
||||
platform: cfg.Platform,
|
||||
streamsFn: cfg.StreamsAlive,
|
||||
autoSolver: cfg.AutoSolver,
|
||||
manualSolve: cfg.ManualSolver,
|
||||
@@ -105,33 +85,53 @@ func New(cfg Config) *Client {
|
||||
c.minFetchIntervalFn = func() time.Duration {
|
||||
return 3*time.Second + time.Duration(randx.Intn(3000))*time.Millisecond
|
||||
}
|
||||
seed := cfg.FingerprintSeed
|
||||
if seed == "" {
|
||||
seed = randx.Hex(16)
|
||||
}
|
||||
c.gens = genStore{paths: cfg.StatePaths}
|
||||
c.identity = browserprofile.Identity{Seed: seed, Gen: c.gens.load(seed)}
|
||||
c.persona = browserprofile.For(c.platform, c.identity)
|
||||
if c.identity.Gen > 0 {
|
||||
c.log.Debugf("[VK Auth] Persona gen=%d restored | User-Agent: %s", c.identity.Gen, c.persona.UserAgent)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// GetCredentials возвращает (username, password, server-addrs) для TURN-allocate,
|
||||
// обращаясь к VK (с throttle + кэшем) только при необходимости. Адреса отдаются
|
||||
// в порядке предпочтения для streamID (предпочтительный первым) - pipeline
|
||||
// пробует их по очереди при allocate.
|
||||
func (c *Client) currentPersona() browserprofile.Profile {
|
||||
c.personaMu.RLock()
|
||||
defer c.personaMu.RUnlock()
|
||||
return c.persona
|
||||
}
|
||||
|
||||
func (c *Client) burnPersona(streamID int) {
|
||||
c.personaMu.Lock()
|
||||
c.identity.Gen++
|
||||
c.persona = browserprofile.For(c.platform, c.identity)
|
||||
ua, gen, seed := c.persona.UserAgent, c.identity.Gen, c.identity.Seed
|
||||
c.personaMu.Unlock()
|
||||
c.log.Infof("[STREAM %d] [VK Auth] Persona burned, gen=%d | User-Agent: %s", streamID, gen, ua)
|
||||
if !c.gens.save(seed, gen) && len(c.gens.paths) > 0 {
|
||||
c.log.Warnf("[STREAM %d] [VK Auth] Persona gen not persisted (%v) - burned fingerprint returns after restart", streamID, c.gens.paths)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCredentials возвращает учетные данные TURN, используя кеш или запрашивая их у VK.
|
||||
func (c *Client) GetCredentials(ctx context.Context, link string, streamID int) (string, string, []string, error) {
|
||||
cache := c.store.Get(streamID)
|
||||
cacheID := c.store.CacheID(streamID)
|
||||
|
||||
cache.mutex.RLock()
|
||||
if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 {
|
||||
expires := time.Until(cache.creds.ExpiresAt)
|
||||
u, p := cache.creds.Username, cache.creds.Password
|
||||
addrs := orderAddrs(cache.creds.ServerAddrs, streamID)
|
||||
cache.mutex.RUnlock()
|
||||
c.log.Debugf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v, server=%s)", streamID, cacheID, expires, addrs[0])
|
||||
return u, p, addrs, nil
|
||||
if creds, addrs, ok := cache.lookup(link, streamID); ok {
|
||||
c.log.Debugf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v, server=%s)",
|
||||
streamID, cacheID, time.Until(creds.ExpiresAt), addrs[0])
|
||||
return creds.Username, creds.Password, addrs, nil
|
||||
}
|
||||
cache.mutex.RUnlock()
|
||||
|
||||
cache.mutex.Lock()
|
||||
defer cache.mutex.Unlock()
|
||||
cache.fetchMu.Lock()
|
||||
defer cache.fetchMu.Unlock()
|
||||
|
||||
if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 {
|
||||
return cache.creds.Username, cache.creds.Password, orderAddrs(cache.creds.ServerAddrs, streamID), nil
|
||||
if creds, addrs, ok := cache.lookup(link, streamID); ok {
|
||||
return creds.Username, creds.Password, addrs, nil
|
||||
}
|
||||
|
||||
user, pass, addrs, err := c.fetchSerialized(ctx, link, streamID)
|
||||
@@ -139,20 +139,16 @@ func (c *Client) GetCredentials(ctx context.Context, link string, streamID int)
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
cache.creds = TurnCredentials{
|
||||
cache.store(TurnCredentials{
|
||||
Username: user,
|
||||
Password: pass,
|
||||
ServerAddrs: addrs,
|
||||
ExpiresAt: time.Now().Add(CredentialLifetime - CacheSafetyMargin),
|
||||
ExpiresAt: time.Now().Add(CredentialLifetime - CacheSafetyMargin).Round(0),
|
||||
Link: link,
|
||||
}
|
||||
})
|
||||
return user, pass, orderAddrs(addrs, streamID), nil
|
||||
}
|
||||
|
||||
// orderAddrs возвращает копию addrs, ротированную так, что предпочтительный
|
||||
// для streamID адрес стоит первым, остальные - следом (сохраняя порядок).
|
||||
// Раскидывает primary по стримам (балансировка relay-IP), оставляя остальные
|
||||
// как фоллбэк при DPI-дропе.
|
||||
func orderAddrs(addrs []string, streamID int) []string {
|
||||
n := len(addrs)
|
||||
if n <= 1 {
|
||||
@@ -165,9 +161,6 @@ func orderAddrs(addrs []string, streamID int) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// HandleAuthError увеличивает счётчик ошибок аутентификации кэша потока и
|
||||
// инвалидирует кэш при достижении порога внутри скользящего окна.
|
||||
// Возвращает true при инвалидации.
|
||||
func (c *Client) HandleAuthError(streamID int) bool {
|
||||
cache := c.store.Get(streamID)
|
||||
cacheID := c.store.CacheID(streamID)
|
||||
@@ -190,35 +183,36 @@ func (c *Client) HandleAuthError(streamID int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ResetErrors обнуляет счётчик ошибок аутентификации (вызывать при успешном allocate).
|
||||
func (c *Client) ResetErrors(streamID int) {
|
||||
c.store.Get(streamID).errorCount.Store(0)
|
||||
}
|
||||
|
||||
// LockoutUntilUnix возвращает unix-секунду дедлайна глобальной блокировки captcha
|
||||
// или 0, если блокировки нет.
|
||||
func (c *Client) DropCredentials(streamID int) {
|
||||
if !c.store.Get(streamID).Invalidate() {
|
||||
return
|
||||
}
|
||||
c.log.Warnf("[STREAM %d] [VK Auth] Deallocate unconfirmed - credentials dropped (cache=%d)",
|
||||
streamID, c.store.CacheID(streamID))
|
||||
}
|
||||
|
||||
func (c *Client) LockoutUntilUnix() int64 {
|
||||
return c.lockout.Load()
|
||||
}
|
||||
|
||||
// BackoffUntilUnix реализует provider.Provider - алиас LockoutUntilUnix.
|
||||
// vkauth-lockout глобальный (без per-stream), streamID-параметра в самом
|
||||
// методе нет; интерфейс provider.Provider определяет no-arg сигнатуру.
|
||||
// BackoffUntilUnix - алиас LockoutUntilUnix: lockout глобальный, а provider.Provider
|
||||
// требует no-arg сигнатуру (без streamID).
|
||||
func (c *Client) BackoffUntilUnix() int64 { return c.LockoutUntilUnix() }
|
||||
|
||||
// Name реализует provider.Provider.
|
||||
func (*Client) Name() string { return "vk" }
|
||||
|
||||
// IsAuthError оборачивает пакетный IsAuthError как метод для работы через интерфейс.
|
||||
func (*Client) IsAuthError(err error) bool { return IsAuthError(err) }
|
||||
|
||||
// engageLockout устанавливает глобальную блокировку captcha на d с момента вызова.
|
||||
func (c *Client) engageLockout(d time.Duration) {
|
||||
c.lockout.Store(time.Now().Add(d).Unix())
|
||||
}
|
||||
|
||||
// fetchSerialized соблюдает интервал 3s+jitter между запросами, затем
|
||||
// выполняет перебор всех credentials.
|
||||
// fetchSerialized сериализует fetch и держит min-интервал между запросами (анти
|
||||
// rate-limit VK).
|
||||
func (c *Client) fetchSerialized(ctx context.Context, link string, streamID int) (string, string, []string, error) {
|
||||
c.fetchMu.Lock()
|
||||
defer c.fetchMu.Unlock()
|
||||
@@ -234,34 +228,53 @@ func (c *Client) fetchSerialized(ctx context.Context, link string, streamID int)
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
defer func() { c.lastFetchTime = time.Now() }()
|
||||
return c.fetch(ctx, link, streamID)
|
||||
user, pass, addrs, err := c.fetch(ctx, link, streamID)
|
||||
if ctx.Err() == nil {
|
||||
c.lastFetchTime = time.Now()
|
||||
}
|
||||
return user, pass, addrs, err
|
||||
}
|
||||
|
||||
// fetch перебирает c.credentials, возвращая первый успех или терминальную ошибку.
|
||||
func (c *Client) fetch(ctx context.Context, link string, streamID int) (string, string, []string, error) {
|
||||
if time.Now().Unix() < c.lockout.Load() {
|
||||
return "", "", nil, fmt.Errorf("%w: %w", ErrCaptchaWaitRequired, ErrLockoutActive)
|
||||
}
|
||||
|
||||
c.captchaAttempt = 0
|
||||
|
||||
var lastErr error
|
||||
jar := tlsclient.NewCookieJar()
|
||||
for _, creds := range c.credentials {
|
||||
c.log.Infof("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID)
|
||||
burns := 0
|
||||
jar := personanet.NewCookieJar()
|
||||
for i := 0; i < len(c.credentials); {
|
||||
creds := c.credentials[i]
|
||||
c.log.Debugf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID)
|
||||
|
||||
user, pass, addrs, err := c.tokenChain(ctx, link, streamID, creds, jar)
|
||||
if err == nil {
|
||||
c.log.Infof("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID)
|
||||
c.log.Debugf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID)
|
||||
return user, pass, addrs, nil
|
||||
}
|
||||
lastErr = err
|
||||
c.log.Warnf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err)
|
||||
|
||||
if errors.Is(err, ErrCaptchaWaitRequired) || errors.Is(err, ErrFatalCaptchaNoStreams) {
|
||||
if ctx.Err() != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
es := err.Error()
|
||||
if strings.Contains(es, "error_code:29") || strings.Contains(es, "error_code: 29") || strings.Contains(es, "Rate limit") {
|
||||
c.log.Warnf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err)
|
||||
|
||||
// Личность сменилась - тот же client_id проходится заново с чистыми
|
||||
// куками, пока не кончатся режимы решения captcha.
|
||||
if errors.Is(err, ErrPersonaBurned) && burns < maxPersonaBurns {
|
||||
burns++
|
||||
jar = personanet.NewCookieJar()
|
||||
continue
|
||||
}
|
||||
i++
|
||||
|
||||
if errors.Is(err, ErrCaptchaWaitRequired) || errors.Is(err, ErrFatalCaptchaNoStreams) ||
|
||||
errors.Is(err, ErrInvalidJoinLink) || errors.Is(err, ErrAnonymousBlocked) ||
|
||||
errors.Is(err, ErrCallFull) || errors.Is(err, captcha.ErrUnavailable) {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if errors.Is(err, ErrVKRateLimit) {
|
||||
c.log.Warnf("[STREAM %d] [VK Auth] Rate limit detected, trying next credentials", streamID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/pion/stun/v3"
|
||||
)
|
||||
|
||||
// newTestClient builds a Client with a zero-interval throttle and the supplied
|
||||
@@ -30,19 +31,17 @@ func newTestClient(t *testing.T, fake tokenChainFn, opts ...func(*Client)) *Clie
|
||||
func TestIsAuthError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]bool{
|
||||
"401 Unauthorized": true,
|
||||
"alloc failed: 401": true,
|
||||
"stale nonce": true,
|
||||
"invalid credential": true,
|
||||
"authentication required": true,
|
||||
"connection refused": false,
|
||||
"": false,
|
||||
cases := map[error]bool{
|
||||
fmt.Errorf("%w: map[error_code:5]", ErrVKAuthFailed): true,
|
||||
fmt.Errorf("%w: map[error_code:29]", ErrVKRateLimit): false,
|
||||
// Порт с "401" внутри не делает сетевую ошибку ошибкой авторизации.
|
||||
errors.New("dial udp 77.88.8.8:10401: i/o timeout"): false,
|
||||
errors.New("401 Unauthorized"): false,
|
||||
errors.New("connection refused"): false,
|
||||
}
|
||||
for msg, want := range cases {
|
||||
got := IsAuthError(errors.New(msg))
|
||||
if got != want {
|
||||
t.Errorf("IsAuthError(%q) = %v, want %v", msg, got, want)
|
||||
for err, want := range cases {
|
||||
if got := IsAuthError(err); got != want {
|
||||
t.Errorf("IsAuthError(%v) = %v, want %v", err, got, want)
|
||||
}
|
||||
}
|
||||
if IsAuthError(nil) {
|
||||
@@ -50,6 +49,29 @@ func TestIsAuthError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAuthErrorTurnCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
codes := map[stun.ErrorCode]bool{
|
||||
// 486 - занята квота по валидным реквизитам, менять их незачем.
|
||||
stun.CodeAllocQuotaReached: false,
|
||||
stun.CodeUnauthorized: true,
|
||||
stun.CodeStaleNonce: true,
|
||||
stun.CodeWrongCredentials: true,
|
||||
stun.CodeInsufficientCapacity: false,
|
||||
stun.CodeForbidden: false,
|
||||
}
|
||||
for code, want := range codes {
|
||||
err := fmt.Errorf("TURN allocate: %w", &stun.TurnError{
|
||||
StunMessageType: stun.NewType(stun.MethodAllocate, stun.ClassErrorResponse),
|
||||
ErrorCodeAttr: stun.ErrorCodeAttribute{Code: code, Reason: []byte("reason")},
|
||||
})
|
||||
if got := IsAuthError(err); got != want {
|
||||
t.Errorf("IsAuthError(code %d) = %v, want %v", code, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCacheGrouping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -221,3 +243,26 @@ func TestThrottleHonorsContextCancel(t *testing.T) {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropCredentialsClearsCacheOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestClient(t, nil)
|
||||
|
||||
cache := c.store.Get(1)
|
||||
cache.creds = TurnCredentials{
|
||||
Username: "u",
|
||||
Password: "p",
|
||||
ServerAddrs: []string{"turn:1.2.3.4:19302"},
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Link: "l",
|
||||
}
|
||||
|
||||
c.DropCredentials(1)
|
||||
if cache.creds.Username != "" {
|
||||
t.Fatal("credentials survived DropCredentials")
|
||||
}
|
||||
// Второй стрим того же кэша не должен повторять сброс: иначе один обрыв даёт N строк.
|
||||
if cache.Invalidate() {
|
||||
t.Fatal("empty cache reported as invalidated")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +1,12 @@
|
||||
package vkauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netconn"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/personanet"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
// clientHelloSplitAt - фоллбэк-offset разбиения, когда SNI в ClientHello не
|
||||
// распарсился. Совпадает с TURN STUN-split.
|
||||
clientHelloSplitAt = 6
|
||||
// clientHelloSplitDelay / clientHelloSplitJitter - пауза между сегментами
|
||||
// ClientHello (base + случайная добавка [0,jitter)) для антифингерпринта тайминга.
|
||||
clientHelloSplitDelay = 20 * time.Millisecond
|
||||
clientHelloSplitJitter = 15 * time.Millisecond
|
||||
)
|
||||
|
||||
// splitDialer оборачивает base.DialContext и дробит первый Write результирующего
|
||||
// conn (TLS ClientHello) по границам внутри SNI host_name для обхода SNI-based
|
||||
// DPI RST. Реализует proxy.ContextDialer - tls-client берёт его через
|
||||
// WithProxyDialerFactory как прямой (не прокси) дилер.
|
||||
type splitDialer struct {
|
||||
base net.Dialer
|
||||
}
|
||||
|
||||
func (d *splitDialer) Dial(network, addr string) (net.Conn, error) {
|
||||
return d.DialContext(context.Background(), network, addr)
|
||||
}
|
||||
|
||||
func (d *splitDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
c, err := d.base.DialContext(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &netconn.MultiSplitWriteConn{
|
||||
Conn: c,
|
||||
Delay: clientHelloSplitDelay,
|
||||
Jitter: clientHelloSplitJitter,
|
||||
FallbackSplitAt: clientHelloSplitAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// clientProfileForKind мапит семейство браузера в TLS/HTTP2-профиль.
|
||||
func clientProfileForKind(kind browserprofile.Kind) profiles.ClientProfile {
|
||||
switch kind {
|
||||
case browserprofile.Safari:
|
||||
return profiles.Safari_16_0
|
||||
case browserprofile.Firefox:
|
||||
return profiles.Firefox_148
|
||||
}
|
||||
return profiles.Chrome_146
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient(jar tlsclient.CookieJar) (tlsclient.HttpClient, error) {
|
||||
return c.newTLSClientForKind(c.browser, jar)
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClientForKind(kind browserprofile.Kind, jar tlsclient.CookieJar) (tlsclient.HttpClient, error) {
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(),
|
||||
tlsclient.WithTimeoutSeconds(20),
|
||||
tlsclient.WithClientProfile(clientProfileForKind(kind)),
|
||||
tlsclient.WithCookieJar(jar),
|
||||
tlsclient.WithProxyDialerFactory(func(_ string, timeout time.Duration, localAddr *net.TCPAddr, _ fhttp.Header, _ tlsclient.Logger) (proxy.ContextDialer, error) {
|
||||
base := c.dialer
|
||||
base.Timeout = timeout
|
||||
if localAddr != nil {
|
||||
base.LocalAddr = localAddr
|
||||
}
|
||||
return &splitDialer{base: base}, nil
|
||||
}),
|
||||
)
|
||||
func (c *Client) newTLSClient(profile browserprofile.Profile, jar tlsclient.CookieJar) (tlsclient.HttpClient, error) {
|
||||
return personanet.NewClient(profile, c.dialer, jar)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package vkauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha"
|
||||
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
func testCaptchaErr() *captcha.Error {
|
||||
return &captcha.Error{SessionToken: "sess", RedirectURI: "https://vk.ru/captcha"}
|
||||
}
|
||||
|
||||
func TestPersonaStableUntilBurned(t *testing.T) {
|
||||
c := New(Config{FingerprintSeed: "install-1"})
|
||||
first, again := c.currentPersona(), c.currentPersona()
|
||||
if !reflect.DeepEqual(first, again) {
|
||||
t.Fatal("persona not stable")
|
||||
}
|
||||
same := New(Config{FingerprintSeed: "install-1"})
|
||||
if !reflect.DeepEqual(same.currentPersona(), c.currentPersona()) {
|
||||
t.Fatal("same seed produced different persona")
|
||||
}
|
||||
other := New(Config{FingerprintSeed: "install-2"})
|
||||
if other.currentPersona().VisitorID == c.currentPersona().VisitorID {
|
||||
t.Fatal("different seeds share visitorId")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySeedIsRandomPerProcess(t *testing.T) {
|
||||
a := New(Config{}).currentPersona()
|
||||
b := New(Config{}).currentPersona()
|
||||
if a.VisitorID == b.VisitorID {
|
||||
t.Fatal("empty seed shares visitorId across processes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBurnPersonaAdvancesIdentity(t *testing.T) {
|
||||
c := New(Config{FingerprintSeed: "install-1"})
|
||||
before := c.currentPersona()
|
||||
c.burnPersona(1)
|
||||
if c.currentPersona().VisitorID == before.VisitorID {
|
||||
t.Fatal("burn kept the rejected visitorId")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFailureBurnsPersonaAndRestartsChain(t *testing.T) {
|
||||
c := newTestClient(t, nil)
|
||||
c.autoSolver = func(context.Context, *captcha.Error, int, tlsclient.HttpClient, browserprofile.Profile) (string, error) {
|
||||
return "", errors.New("auto rejected")
|
||||
}
|
||||
var manualPersona browserprofile.Profile
|
||||
c.manualSolve = func(_ context.Context, _ *captcha.Error, _ net.Dialer, p browserprofile.Profile) (string, error) {
|
||||
manualPersona = p
|
||||
return "manual-token", nil
|
||||
}
|
||||
|
||||
burned := c.currentPersona()
|
||||
_, err := c.solveCaptcha(context.Background(), nil, burned, 1, "lnk", "name", "t1", testCaptchaErr())
|
||||
if !errors.Is(err, ErrPersonaBurned) {
|
||||
t.Fatalf("auto failure err = %v, want ErrPersonaBurned", err)
|
||||
}
|
||||
fresh := c.currentPersona()
|
||||
if fresh.VisitorID == burned.VisitorID {
|
||||
t.Fatal("auto failure kept the rejected persona")
|
||||
}
|
||||
|
||||
data, err := c.solveCaptcha(context.Background(), nil, fresh, 1, "lnk", "name", "t1", testCaptchaErr())
|
||||
if err != nil {
|
||||
t.Fatalf("manual attempt: %v", err)
|
||||
}
|
||||
if !strings.Contains(data, "success_token=manual-token") {
|
||||
t.Fatalf("retry data = %q", data)
|
||||
}
|
||||
if manualPersona.VisitorID != fresh.VisitorID {
|
||||
t.Fatal("manual ran with a persona other than the current one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExhaustedModesBurnPersonaAndLock(t *testing.T) {
|
||||
c := newTestClient(t, nil)
|
||||
c.captchaAttempt = 2
|
||||
|
||||
burned := c.currentPersona()
|
||||
_, err := c.solveCaptcha(context.Background(), nil, burned, 1, "lnk", "name", "t1", testCaptchaErr())
|
||||
if !errors.Is(err, ErrCaptchaWaitRequired) {
|
||||
t.Fatalf("err = %v, want ErrCaptchaWaitRequired", err)
|
||||
}
|
||||
if c.currentPersona().VisitorID == burned.VisitorID {
|
||||
t.Fatal("exhausted modes kept the rejected persona")
|
||||
}
|
||||
if c.LockoutUntilUnix() == 0 {
|
||||
t.Fatal("lockout not engaged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRestartsSameCredsWithFreshJar(t *testing.T) {
|
||||
var ids []string
|
||||
var jars []tlsclient.CookieJar
|
||||
calls := 0
|
||||
fake := func(_ context.Context, _ string, _ int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, []string, error) {
|
||||
calls++
|
||||
ids = append(ids, creds.ClientID)
|
||||
jars = append(jars, jar)
|
||||
if calls == 1 {
|
||||
return "", "", nil, ErrPersonaBurned
|
||||
}
|
||||
return "u", "p", []string{"1.1.1.1:443"}, nil
|
||||
}
|
||||
c := newTestClient(t, fake)
|
||||
|
||||
if _, _, _, err := c.fetch(context.Background(), "lnk", 1); err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("tokenChain called %d times, want 2", calls)
|
||||
}
|
||||
if ids[0] != ids[1] {
|
||||
t.Fatalf("restart switched client_id: %v", ids)
|
||||
}
|
||||
if jars[0] == jars[1] {
|
||||
t.Fatal("restart reused the cookie jar of the burned persona")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBoundsPersonaRestarts(t *testing.T) {
|
||||
calls := 0
|
||||
fake := func(context.Context, string, int, VKCredentials, tlsclient.CookieJar) (string, string, []string, error) {
|
||||
calls++
|
||||
return "", "", nil, ErrPersonaBurned
|
||||
}
|
||||
c := newTestClient(t, fake)
|
||||
|
||||
if _, _, _, err := c.fetch(context.Background(), "lnk", 1); err == nil {
|
||||
t.Fatal("fetch returned nil error on endless burns")
|
||||
}
|
||||
if want := len(c.credentials) + maxPersonaBurns; calls != want {
|
||||
t.Fatalf("tokenChain called %d times, want %d", calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaUnavailableKeepsPersona(t *testing.T) {
|
||||
c := newTestClient(t, nil)
|
||||
c.autoSolver = func(context.Context, *captcha.Error, int, tlsclient.HttpClient, browserprofile.Profile) (string, error) {
|
||||
return "", fmt.Errorf("%w: captcha page http 429 (bytes=120)", captcha.ErrUnavailable)
|
||||
}
|
||||
|
||||
kept := c.currentPersona()
|
||||
_, err := c.solveCaptcha(context.Background(), nil, kept, 1, "lnk", "name", "t1", testCaptchaErr())
|
||||
if !errors.Is(err, captcha.ErrUnavailable) {
|
||||
t.Fatalf("err = %v, want captcha.ErrUnavailable", err)
|
||||
}
|
||||
if c.currentPersona().VisitorID != kept.VisitorID {
|
||||
t.Fatal("persona burned on an unavailable captcha page")
|
||||
}
|
||||
if c.captchaAttempt != 0 {
|
||||
t.Fatalf("solve-mode ladder advanced to %d", c.captchaAttempt)
|
||||
}
|
||||
if c.LockoutUntilUnix() != 0 {
|
||||
t.Fatal("lockout engaged on an unavailable captcha page")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package vkauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/statedir"
|
||||
)
|
||||
|
||||
// PersonaStateFile - имя файла с поколением персоны рядом с client_config.json.
|
||||
const PersonaStateFile = "vk_persona.json"
|
||||
|
||||
// personaState переживает перезапуск: без него сожжённый отпечаток воскресает на
|
||||
// старте и VK каждый раз видит тот, что уже отверг. Seed фиксирует, чьё это
|
||||
// поколение - смена -client-id даёт новую личность и счётчик с нуля.
|
||||
type personaState struct {
|
||||
Seed string `json:"seed"`
|
||||
Gen int `json:"gen"`
|
||||
}
|
||||
|
||||
// genStore с пустым paths - no-op (тесты и хосты без записи на диск).
|
||||
type genStore struct {
|
||||
paths []string
|
||||
}
|
||||
|
||||
func (s genStore) load(seed string) int {
|
||||
for _, b := range statedir.ReadEach(s.paths) {
|
||||
var st personaState
|
||||
if json.Unmarshal(b, &st) == nil && st.Seed == seed && st.Gen > 0 {
|
||||
return st.Gen
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// genMu сериализует load+write: сравнение с диском и запись должны быть одной
|
||||
// операцией, иначе параллельные save затрут друг друга.
|
||||
//
|
||||
//nolint:gochecknoglobals // единственный писатель файла на процесс, лок процессный
|
||||
var genMu sync.Mutex
|
||||
|
||||
// save не понижает поколение: несколько vk.Provider (multi-link) делят один файл
|
||||
// и сжигают персоны независимо.
|
||||
func (s genStore) save(seed string, gen int) bool {
|
||||
if len(s.paths) == 0 {
|
||||
return false
|
||||
}
|
||||
genMu.Lock()
|
||||
defer genMu.Unlock()
|
||||
if gen <= s.load(seed) {
|
||||
return true
|
||||
}
|
||||
b, err := json.Marshal(personaState{Seed: seed, Gen: gen})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return statedir.WriteFirst(s.paths, b)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package vkauth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func statePaths(t *testing.T) []string {
|
||||
t.Helper()
|
||||
return []string{filepath.Join(t.TempDir(), PersonaStateFile)}
|
||||
}
|
||||
|
||||
func TestBurnedPersonaSurvivesRestart(t *testing.T) {
|
||||
paths := statePaths(t)
|
||||
|
||||
c := New(Config{FingerprintSeed: "install-1", StatePaths: paths})
|
||||
burned := c.currentPersona()
|
||||
c.burnPersona(1)
|
||||
fresh := c.currentPersona()
|
||||
|
||||
restarted := New(Config{FingerprintSeed: "install-1", StatePaths: paths})
|
||||
if got := restarted.currentPersona(); got.VisitorID == burned.VisitorID {
|
||||
t.Fatal("restart resurrected the burned persona")
|
||||
} else if got.VisitorID != fresh.VisitorID {
|
||||
t.Fatal("restart did not continue from the persisted generation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonaStateIsSeedScoped(t *testing.T) {
|
||||
paths := statePaths(t)
|
||||
|
||||
c := New(Config{FingerprintSeed: "install-1", StatePaths: paths})
|
||||
c.burnPersona(1)
|
||||
|
||||
other := New(Config{FingerprintSeed: "install-2", StatePaths: paths})
|
||||
if other.identity.Gen != 0 {
|
||||
t.Fatalf("gen = %d for a foreign seed, want 0", other.identity.Gen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonaStateSurvivesCorruptFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
broken, good := filepath.Join(dir, "broken.json"), filepath.Join(dir, PersonaStateFile)
|
||||
if err := os.WriteFile(broken, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(good, []byte(`{"seed":"install-1","gen":4}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := New(Config{FingerprintSeed: "install-1", StatePaths: []string{broken, good}})
|
||||
if c.identity.Gen != 4 {
|
||||
t.Fatalf("gen = %d, want 4", c.identity.Gen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenStoreKeepsHighestGen(t *testing.T) {
|
||||
s := genStore{paths: statePaths(t)}
|
||||
if !s.save("install-1", 3) || !s.save("install-1", 2) {
|
||||
t.Fatal("save reported failure")
|
||||
}
|
||||
if got := s.load("install-1"); got != 3 {
|
||||
t.Fatalf("gen = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenStoreWithoutPathsIsNoop(t *testing.T) {
|
||||
var s genStore
|
||||
if s.save("install-1", 1) || s.load("install-1") != 0 {
|
||||
t.Fatal("empty genStore touched state")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// CaptchaSolveMode - выбор между автоматическим решением и ручным браузерным fallback.
|
||||
type CaptchaSolveMode int
|
||||
|
||||
const (
|
||||
@@ -18,8 +17,6 @@ const (
|
||||
CaptchaSolveModeManual
|
||||
)
|
||||
|
||||
// CaptchaSolveModeForAttempt выбирает решалку для конкретной попытки retry.
|
||||
// Возвращает (mode, true) если режим доступен, (_, false) если исчерпан.
|
||||
func CaptchaSolveModeForAttempt(attempt int, manualOnly bool) (CaptchaSolveMode, bool) {
|
||||
if manualOnly {
|
||||
return CaptchaSolveModeManual, attempt == 0
|
||||
@@ -44,8 +41,6 @@ func CaptchaSolveModeLabel(mode CaptchaSolveMode) string {
|
||||
}
|
||||
}
|
||||
|
||||
// AutoSolveFunc возвращает success_token для captcha через in-page widget flow.
|
||||
// Реализации обязаны соблюдать отмену ctx.
|
||||
type AutoSolveFunc func(
|
||||
ctx context.Context,
|
||||
captchaErr *captcha.Error,
|
||||
@@ -54,11 +49,9 @@ type AutoSolveFunc func(
|
||||
profile browserprofile.Profile,
|
||||
) (token string, err error)
|
||||
|
||||
// ManualSolveFunc открывает локальный браузерный fallback. Возвращает либо
|
||||
// success_token (token != ""), либо captcha_key - в зависимости от пути
|
||||
// ошибки VK.
|
||||
type ManualSolveFunc func(
|
||||
ctx context.Context,
|
||||
captchaErr *captcha.Error,
|
||||
dialer net.Dialer,
|
||||
) (token, key string, err error)
|
||||
profile browserprofile.Profile,
|
||||
) (token string, err error)
|
||||
|
||||
@@ -2,8 +2,6 @@ package vkauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
)
|
||||
|
||||
func TestCaptchaSolveModeForAttempt(t *testing.T) {
|
||||
@@ -40,38 +38,3 @@ func TestCaptchaSolveModeForAttempt(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSavedProfileUsable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if savedProfileUsable(browserprofile.Saved{}) {
|
||||
t.Fatal("empty profile must be unusable (no UA to impersonate)")
|
||||
}
|
||||
if savedProfileUsable(browserprofile.Saved{Profile: browserprofile.Profile{UserAgent: " "}}) {
|
||||
t.Fatal("whitespace UA must be unusable")
|
||||
}
|
||||
mobile := browserprofile.Saved{Profile: browserprofile.Profile{
|
||||
UserAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36",
|
||||
SecChUaMobile: "?1",
|
||||
}}
|
||||
if !savedProfileUsable(mobile) {
|
||||
t.Fatal("captured mobile profile must be usable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFamily(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[browserprofile.Kind]browserprofile.Kind{
|
||||
browserprofile.Firefox: browserprofile.Firefox,
|
||||
browserprofile.Safari: browserprofile.Safari,
|
||||
browserprofile.Chrome: browserprofile.Chrome,
|
||||
"": browserprofile.Chrome,
|
||||
"weird": browserprofile.Chrome,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeFamily(in); got != want {
|
||||
t.Fatalf("normalizeFamily(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,15 @@ import (
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/namegen"
|
||||
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// getTokenChain выполняет 4-шаговый обмен токенами VK для одной пары client_id/secret
|
||||
// и возвращает тройку TURN-allocate. Ошибки captcha запускают настроенную цепочку
|
||||
// auto/manual solver.
|
||||
func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, []string, error) {
|
||||
profile := browserprofile.ForKind(c.browser)
|
||||
profile := c.currentPersona()
|
||||
|
||||
httpClient, err := c.newTLSClient(jar)
|
||||
httpClient, err := c.newTLSClient(profile, jar)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("failed to initialize tls_client: %w", err)
|
||||
}
|
||||
@@ -25,9 +21,16 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c
|
||||
name := namegen.Generate()
|
||||
escapedName := neturl.QueryEscape(name)
|
||||
|
||||
c.log.Infof("[STREAM %d] [VK Auth] Connecting Identity - Name: %s | User-Agent: %s", streamID, name, profile.UserAgent)
|
||||
c.log.Debugf("[STREAM %d] [VK Auth] Connecting Identity - Name: %s | User-Agent: %s", streamID, name, profile.UserAgent)
|
||||
|
||||
if pageErr := c.openJoinPage(ctx, httpClient, profile, link); pageErr != nil && ctx.Err() == nil {
|
||||
c.log.Warnf("[STREAM %d] [VK Auth] join page warm-up failed: %v", streamID, pageErr)
|
||||
}
|
||||
|
||||
if delayErr := vkDelayRandom(ctx, 300, 600); delayErr != nil {
|
||||
return "", "", nil, delayErr
|
||||
}
|
||||
|
||||
// Шаг 1: анонимный app-токен.
|
||||
token1, err := c.fetchAnonToken(ctx, httpClient, profile, creds)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
@@ -37,7 +40,6 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c
|
||||
return "", "", nil, delayErr
|
||||
}
|
||||
|
||||
// Шаг 1a: прогрев getCallPreview (не критично).
|
||||
previewData := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&fields=photo_200&access_token=%s", link, token1)
|
||||
if _, prevErr := c.doRequest(ctx, httpClient, profile, previewData,
|
||||
"https://api.vk.ru/method/calls.getCallPreview?v="+APIVersion+"&client_id="+creds.ClientID); prevErr != nil {
|
||||
@@ -48,7 +50,6 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c
|
||||
return "", "", nil, delayErr
|
||||
}
|
||||
|
||||
// Шаг 2: анонимный call-токен (здесь может сработать captcha).
|
||||
token2, err := c.fetchCallToken(ctx, httpClient, profile, streamID, link, escapedName, token1, creds)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
@@ -58,7 +59,6 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c
|
||||
return "", "", nil, delayErr
|
||||
}
|
||||
|
||||
// Шаг 3: ok.ru session_key.
|
||||
sessionKey, err := c.fetchOkRuSession(ctx, httpClient, profile)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
@@ -68,6 +68,5 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c
|
||||
return "", "", nil, delayErr
|
||||
}
|
||||
|
||||
// Шаг 4: TURN-реквизиты.
|
||||
return c.fetchTurnCreds(ctx, httpClient, profile, streamID, link, token2, sessionKey)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// fetchAnonToken - шаг 1 цепочки: обменивает app client_id/client_secret
|
||||
// на анонимный access token из login.vk.ru.
|
||||
func (c *Client) fetchAnonToken(ctx context.Context, httpClient tlsclient.HttpClient, profile browserprofile.Profile, creds VKCredentials) (string, error) {
|
||||
data := fmt.Sprintf("client_secret=%s&client_id=%s&scopes=audio_anonymous,video_anonymous,photos_anonymous,profile_anonymous&isApiOauthAnonymEnabled=false&version=1&app_id=%s",
|
||||
creds.ClientSecret, creds.ClientID, creds.ClientID)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
@@ -13,8 +14,6 @@ import (
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// fetchCallToken - шаг 2 цепочки: вызывает calls.getAnonymousToken и ведёт
|
||||
// цикл retry captcha до получения call-токена или исчерпания всех режимов решения.
|
||||
func (c *Client) fetchCallToken(
|
||||
ctx context.Context,
|
||||
httpClient tlsclient.HttpClient,
|
||||
@@ -27,7 +26,7 @@ func (c *Client) fetchCallToken(
|
||||
data := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&name=%s&access_token=%s",
|
||||
link, escapedName, token1)
|
||||
|
||||
for attempt := 0; ; attempt++ {
|
||||
for {
|
||||
resp, err := c.doRequest(ctx, httpClient, profile, data, urlAddr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -36,13 +35,20 @@ func (c *Client) fetchCallToken(
|
||||
if errObj, hasErr := resp["error"].(map[string]any); hasErr {
|
||||
captchaErr := captcha.ParseError(errObj)
|
||||
if captchaErr != nil && captchaErr.IsCaptcha() {
|
||||
retryData, err := c.solveCaptcha(ctx, httpClient, profile, streamID, attempt, link, escapedName, token1, captchaErr)
|
||||
retryData, err := c.solveCaptcha(ctx, httpClient, profile, streamID, link, escapedName, token1, captchaErr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data = retryData
|
||||
continue
|
||||
}
|
||||
if termErr := classifyLinkError(errObj); termErr != nil {
|
||||
c.log.Errorf("[STREAM %d] [VK Auth] terminal link error: %v", streamID, termErr)
|
||||
return "", termErr
|
||||
}
|
||||
if sentinel := apiErrSentinel(errObj); sentinel != nil {
|
||||
return "", fmt.Errorf("%w: %v", sentinel, errObj)
|
||||
}
|
||||
return "", fmt.Errorf("VK API error: %v", errObj)
|
||||
}
|
||||
|
||||
@@ -58,19 +64,64 @@ func (c *Client) fetchCallToken(
|
||||
}
|
||||
}
|
||||
|
||||
// solveCaptcha выполняет одну попытку решения captcha и возвращает тело POST
|
||||
// для следующего retry или ошибку при исчерпании всех режимов.
|
||||
func apiErrCode(errObj map[string]any) int {
|
||||
if f, ok := errObj["error_code"].(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func apiErrSentinel(errObj map[string]any) error {
|
||||
switch apiErrCode(errObj) {
|
||||
case 5:
|
||||
return ErrVKAuthFailed
|
||||
case 29:
|
||||
return ErrVKRateLimit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyLinkError(errObj map[string]any) error {
|
||||
code := apiErrCode(errObj)
|
||||
|
||||
if code == 9000 || code == 9008 {
|
||||
return ErrInvalidJoinLink
|
||||
}
|
||||
switch code {
|
||||
case 5, 6, 9, 14, 29:
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := ""
|
||||
if s, ok := errObj["error_msg"].(string); ok {
|
||||
msg = strings.ToLower(s)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(msg, "not valid") || strings.Contains(msg, "not found"):
|
||||
return ErrInvalidJoinLink
|
||||
case strings.Contains(msg, "anonym"):
|
||||
return ErrAnonymousBlocked
|
||||
case strings.Contains(msg, "full"):
|
||||
return ErrCallFull
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) solveCaptcha(
|
||||
ctx context.Context,
|
||||
httpClient tlsclient.HttpClient,
|
||||
profile browserprofile.Profile,
|
||||
streamID, attempt int,
|
||||
streamID int,
|
||||
link, escapedName, token1 string,
|
||||
captchaErr *captcha.Error,
|
||||
) (retryData string, err error) {
|
||||
attempt := c.captchaAttempt
|
||||
c.captchaAttempt++
|
||||
|
||||
solveMode, hasSolveMode := CaptchaSolveModeForAttempt(attempt, c.manualOnly)
|
||||
if !hasSolveMode {
|
||||
c.log.Warnf("[STREAM %d] [Captcha] No more solve modes available (attempt %d)", streamID, attempt+1)
|
||||
c.burnPersona(streamID)
|
||||
c.engageLockout(60 * time.Second)
|
||||
if c.streamsFn() == 0 {
|
||||
c.log.Errorf("[STREAM %d] [Captcha] FATAL: 0 connected streams and solve modes exhausted", streamID)
|
||||
@@ -80,7 +131,6 @@ func (c *Client) solveCaptcha(
|
||||
}
|
||||
|
||||
var successToken string
|
||||
var captchaKey string
|
||||
var solveErr error
|
||||
|
||||
switch solveMode {
|
||||
@@ -104,28 +154,23 @@ func (c *Client) solveCaptcha(
|
||||
break
|
||||
}
|
||||
c.log.Infof("[STREAM %d] [Captcha] Triggering manual captcha fallback", streamID)
|
||||
// Ручной решалке выделяется свой 3-минутный бюджет - жёсткий parent-deadline
|
||||
// не обрезает время пользователя. Отмена parent (завершение приложения)
|
||||
// всё равно propagate, горутина не переживает процесс.
|
||||
manualCtx, manualCancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||
|
||||
type manualRes struct {
|
||||
token string
|
||||
key string
|
||||
err error
|
||||
}
|
||||
resCh := make(chan manualRes, 1)
|
||||
go func() {
|
||||
t, k, e := c.manualSolve(manualCtx, captchaErr, c.dialer)
|
||||
resCh <- manualRes{t, k, e}
|
||||
t, e := c.manualSolve(manualCtx, captchaErr, c.dialer, profile)
|
||||
resCh <- manualRes{t, e}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resCh:
|
||||
successToken = res.token
|
||||
captchaKey = res.key
|
||||
solveErr = res.err
|
||||
if successToken != "" || captchaKey != "" {
|
||||
if successToken != "" {
|
||||
if solveErr != nil {
|
||||
c.log.Debugf("[STREAM %d] [Captcha] Token received (ignoring cleanup error: %v)", streamID, solveErr)
|
||||
solveErr = nil
|
||||
@@ -144,15 +189,31 @@ func (c *Client) solveCaptcha(
|
||||
manualCancel()
|
||||
}
|
||||
|
||||
// Сессию свернули (стоп, смена сети, рецикл) - решателю просто не дали доработать.
|
||||
// Считать это провалом значит жечь поколение персоны и вешать lockout на своих же
|
||||
// перезапусках.
|
||||
if solveErr != nil && ctx.Err() != nil {
|
||||
c.log.Warnf("[STREAM %d] [Captcha] solve interrupted by shutdown: %v", streamID, solveErr)
|
||||
return "", solveErr
|
||||
}
|
||||
|
||||
if solveErr != nil && errors.Is(solveErr, captcha.ErrUnavailable) {
|
||||
c.captchaAttempt = attempt
|
||||
c.log.Warnf("[STREAM %d] [Captcha] captcha unavailable, persona kept: %v", streamID, solveErr)
|
||||
return "", solveErr
|
||||
}
|
||||
|
||||
if solveErr != nil {
|
||||
c.log.Warnf("[STREAM %d] [Captcha] %s failed (attempt %d): %v",
|
||||
streamID, CaptchaSolveModeLabel(solveMode), attempt+1, solveErr)
|
||||
nextSolveMode, hasNextSolveMode := CaptchaSolveModeForAttempt(attempt+1, c.manualOnly)
|
||||
if hasNextSolveMode {
|
||||
c.log.Infof("[STREAM %d] [Captcha] Falling back to %s",
|
||||
c.log.Infof("[STREAM %d] [Captcha] Falling back to %s with a new persona",
|
||||
streamID, CaptchaSolveModeLabel(nextSolveMode))
|
||||
return buildCaptchaRetryData(link, escapedName, token1, captchaErr, "", captchaKey), nil
|
||||
c.burnPersona(streamID)
|
||||
return "", ErrPersonaBurned
|
||||
}
|
||||
c.burnPersona(streamID)
|
||||
c.engageLockout(60 * time.Second)
|
||||
if c.streamsFn() == 0 {
|
||||
c.log.Errorf("[STREAM %d] [Captcha] FATAL: 0 connected streams and manual captcha failed/timed out", streamID)
|
||||
@@ -164,23 +225,16 @@ func (c *Client) solveCaptcha(
|
||||
if captchaErr.CaptchaAttempt == "0" || captchaErr.CaptchaAttempt == "" {
|
||||
captchaErr.CaptchaAttempt = "1"
|
||||
}
|
||||
return buildCaptchaRetryData(link, escapedName, token1, captchaErr, successToken, captchaKey), nil
|
||||
return buildCaptchaRetryData(link, escapedName, token1, captchaErr, successToken), nil
|
||||
}
|
||||
|
||||
// buildCaptchaRetryData формирует тело POST для следующей попытки captcha.
|
||||
func buildCaptchaRetryData(link, escapedName, token1 string, captchaErr *captcha.Error, successToken, captchaKey string) string {
|
||||
func buildCaptchaRetryData(link, escapedName, token1 string, captchaErr *captcha.Error, successToken string) string {
|
||||
if captchaErr.CaptchaSid == "" {
|
||||
return fmt.Sprintf(
|
||||
"vk_join_link=https://vk.ru/call/join/%s&name=%s&success_token=%s&access_token=%s",
|
||||
link, escapedName, neturl.QueryEscape(successToken), token1,
|
||||
)
|
||||
}
|
||||
if captchaKey != "" {
|
||||
return fmt.Sprintf(
|
||||
"vk_join_link=https://vk.ru/call/join/%s&name=%s&captcha_key=%s&captcha_sid=%s&access_token=%s",
|
||||
link, escapedName, neturl.QueryEscape(captchaKey), captchaErr.CaptchaSid, token1,
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"vk_join_link=https://vk.ru/call/join/%s&name=%s&captcha_key=&captcha_sid=%s&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s&access_token=%s",
|
||||
link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken),
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// fetchTurnCreds - шаг 4 цепочки: вызывает vchat.joinConversationByLink
|
||||
// и извлекает TURN username, credential и список адресов из ответа.
|
||||
func (c *Client) fetchTurnCreds(
|
||||
ctx context.Context,
|
||||
httpClient tlsclient.HttpClient,
|
||||
@@ -45,9 +43,9 @@ func (c *Client) fetchTurnCreds(
|
||||
return "", "", nil, fmt.Errorf("missing or empty urls in turn_server")
|
||||
}
|
||||
|
||||
c.log.Infof("[STREAM %d] [VK Auth] TURN urls (%d total):", streamID, len(urlsRaw))
|
||||
c.log.Debugf("[STREAM %d] [VK Auth] TURN urls (%d total):", streamID, len(urlsRaw))
|
||||
for i, u := range urlsRaw {
|
||||
c.log.Infof("[STREAM %d] [VK Auth] [%d] %v", streamID, i, u)
|
||||
c.log.Debugf("[STREAM %d] [VK Auth] [%d] %v", streamID, i, u)
|
||||
}
|
||||
|
||||
for _, u := range urlsRaw {
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// fetchOkRuSession - шаг 3 цепочки: получает анонимный ok.ru session_key
|
||||
// через auth.anonymLogin.
|
||||
func (c *Client) fetchOkRuSession(ctx context.Context, httpClient tlsclient.HttpClient, profile browserprofile.Profile) (string, error) {
|
||||
sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New())
|
||||
data := fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA",
|
||||
|
||||
@@ -14,8 +14,34 @@ import (
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// doRequest отправляет POST form-запрос по url через tls-клиент с браузерным
|
||||
// профилем и десериализует JSON-тело ответа.
|
||||
func (c *Client) openJoinPage(ctx context.Context, httpClient tlsclient.HttpClient, profile browserprofile.Profile, link string) error {
|
||||
req, err := fhttp.NewRequestWithContext(ctx, fhttp.MethodGet, "https://vk.ru/call/join/"+link, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
|
||||
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
req.Header.Set("Sec-Fetch-User", "?1")
|
||||
req.Header.Set("Sec-Fetch-Site", "none")
|
||||
browserprofile.ApplyFhttp(req, profile)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||
c.log.Warnf("[VK Auth] close join page body: %s", closeErr)
|
||||
}
|
||||
}()
|
||||
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(ctx context.Context, httpClient tlsclient.HttpClient, profile browserprofile.Profile, data, url string) (map[string]any, error) {
|
||||
parsedURL, err := neturl.Parse(url)
|
||||
if err != nil {
|
||||
@@ -28,7 +54,6 @@ func (c *Client) doRequest(ctx context.Context, httpClient tlsclient.HttpClient,
|
||||
return nil, err
|
||||
}
|
||||
req.Host = domain
|
||||
browserprofile.ApplyFhttp(req, profile)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Origin", "https://vk.ru")
|
||||
@@ -36,9 +61,7 @@ func (c *Client) doRequest(ctx context.Context, httpClient tlsclient.HttpClient,
|
||||
req.Header.Set("Sec-Fetch-Site", "same-site")
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
if browserprofile.Family(profile) != browserprofile.Firefox {
|
||||
req.Header.Set("Priority", "u=1, i")
|
||||
}
|
||||
browserprofile.ApplyFhttp(req, profile)
|
||||
|
||||
httpResp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,13 +7,11 @@ import (
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider"
|
||||
)
|
||||
|
||||
// VKCredentials - пара app_id/app_secret для получения анонимных токенов.
|
||||
type VKCredentials struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
// TurnCredentials - разрешённые TURN-реквизиты для группы потоков.
|
||||
type TurnCredentials struct {
|
||||
Username string
|
||||
Password string
|
||||
@@ -22,23 +20,15 @@ type TurnCredentials struct {
|
||||
Link string
|
||||
}
|
||||
|
||||
// DefaultCredentials - публичные app_id/secret официальных VK-клиентов. Это НЕ
|
||||
// приватные креды пользователя - VK раздаёт их в JS-бандле страницы калла.
|
||||
// Клиент перебирает по порядку при ошибках авторизации. В списке только app_id
|
||||
// с доступом к calls.getAnonymousToken (проверено живым звонком); приложения
|
||||
// без calls-scope ("Unknown method passed") исключены.
|
||||
//
|
||||
//nolint:gosec // public VK SDK app credentials, not user secrets
|
||||
//nolint:gosec
|
||||
var DefaultCredentials = []VKCredentials{
|
||||
{ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"}, // VK_WEB_APP_ID
|
||||
{ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"}, // VK_MVK_APP_ID
|
||||
{ClientID: "2274003", ClientSecret: "hHbZxrka2uZ6jB1inYsH"}, // VK_ANDROID_APP
|
||||
{ClientID: "51453752", ClientSecret: "4UyuCUsdK8pVCNoeQuGi"}, // VK_MESSENGER_DESKTOP
|
||||
{ClientID: "3140623", ClientSecret: "VeWdmVclDCtn6ihuP1nt"}, // VK_IPHONE_APP
|
||||
{ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"},
|
||||
{ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"},
|
||||
{ClientID: "2274003", ClientSecret: "hHbZxrka2uZ6jB1inYsH"},
|
||||
{ClientID: "51453752", ClientSecret: "4UyuCUsdK8pVCNoeQuGi"},
|
||||
{ClientID: "3140623", ClientSecret: "VeWdmVclDCtn6ihuP1nt"},
|
||||
}
|
||||
|
||||
// APIVersion - версия VK API во всех calls.* запросах. Держать единой и близкой
|
||||
// к версии живого web-клиента звонка (рассинхрон = fingerprint-аномалия).
|
||||
const APIVersion = "5.282"
|
||||
|
||||
const (
|
||||
@@ -47,16 +37,19 @@ const (
|
||||
MaxCacheErrors = 3
|
||||
ErrorWindow = 10 * time.Second
|
||||
|
||||
DefaultStreamsPerCache = 10
|
||||
DefaultStreamsPerCache = 12
|
||||
|
||||
maxPersonaBurns = 2
|
||||
)
|
||||
|
||||
// Sentinel-ошибки auth-потока. Строковые формы стабильны (используются в логах).
|
||||
//
|
||||
// ErrCaptchaWaitRequired и ErrFatalCaptchaNoStreams также матчатся через
|
||||
// provider.ErrBackoffActive / provider.ErrFatalNoStreams - pipeline проверяет
|
||||
// generic-sentinels, vkauth-внутренний код может проверять и старые.
|
||||
var (
|
||||
ErrCaptchaWaitRequired = errors.Join(provider.ErrBackoffActive, errors.New("CAPTCHA_WAIT_REQUIRED"))
|
||||
ErrFatalCaptchaNoStreams = errors.Join(provider.ErrFatalNoStreams, errors.New("FATAL_CAPTCHA_FAILED_NO_STREAMS"))
|
||||
ErrLockoutActive = errors.New("global lockout active")
|
||||
ErrPersonaBurned = errors.New("persona burned")
|
||||
ErrInvalidJoinLink = errors.Join(provider.ErrFatalNoStreams, errors.New("INVALID_JOIN_LINK"))
|
||||
ErrAnonymousBlocked = errors.Join(provider.ErrFatalNoStreams, errors.New("ANON_BLOCKED"))
|
||||
ErrCallFull = errors.Join(provider.ErrFatalNoStreams, errors.New("CALL_FULL"))
|
||||
ErrVKAuthFailed = errors.New("VK authorization failed")
|
||||
ErrVKRateLimit = errors.New("VK rate limit")
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user