16 Commits

Author SHA1 Message Date
Anton e009db0b9a Merge pull request #45 from samosvalishe/release-please--branches--master
chore(master): release 1.7.3
2026-07-12 19:15:19 +03:00
Anton 321a0995c3 chore(master): release 1.7.3 2026-07-12 19:13:46 +03:00
samosvalishe 6f934f10f4 chore: fmt autofix 2026-07-12 19:13:26 +03:00
samosvalishe ea410a044a fix(deps): обновить Go 1.26.4 -> 1.26.5 2026-07-12 19:10:27 +03:00
samosvalishe b4189f8f43 fix(captcha): адаптировать авторешение под SPA-капчу VK 2026-07-12 18:54:08 +03:00
Anton 2121b55190 Merge pull request #44 from samosvalishe/release-please--branches--master
chore(master): release 1.7.2
2026-07-08 01:10:30 +03:00
Anton 2df39d460e chore(master): release 1.7.2 2026-07-08 01:05:43 +03:00
samosvalishe 4c0bed1797 fix(captcha): linter 2026-07-08 01:04:39 +03:00
samosvalishe ea1cb930cc refactor(captcha): вынести inject.js из Go и убрать легаси image-капчу 2026-07-08 00:50:41 +03:00
samosvalishe adcf0a0d21 chore: update readme 2026-07-08 00:06:47 +03:00
Anton 15111ea72f Merge pull request #43 from samosvalishe/release-please--branches--master
chore(master): release 1.7.1
2026-07-07 21:15:54 +03:00
Anton 10e6b375c6 chore(master): release 1.7.1 2026-07-07 20:05:38 +03:00
samosvalishe 3b33d33b9e fix(captcha): устойчивый разбор debug_info и сигнал дрейфа версии скрипта 2026-07-07 20:05:12 +03:00
Anton 623d9a22c2 chore: update README.md 2026-07-07 12:56:45 +03:00
Anton 8a309fbf7e chore: update README.md 2026-07-07 12:55:27 +03:00
Anton 4493fc63a2 chore: update README.md 2026-07-07 12:55:03 +03:00
15 changed files with 343 additions and 441 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.7.0"
".": "1.7.3"
}
+27
View File
@@ -1,5 +1,32 @@
# 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)
+5 -4
View File
@@ -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)
---
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/samosvalishe/free-turn-proxy
go 1.26.4
go 1.26.5
require (
github.com/bogdanfinn/fhttp v0.6.8
+72 -140
View File
@@ -17,7 +17,6 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
fhttp "github.com/bogdanfinn/fhttp"
@@ -36,7 +35,6 @@ func SetLogger(l logx.Logger) { Log = logx.OrNop(l) }
const (
captchaAPIVersion = "5.131"
captchaScriptVersion = "1.1.1370"
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"}`
@@ -46,17 +44,13 @@ const (
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",
@@ -78,15 +72,6 @@ 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"`
@@ -96,8 +81,7 @@ type captchaInitSetting struct {
type captchaPage struct {
PowInput string
PowDifficulty int
ScriptURL string
Init *captchaInit
DebugInfo string
}
type captchaCheck struct {
@@ -182,64 +166,46 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
page, err := parseCaptchaPage(html)
if err != nil {
s.dumpChunks("html", html)
return "", err
}
s.logCaptchaPage(page)
if page.PowInput == "" {
return "", errors.New("failed to find PoW settings")
}
sliderContent := captchaContentRef{}
if page.Init != nil {
for _, setting := range page.Init.Data.CaptchaSettings {
if setting.Type == "slider" {
sliderContent = setting.contentRef()
}
}
}
if page.Init != nil && page.Init.Data.ShowCaptchaType == "slider" && sliderContent.Value == "" {
return "", errors.New("failed to find slider captcha settings key")
}
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 hash_len=%d", len(hash))
base := s.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()
if err != nil {
return "", err
}
browserFPSource := "generated"
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.BrowserFp) != "" {
browserFP = s.savedProfile.BrowserFp
browserFPSource = "saved"
}
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])
}
}
debugInfo, err := s.fetchDebugInfo(page.ScriptURL)
base := s.captchaBaseValues(captchaErr.SessionToken)
if _, settingsErr := s.captchaRequest("captchaNotRobot.settings", base); settingsErr != nil {
return "", fmt.Errorf("captcha settings failed: %w", settingsErr)
}
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
}
s.logger().Debugf("[Captcha] solving show_type=%s", showType)
var token string
switch showType {
case "slider":
@@ -259,6 +225,36 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
return token, nil
}
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
@@ -317,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": captchaAPIOrigin + "/",
})
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
@@ -536,26 +507,12 @@ func solveCaptchaPoW(ctx context.Context, input string, difficulty int) string {
sum := sha256.Sum256(buf)
hashHex := hex.EncodeToString(sum[:])
if strings.HasPrefix(hashHex, target) {
return encodeCaptchaPoW(hashHex, nonce)
return hashHex
}
}
return ""
}
func encodeCaptchaPoW(hash string, nonce int) string {
payload, err := json.Marshal(struct {
Hash string `json:"hash"`
Nonce int `json:"nonce"`
}{
Hash: hash,
Nonce: nonce,
})
if err != nil {
return ""
}
return "v2." + base64.RawURLEncoding.EncodeToString(payload)
}
func (s *captchaSession) doRaw(
method string,
endpoint string,
@@ -602,31 +559,6 @@ func (s *captchaSession) doRaw(
return data, readErr
}
func (s *captchaSession) logCaptchaPage(page *captchaPage) {
showType := ""
settingsSummary := "none"
if page.Init != nil {
showType = page.Init.Data.ShowCaptchaType
parts := make([]string, 0, len(page.Init.Data.CaptchaSettings))
for _, setting := range page.Init.Data.CaptchaSettings {
ref := setting.contentRef()
valueLen := 0
if ref.Value != "" {
valueLen = len(ref.Value)
}
parts = append(parts, fmt.Sprintf("%s:%s:%d", setting.Type, ref.Source, valueLen))
}
if len(parts) > 0 {
settingsSummary = strings.Join(parts, ",")
}
}
scriptVersion := ""
if m := reCaptchaVersion.FindStringSubmatch(page.ScriptURL); len(m) > 1 {
scriptVersion = m[1]
}
s.logger().Debugf("[Captcha] page parsed show_type=%q settings=%s pow_difficulty=%d script_version=%q script_url=%s", showType, settingsSummary, page.PowDifficulty, scriptVersion, captchaSafeURL(page.ScriptURL))
}
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))
@@ -1,7 +1,7 @@
package captcha
import (
"encoding/base64"
"context"
"encoding/json"
"strings"
"testing"
@@ -34,46 +34,73 @@ func TestCaptchaInitSettingContentRefLegacySettings(t *testing.T) {
}
}
func TestParseCaptchaPageSettingsKey(t *testing.T) {
html := `<html><head><script src="https://static.vk.ru/vkid/1.1.1359/not_robot_captcha.js"></script></head><body>
<script>
window.init = {"data":{"show_captcha_type":"slider","captcha_settings":[{"type":"slider","settings_key":"abc123"}]}};
const powInput = "input";
func TestParseCaptchaPageSPA(t *testing.T) {
html := `<html><head><script>
const powInput = "Pihj7tyAHFxdwm4t";
const difficulty = 2;
</script>
</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.Init == nil || len(page.Init.Data.CaptchaSettings) != 1 {
t.Fatalf("captcha settings missing: %+v", page.Init)
if page.PowInput != "Pihj7tyAHFxdwm4t" || page.PowDifficulty != 2 {
t.Fatalf("pow parse = %q/%d", page.PowInput, page.PowDifficulty)
}
got := page.Init.Data.CaptchaSettings[0].contentRef()
if got.Source != "settings_key" || got.Value != "abc123" {
t.Fatalf("contentRef = %+v, want settings_key/abc123", got)
if page.DebugInfo != "1d958dbe-0663-42f2-95e5-0e2780ed2f93" {
t.Fatalf("debug_info = %q, want brlefapmjnpg uuid", page.DebugInfo)
}
}
func TestEncodeCaptchaPoW(t *testing.T) {
got := encodeCaptchaPoW("00abcdef", 147)
if !strings.HasPrefix(got, "v2.") {
t.Fatalf("pow = %q, want v2 prefix", got)
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")
}
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(got, "v2."))
if err != nil {
t.Fatal(err)
}
func TestSolveCaptchaPoWRawHex(t *testing.T) {
got := solveCaptchaPoW(context.Background(), "input", 1)
if len(got) != 64 {
t.Fatalf("pow = %q, want 64-hex", got)
}
var decoded struct {
Hash string `json:"hash"`
Nonce int `json:"nonce"`
if !strings.HasPrefix(got, "0") {
t.Fatalf("pow = %q, want leading zero for difficulty 1", got)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatal(err)
if again := solveCaptchaPoW(context.Background(), "input", 1); again != got {
t.Fatalf("pow not deterministic: %q vs %q", got, again)
}
if decoded.Hash != "00abcdef" || decoded.Nonce != 147 {
t.Fatalf("decoded = %+v, want hash/nonce", decoded)
}
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)
}
}
@@ -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)
@@ -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)
@@ -127,7 +127,6 @@ func (c *Client) solveCaptcha(
}
var successToken string
var captchaKey string
var solveErr error
switch solveMode {
@@ -158,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
@@ -198,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 {
@@ -211,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),
+3 -4
View File
@@ -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)
}
}
+4 -9
View File
@@ -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)
}