mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e009db0b9a | |||
| 321a0995c3 | |||
| 6f934f10f4 | |||
| ea410a044a | |||
| b4189f8f43 | |||
| 2121b55190 | |||
| 2df39d460e | |||
| 4c0bed1797 | |||
| ea1cb930cc | |||
| adcf0a0d21 | |||
| 15111ea72f | |||
| 10e6b375c6 | |||
| 3b33d33b9e | |||
| 623d9a22c2 | |||
| 8a309fbf7e | |||
| 4493fc63a2 | |||
| 859c7ff683 | |||
| f3d8da6919 | |||
| b391d7bf25 | |||
| 0a6bc0ae4f | |||
| 805228e62a | |||
| b2e1d02b8f | |||
| 4824bff766 | |||
| 12161aed4a | |||
| 3481de79a3 | |||
| 014f964ad9 |
@@ -12,7 +12,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.26.4"
|
||||
GO_VERSION: "1.26.5"
|
||||
GOLANGCI_VERSION: v2.12.2
|
||||
GOVULN_VERSION: v1.1.4
|
||||
GORELEASER_VERSION: v2.15.4
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.26.4"
|
||||
GO_VERSION: "1.26.5"
|
||||
GORELEASER_VERSION: v2.15.4
|
||||
GOMOBILE_VERSION: latest
|
||||
TASK_VERSION: 3.51.1
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.5.2"
|
||||
".": "1.7.3"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **vk:** улучшение браузерных отпечатков для captcha ([12161ae](https://github.com/samosvalishe/free-turn-proxy/commit/12161aed4ab582504785ff79f2f9f4f051ee0d9d))
|
||||
|
||||
## [1.5.2](https://github.com/samosvalishe/free-turn-proxy/compare/v1.5.1...v1.5.2) (2026-06-25)
|
||||
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
|
||||
## О проекте
|
||||
|
||||
**Free Turn Proxy** — универсальный прокси-туннель для инкапсуляции UDP/TCP трафика поверх протокола TURN. Клиент извлекает временные TURN-учётки из ссылок на WebRTC-звонки и прозрачно маршрутизирует ваш VPN-трафик (WireGuard, Xray/VLESS) до сервера на VPS, используя DTLS и мощные механизмы маскировки пакетов.
|
||||
**Free Turn Proxy** - универсальный прокси-туннель для инкапсуляции UDP/TCP трафика поверх протокола TURN. Клиент извлекает временные TURN-учётки из ссылок на WebRTC-звонки и прозрачно маршрутизирует ваш VPN-трафик (WireGuard, Xray/VLESS) до сервера на VPS, используя DTLS и мощные механизмы маскировки пакетов.
|
||||
|
||||
## Разработка
|
||||
|
||||
### Зависимости
|
||||
|
||||
- **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:
|
||||
|
||||
@@ -34,7 +34,7 @@ task build:all # кросс-сборка всех target через gorelea
|
||||
task build:ios # сборка XCFramework для iOS в dist/Mobile.xcframework
|
||||
task build:android # сборка AAR архива для Android в dist/mobile.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,6 +63,7 @@ task clean # удалить dist/, cover.out, cover.html
|
||||
- [@cacggghp](https://github.com/cacggghp)
|
||||
- [@Moroka8](https://github.com/Moroka8)
|
||||
- [@alxmcp](https://github.com/alxmcp)
|
||||
- [@TheAirBlow](https://github.com/TheAirBlow)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-1
@@ -40,7 +40,11 @@ tasks:
|
||||
cmds:
|
||||
- rm -rf dist/mobile.aar dist/mobile-sources.jar
|
||||
- mkdir -p dist
|
||||
- gomobile bind -target android -androidapi 21 -ldflags "-checklinkname=0" -o dist/mobile.aar ./mobile
|
||||
- cmd: gomobile bind -target android -androidapi 21 -ldflags "-checklinkname=0" -o dist/mobile.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'
|
||||
platforms: [windows]
|
||||
|
||||
test:
|
||||
desc: Run unit tests with race detector
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
| `-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` (только `-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 клиента (автогенерация если не задан) |
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
module github.com/samosvalishe/free-turn-proxy
|
||||
|
||||
go 1.26.4
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/bogdanfinn/fhttp v0.6.8
|
||||
github.com/bogdanfinn/tls-client v1.14.0
|
||||
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
|
||||
|
||||
@@ -10,8 +10,8 @@ github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws
|
||||
github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
|
||||
github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s=
|
||||
github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg=
|
||||
github.com/bogdanfinn/tls-client v1.14.0 h1:vyk7Cn4BIvLAGVuMfb0tP22OqogfO1lYamquQNEZU1A=
|
||||
github.com/bogdanfinn/tls-client v1.14.0/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM=
|
||||
github.com/bogdanfinn/tls-client v1.15.1 h1:KiFAlED55DJ8Fcocn+/1nX6PrDFcttIHAf/GDkV6KN8=
|
||||
github.com/bogdanfinn/tls-client v1.15.1/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM=
|
||||
github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
|
||||
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
|
||||
github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
|
||||
@@ -93,8 +93,6 @@ 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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
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=
|
||||
@@ -105,8 +103,6 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx
|
||||
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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
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/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -116,16 +112,12 @@ 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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
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/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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -134,16 +126,12 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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/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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
@@ -153,10 +141,12 @@ 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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
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/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=
|
||||
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=
|
||||
|
||||
@@ -21,6 +21,7 @@ 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).
|
||||
_ "golang.org/x/crypto/x509roots/fallback"
|
||||
@@ -98,7 +99,7 @@ func newBootstrapTransport(endpoints []DohEndpoint) *http.Transport {
|
||||
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,
|
||||
@@ -440,6 +441,7 @@ func newAppDialer(dial dialFunc) net.Dialer {
|
||||
Timeout: appDialerTimeout,
|
||||
KeepAlive: appDialerKeepAlive,
|
||||
Resolver: &net.Resolver{PreferGo: true, Dial: dial},
|
||||
Control: netctl.Apply,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,14 +78,13 @@ type ProxyOpts struct {
|
||||
Peer string // -peer: адрес серверного прокси, куда дозванивается клиент (только клиент)
|
||||
}
|
||||
|
||||
// Browser выбирает браузерный профиль (UA + TLS JA3 + client hints) для
|
||||
// control-plane запросов VK-провайдера. firefox несёт меньше client hints
|
||||
// (sec-ch-ua* - Chromium-only), chrome даёт herd-cover.
|
||||
// Browser выбирает браузерный профиль для control-plane запросов VK-провайдера.
|
||||
type Browser string
|
||||
|
||||
const (
|
||||
BrowserChrome Browser = "chrome"
|
||||
BrowserFirefox Browser = "firefox"
|
||||
BrowserSafari Browser = "safari"
|
||||
)
|
||||
|
||||
// VKOpts - опции VK-учёток и captcha (только клиент, провайдер "vk").
|
||||
@@ -93,7 +92,7 @@ type VKOpts struct {
|
||||
Links []string // -links (нормализованные join-коды); несколько = больше стримов
|
||||
StreamsPerCred int // -streams-per-cred
|
||||
ManualCaptcha bool // -manual-captcha
|
||||
Browser Browser // -browser: chrome | firefox
|
||||
Browser Browser // -browser: chrome | firefox | safari
|
||||
}
|
||||
|
||||
// ProviderOpts выбирает реализацию provider.Provider.
|
||||
@@ -192,7 +191,7 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) {
|
||||
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; только -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 клиента (автогенерация если не задан)")
|
||||
@@ -336,9 +335,9 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) {
|
||||
return nil, fmt.Errorf("-streams-per-cred must be positive")
|
||||
}
|
||||
switch c.VK.Browser {
|
||||
case BrowserChrome, BrowserFirefox:
|
||||
case BrowserChrome, BrowserFirefox, BrowserSafari:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid -browser value %q: must be %s | %s", c.VK.Browser, BrowserChrome, BrowserFirefox)
|
||||
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] == "" {
|
||||
|
||||
@@ -115,6 +115,25 @@ func TestParseClient_InvalidDNS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_BrowserSafari(t *testing.T) {
|
||||
args := append(validClientArgs(), "-browser", "safari")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_BrowserInvalid(t *testing.T) {
|
||||
args := append(validClientArgs(), "-browser", "opera")
|
||||
_, 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)
|
||||
|
||||
+69
-10
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/pion/transport/v4"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netctl"
|
||||
)
|
||||
|
||||
// DirectNet реализует transport.Net через стандартный net.
|
||||
@@ -34,32 +35,84 @@ type directTCPListener struct {
|
||||
*net.TCPListener
|
||||
}
|
||||
|
||||
// All socket-creating methods route through a net.Dialer / net.ListenConfig
|
||||
// carrying netctl.Apply as Control so the host can protect each fd from the
|
||||
// VPN tunnel. (net.DialUDP / net.ListenUDP take no Control, so we go via
|
||||
// Dialer/ListenConfig and type-assert back to the concrete UDP/TCP conn.)
|
||||
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 +140,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}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package netctl carries a process-global socket Control hook applied to every
|
||||
// OUTBOUND socket the vendored free-turn client opens. The host installs it so
|
||||
// those sockets are excluded from the VPN tunnel (Android VpnService.protect on
|
||||
// the raw fd) — without it the client's own TURN/VK/DNS traffic loops back into
|
||||
// the tunnel and hangs. This patch is local to our vendored copy: upstream has
|
||||
// no such hook, and the dialers it creates (netconn.DirectNet, dnsdial,
|
||||
// turndial) are routed through Apply below.
|
||||
package netctl
|
||||
|
||||
import "syscall"
|
||||
|
||||
var control func(network, address string, c syscall.RawConn) error
|
||||
|
||||
// SetControl installs the per-socket protector. Call before the client starts.
|
||||
// nil (the default) makes Apply a no-op, so desktop builds are unaffected.
|
||||
func SetControl(fn func(network, address string, c syscall.RawConn) error) {
|
||||
control = fn
|
||||
}
|
||||
|
||||
// Apply is assigned as the Control func of every outbound net.Dialer /
|
||||
// net.ListenConfig in the vendored client. It reads `control` at dial time so
|
||||
// the host may register the protector before or after the client starts.
|
||||
func Apply(network, address string, c syscall.RawConn) error {
|
||||
if control != nil {
|
||||
return control(network, address, c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -3,53 +3,85 @@ package browserprofile
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
// Kind - семейство браузера. Определяет UA, JA3-профиль и набор client hints.
|
||||
// Kind - семейство браузера.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
Chrome Kind = "chrome"
|
||||
Firefox Kind = "firefox"
|
||||
Safari Kind = "safari"
|
||||
)
|
||||
|
||||
// KindFromString мапит строку флага -browser в Kind. Пустое/неизвестное -
|
||||
// Firefox (текущий дефолт продукта).
|
||||
func KindFromString(s string) Kind {
|
||||
if s == string(Chrome) {
|
||||
switch s {
|
||||
case string(Chrome):
|
||||
return Chrome
|
||||
case string(Safari):
|
||||
return Safari
|
||||
}
|
||||
return Firefox
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
UserAgent string
|
||||
SecChUa string // пусто для Firefox (Chromium-only client hint)
|
||||
SecChUa string // пусто для non-Chromium профилей (Chromium-only client hint)
|
||||
SecChUaMobile string
|
||||
SecChUaPlatform string
|
||||
AcceptLanguage string
|
||||
}
|
||||
|
||||
// ForKind возвращает канонический профиль для браузера. JA3 (см.
|
||||
// vkauth.clientProfile) обязан совпадать с UA отсюда, иначе рассинхрон = флаг.
|
||||
// ForKind возвращает канонический профиль для браузера.
|
||||
func ForKind(k Kind) Profile {
|
||||
if k == Firefox {
|
||||
switch k {
|
||||
case Safari:
|
||||
return Profile{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0",
|
||||
AcceptLanguage: "en-US,en;q=0.5",
|
||||
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",
|
||||
}
|
||||
}
|
||||
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: `"Not(A:Brand";v="99", "Google Chrome";v="146", "Chromium";v="146"`,
|
||||
SecChUa: `"Google Chrome";v="146", "Chromium";v="146", "Not)A;Brand";v="24"`,
|
||||
SecChUaMobile: "?0",
|
||||
SecChUaPlatform: `"Windows"`,
|
||||
AcceptLanguage: "en-US,en;q=0.9",
|
||||
AcceptLanguage: "ru-RU,ru;q=0.9",
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
@@ -58,8 +90,16 @@ type Saved struct {
|
||||
|
||||
const profileFile = "vk_profile.json"
|
||||
|
||||
// profilePath возвращает путь к сохранённому профилю.
|
||||
func profilePath() string {
|
||||
if p := strings.TrimSpace(os.Getenv("VK_PROFILE_PATH")); p != "" {
|
||||
return p
|
||||
}
|
||||
return profileFile
|
||||
}
|
||||
|
||||
func Load() (*Saved, error) {
|
||||
data, err := os.ReadFile(profileFile)
|
||||
data, err := os.ReadFile(profilePath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -75,13 +115,11 @@ func Save(sp Saved) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(profileFile, data, 0600)
|
||||
return os.WriteFile(profilePath(), data, 0600)
|
||||
}
|
||||
|
||||
func ApplyFhttp(req *fhttp.Request, profile Profile) {
|
||||
req.Header.Set("User-Agent", profile.UserAgent)
|
||||
// sec-ch-ua* - Chromium-only client hints. Для Firefox SecChUa пуст -
|
||||
// заголовки не ставим (UA Firefox + sec-ch-ua = мгновенный флаг).
|
||||
if profile.SecChUa != "" {
|
||||
req.Header.Set("sec-ch-ua", profile.SecChUa)
|
||||
req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile)
|
||||
@@ -89,8 +127,7 @@ func ApplyFhttp(req *fhttp.Request, profile Profile) {
|
||||
}
|
||||
acceptLang := profile.AcceptLanguage
|
||||
if acceptLang == "" {
|
||||
acceptLang = "en-US,en;q=0.9"
|
||||
acceptLang = "ru-RU,ru;q=0.9"
|
||||
}
|
||||
req.Header.Set("Accept-Language", acceptLang)
|
||||
req.Header.Set("DNT", "1")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package browserprofile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
func TestKindFromString(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want Kind
|
||||
}{
|
||||
{"chrome", Chrome},
|
||||
{"firefox", Firefox},
|
||||
{"safari", Safari},
|
||||
{"", Firefox},
|
||||
{"unknown", Firefox},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := KindFromString(tc.in); got != tc.want {
|
||||
t.Fatalf("KindFromString(%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 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFhttpOmitsClientHintsForFirefoxAndSafari(t *testing.T) {
|
||||
for _, kind := range []Kind{Firefox, Safari} {
|
||||
req, err := fhttp.NewRequest(fhttp.MethodGet, "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"))
|
||||
}
|
||||
if req.Header.Get("DNT") != "" {
|
||||
t.Fatalf("%s DNT = %q, want empty", kind, req.Header.Get("DNT"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
if got := Family(Profile{UserAgent: "curl/8.0"}); got != "" {
|
||||
t.Fatalf("Family(unknown) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMobile(t *testing.T) {
|
||||
if IsMobile(ForKind(Chrome)) {
|
||||
t.Fatal("desktop chrome detected as mobile")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
neturl "net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
@@ -25,34 +27,30 @@ import (
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/randx"
|
||||
)
|
||||
|
||||
// Log - пакетный логгер. По умолчанию no-op; main устанавливает его через
|
||||
// SetLogger, чтобы captcha-вывод подчинялся глобальному -debug. Solve также
|
||||
// принимает logger параметром для DI.
|
||||
// Log - пакетный логгер.
|
||||
var Log logx.Logger = logx.Nop()
|
||||
|
||||
// SetLogger ставит логгер пакета. Безопасно вызывать один раз при старте.
|
||||
// SetLogger ставит логгер пакета.
|
||||
func SetLogger(l logx.Logger) { Log = logx.OrNop(l) }
|
||||
|
||||
const (
|
||||
captchaAPIVersion = "5.131"
|
||||
captchaScriptVersion = "1.1.1359"
|
||||
captchaDeviceInfo = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1080,"innerWidth":1920,"innerHeight":951,"devicePixelRatio":1,"language":"en-US","languages":["en-US","en"],"webdriver":false,"hardwareConcurrency":8,"notificationsPermission":"denied"}`
|
||||
captchaAPIVersion = "5.131"
|
||||
captchaAPIOrigin = "https://id.vk.ru"
|
||||
captchaDomain = "vk.ru"
|
||||
captchaDeviceInfo = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1032,"innerWidth":1147,"innerHeight":945,"devicePixelRatio":1,"language":"ru-RU","languages":["ru-RU"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":16,"connectionEffectiveType":"4g","notificationsPermission":"denied"}`
|
||||
captchaConnectionSamples = 4
|
||||
)
|
||||
|
||||
var (
|
||||
reCaptchaPowInput = regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`)
|
||||
reCaptchaDifficulty = regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`)
|
||||
reCaptchaWindowInit = regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;`)
|
||||
reCaptchaScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`)
|
||||
reCaptchaDebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`)
|
||||
reCaptchaVersion = regexp.MustCompile(`vkid/([0-9.]*)/not_robot_captcha\.js`)
|
||||
reCaptchaDebugUUID = regexp.MustCompile(`[a-zA-Z0-9_]{6,}:\s*"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"`)
|
||||
|
||||
errCaptchaRateLimit = errors.New("captcha session rate limit reached")
|
||||
errCaptchaBot = errors.New("captcha bot challenge")
|
||||
|
||||
captchaMaxAttempts = 2
|
||||
|
||||
captchaDebugCache sync.Map // scriptURL -> string
|
||||
captchaHeaderOrder = []string{
|
||||
"host",
|
||||
"content-length",
|
||||
@@ -74,25 +72,16 @@ var (
|
||||
captchaPHeaderOrder = []string{":method", ":path", ":authority", ":scheme"}
|
||||
)
|
||||
|
||||
type captchaInit struct {
|
||||
Data captchaInitData `json:"data"`
|
||||
}
|
||||
|
||||
type captchaInitData struct {
|
||||
ShowCaptchaType string `json:"show_captcha_type"`
|
||||
CaptchaSettings []captchaInitSetting `json:"captcha_settings"`
|
||||
}
|
||||
|
||||
type captchaInitSetting struct {
|
||||
Type string `json:"type"`
|
||||
Settings string `json:"settings"`
|
||||
Type string `json:"type"`
|
||||
Settings string `json:"settings"`
|
||||
SettingsKey string `json:"settings_key"`
|
||||
}
|
||||
|
||||
type captchaPage struct {
|
||||
PowInput string
|
||||
PowDifficulty int
|
||||
ScriptURL string
|
||||
Init *captchaInit
|
||||
DebugInfo string
|
||||
}
|
||||
|
||||
type captchaCheck struct {
|
||||
@@ -114,6 +103,7 @@ type captchaSession struct {
|
||||
client tlsclient.HttpClient
|
||||
profile browserprofile.Profile
|
||||
savedProfile *browserprofile.Saved
|
||||
domain string
|
||||
log logx.Logger
|
||||
}
|
||||
|
||||
@@ -124,9 +114,7 @@ func (s *captchaSession) logger() logx.Logger {
|
||||
return Log
|
||||
}
|
||||
|
||||
// Solve запускает авторешение captcha против VK captchaNotRobot API
|
||||
// и возвращает success-токен при успехе. log может быть nil - тогда
|
||||
// используется пакетный Log.
|
||||
// Solve запускает авторешение captcha против VK captchaNotRobot API.
|
||||
func Solve(
|
||||
ctx context.Context,
|
||||
captchaErr *Error,
|
||||
@@ -142,7 +130,7 @@ func Solve(
|
||||
l := logx.OrNop(log)
|
||||
l.Infof("[STREAM %d] [Captcha] Solving VK Smart Captcha automatically...", streamID)
|
||||
|
||||
s := &captchaSession{ctx: ctx, client: client, profile: profile, savedProfile: savedProfile, log: l}
|
||||
s := &captchaSession{ctx: ctx, client: client, profile: profile, savedProfile: savedProfile, domain: captchaDomain, log: l}
|
||||
|
||||
for attempt := 1; attempt <= captchaMaxAttempts; attempt++ {
|
||||
token, solveErr := s.solveOnce(captchaErr)
|
||||
@@ -150,7 +138,7 @@ func Solve(
|
||||
return token, nil
|
||||
}
|
||||
l.Warnf("[STREAM %d] [Captcha] solve attempt %d failed: %v", streamID, attempt, solveErr)
|
||||
if errors.Is(solveErr, errCaptchaRateLimit) {
|
||||
if errors.Is(solveErr, errCaptchaRateLimit) || errors.Is(solveErr, errCaptchaBot) {
|
||||
return "", solveErr
|
||||
}
|
||||
|
||||
@@ -167,41 +155,31 @@ func Solve(
|
||||
}
|
||||
|
||||
func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
|
||||
s.domain = captchaDomainFromRedirectURI(captchaErr.RedirectURI)
|
||||
s.logger().Debugf("[Captcha] using domain=%s", s.domain)
|
||||
|
||||
html, err := s.fetchCaptchaHTML(captchaErr.RedirectURI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.logger().Debugf("[Captcha] html fetched bytes=%d", len(html))
|
||||
|
||||
page, err := parseCaptchaPage(html)
|
||||
if err != nil {
|
||||
s.dumpChunks("html", html)
|
||||
return "", err
|
||||
}
|
||||
if page.PowInput == "" {
|
||||
return "", errors.New("failed to find PoW settings")
|
||||
}
|
||||
|
||||
sliderSettings := ""
|
||||
if page.Init != nil {
|
||||
for _, setting := range page.Init.Data.CaptchaSettings {
|
||||
if setting.Type == "slider" {
|
||||
sliderSettings = setting.Settings
|
||||
}
|
||||
}
|
||||
}
|
||||
if page.Init != nil && page.Init.Data.ShowCaptchaType == "slider" && sliderSettings == "" {
|
||||
return "", errors.New("failed to find slider captcha settings")
|
||||
}
|
||||
|
||||
s.logger().Debugf("[Captcha] solving pow difficulty=%d", page.PowDifficulty)
|
||||
hash := solveCaptchaPoW(s.ctx, page.PowInput, page.PowDifficulty)
|
||||
if hash == "" {
|
||||
return "", errors.New("captcha pow failed")
|
||||
}
|
||||
s.logger().Debugf("[Captcha] pow solved")
|
||||
|
||||
base := captchaBaseValues(captchaErr.SessionToken)
|
||||
if _, settingsErr := s.captchaRequest("captchaNotRobot.settings", base); settingsErr != nil {
|
||||
return "", fmt.Errorf("captcha settings failed: %w", settingsErr)
|
||||
debugInfo := page.DebugInfo
|
||||
if debugInfo == "" {
|
||||
debugInfo = newCaptchaUUID()
|
||||
s.logger().Warnf("[Captcha] debug_info uuid not in HTML; generated fallback")
|
||||
}
|
||||
|
||||
browserFP, err := captchaBrowserFP()
|
||||
@@ -212,45 +190,33 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
|
||||
browserFP = s.savedProfile.BrowserFp
|
||||
}
|
||||
|
||||
if m := reCaptchaVersion.FindStringSubmatch(page.ScriptURL); len(m) > 1 {
|
||||
if m[1] != captchaScriptVersion {
|
||||
s.logger().Warnf("[Captcha] script version drift: known=%s latest=%s", captchaScriptVersion, m[1])
|
||||
}
|
||||
base := s.captchaBaseValues(captchaErr.SessionToken)
|
||||
if _, settingsErr := s.captchaRequest("captchaNotRobot.settings", base); settingsErr != nil {
|
||||
return "", fmt.Errorf("captcha settings failed: %w", settingsErr)
|
||||
}
|
||||
|
||||
debugInfo, err := s.fetchDebugInfo(page.ScriptURL)
|
||||
initResp, err := s.captchaRequest("captchaNotRobot.initSession", [][2]string{
|
||||
{"session_token", captchaErr.SessionToken},
|
||||
{"domain", s.domain},
|
||||
{"lang", "0"},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch debug info: %w (script_version=%s)", err, captchaScriptVersion)
|
||||
return "", fmt.Errorf("captcha initSession failed: %w", err)
|
||||
}
|
||||
showType, sliderContent := parseCaptchaInitSession(initResp)
|
||||
s.logger().Debugf("[Captcha] initSession show_type=%q slider_len=%d", showType, len(sliderContent.Value))
|
||||
|
||||
showType := ""
|
||||
if page.Init != nil {
|
||||
showType = page.Init.Data.ShowCaptchaType
|
||||
}
|
||||
var token string
|
||||
for {
|
||||
s.logger().Debugf("[Captcha] solving show_type=%s", showType)
|
||||
switch showType {
|
||||
case "slider":
|
||||
token, err = s.solveSliderCaptcha(captchaErr.SessionToken, browserFP, hash, sliderSettings, debugInfo)
|
||||
case "checkbox", "":
|
||||
token, err = s.solveCheckboxCaptcha(captchaErr.SessionToken, browserFP, hash, debugInfo)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported captcha type: %s", showType)
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, errCaptchaBot) && !strings.EqualFold(showType, "slider") && sliderSettings != "" {
|
||||
s.logger().Infof("[Captcha] checkbox returned BOT, trying slider challenge")
|
||||
showType = "slider"
|
||||
continue
|
||||
}
|
||||
var stErr *captchaShowTypeError
|
||||
if !errors.As(err, &stErr) || stErr.ShowType == "" {
|
||||
return "", err
|
||||
}
|
||||
showType = stErr.ShowType
|
||||
switch showType {
|
||||
case "slider":
|
||||
token, err = s.solveSliderCaptcha(captchaErr.SessionToken, browserFP, hash, sliderContent, debugInfo)
|
||||
case "checkbox", "":
|
||||
token, err = s.solveCheckboxCaptcha(captchaErr.SessionToken, browserFP, hash, debugInfo)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported captcha type: %s", showType)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, endErr := s.captchaRequest("captchaNotRobot.endSession", base); endErr != nil {
|
||||
@@ -259,15 +225,73 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func captchaBaseValues(sessionToken string) [][2]string {
|
||||
func parseCaptchaInitSession(raw map[string]any) (string, captchaContentRef) {
|
||||
resp, ok := raw["response"].(map[string]any)
|
||||
if !ok {
|
||||
return "", captchaContentRef{}
|
||||
}
|
||||
showType := captchaStringifyAny(resp["show_captcha_type"])
|
||||
content := captchaContentRef{}
|
||||
if data, err := json.Marshal(resp["content_settings"]); err == nil {
|
||||
var settings []captchaInitSetting
|
||||
if json.Unmarshal(data, &settings) == nil {
|
||||
for _, setting := range settings {
|
||||
if setting.Type == "slider" {
|
||||
content = setting.contentRef()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return showType, content
|
||||
}
|
||||
|
||||
func newCaptchaUUID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return ""
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
// captchaContentRef несёт значение настроек слайдера.
|
||||
type captchaContentRef struct {
|
||||
Source string
|
||||
Value string
|
||||
}
|
||||
|
||||
func (s captchaInitSetting) contentRef() captchaContentRef {
|
||||
if v := strings.TrimSpace(s.SettingsKey); v != "" {
|
||||
return captchaContentRef{Source: "settings_key", Value: v}
|
||||
}
|
||||
if v := strings.TrimSpace(s.Settings); v != "" {
|
||||
return captchaContentRef{Source: "captcha_settings", Value: v}
|
||||
}
|
||||
return captchaContentRef{}
|
||||
}
|
||||
|
||||
func (s *captchaSession) captchaBaseValues(sessionToken string) [][2]string {
|
||||
return [][2]string{
|
||||
{"session_token", sessionToken},
|
||||
{"domain", "vk.ru"},
|
||||
{"domain", s.domain},
|
||||
{"adFp", ""},
|
||||
{"access_token", ""},
|
||||
}
|
||||
}
|
||||
|
||||
func captchaDomainFromRedirectURI(redirectURI string) string {
|
||||
u, err := neturl.Parse(redirectURI)
|
||||
if err != nil {
|
||||
return captchaDomain
|
||||
}
|
||||
domain := strings.TrimSpace(u.Query().Get("domain"))
|
||||
if domain == "" {
|
||||
return captchaDomain
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
func captchaBrowserFP() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -289,63 +313,38 @@ func (s *captchaSession) fetchCaptchaHTML(redirectURI string) (string, error) {
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func (s *captchaSession) fetchDebugInfo(scriptURL string) (string, error) {
|
||||
if cached, ok := captchaDebugCache.Load(scriptURL); ok {
|
||||
if cachedDebugInfo, ok := cached.(string); ok {
|
||||
return cachedDebugInfo, nil
|
||||
}
|
||||
captchaDebugCache.Delete(scriptURL)
|
||||
func (s *captchaSession) dumpChunks(label, body string) {
|
||||
const chunk = 1800
|
||||
total := (len(body) + chunk - 1) / chunk
|
||||
l := s.logger()
|
||||
l.Debugf("[Captcha][DUMP] %s bytes=%d chunks=%d", label, len(body), total)
|
||||
for i := range total {
|
||||
start := i * chunk
|
||||
end := min(start+chunk, len(body))
|
||||
l.Debugf("[Captcha][DUMP %s %d/%d] %s", label, i+1, total, body[start:end])
|
||||
}
|
||||
body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{
|
||||
"Accept": "text/javascript,*/*",
|
||||
"Referer": "https://id.vk.ru/",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m := reCaptchaDebugInfo.FindSubmatch(body)
|
||||
if len(m) < 2 {
|
||||
return "", errors.New("debug_info match not found")
|
||||
}
|
||||
v := string(m[1])
|
||||
captchaDebugCache.Store(scriptURL, v)
|
||||
s.logger().Debugf("[Captcha] debug_info fetched url=%s", scriptURL)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func parseCaptchaPage(html string) (*captchaPage, error) {
|
||||
page := &captchaPage{}
|
||||
|
||||
match := reCaptchaWindowInit.FindStringSubmatch(html)
|
||||
if len(match) < 2 {
|
||||
return nil, errors.New("captcha init json not found")
|
||||
}
|
||||
var init captchaInit
|
||||
if err := json.Unmarshal([]byte(match[1]), &init); err != nil {
|
||||
return nil, fmt.Errorf("captcha init json parse: %w", err)
|
||||
}
|
||||
page.Init = &init
|
||||
|
||||
match = reCaptchaScriptSrc.FindStringSubmatch(html)
|
||||
if len(match) < 2 {
|
||||
return nil, errors.New("captcha script url not found")
|
||||
}
|
||||
page.ScriptURL = match[1]
|
||||
|
||||
if m := reCaptchaPowInput.FindStringSubmatch(html); len(m) >= 2 {
|
||||
page.PowInput = m[1]
|
||||
}
|
||||
if page.PowInput == "" {
|
||||
return page, nil
|
||||
if m := reCaptchaDebugUUID.FindStringSubmatch(html); len(m) >= 2 {
|
||||
page.DebugInfo = m[1]
|
||||
}
|
||||
|
||||
match = reCaptchaDifficulty.FindStringSubmatch(html)
|
||||
if len(match) < 2 {
|
||||
m := reCaptchaPowInput.FindStringSubmatch(html)
|
||||
if len(m) < 2 {
|
||||
return nil, errors.New("captcha powInput not found")
|
||||
}
|
||||
page.PowInput = m[1]
|
||||
|
||||
dm := reCaptchaDifficulty.FindStringSubmatch(html)
|
||||
if len(dm) < 2 {
|
||||
return nil, errors.New("captcha difficulty const not found")
|
||||
}
|
||||
difficulty, err := strconv.Atoi(match[1])
|
||||
difficulty, err := strconv.Atoi(dm[1])
|
||||
if err != nil || difficulty <= 0 {
|
||||
return nil, fmt.Errorf("invalid captcha difficulty %q", match[1])
|
||||
return nil, fmt.Errorf("invalid captcha difficulty %q", dm[1])
|
||||
}
|
||||
page.PowDifficulty = difficulty
|
||||
return page, nil
|
||||
@@ -353,11 +352,14 @@ func parseCaptchaPage(html string) (*captchaPage, error) {
|
||||
|
||||
func (s *captchaSession) captchaRequest(method string, form [][2]string) (map[string]any, error) {
|
||||
endpoint := "https://api.vk.ru/method/" + method + "?v=" + captchaAPIVersion
|
||||
body, err := s.doRaw(fhttp.MethodPost, endpoint, form, map[string]string{
|
||||
"Origin": "https://id.vk.ru",
|
||||
"Referer": "https://id.vk.ru/",
|
||||
"Priority": "u=1, i",
|
||||
})
|
||||
headers := map[string]string{
|
||||
"Origin": captchaAPIOrigin,
|
||||
"Referer": captchaAPIOrigin + "/",
|
||||
}
|
||||
if browserprofile.Family(s.profile) != browserprofile.Firefox {
|
||||
headers["Priority"] = "u=1, i"
|
||||
}
|
||||
body, err := s.doRaw(fhttp.MethodPost, endpoint, form, headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -365,6 +367,7 @@ func (s *captchaSession) captchaRequest(method string, form [][2]string) (map[st
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("captcha api decode: %w", err)
|
||||
}
|
||||
s.logger().Debugf("[Captcha] api %s response=%s", method, captchaAPIResponseSummary(out))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -376,27 +379,35 @@ func (s *captchaSession) performCaptchaCheck(
|
||||
cursor string,
|
||||
debugInfo string,
|
||||
) (*captchaCheck, error) {
|
||||
values := [][2]string{
|
||||
{"session_token", sessionToken},
|
||||
{"domain", "vk.ru"},
|
||||
{"adFp", ""},
|
||||
{"accelerometer", "[]"},
|
||||
{"gyroscope", "[]"},
|
||||
{"motion", "[]"},
|
||||
{"cursor", cursor},
|
||||
{"taps", "[]"},
|
||||
{"connectionRtt", "[]"},
|
||||
{"connectionDownlink", "[]"},
|
||||
{"browser_fp", browserFP},
|
||||
{"hash", hash},
|
||||
{"answer", base64.StdEncoding.EncodeToString([]byte(answerJSON))},
|
||||
{"debug_info", debugInfo},
|
||||
{"access_token", ""},
|
||||
mobile := browserprofile.IsMobile(s.profile)
|
||||
accelerometer := "[]"
|
||||
if mobile {
|
||||
accelerometer = captchaMobileAccelerometer()
|
||||
}
|
||||
values := make([][2]string, 0, 15)
|
||||
values = append(values,
|
||||
[2]string{"session_token", sessionToken},
|
||||
[2]string{"domain", s.domain},
|
||||
[2]string{"adFp", ""},
|
||||
[2]string{"accelerometer", accelerometer},
|
||||
[2]string{"gyroscope", "[]"},
|
||||
[2]string{"motion", "[]"},
|
||||
[2]string{"cursor", cursor},
|
||||
[2]string{"taps", "[]"},
|
||||
)
|
||||
values = append(values, captchaConnectionFields(browserprofile.Family(s.profile), mobile)...)
|
||||
values = append(values,
|
||||
[2]string{"browser_fp", browserFP},
|
||||
[2]string{"hash", hash},
|
||||
[2]string{"answer", base64.StdEncoding.EncodeToString([]byte(answerJSON))},
|
||||
[2]string{"debug_info", debugInfo},
|
||||
[2]string{"access_token", ""},
|
||||
)
|
||||
resp, err := s.captchaRequest("captchaNotRobot.check", values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("captcha check failed: %w", err)
|
||||
}
|
||||
s.logger().Debugf("[Captcha] check payload answer_bytes=%d cursor_bytes=%d debug_info=%t", len(answerJSON), len(cursor), debugInfo != "")
|
||||
check, err := parseCaptchaCheck(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -432,12 +443,15 @@ func (s *captchaSession) solveCheckboxCaptcha(
|
||||
debugInfo string,
|
||||
) (string, error) {
|
||||
deviceJSON := captchaDeviceInfo
|
||||
deviceSource := "default"
|
||||
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
|
||||
deviceJSON = s.savedProfile.DeviceJSON
|
||||
deviceSource = "saved"
|
||||
}
|
||||
s.logger().Debugf("[Captcha] checkbox componentDone device_source=%s device_bytes=%d", deviceSource, len(deviceJSON))
|
||||
if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
|
||||
{"session_token", sessionToken},
|
||||
{"domain", "vk.ru"},
|
||||
{"domain", s.domain},
|
||||
{"adFp", ""},
|
||||
{"browser_fp", browserFP},
|
||||
{"device", deviceJSON},
|
||||
@@ -479,8 +493,6 @@ func solveCaptchaPoW(ctx context.Context, input string, difficulty int) string {
|
||||
return ""
|
||||
}
|
||||
target := strings.Repeat("0", difficulty)
|
||||
// ctx-check каждые 1024 итерации - задержка отмены в пределах нескольких мс
|
||||
// даже на слабом ARM (было 4096).
|
||||
buf := make([]byte, 0, len(input)+20)
|
||||
buf = append(buf, input...)
|
||||
for nonce := 1; nonce <= 10_000_000; nonce++ {
|
||||
@@ -520,8 +532,8 @@ func (s *captchaSession) doRaw(
|
||||
req.Header.Set("Sec-Fetch-Site", "same-site")
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
req.Header.Set("Origin", "https://vk.ru")
|
||||
req.Header.Set("Referer", "https://vk.ru/")
|
||||
req.Header.Set("Origin", captchaAPIOrigin)
|
||||
req.Header.Set("Referer", captchaAPIOrigin+"/")
|
||||
if form != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
@@ -531,8 +543,10 @@ func (s *captchaSession) doRaw(
|
||||
req.Header[fhttp.HeaderOrderKey] = captchaHeaderOrder
|
||||
req.Header[fhttp.PHeaderOrderKey] = captchaPHeaderOrder
|
||||
|
||||
start := time.Now()
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
s.logger().Debugf("[Captcha] http %s %s failed after=%s form=%s err=%v", method, captchaSafeURL(endpoint), time.Since(start).Truncate(time.Millisecond), captchaFormSummary(form), err)
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
@@ -540,7 +554,62 @@ func (s *captchaSession) doRaw(
|
||||
s.logger().Warnf("[Captcha] close body: %s", closeErr)
|
||||
}
|
||||
}()
|
||||
return io.ReadAll(resp.Body)
|
||||
data, readErr := io.ReadAll(resp.Body)
|
||||
s.logger().Debugf("[Captcha] http %s %s status=%d bytes=%d after=%s form=%s", method, captchaSafeURL(endpoint), resp.StatusCode, len(data), time.Since(start).Truncate(time.Millisecond), captchaFormSummary(form))
|
||||
return data, readErr
|
||||
}
|
||||
|
||||
func captchaAPIResponseSummary(raw map[string]any) string {
|
||||
if errData, ok := raw["error"].(map[string]any); ok {
|
||||
return fmt.Sprintf("error code=%s msg=%q keys=%s", captchaStringifyAny(errData["error_code"]), captchaStringifyAny(errData["error_msg"]), captchaMapKeys(errData))
|
||||
}
|
||||
if resp, ok := raw["response"].(map[string]any); ok {
|
||||
status := captchaStringifyAny(resp["status"])
|
||||
showType := captchaStringifyAny(resp["show_captcha_type"])
|
||||
tokenLen := len(captchaStringifyAny(resp["success_token"]))
|
||||
return fmt.Sprintf("ok status=%q show_type=%q success_token_len=%d keys=%s", status, showType, tokenLen, captchaMapKeys(resp))
|
||||
}
|
||||
return "unknown keys=" + captchaMapKeys(raw)
|
||||
}
|
||||
|
||||
func captchaMapKeys(m map[string]any) string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return strings.Join(keys, ",")
|
||||
}
|
||||
|
||||
func captchaSafeURL(raw string) string {
|
||||
u, err := neturl.Parse(raw)
|
||||
if err != nil {
|
||||
return "<invalid-url>"
|
||||
}
|
||||
if u.Host == "" {
|
||||
return u.Path
|
||||
}
|
||||
path := u.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
return u.Host + path
|
||||
}
|
||||
|
||||
func captchaFormSummary(values [][2]string) string {
|
||||
if len(values) == 0 {
|
||||
return "none"
|
||||
}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, kv := range values {
|
||||
switch kv[0] {
|
||||
case "session_token", "browser_fp", "hash", "answer", "debug_info", "device", "settings_key", "captcha_settings":
|
||||
parts = append(parts, fmt.Sprintf("%s:%d", kv[0], len(kv[1])))
|
||||
default:
|
||||
parts = append(parts, kv[0])
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func captchaEncodeForm(values [][2]string) string {
|
||||
@@ -598,3 +667,98 @@ func captchaStringifyAny(value any) string {
|
||||
return string(data)
|
||||
}
|
||||
}
|
||||
|
||||
// captchaConnectionFields возвращает Chrome-only поля NetworkInformation API.
|
||||
func captchaConnectionFields(family browserprofile.Kind, mobile bool) [][2]string {
|
||||
if family != browserprofile.Chrome {
|
||||
return nil
|
||||
}
|
||||
n := captchaConnectionSamples
|
||||
if mobile {
|
||||
rtt := 50 * (1 + randx.Intn(2)) // 50 | 100
|
||||
dl := 1.4 + randx.Float64()*0.7 // [1.4, 2.1)
|
||||
return [][2]string{
|
||||
{"connectionRtt", captchaRepeatNumberJSON(rtt, n)},
|
||||
{"connectionDownlink", captchaRepeatFloatJSON(round2(dl), n)},
|
||||
}
|
||||
}
|
||||
rtt := 50 * (1 + randx.Intn(3)) // 50 | 100 | 150
|
||||
dl := 5.0 + randx.Float64()*5.0 // [5, 10)
|
||||
return [][2]string{
|
||||
{"connectionRtt", captchaRepeatNumberJSON(rtt, n)},
|
||||
{"connectionDownlink", captchaRepeatFloatJSON(round1(dl), n)},
|
||||
}
|
||||
}
|
||||
|
||||
// captchaMobileAccelerometer генерирует сэмплы покоящегося телефона.
|
||||
func captchaMobileAccelerometer() string {
|
||||
type sample struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
}
|
||||
const g = 9.81
|
||||
bx := -2.5 + randx.Float64()*5.0 // [-2.5, 2.5)
|
||||
by := 4.0 + randx.Float64()*4.0 // [4, 8)
|
||||
bz := g
|
||||
if rem := g*g - bx*bx - by*by; rem > 0 {
|
||||
bz = math.Sqrt(rem)
|
||||
}
|
||||
jitter := func() float64 { return (randx.Float64() - 0.5) * 0.2 } // ±0.1
|
||||
pts := make([]sample, 3)
|
||||
for i := range pts {
|
||||
pts[i] = sample{X: round1(bx + jitter()), Y: round1(by + jitter()), Z: round1(bz + jitter())}
|
||||
}
|
||||
data, err := json.Marshal(pts)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func round1(v float64) float64 { return math.Round(v*10) / 10 }
|
||||
func round2(v float64) float64 { return math.Round(v*100) / 100 }
|
||||
|
||||
func captchaRepeatFloatJSON(value float64, count int) string {
|
||||
if count <= 0 {
|
||||
return "[]"
|
||||
}
|
||||
s := strconv.FormatFloat(value, 'g', -1, 64)
|
||||
parts := make([]string, count)
|
||||
for i := range parts {
|
||||
parts[i] = s
|
||||
}
|
||||
return "[" + strings.Join(parts, ",") + "]"
|
||||
}
|
||||
|
||||
func captchaCursorPointCount(cursor string) int {
|
||||
cursor = strings.TrimSpace(cursor)
|
||||
if cursor == "" || cursor == "[]" {
|
||||
return 0
|
||||
}
|
||||
var points []struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(cursor), &points); err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(points)
|
||||
}
|
||||
|
||||
func captchaRepeatNumberJSON(value int, count int) string {
|
||||
if count <= 0 {
|
||||
return "[]"
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.Grow(count*4 + 2)
|
||||
sb.WriteByte('[')
|
||||
for i := 0; i < count; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
sb.WriteString(strconv.Itoa(value))
|
||||
}
|
||||
sb.WriteByte(']')
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
)
|
||||
|
||||
func TestCaptchaInitSettingContentRefPrefersSettingsKey(t *testing.T) {
|
||||
setting := captchaInitSetting{
|
||||
Type: "slider",
|
||||
Settings: "legacy-settings",
|
||||
SettingsKey: "new-settings-key",
|
||||
}
|
||||
|
||||
got := setting.contentRef()
|
||||
if got.Source != "settings_key" || got.Value != "new-settings-key" {
|
||||
t.Fatalf("contentRef = %+v, want settings_key/new-settings-key", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaInitSettingContentRefLegacySettings(t *testing.T) {
|
||||
setting := captchaInitSetting{
|
||||
Type: "slider",
|
||||
Settings: "legacy-settings",
|
||||
}
|
||||
|
||||
got := setting.contentRef()
|
||||
if got.Source != "captcha_settings" || got.Value != "legacy-settings" {
|
||||
t.Fatalf("contentRef = %+v, want captcha_settings/legacy-settings", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCaptchaPageSPA(t *testing.T) {
|
||||
html := `<html><head><script>
|
||||
const powInput = "Pihj7tyAHFxdwm4t";
|
||||
const difficulty = 2;
|
||||
</script></head><body><div id="spa_root"></div>
|
||||
<script>window.vk = { statsMeta: {"hash":"39RzeEGG2Qq7t4YUwG"}, brlefapmjnpg: "1d958dbe-0663-42f2-95e5-0e2780ed2f93", id: 0 };</script>
|
||||
</body></html>`
|
||||
|
||||
page, err := parseCaptchaPage(html)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.PowInput != "Pihj7tyAHFxdwm4t" || page.PowDifficulty != 2 {
|
||||
t.Fatalf("pow parse = %q/%d", page.PowInput, page.PowDifficulty)
|
||||
}
|
||||
if page.DebugInfo != "1d958dbe-0663-42f2-95e5-0e2780ed2f93" {
|
||||
t.Fatalf("debug_info = %q, want brlefapmjnpg uuid", page.DebugInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCaptchaPageMissingPoW(t *testing.T) {
|
||||
if _, err := parseCaptchaPage(`<html><body><div id="spa_root"></div></body></html>`); err == nil {
|
||||
t.Fatal("expected error when powInput/difficulty absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveCaptchaPoWRawHex(t *testing.T) {
|
||||
got := solveCaptchaPoW(context.Background(), "input", 1)
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("pow = %q, want 64-hex", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "0") {
|
||||
t.Fatalf("pow = %q, want leading zero for difficulty 1", got)
|
||||
}
|
||||
if again := solveCaptchaPoW(context.Background(), "input", 1); again != got {
|
||||
t.Fatalf("pow not deterministic: %q vs %q", got, again)
|
||||
}
|
||||
}
|
||||
|
||||
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 content.Value != "sliderkey" || content.Source != "settings_key" {
|
||||
t.Fatalf("content = %+v, want sliderkey/settings_key", content)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaDomainFromRedirectURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
redirectURI string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "vk com from query",
|
||||
redirectURI: "https://id.vk.ru/not_robot_captcha?domain=vk.com&session_token=x",
|
||||
want: "vk.com",
|
||||
},
|
||||
{
|
||||
name: "vk ru from query",
|
||||
redirectURI: "https://id.vk.ru/not_robot_captcha?domain=vk.ru&session_token=x",
|
||||
want: "vk.ru",
|
||||
},
|
||||
{
|
||||
name: "fallback without domain",
|
||||
redirectURI: "https://id.vk.ru/not_robot_captcha?session_token=x",
|
||||
want: captchaDomain,
|
||||
},
|
||||
{
|
||||
name: "fallback invalid url",
|
||||
redirectURI: "%",
|
||||
want: captchaDomain,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := captchaDomainFromRedirectURI(tt.redirectURI); got != tt.want {
|
||||
t.Fatalf("domain = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaMobileAccelerometerIsGravity(t *testing.T) {
|
||||
var pts []struct{ X, Y, Z float64 }
|
||||
if err := json.Unmarshal([]byte(captchaMobileAccelerometer()), &pts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("reverseSwapPairs = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ type Error struct {
|
||||
ErrorCode int
|
||||
ErrorMsg string
|
||||
CaptchaSid string
|
||||
CaptchaImg string
|
||||
RedirectURI string
|
||||
IsSoundCaptchaAvailable bool
|
||||
SessionToken string
|
||||
@@ -39,13 +38,9 @@ func ParseError(errData map[string]any) *Error {
|
||||
if !ok {
|
||||
if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 {
|
||||
captchaSid = fmt.Sprintf("%.0f", sidNum)
|
||||
} else {
|
||||
Log.Debugf("[Captcha] captcha_sid not present in error data")
|
||||
}
|
||||
}
|
||||
|
||||
captchaImg, _ := errData["captcha_img"].(string)
|
||||
|
||||
errorMsg, ok := errData["error_msg"].(string)
|
||||
if !ok {
|
||||
Log.Warnf("[Captcha] missing error_msg in error data")
|
||||
@@ -90,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,25 @@
|
||||
package captcha
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseErrorSmartCaptchaWithoutSid(t *testing.T) {
|
||||
errData := map[string]any{
|
||||
"error_code": float64(14),
|
||||
"error_msg": "Captcha needed",
|
||||
"redirect_uri": "https://id.vk.ru/not_robot_captcha?domain=vk.com&session_token=token",
|
||||
}
|
||||
|
||||
got := ParseError(errData)
|
||||
if got == nil {
|
||||
t.Fatal("ParseError returned nil")
|
||||
}
|
||||
if !got.IsCaptcha() {
|
||||
t.Fatalf("IsCaptcha = false, got %+v", got)
|
||||
}
|
||||
if got.CaptchaSid != "" {
|
||||
t.Fatalf("CaptchaSid = %q, want empty", got.CaptchaSid)
|
||||
}
|
||||
if got.SessionToken != "token" {
|
||||
t.Fatalf("SessionToken = %q, want token", got.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);
|
||||
})();
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -42,6 +42,16 @@ func SetLogger(l logx.Logger) { Log = logx.OrNop(l) }
|
||||
|
||||
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
|
||||
@@ -68,9 +78,9 @@ func localCaptchaHosts() []string {
|
||||
|
||||
func isAllowedProxyHost(hostname string) bool {
|
||||
allowed := []string{
|
||||
".vk.ru", ".vk.ru", ".vkontakte.ru",
|
||||
".vk.com", ".vk.ru", ".vkontakte.ru",
|
||||
".userapi.com", ".okcdn.ru", ".mycdn.me",
|
||||
".api.vk.ru",
|
||||
".api.vk.com", ".api.vk.ru",
|
||||
}
|
||||
for _, suffix := range allowed {
|
||||
if strings.HasSuffix(hostname, suffix) || hostname == suffix[1:] {
|
||||
@@ -296,199 +306,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>.
|
||||
@@ -592,39 +410,6 @@ 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
|
||||
}
|
||||
@@ -663,6 +448,7 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
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,
|
||||
@@ -710,10 +496,7 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
|
||||
},
|
||||
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()))
|
||||
http.Error(w, "ошибка прокси капчи, детали в консоли клиента", http.StatusBadGateway)
|
||||
},
|
||||
ModifyResponse: func(res *http.Response) error {
|
||||
rewriteProxyCookies(res.Header)
|
||||
@@ -847,10 +630,7 @@ func solveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer, p
|
||||
// разрешаем cross-origin доступ к ресурсу
|
||||
res.Header.Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// captchaNotRobot.check уходит на api.vk.ru (другой хост, чем
|
||||
// upstream vk.ru), поэтому идёт через /generic_proxy. Извлекаем
|
||||
// success_token здесь - серверный путь работает на iOS даже если
|
||||
// JS-callback в браузере не сработал.
|
||||
// captchaNotRobot.check идёт через /generic_proxy на api.vk.com/api.vk.ru.
|
||||
if strings.Contains(targetAuthURL, "captchaNotRobot.check") {
|
||||
bodyBytes, readErr := io.ReadAll(res.Body)
|
||||
if readErr == nil {
|
||||
|
||||
@@ -32,6 +32,7 @@ type sliderGuess struct {
|
||||
Score int64
|
||||
ScoreRGB int64
|
||||
ScoreLuma int64
|
||||
ScoreReverse int64
|
||||
ScoreText float64
|
||||
ConsensusRank int
|
||||
}
|
||||
@@ -40,15 +41,19 @@ func (s *captchaSession) solveSliderCaptcha(
|
||||
sessionToken string,
|
||||
browserFP string,
|
||||
hash string,
|
||||
settings string,
|
||||
content captchaContentRef,
|
||||
debugInfo string,
|
||||
) (string, error) {
|
||||
if content.Value == "" {
|
||||
return "", errors.New("slider content settings missing")
|
||||
}
|
||||
s.logger().Debugf("[Captcha] slider getContent source=%s len=%d", content.Source, len(content.Value))
|
||||
values := [][2]string{
|
||||
{"session_token", sessionToken},
|
||||
{"domain", "vk.ru"},
|
||||
{"domain", s.domain},
|
||||
{"adFp", ""},
|
||||
{"access_token", ""},
|
||||
{"captcha_settings", settings},
|
||||
{"captcha_settings", content.Value},
|
||||
}
|
||||
|
||||
resp, err := s.captchaRequest("captchaNotRobot.getContent", values)
|
||||
@@ -59,7 +64,7 @@ func (s *captchaSession) solveSliderCaptcha(
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
Log.Debugf("[Captcha] slider puzzle decoded: grid=%d attempts=%d swaps=%d", puzzle.Size, puzzle.Attempts, len(puzzle.Swaps))
|
||||
s.logger().Debugf("[Captcha] slider puzzle decoded: grid=%d attempts=%d swaps=%d", puzzle.Size, puzzle.Attempts, len(puzzle.Swaps))
|
||||
|
||||
guesses, err := rankSliderGuesses(puzzle.Image, puzzle.Size, puzzle.Swaps)
|
||||
if err != nil {
|
||||
@@ -73,15 +78,18 @@ func (s *captchaSession) solveSliderCaptcha(
|
||||
if limit <= 0 {
|
||||
return "", errors.New("slider has no attempts available")
|
||||
}
|
||||
Log.Debugf("[Captcha] slider guesses ranked: total=%d limit=%d", len(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", "vk.ru"},
|
||||
{"domain", s.domain},
|
||||
{"adFp", ""},
|
||||
{"access_token", ""},
|
||||
{"browser_fp", browserFP},
|
||||
@@ -91,7 +99,7 @@ func (s *captchaSession) solveSliderCaptcha(
|
||||
}
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
Log.Debugf("[Captcha] slider attempt %d/%d (guess #%d)", i+1, limit, guesses[i].Index)
|
||||
s.logger().Debugf("[Captcha] slider attempt %d/%d (guess #%d)", i+1, limit, guesses[i].Index)
|
||||
answerData, err := json.Marshal(struct {
|
||||
Value []int `json:"value"`
|
||||
}{Value: guesses[i].Swaps})
|
||||
@@ -109,11 +117,12 @@ func (s *captchaSession) solveSliderCaptcha(
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.logger().Debugf("[Captcha] slider attempt %d status=%s show_type=%s token_len=%d", i+1, check.Status, check.ShowType, len(check.SuccessToken))
|
||||
if strings.EqualFold(check.Status, "ok") {
|
||||
if check.SuccessToken == "" {
|
||||
return "", errors.New("captcha success token not found")
|
||||
}
|
||||
Log.Infof("[Captcha] slider accepted on attempt %d", i+1)
|
||||
s.logger().Infof("[Captcha] slider accepted on attempt %d", i+1)
|
||||
return check.SuccessToken, nil
|
||||
}
|
||||
if strings.EqualFold(check.Status, "error_limit") {
|
||||
@@ -211,6 +220,15 @@ 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...)
|
||||
@@ -226,9 +244,6 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
|
||||
}
|
||||
|
||||
stage2Count := candidateCount
|
||||
if stage2Count > 12 {
|
||||
stage2Count = 12
|
||||
}
|
||||
stage2Set := make(map[int]struct{}, stage2Count)
|
||||
for i := 0; i < stage2Count; i++ {
|
||||
stage2Set[lumaOrder[i].Index] = struct{}{}
|
||||
@@ -314,9 +329,21 @@ 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]
|
||||
g.ConsensusRank = lumaRank[g.Index] + reverseRank[g.Index]
|
||||
if _, ok := stage2Set[g.Index]; ok {
|
||||
g.ConsensusRank += rgbRank[g.Index] + textRank[g.Index]
|
||||
} else {
|
||||
@@ -337,6 +364,14 @@ 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])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func activeSwapsForIndex(swaps []int, index int) []int {
|
||||
if index <= 0 {
|
||||
return []int{}
|
||||
@@ -580,41 +615,45 @@ func buildSliderCursor(candidateIndex int, candidateCount int) string {
|
||||
targetX := baseTargetX + randx.Intn(10) - 5
|
||||
targetY := 655 + randx.Intn(14)
|
||||
|
||||
points := make([]cursorPoint, 0, 28)
|
||||
points := make([]cursorPoint, 0, 128)
|
||||
|
||||
for i := 0; i < 1+randx.Intn(3); i++ {
|
||||
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 := 2 + randx.Intn(3)
|
||||
transitSteps := 32 + randx.Intn(26)
|
||||
arcOffX := randx.Intn(60) - 30
|
||||
arcOffY := -(randx.Intn(30) + 10)
|
||||
for i := 1; i <= transitSteps; i++ {
|
||||
t := float64(i) / float64(transitSteps+1)
|
||||
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)*8) + 2
|
||||
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 := 4 + randx.Intn(4)
|
||||
approachSteps := 16 + randx.Intn(14)
|
||||
prev := points[len(points)-1]
|
||||
for i := 1; i <= approachSteps; i++ {
|
||||
t := float64(i) / float64(approachSteps)
|
||||
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 := 3 + randx.Intn(5)
|
||||
settleCount := 18 + randx.Intn(24)
|
||||
for i := 0; i < settleCount; i++ {
|
||||
points = append(points, cursorPoint{
|
||||
X: targetX + randx.Intn(7) - 3,
|
||||
@@ -628,3 +667,16 @@ func buildSliderCursor(candidateIndex int, candidateCount int) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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"
|
||||
@@ -10,10 +11,7 @@ import (
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// DefaultAutoSolve запускает in-page widget flow. Загружает сохранённый профиль
|
||||
// браузера, если доступен, - капча видит стабильные fingerprints на этапе
|
||||
// загрузки страницы и POST-запроса.
|
||||
func DefaultAutoSolve(
|
||||
func (c *Client) defaultAutoSolve(
|
||||
ctx context.Context,
|
||||
captchaErr *captcha.Error,
|
||||
streamID int,
|
||||
@@ -31,10 +29,25 @@ func DefaultAutoSolve(
|
||||
}
|
||||
|
||||
var savedProfile *browserprofile.Saved
|
||||
if sp, err := browserprofile.Load(); err == nil {
|
||||
log.Infof("[STREAM %d] [Captcha] Using saved real browser profile", streamID)
|
||||
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)
|
||||
@@ -44,3 +57,16 @@ func DefaultAutoSolve(
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,9 @@ func (c *Client) fetch(ctx context.Context, link string, streamID int) (string,
|
||||
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 errors.Is(err, ErrCaptchaWaitRequired) || errors.Is(err, ErrFatalCaptchaNoStreams) ||
|
||||
errors.Is(err, ErrInvalidJoinLink) || errors.Is(err, ErrAnonymousBlocked) ||
|
||||
errors.Is(err, ErrCallFull) {
|
||||
return "", "", nil, err
|
||||
}
|
||||
es := err.Error()
|
||||
|
||||
@@ -49,23 +49,25 @@ func (d *splitDialer) DialContext(ctx context.Context, network, addr string) (ne
|
||||
}, nil
|
||||
}
|
||||
|
||||
// clientProfile выбирает TLS/HTTP2-профиль (JA3 + client hints) под браузер.
|
||||
// JA3 обязан совпадать с UA из browserprofile.ForKind, иначе рассинхрон = флаг.
|
||||
func (c *Client) clientProfile() profiles.ClientProfile {
|
||||
if c.browser == browserprofile.Firefox {
|
||||
return profiles.Firefox_147
|
||||
// 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
|
||||
}
|
||||
|
||||
// newTLSClient строит tls-client с Chrome-fingerprint и фрагментацией
|
||||
// ClientHello на всех исходящих control-plane TLS-соединениях. Базовый дилер -
|
||||
// c.dialer (несёт DNS-резолвер dnsdial); фабрика вызывается без proxyUrl, поэтому
|
||||
// CONNECT не используется - splitDialer работает как прямой транспорт.
|
||||
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(c.clientProfile()),
|
||||
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
|
||||
|
||||
@@ -54,11 +54,9 @@ type AutoSolveFunc func(
|
||||
profile browserprofile.Profile,
|
||||
) (token string, err error)
|
||||
|
||||
// ManualSolveFunc открывает локальный браузерный fallback. Возвращает либо
|
||||
// success_token (token != ""), либо captcha_key - в зависимости от пути
|
||||
// ошибки VK.
|
||||
// ManualSolveFunc открывает локальный браузерный fallback и возвращает success_token.
|
||||
type ManualSolveFunc func(
|
||||
ctx context.Context,
|
||||
captchaErr *captcha.Error,
|
||||
dialer net.Dialer,
|
||||
) (token, key string, err error)
|
||||
) (token string, err error)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package vkauth
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
)
|
||||
|
||||
func TestCaptchaSolveModeForAttempt(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -36,3 +40,38 @@ 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,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
@@ -43,6 +44,10 @@ func (c *Client) fetchCallToken(
|
||||
data = retryData
|
||||
continue
|
||||
}
|
||||
if termErr := classifyLinkError(errObj); termErr != nil {
|
||||
c.log.Errorf("[STREAM %d] [VK Auth] terminal link error: %v", streamID, termErr)
|
||||
return "", termErr
|
||||
}
|
||||
return "", fmt.Errorf("VK API error: %v", errObj)
|
||||
}
|
||||
|
||||
@@ -58,6 +63,48 @@ func (c *Client) fetchCallToken(
|
||||
}
|
||||
}
|
||||
|
||||
// classifyLinkError распознаёт ТЕРМИНАЛЬНЫЕ ответы VK в join-флоу и возвращает
|
||||
// соответствующий sentinel (или nil, если ошибку можно ретраить дальше по
|
||||
// client_id). Терминал = ни client_id, ни captcha не помогут, поэтому fast-fail
|
||||
// вместо бесконечного цикла "решить captcha -> ошибка -> следующий client_id".
|
||||
//
|
||||
// Порядок важен: сначала явные коды (терминальные и транзиентные), и только для
|
||||
// неизвестного кода - матч по тексту error_msg. Так текстовый матч не может
|
||||
// перекрыть транзиентный код (5 auth-failed, 6/9/29 rate/flood, 14 captcha) и
|
||||
// убить рабочее подключение из-за подстроки в сообщении.
|
||||
func classifyLinkError(errObj map[string]any) error {
|
||||
code := 0
|
||||
if f, ok := errObj["error_code"].(float64); ok {
|
||||
code = int(f)
|
||||
}
|
||||
|
||||
// Явные терминальные коды: 9008 Join link is not valid, 9000 Call not found.
|
||||
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
|
||||
// Анонимный вход запрещён. Матчим по "anonym" (не "authoriz" - коллизия с auth-failed).
|
||||
case strings.Contains(msg, "anonym"):
|
||||
return ErrAnonymousBlocked
|
||||
case strings.Contains(msg, "full"):
|
||||
return ErrCallFull
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// solveCaptcha выполняет одну попытку решения captcha и возвращает тело POST
|
||||
// для следующего retry или ошибку при исчерпании всех режимов.
|
||||
func (c *Client) solveCaptcha(
|
||||
@@ -80,14 +127,13 @@ func (c *Client) solveCaptcha(
|
||||
}
|
||||
|
||||
var successToken string
|
||||
var captchaKey string
|
||||
var solveErr error
|
||||
|
||||
switch solveMode {
|
||||
case CaptchaSolveModeAuto:
|
||||
solveFn := c.autoSolver
|
||||
if solveFn == nil {
|
||||
solveFn = DefaultAutoSolve
|
||||
solveFn = c.defaultAutoSolve
|
||||
}
|
||||
if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" {
|
||||
successToken, solveErr = solveFn(ctx, captchaErr, streamID, httpClient, profile)
|
||||
@@ -111,21 +157,19 @@ func (c *Client) solveCaptcha(
|
||||
|
||||
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)
|
||||
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
|
||||
@@ -151,7 +195,7 @@ func (c *Client) solveCaptcha(
|
||||
if hasNextSolveMode {
|
||||
c.log.Infof("[STREAM %d] [Captcha] Falling back to %s",
|
||||
streamID, CaptchaSolveModeLabel(nextSolveMode))
|
||||
return buildCaptchaRetryData(link, escapedName, token1, captchaErr, "", captchaKey), nil
|
||||
return buildCaptchaRetryData(link, escapedName, token1, captchaErr, ""), nil
|
||||
}
|
||||
c.engageLockout(60 * time.Second)
|
||||
if c.streamsFn() == 0 {
|
||||
@@ -164,23 +208,17 @@ 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),
|
||||
|
||||
@@ -36,7 +36,9 @@ 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")
|
||||
req.Header.Set("Priority", "u=1, i")
|
||||
if browserprofile.Family(profile) != browserprofile.Firefox {
|
||||
req.Header.Set("Priority", "u=1, i")
|
||||
}
|
||||
|
||||
httpResp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -59,4 +59,19 @@ 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")
|
||||
|
||||
// Terminal call/link errors: retrying other client_ids or solving the captcha
|
||||
// won't help — only the user changing the link / call settings will. We fast-
|
||||
// fail on these instead of looping "solve captcha -> error -> next credential"
|
||||
// forever, and the joined provider.ErrFatalNoStreams lets the host surface a
|
||||
// specific, actionable message.
|
||||
//
|
||||
// ErrInvalidJoinLink: VK 9008 "Join link is not valid" (broken link) or 9000
|
||||
// "Call not found" (the call ended / no longer exists).
|
||||
ErrInvalidJoinLink = errors.Join(provider.ErrFatalNoStreams, errors.New("INVALID_JOIN_LINK"))
|
||||
// ErrAnonymousBlocked: the call is live but anonymous join is disabled
|
||||
// ("authorized users only") — the user must allow joining by link in VK.
|
||||
ErrAnonymousBlocked = errors.Join(provider.ErrFatalNoStreams, errors.New("ANON_BLOCKED"))
|
||||
// ErrCallFull: the call has reached its participant limit.
|
||||
ErrCallFull = errors.Join(provider.ErrFatalNoStreams, errors.New("CALL_FULL"))
|
||||
)
|
||||
|
||||
@@ -14,9 +14,9 @@ var proxyManualMu sync.Mutex
|
||||
|
||||
// ProxyManualSolver показывает UI после запуска локального captcha-прокси.
|
||||
func ProxyManualSolver(show func(url string), hide func()) ManualSolverFunc {
|
||||
return func(ctx context.Context, e *captcha.Error, dialer net.Dialer) (string, string, error) {
|
||||
return func(ctx context.Context, e *captcha.Error, dialer net.Dialer) (string, error) {
|
||||
if e.RedirectURI == "" {
|
||||
return "", "", fmt.Errorf("manual captcha: no redirect_uri")
|
||||
return "", fmt.Errorf("manual captcha: no redirect_uri")
|
||||
}
|
||||
|
||||
proxyManualMu.Lock()
|
||||
@@ -25,7 +25,6 @@ func ProxyManualSolver(show func(url string), hide func()) ManualSolverFunc {
|
||||
defer hide()
|
||||
}
|
||||
|
||||
token, err := manualcaptcha.SolveViaProxyWithPresenter(ctx, e.RedirectURI, dialer, show)
|
||||
return token, "", err
|
||||
return manualcaptcha.SolveViaProxyWithPresenter(ctx, e.RedirectURI, dialer, show)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ type Config struct {
|
||||
// ManualOnly форсирует ручной путь captcha с первой попытки.
|
||||
ManualOnly bool
|
||||
|
||||
// Browser - браузерный профиль control-plane: "chrome" | "firefox".
|
||||
// Browser - браузерный профиль control-plane: "chrome" | "firefox" | "safari".
|
||||
// Пустое -> firefox (дефолт продукта).
|
||||
Browser string
|
||||
|
||||
@@ -113,14 +113,9 @@ func (*Provider) Name() string { return "vk" }
|
||||
|
||||
// DefaultManualSolver - стандартный manual-captcha solver, использует
|
||||
// internal/provider/vk/internal/captcha/manual (HTTP-сервер 127.0.0.1:8765 + браузер).
|
||||
func DefaultManualSolver(ctx context.Context, e *captcha.Error, d net.Dialer) (string, string, error) {
|
||||
if e.RedirectURI != "" {
|
||||
t, err := manualcaptcha.SolveViaProxy(ctx, e.RedirectURI, d)
|
||||
return t, "", err
|
||||
func DefaultManualSolver(ctx context.Context, e *captcha.Error, d net.Dialer) (string, error) {
|
||||
if e.RedirectURI == "" {
|
||||
return "", fmt.Errorf("no redirect_uri")
|
||||
}
|
||||
if e.CaptchaImg != "" {
|
||||
k, err := manualcaptcha.SolveViaHTTP(ctx, e.CaptchaImg)
|
||||
return "", k, err
|
||||
}
|
||||
return "", "", fmt.Errorf("no redirect_uri or captcha_img")
|
||||
return manualcaptcha.SolveViaProxy(ctx, e.RedirectURI, d)
|
||||
}
|
||||
|
||||
@@ -271,16 +271,11 @@ func createSmuxSession(ctx context.Context, deps *Deps, params *Params, peer *ne
|
||||
return nil, nil, fmt.Errorf("send client ID: %w", werr)
|
||||
}
|
||||
|
||||
statsCtx, statsCancel := context.WithCancel(ctx) //nolint:gosec // cancel is held in cleanupFns and invoked via cleanup() LIFO
|
||||
cleanupFns = append(cleanupFns, statsCancel)
|
||||
st := stats.New(deps.log().DebugEnabled())
|
||||
go st.LogEvery(statsCtx, deps.log().Debugf, fmt.Sprintf("[session %d] TCP", id), "to-turn", "from-turn")
|
||||
|
||||
var countedConn net.Conn = dtlsConn
|
||||
if params.TrafficStats != nil {
|
||||
countedConn = &stats.CountingConn{Conn: countedConn, Stats: params.TrafficStats}
|
||||
}
|
||||
kcpSess, err := kcptun.NewKCPOverDTLS(&stats.CountingConn{Conn: countedConn, Stats: st}, false, params.KCPProfile, params.KCPFEC)
|
||||
kcpSess, err := kcptun.NewKCPOverDTLS(countedConn, false, params.KCPProfile, params.KCPFEC)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("KCP session: %w", err)
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/logx"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netconn"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/proxy/bondserver"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/stats"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/transport/kcptun"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/wire/bondframe"
|
||||
"github.com/xtaci/smux"
|
||||
@@ -24,18 +23,7 @@ import (
|
||||
// TCP-соединение к connectAddr. Потоки, чьи первые 4 байта совпадают с bond magic,
|
||||
// передаются в registry.
|
||||
func Handle(ctx context.Context, logger logx.Logger, registry *bondserver.Registry, dtlsConn net.Conn, connectAddr string, kcpProfile kcptun.Profile, kcpFEC kcptun.FEC) {
|
||||
statsCtx, statsCancel := context.WithCancel(ctx)
|
||||
defer statsCancel()
|
||||
st := stats.New(logger.DebugEnabled())
|
||||
go st.LogEvery(
|
||||
statsCtx,
|
||||
logger.Debugf,
|
||||
"[VLESS "+dtlsConn.RemoteAddr().String()+"]",
|
||||
"to-client",
|
||||
"from-client",
|
||||
)
|
||||
|
||||
kcpSess, err := kcptun.NewKCPOverDTLS(&stats.CountingConn{Conn: dtlsConn, Stats: st}, true, kcpProfile, kcpFEC)
|
||||
kcpSess, err := kcptun.NewKCPOverDTLS(dtlsConn, true, kcpProfile, kcpFEC)
|
||||
if err != nil {
|
||||
logger.Errorf("tcpfwdserver: KCP session: %s", err)
|
||||
return
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/proxy/common"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/randx"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/stats"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/wire/shape"
|
||||
)
|
||||
|
||||
@@ -258,8 +257,6 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr,
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
turnctx, turncancel := context.WithCancel(ctx)
|
||||
st := stats.New(deps.log().DebugEnabled())
|
||||
go st.LogEvery(turnctx, deps.log().Debugf, fmt.Sprintf("[STREAM %d] TURN", streamID), "to-turn", "from-turn")
|
||||
|
||||
context.AfterFunc(turnctx, func() {
|
||||
if err := relayConn.SetDeadline(time.Now()); err != nil {
|
||||
@@ -330,7 +327,6 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr,
|
||||
}
|
||||
|
||||
written, err1 := relayConn.WriteTo(out, peer)
|
||||
st.AddTx(written)
|
||||
if params.TrafficStats != nil {
|
||||
params.TrafficStats.AddTx(written)
|
||||
}
|
||||
@@ -367,7 +363,6 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr,
|
||||
}
|
||||
payload = p
|
||||
}
|
||||
st.AddRx(len(payload))
|
||||
if params.TrafficStats != nil {
|
||||
params.TrafficStats.AddRx(len(payload))
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ package udpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/logx"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/stats"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,14 +32,6 @@ func Handle(ctx context.Context, logger logx.Logger, conn net.Conn, connectAddr
|
||||
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
st := stats.New(logger.DebugEnabled())
|
||||
go st.LogEvery(
|
||||
ctx2,
|
||||
logger.Debugf,
|
||||
fmt.Sprintf("[DTLS %s]", conn.RemoteAddr()),
|
||||
"dtls-to-backend",
|
||||
"backend-to-dtls",
|
||||
)
|
||||
|
||||
context.AfterFunc(ctx2, func() {
|
||||
if err := conn.SetDeadline(time.Now()); err != nil {
|
||||
@@ -55,18 +45,18 @@ func Handle(ctx context.Context, logger logx.Logger, conn net.Conn, connectAddr
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer cancel()
|
||||
copyOne(ctx2, logger, conn, serverConn, st.AddTx)
|
||||
copyOne(ctx2, logger, conn, serverConn)
|
||||
})
|
||||
wg.Go(func() {
|
||||
defer cancel()
|
||||
copyOne(ctx2, logger, serverConn, conn, st.AddRx)
|
||||
copyOne(ctx2, logger, serverConn, conn)
|
||||
})
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// copyOne читает из src и пишет в dst до отмены ctx или ошибки любой стороны.
|
||||
// Каждый read/write сбрасывает idle timeout - зависший конец закрывается, а не висит.
|
||||
func copyOne(ctx context.Context, logger logx.Logger, src, dst net.Conn, count func(int)) {
|
||||
func copyOne(ctx context.Context, logger logx.Logger, src, dst net.Conn) {
|
||||
buf := make([]byte, udpRelayBufSize)
|
||||
for {
|
||||
select {
|
||||
@@ -87,9 +77,7 @@ func copyOne(ctx context.Context, logger logx.Logger, src, dst net.Conn, count f
|
||||
logger.Errorf("udpserver: set write deadline: %s", werr)
|
||||
return
|
||||
}
|
||||
written, werr := dst.Write(buf[:n])
|
||||
count(written)
|
||||
if werr != nil {
|
||||
if _, werr := dst.Write(buf[:n]); werr != nil {
|
||||
logger.Debugf("udpserver: write: %s", werr)
|
||||
return
|
||||
}
|
||||
|
||||
+1
-46
@@ -3,15 +3,13 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Stats хранит счётчики tx/rx байт. При enabled=false Add* - no-op,
|
||||
// LogEvery возвращается сразу (поведение под isDebug).
|
||||
// Stats хранит счётчики tx/rx байт. При enabled=false Add* - no-op.
|
||||
type Stats struct {
|
||||
tx atomic.Uint64
|
||||
rx atomic.Uint64
|
||||
@@ -49,49 +47,6 @@ func (s *Stats) AddRx(n int) {
|
||||
s.rx.Add(uint64(n))
|
||||
}
|
||||
|
||||
// LogEvery каждые 5с печатает сводку пропускной способности через logf,
|
||||
// пока не отменён ctx. No-op, если Stats выключен.
|
||||
func (s *Stats) LogEvery(ctx context.Context, logf func(string, ...any), label, txName, rxName string) {
|
||||
if !s.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
var prevTx, prevRx uint64
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
tx := s.tx.Load()
|
||||
rx := s.rx.Load()
|
||||
deltaTx := tx - prevTx
|
||||
deltaRx := rx - prevRx
|
||||
prevTx = tx
|
||||
prevRx = rx
|
||||
|
||||
if deltaTx == 0 && deltaRx == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
logf(
|
||||
"%s throughput: %s=%s %s=%s total_%s=%s total_%s=%s",
|
||||
label,
|
||||
txName,
|
||||
FormatBitsPerSecond(deltaTx, 5*time.Second),
|
||||
rxName,
|
||||
FormatBitsPerSecond(deltaRx, 5*time.Second),
|
||||
txName,
|
||||
FormatByteCount(tx),
|
||||
rxName,
|
||||
FormatByteCount(rx),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FormatBitsPerSecond форматирует пропускную способность из числа байт и интервала.
|
||||
func FormatBitsPerSecond(bytes uint64, interval time.Duration) string {
|
||||
if interval <= 0 {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/pion/logging"
|
||||
"github.com/pion/turn/v5"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netconn"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netctl"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/randx"
|
||||
)
|
||||
|
||||
@@ -83,10 +84,15 @@ func Open(ctx context.Context, cfg Config, peer *net.UDPAddr, user, pass, rawAdd
|
||||
closeConn func() error
|
||||
)
|
||||
if cfg.TransportUDP {
|
||||
c, derr := net.DialUDP("udp", nil, turnServerUDPAddr) //nolint:noctx
|
||||
raw, derr := (&net.Dialer{Control: netctl.Apply}).Dial("udp", turnServerUDPAddr.String())
|
||||
if derr != nil {
|
||||
return nil, fmt.Errorf("dial TURN (udp): %w", derr)
|
||||
}
|
||||
c, ok := raw.(*net.UDPConn)
|
||||
if !ok {
|
||||
_ = raw.Close()
|
||||
return nil, fmt.Errorf("turndial: expected *net.UDPConn, got %T", raw)
|
||||
}
|
||||
turnConn = &netconn.ConnectedUDPConn{UDPConn: c}
|
||||
closeConn = c.Close
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package mobile
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netctl"
|
||||
)
|
||||
|
||||
// Protector is implemented by the host to keep the client's own sockets out of
|
||||
// a VPN tunnel it is feeding. On Android that is VpnService.protect(fd); on
|
||||
// other tun-based hosts the equivalent. Without it the client's TURN / VK API /
|
||||
// DNS traffic is routed back into the tunnel and the connection deadlocks.
|
||||
type Protector interface {
|
||||
// Protect is invoked once for every outbound socket the client opens, with
|
||||
// the socket's raw file descriptor, before it connects.
|
||||
Protect(fd int) bool
|
||||
}
|
||||
|
||||
// SetProtect installs the host socket protector. Pass nil to clear it (the
|
||||
// default is a no-op, so desktop builds are unaffected). May be called before
|
||||
// or after Start; it is read at dial time.
|
||||
func SetProtect(p Protector) {
|
||||
if p == nil {
|
||||
netctl.SetControl(nil)
|
||||
return
|
||||
}
|
||||
netctl.SetControl(func(_, _ string, c syscall.RawConn) error {
|
||||
return c.Control(func(fd uintptr) { p.Protect(int(fd)) })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user