mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2121b55190 | |||
| 2df39d460e | |||
| 4c0bed1797 | |||
| ea1cb930cc | |||
| adcf0a0d21 | |||
| 15111ea72f | |||
| 10e6b375c6 | |||
| 3b33d33b9e | |||
| 623d9a22c2 | |||
| 8a309fbf7e | |||
| 4493fc63a2 | |||
| 859c7ff683 | |||
| f3d8da6919 | |||
| b391d7bf25 | |||
| 0a6bc0ae4f | |||
| 805228e62a | |||
| 3481de79a3 | |||
| 014f964ad9 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.6.0"
|
||||
".": "1.7.2"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func SetLogger(l logx.Logger) { Log = logx.OrNop(l) }
|
||||
|
||||
const (
|
||||
captchaAPIVersion = "5.131"
|
||||
captchaScriptVersion = "1.1.1370"
|
||||
captchaScriptVersion = "1.1.1374"
|
||||
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"}`
|
||||
@@ -49,6 +49,7 @@ var (
|
||||
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})"`)
|
||||
reCaptchaHex64 = regexp.MustCompile(`"([a-fA-F0-9]{64})"`)
|
||||
reCaptchaVersion = regexp.MustCompile(`vkid/([0-9.]*)/not_robot_captcha\.js`)
|
||||
|
||||
errCaptchaRateLimit = errors.New("captcha session rate limit reached")
|
||||
@@ -57,6 +58,7 @@ var (
|
||||
captchaMaxAttempts = 2
|
||||
|
||||
captchaDebugCache sync.Map // scriptURL -> string
|
||||
captchaVersionSeen sync.Map // script version -> struct{} (drift warned once)
|
||||
captchaHeaderOrder = []string{
|
||||
"host",
|
||||
"content-length",
|
||||
@@ -224,11 +226,7 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
|
||||
}
|
||||
s.logger().Debugf("[Captcha] browser_fp source=%s len=%d", browserFPSource, len(browserFP))
|
||||
|
||||
if m := reCaptchaVersion.FindStringSubmatch(page.ScriptURL); len(m) > 1 {
|
||||
if m[1] != captchaScriptVersion {
|
||||
s.logger().Debugf("[Captcha] script version drift: known=%s latest=%s", captchaScriptVersion, m[1])
|
||||
}
|
||||
}
|
||||
s.warnVersionDrift(page.ScriptURL)
|
||||
|
||||
debugInfo, err := s.fetchDebugInfo(page.ScriptURL)
|
||||
if err != nil {
|
||||
@@ -331,16 +329,54 @@ func (s *captchaSession) fetchDebugInfo(scriptURL string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m := reCaptchaDebugInfo.FindSubmatch(body)
|
||||
if len(m) < 2 {
|
||||
return "", errors.New("debug_info match not found")
|
||||
v, usedFallback, err := extractDebugInfo(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if usedFallback {
|
||||
s.logger().Warnf("[Captcha] debug_info primary pattern missed; used windowed fallback (script format drift)")
|
||||
}
|
||||
v := string(m[1])
|
||||
captchaDebugCache.Store(scriptURL, v)
|
||||
s.logger().Debugf("[Captcha] debug_info fetched url=%s", scriptURL)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// warnVersionDrift предупреждает один раз на версию, если VK отдал скрипт новее
|
||||
// оттестированного baseline: wire мог измениться, надо перепроверить против live.
|
||||
func (s *captchaSession) warnVersionDrift(scriptURL string) {
|
||||
m := reCaptchaVersion.FindStringSubmatch(scriptURL)
|
||||
if len(m) < 2 || m[1] == "" || m[1] == captchaScriptVersion {
|
||||
return
|
||||
}
|
||||
if _, seen := captchaVersionSeen.LoadOrStore(m[1], struct{}{}); seen {
|
||||
return
|
||||
}
|
||||
s.logger().Warnf("[Captcha] script version %s differs from tested %s; wire unverified - re-check if BOT-rejections rise", m[1], captchaScriptVersion)
|
||||
}
|
||||
|
||||
// extractDebugInfo достаёт fallback-константу debug_info из тела скрипта.
|
||||
// VK-формат: `debug_info:(...window.vk.X)||"<64hex>"`; имя window.vk.X плавает
|
||||
// от билда к билду, поэтому основной паттерн ловит только hash-литерал.
|
||||
// usedFallback=true - основной паттерн промазал, взят первый 64-hex в окне после
|
||||
// `debug_info` (страховка смены обёртки). Иначе - ошибка со снапшотом окна.
|
||||
func extractDebugInfo(body []byte) (value string, usedFallback bool, err error) {
|
||||
if m := reCaptchaDebugInfo.FindSubmatch(body); len(m) >= 2 {
|
||||
return string(m[1]), false, nil
|
||||
}
|
||||
idx := bytes.Index(body, []byte("debug_info"))
|
||||
if idx < 0 {
|
||||
return "", false, errors.New("debug_info marker not found in script")
|
||||
}
|
||||
end := idx + 256
|
||||
if end > len(body) {
|
||||
end = len(body)
|
||||
}
|
||||
if m := reCaptchaHex64.FindSubmatch(body[idx:end]); len(m) >= 2 {
|
||||
return string(m[1]), true, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("debug_info match not found near: %q", body[idx:end])
|
||||
}
|
||||
|
||||
func parseCaptchaPage(html string) (*captchaPage, error) {
|
||||
page := &captchaPage{}
|
||||
|
||||
|
||||
@@ -77,6 +77,59 @@ func TestEncodeCaptchaPoW(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDebugInfo(t *testing.T) {
|
||||
const hash = "59f60d917b13be6a22c076adb2c17df37302c5314d8353a27e72f9fbcc9b4838"
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
wantFallback bool
|
||||
fail bool
|
||||
}{
|
||||
{
|
||||
name: "primary vk format",
|
||||
body: `x,debug_info:(null===(r=window.vk)||void 0===r?void 0:r.brlefapmjnpg)||"` + hash + `"});var a`,
|
||||
want: hash,
|
||||
},
|
||||
{
|
||||
name: "windowed fallback no or-wrapper",
|
||||
body: `debug_info: someRuntimeCall(),foo:"` + hash + `"`,
|
||||
want: hash,
|
||||
wantFallback: true,
|
||||
},
|
||||
{
|
||||
name: "no hash near marker",
|
||||
body: `debug_info:window.vk.x||"tooshort"`,
|
||||
fail: true,
|
||||
},
|
||||
{
|
||||
name: "no marker",
|
||||
body: `nothing here`,
|
||||
fail: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, usedFallback, err := extractDebugInfo([]byte(tt.body))
|
||||
if tt.fail {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("extractDebugInfo = %q, want %q", got, tt.want)
|
||||
}
|
||||
if usedFallback != tt.wantFallback {
|
||||
t.Fatalf("usedFallback = %v, want %v", usedFallback, tt.wantFallback)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaDomainFromRedirectURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -11,7 +11,6 @@ type Error struct {
|
||||
ErrorCode int
|
||||
ErrorMsg string
|
||||
CaptchaSid string
|
||||
CaptchaImg string
|
||||
RedirectURI string
|
||||
IsSoundCaptchaAvailable bool
|
||||
SessionToken string
|
||||
@@ -42,8 +41,6 @@ func ParseError(errData map[string]any) *Error {
|
||||
}
|
||||
}
|
||||
|
||||
captchaImg, _ := errData["captcha_img"].(string)
|
||||
|
||||
errorMsg, ok := errData["error_msg"].(string)
|
||||
if !ok {
|
||||
Log.Warnf("[Captcha] missing error_msg in error data")
|
||||
@@ -88,7 +85,6 @@ func ParseError(errData map[string]any) *Error {
|
||||
ErrorCode: code,
|
||||
ErrorMsg: errorMsg,
|
||||
CaptchaSid: captchaSid,
|
||||
CaptchaImg: captchaImg,
|
||||
RedirectURI: redirectURI,
|
||||
IsSoundCaptchaAvailable: isSound,
|
||||
SessionToken: sessionToken,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const { local: LOCAL, upstream: UPSTREAM } = window.__ftpCaptcha ?? {};
|
||||
const PROXY = '/generic_proxy?proxy_url=';
|
||||
const RESULT = '/local-captcha-result';
|
||||
const CHECK = 'captchaNotRobot.check';
|
||||
|
||||
function rewriteUrl(url) {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
if (url.startsWith(LOCAL)) return url;
|
||||
if (url.startsWith(UPSTREAM)) return LOCAL + url.slice(UPSTREAM.length);
|
||||
if (url.startsWith('//')) return PROXY + encodeURIComponent(location.protocol + url);
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return PROXY + encodeURIComponent(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
function rewriteAttr(el, attr) {
|
||||
const value = el.getAttribute?.(attr);
|
||||
if (!value) return;
|
||||
const rewritten = rewriteUrl(value);
|
||||
if (rewritten !== value) el.setAttribute(attr, rewritten);
|
||||
}
|
||||
|
||||
function rewriteTree(root) {
|
||||
if (!root.querySelectorAll) return;
|
||||
for (const attr of ['href', 'src', 'action']) {
|
||||
root.querySelectorAll(`[${attr}]`).forEach((el) => rewriteAttr(el, attr));
|
||||
}
|
||||
}
|
||||
|
||||
const extractToken = (data) => data?.response?.success_token;
|
||||
|
||||
function sendToken(token) {
|
||||
if (!token) return;
|
||||
const body = `token=${encodeURIComponent(token)}`;
|
||||
|
||||
// sendBeacon переживает уход со страницы (mobile Safari)
|
||||
if (navigator.sendBeacon?.(RESULT, new Blob([body], { type: 'application/x-www-form-urlencoded' }))) {
|
||||
showDone();
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(RESULT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
}).then(showDone).catch(() => {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = RESULT;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'token';
|
||||
input.value = token;
|
||||
form.append(input);
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
function showPending() {
|
||||
document.body.style.background = '#000';
|
||||
const banner = document.createElement('div');
|
||||
banner.textContent = 'free turn proxy - captcha';
|
||||
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;padding:12px;text-align:center;font:13.5px ui-monospace,monospace;color:#e1e1e1;background:#000';
|
||||
document.body.append(banner);
|
||||
}
|
||||
|
||||
function showDone() {
|
||||
document.body.style.background = '#000';
|
||||
document.body.innerHTML =
|
||||
'<div style="min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;font-family:ui-monospace,monospace;text-align:center">' +
|
||||
'<div style="font-size:16.5px;font-weight:700;color:#e1e1e1">free turn proxy</div>' +
|
||||
'<div style="font-size:13.5px;color:#818181">gg</div></div>';
|
||||
setTimeout(() => window.close(), 1000);
|
||||
}
|
||||
|
||||
const xhrOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (...args) {
|
||||
if (typeof args[1] === 'string') {
|
||||
this._origUrl = args[1];
|
||||
args[1] = rewriteUrl(args[1]);
|
||||
}
|
||||
return xhrOpen.apply(this, args);
|
||||
};
|
||||
|
||||
const xhrSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.send = function (...args) {
|
||||
if (this._origUrl?.includes(CHECK)) {
|
||||
this.addEventListener('load', () => {
|
||||
try { sendToken(extractToken(JSON.parse(this.responseText))); } catch {}
|
||||
});
|
||||
}
|
||||
return xhrSend.apply(this, args);
|
||||
};
|
||||
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = function (...args) {
|
||||
const urlStr = typeof args[0] === 'object' ? args[0]?.url : args[0];
|
||||
if (typeof args[0] === 'string') args[0] = rewriteUrl(args[0]);
|
||||
|
||||
const promise = origFetch.apply(this, args);
|
||||
if (typeof urlStr === 'string' && urlStr.includes(CHECK)) {
|
||||
promise
|
||||
.then((res) => res.clone().json())
|
||||
.then((data) => sendToken(extractToken(data)))
|
||||
.catch(() => {});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
document.addEventListener('submit', (event) => {
|
||||
if (event.target?.action) event.target.action = rewriteUrl(event.target.action);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const link = event.target?.closest?.('a[href]');
|
||||
if (link?.href) link.href = rewriteUrl(link.href);
|
||||
}, true);
|
||||
|
||||
const formSubmit = HTMLFormElement.prototype.submit;
|
||||
HTMLFormElement.prototype.submit = function (...args) {
|
||||
if (this.action) this.action = rewriteUrl(this.action);
|
||||
return formSubmit.apply(this, args);
|
||||
};
|
||||
|
||||
const windowOpen = window.open;
|
||||
window.open = function (...args) {
|
||||
if (typeof args[0] === 'string') args[0] = rewriteUrl(args[0]);
|
||||
return windowOpen.apply(this, args);
|
||||
};
|
||||
|
||||
rewriteTree(document);
|
||||
new MutationObserver((mutations) => {
|
||||
for (const { type, target, attributeName, addedNodes } of mutations) {
|
||||
if (type === 'attributes') {
|
||||
rewriteAttr(target, attributeName);
|
||||
continue;
|
||||
}
|
||||
for (const node of addedNodes) {
|
||||
if (node.nodeType === 1) rewriteTree(node);
|
||||
}
|
||||
}
|
||||
}).observe(document.documentElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['href', 'src', 'action'],
|
||||
});
|
||||
|
||||
if (document.body) showPending();
|
||||
else document.addEventListener('DOMContentLoaded', showPending);
|
||||
})();
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -711,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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,7 +127,6 @@ func (c *Client) solveCaptcha(
|
||||
}
|
||||
|
||||
var successToken string
|
||||
var captchaKey string
|
||||
var solveErr error
|
||||
|
||||
switch solveMode {
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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