Initial v1.1.8 Commits
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
mathrand "math/rand"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
neturl "net/url"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
const (
|
||||
captchaV2APIVersion = "5.131"
|
||||
captchaV2ScriptVersion = "1.1.1324"
|
||||
captchaV2DeviceInfo = `{"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"}`
|
||||
)
|
||||
|
||||
var (
|
||||
reCaptchaV2PowInput = regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`)
|
||||
reCaptchaV2Difficulty = regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`)
|
||||
reCaptchaV2WindowInit = regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;`)
|
||||
reCaptchaV2ScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`)
|
||||
reCaptchaV2DebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`)
|
||||
reCaptchaV2Version = regexp.MustCompile(`vkid/([0-9.]*)/not_robot_captcha\.js`)
|
||||
|
||||
errCaptchaV2RateLimit = errors.New("captcha session rate limit reached")
|
||||
errCaptchaV2Bot = errors.New("captcha bot challenge")
|
||||
|
||||
captchaV2MaxAttempts = 2
|
||||
|
||||
captchaV2DebugCache sync.Map // scriptURL -> string
|
||||
captchaV2HeaderOrder = []string{
|
||||
"host",
|
||||
"content-length",
|
||||
"sec-ch-ua-platform",
|
||||
"accept-language",
|
||||
"sec-ch-ua",
|
||||
"content-type",
|
||||
"sec-ch-ua-mobile",
|
||||
"user-agent",
|
||||
"accept",
|
||||
"origin",
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-dest",
|
||||
"referer",
|
||||
"accept-encoding",
|
||||
"priority",
|
||||
}
|
||||
captchaV2PHeaderOrder = []string{":method", ":path", ":authority", ":scheme"}
|
||||
)
|
||||
|
||||
type captchaV2Init struct {
|
||||
Data captchaV2InitData `json:"data"`
|
||||
}
|
||||
|
||||
type captchaV2InitData struct {
|
||||
ShowCaptchaType string `json:"show_captcha_type"`
|
||||
CaptchaSettings []captchaV2InitSetting `json:"captcha_settings"`
|
||||
}
|
||||
|
||||
type captchaV2InitSetting struct {
|
||||
Type string `json:"type"`
|
||||
Settings string `json:"settings"`
|
||||
}
|
||||
|
||||
type captchaV2Page struct {
|
||||
PowInput string
|
||||
PowDifficulty int
|
||||
ScriptURL string
|
||||
Init *captchaV2Init
|
||||
}
|
||||
|
||||
type captchaV2Check struct {
|
||||
Status string
|
||||
SuccessToken string
|
||||
ShowType string
|
||||
}
|
||||
|
||||
type captchaV2ShowTypeError struct {
|
||||
ShowType string
|
||||
}
|
||||
|
||||
func (e *captchaV2ShowTypeError) Error() string {
|
||||
return "captcha show type mismatch: " + e.ShowType
|
||||
}
|
||||
|
||||
type captchaV2Session struct {
|
||||
ctx context.Context
|
||||
client tlsclient.HttpClient
|
||||
profile Profile
|
||||
savedProfile *SavedProfile
|
||||
}
|
||||
|
||||
func solveVkCaptchaV2(
|
||||
ctx context.Context,
|
||||
captchaErr *VkCaptchaError,
|
||||
client tlsclient.HttpClient,
|
||||
profile Profile,
|
||||
savedProfile *SavedProfile,
|
||||
) (string, error) {
|
||||
return solveVkCaptchaV2Attempts(ctx, captchaErr, client, profile, savedProfile, captchaV2MaxAttempts)
|
||||
}
|
||||
|
||||
func solveVkCaptchaV2Attempts(
|
||||
ctx context.Context,
|
||||
captchaErr *VkCaptchaError,
|
||||
client tlsclient.HttpClient,
|
||||
profile Profile,
|
||||
savedProfile *SavedProfile,
|
||||
maxAttempts int,
|
||||
) (string, error) {
|
||||
if captchaErr == nil || captchaErr.SessionToken == "" {
|
||||
return "", fmt.Errorf("no session_token in redirect_uri")
|
||||
}
|
||||
if maxAttempts < 1 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
log.Printf("[КАПЧА] Решаю VK Smart Captcha автоматически (v2, попыток=%d)...", maxAttempts)
|
||||
|
||||
s := &captchaV2Session{ctx: ctx, client: client, profile: profile, savedProfile: savedProfile}
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
token, solveErr := s.solveOnce(captchaErr)
|
||||
if solveErr == nil {
|
||||
return token, nil
|
||||
}
|
||||
log.Printf("[КАПЧА] v2 попытка %d ошибка: %v", attempt, solveErr)
|
||||
if errors.Is(solveErr, errCaptchaV2RateLimit) {
|
||||
return "", solveErr
|
||||
}
|
||||
|
||||
backoffSteps := attempt
|
||||
if backoffSteps > 10 {
|
||||
backoffSteps = 10
|
||||
}
|
||||
timer := time.NewTimer(time.Duration(backoffSteps) * 500 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return "", ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("v2 captcha attempts exhausted (%d)", maxAttempts)
|
||||
}
|
||||
|
||||
func (s *captchaV2Session) solveOnce(captchaErr *VkCaptchaError) (string, error) {
|
||||
html, err := s.fetchCaptchaHTML(captchaErr.RedirectURI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
page, err := parseCaptchaV2Page(html)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
log.Printf("[КАПЧА] v2 solving pow difficulty=%d", page.PowDifficulty)
|
||||
hash := solveCaptchaPoWV2(s.ctx, page.PowInput, page.PowDifficulty)
|
||||
if hash == "" {
|
||||
return "", errors.New("captcha pow failed")
|
||||
}
|
||||
log.Printf("[КАПЧА] v2 pow solved")
|
||||
|
||||
base := captchaV2BaseValues(captchaErr.SessionToken)
|
||||
if _, settingsErr := s.captchaRequest("captchaNotRobot.settings", base); settingsErr != nil {
|
||||
return "", fmt.Errorf("captcha settings failed: %w", settingsErr)
|
||||
}
|
||||
|
||||
browserFP, err := captchaV2BrowserFP()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.BrowserFp) != "" {
|
||||
browserFP = s.savedProfile.BrowserFp
|
||||
}
|
||||
|
||||
if m := reCaptchaV2Version.FindStringSubmatch(page.ScriptURL); len(m) > 1 {
|
||||
if m[1] != captchaV2ScriptVersion {
|
||||
log.Printf("[КАПЧА] v2 script version drift: known=%s latest=%s", captchaV2ScriptVersion, m[1])
|
||||
}
|
||||
}
|
||||
|
||||
debugInfo, err := s.fetchDebugInfo(page.ScriptURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch debug info: %w (script_version=%s)", err, captchaV2ScriptVersion)
|
||||
}
|
||||
|
||||
showType := ""
|
||||
if page.Init != nil {
|
||||
showType = page.Init.Data.ShowCaptchaType
|
||||
}
|
||||
var token string
|
||||
for {
|
||||
log.Printf("[КАПЧА] v2 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, errCaptchaV2Bot) && !strings.EqualFold(showType, "slider") && sliderSettings != "" {
|
||||
log.Printf("[КАПЧА] v2 checkbox returned BOT, trying slider")
|
||||
showType = "slider"
|
||||
continue
|
||||
}
|
||||
var stErr *captchaV2ShowTypeError
|
||||
if !errors.As(err, &stErr) || stErr.ShowType == "" {
|
||||
return "", err
|
||||
}
|
||||
showType = stErr.ShowType
|
||||
}
|
||||
|
||||
if _, endErr := s.captchaRequest("captchaNotRobot.endSession", base); endErr != nil {
|
||||
log.Printf("[КАПЧА] v2 endSession failed: %v", endErr)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func captchaV2BaseValues(sessionToken string) [][2]string {
|
||||
return [][2]string{
|
||||
{"session_token", sessionToken},
|
||||
{"domain", "vk.com"},
|
||||
{"adFp", ""},
|
||||
{"access_token", ""},
|
||||
}
|
||||
}
|
||||
|
||||
func captchaV2BrowserFP() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("browser fp generate: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (s *captchaV2Session) fetchCaptchaHTML(redirectURI string) (string, error) {
|
||||
body, err := s.doRaw(fhttp.MethodGet, redirectURI, nil, map[string]string{
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func (s *captchaV2Session) fetchDebugInfo(scriptURL string) (string, error) {
|
||||
if cached, ok := captchaV2DebugCache.Load(scriptURL); ok {
|
||||
if cachedDebugInfo, ok := cached.(string); ok {
|
||||
return cachedDebugInfo, nil
|
||||
}
|
||||
captchaV2DebugCache.Delete(scriptURL)
|
||||
}
|
||||
body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{
|
||||
"Accept": "text/javascript,*/*",
|
||||
"Referer": "https://id.vk.com/",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m := reCaptchaV2DebugInfo.FindSubmatch(body)
|
||||
if len(m) < 2 {
|
||||
return "", errors.New("debug_info match not found")
|
||||
}
|
||||
v := string(m[1])
|
||||
captchaV2DebugCache.Store(scriptURL, v)
|
||||
log.Printf("[КАПЧА] v2 debug_info fetched url=%s", scriptURL)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func parseCaptchaV2Page(html string) (*captchaV2Page, error) {
|
||||
page := &captchaV2Page{}
|
||||
|
||||
match := reCaptchaV2WindowInit.FindStringSubmatch(html)
|
||||
if len(match) < 2 {
|
||||
return nil, errors.New("captcha init json not found")
|
||||
}
|
||||
var init captchaV2Init
|
||||
if err := json.Unmarshal([]byte(match[1]), &init); err != nil {
|
||||
return nil, fmt.Errorf("captcha init json parse: %w", err)
|
||||
}
|
||||
page.Init = &init
|
||||
|
||||
match = reCaptchaV2ScriptSrc.FindStringSubmatch(html)
|
||||
if len(match) < 2 {
|
||||
return nil, errors.New("captcha script url not found")
|
||||
}
|
||||
page.ScriptURL = match[1]
|
||||
|
||||
if m := reCaptchaV2PowInput.FindStringSubmatch(html); len(m) >= 2 {
|
||||
page.PowInput = m[1]
|
||||
}
|
||||
if page.PowInput == "" {
|
||||
return page, nil
|
||||
}
|
||||
|
||||
match = reCaptchaV2Difficulty.FindStringSubmatch(html)
|
||||
if len(match) < 2 {
|
||||
return nil, errors.New("captcha difficulty const not found")
|
||||
}
|
||||
difficulty, err := strconv.Atoi(match[1])
|
||||
if err != nil || difficulty <= 0 {
|
||||
return nil, fmt.Errorf("invalid captcha difficulty %q", match[1])
|
||||
}
|
||||
page.PowDifficulty = difficulty
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *captchaV2Session) captchaRequest(method string, form [][2]string) (map[string]any, error) {
|
||||
endpoint := "https://api.vk.ru/method/" + method + "?v=" + captchaV2APIVersion
|
||||
body, err := s.doRaw(fhttp.MethodPost, endpoint, form, map[string]string{
|
||||
"Origin": "https://id.vk.com",
|
||||
"Referer": "https://id.vk.com/",
|
||||
"Priority": "u=1, i",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("captcha api decode: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *captchaV2Session) performCaptchaCheck(
|
||||
sessionToken string,
|
||||
browserFP string,
|
||||
hash string,
|
||||
answerJSON string,
|
||||
cursor string,
|
||||
debugInfo string,
|
||||
) (*captchaV2Check, error) {
|
||||
values := [][2]string{
|
||||
{"session_token", sessionToken},
|
||||
{"domain", "vk.com"},
|
||||
{"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", ""},
|
||||
}
|
||||
resp, err := s.captchaRequest("captchaNotRobot.check", values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("captcha check failed: %w", err)
|
||||
}
|
||||
check, err := parseCaptchaV2Check(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if check.ShowType != "" {
|
||||
log.Printf("[КАПЧА] v2 check status=%s show_type=%s", check.Status, check.ShowType)
|
||||
} else {
|
||||
log.Printf("[КАПЧА] v2 check status=%s", check.Status)
|
||||
}
|
||||
return check, nil
|
||||
}
|
||||
|
||||
func parseCaptchaV2Check(raw map[string]any) (*captchaV2Check, error) {
|
||||
resp, ok := raw["response"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid captcha check response: %v", raw)
|
||||
}
|
||||
out := &captchaV2Check{
|
||||
Status: captchaV2StringifyAny(resp["status"]),
|
||||
SuccessToken: captchaV2StringifyAny(resp["success_token"]),
|
||||
ShowType: captchaV2StringifyAny(resp["show_captcha_type"]),
|
||||
}
|
||||
if out.Status == "" {
|
||||
return nil, fmt.Errorf("captcha check status missing: %v", raw)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *captchaV2Session) solveCheckboxCaptcha(
|
||||
sessionToken string,
|
||||
browserFP string,
|
||||
hash string,
|
||||
debugInfo string,
|
||||
) (string, error) {
|
||||
deviceJSON := captchaV2DeviceInfo
|
||||
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
|
||||
deviceJSON = s.savedProfile.DeviceJSON
|
||||
}
|
||||
if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
|
||||
{"session_token", sessionToken},
|
||||
{"domain", "vk.com"},
|
||||
{"adFp", ""},
|
||||
{"browser_fp", browserFP},
|
||||
{"device", deviceJSON},
|
||||
{"access_token", ""},
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("captcha componentDone failed: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return "", s.ctx.Err()
|
||||
case <-time.After(time.Duration(400+mathrand.Intn(250)) * time.Millisecond):
|
||||
}
|
||||
|
||||
check, err := s.performCaptchaCheck(sessionToken, browserFP, hash, "{}", "[]", debugInfo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if check.ShowType != "" && !strings.EqualFold(check.ShowType, "checkbox") {
|
||||
return "", &captchaV2ShowTypeError{ShowType: check.ShowType}
|
||||
}
|
||||
if strings.EqualFold(check.Status, "error_limit") {
|
||||
return "", errCaptchaV2RateLimit
|
||||
}
|
||||
if strings.EqualFold(check.Status, "bot") {
|
||||
return "", fmt.Errorf("%w: checkbox captcha rejected: status=%s", errCaptchaV2Bot, check.Status)
|
||||
}
|
||||
if !strings.EqualFold(check.Status, "ok") {
|
||||
return "", fmt.Errorf("checkbox captcha rejected: status=%s", check.Status)
|
||||
}
|
||||
if check.SuccessToken == "" {
|
||||
return "", errors.New("captcha success token not found")
|
||||
}
|
||||
return check.SuccessToken, nil
|
||||
}
|
||||
|
||||
func solveCaptchaPoWV2(ctx context.Context, input string, difficulty int) string {
|
||||
if input == "" || difficulty <= 0 {
|
||||
return ""
|
||||
}
|
||||
target := strings.Repeat("0", difficulty)
|
||||
for nonce := 1; nonce <= 10_000_000; nonce++ {
|
||||
if nonce%4096 == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ""
|
||||
default:
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(input + strconv.Itoa(nonce)))
|
||||
hashHex := hex.EncodeToString(sum[:])
|
||||
if strings.HasPrefix(hashHex, target) {
|
||||
return hashHex
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *captchaV2Session) doRaw(
|
||||
method string,
|
||||
endpoint string,
|
||||
form [][2]string,
|
||||
extraHeaders map[string]string,
|
||||
) ([]byte, error) {
|
||||
var body []byte
|
||||
if form != nil {
|
||||
body = []byte(captchaV2EncodeForm(form))
|
||||
}
|
||||
req, err := fhttp.NewRequestWithContext(s.ctx, method, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyBrowserProfileFhttp(req, s.profile)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
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.com")
|
||||
req.Header.Set("Referer", "https://vk.com/")
|
||||
if form != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header[fhttp.HeaderOrderKey] = captchaV2HeaderOrder
|
||||
req.Header[fhttp.PHeaderOrderKey] = captchaV2PHeaderOrder
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||
log.Printf("[КАПЧА] v2 close body: %s", closeErr)
|
||||
}
|
||||
}()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func captchaV2EncodeForm(values [][2]string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i, kv := range values {
|
||||
if i > 0 {
|
||||
sb.WriteByte('&')
|
||||
}
|
||||
sb.WriteString(captchaV2QueryEscape(kv[0]))
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(captchaV2QueryEscape(kv[1]))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func captchaV2QueryEscape(s string) string {
|
||||
const upper = "0123456789ABCDEF"
|
||||
hexDigits := func(b byte) [3]byte {
|
||||
return [3]byte{'%', upper[b>>4], upper[b&0xF]}
|
||||
}
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == ' ':
|
||||
out = append(out, '+')
|
||||
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('0' <= c && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~':
|
||||
out = append(out, c)
|
||||
default:
|
||||
h := hexDigits(c)
|
||||
out = append(out, h[:]...)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func captchaV2StringifyAny(value any) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(v)
|
||||
default:
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
}
|
||||
|
||||
// applyBrowserProfileFhttp applies browser headers to fhttp requests
|
||||
func applyBrowserProfileFhttp(req *fhttp.Request, profile Profile) {
|
||||
req.Header.Set("User-Agent", profile.UserAgent)
|
||||
req.Header.Set("sec-ch-ua", profile.SecChUa)
|
||||
req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile)
|
||||
req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform)
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("DNT", "1")
|
||||
}
|
||||
|
||||
// VkCaptchaError represents a VK captcha challenge
|
||||
type VkCaptchaError struct {
|
||||
ErrorCode int
|
||||
ErrorMsg string
|
||||
CaptchaSid string
|
||||
RedirectURI string
|
||||
SessionToken string
|
||||
CaptchaTs string
|
||||
CaptchaAttempt string
|
||||
}
|
||||
|
||||
func parseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
|
||||
codeFloat, _ := errData["error_code"].(float64)
|
||||
redirectUri, _ := errData["redirect_uri"].(string)
|
||||
errorMsg, _ := errData["error_msg"].(string)
|
||||
|
||||
captchaSid, _ := errData["captcha_sid"].(string)
|
||||
if captchaSid == "" {
|
||||
if sidNum, ok := errData["captcha_sid"].(float64); ok {
|
||||
captchaSid = fmt.Sprintf("%.0f", sidNum)
|
||||
}
|
||||
}
|
||||
|
||||
var sessionToken string
|
||||
if redirectUri != "" {
|
||||
if parsed, err := neturl.Parse(redirectUri); err == nil {
|
||||
sessionToken = parsed.Query().Get("session_token")
|
||||
}
|
||||
}
|
||||
|
||||
var captchaTs string
|
||||
if tsFloat, ok := errData["captcha_ts"].(float64); ok {
|
||||
captchaTs = fmt.Sprintf("%.0f", tsFloat)
|
||||
} else if tsStr, ok := errData["captcha_ts"].(string); ok {
|
||||
captchaTs = tsStr
|
||||
}
|
||||
|
||||
var captchaAttempt string
|
||||
if attFloat, ok := errData["captcha_attempt"].(float64); ok {
|
||||
captchaAttempt = fmt.Sprintf("%.0f", attFloat)
|
||||
} else if attStr, ok := errData["captcha_attempt"].(string); ok {
|
||||
captchaAttempt = attStr
|
||||
}
|
||||
|
||||
return &VkCaptchaError{
|
||||
ErrorCode: int(codeFloat),
|
||||
ErrorMsg: errorMsg,
|
||||
CaptchaSid: captchaSid,
|
||||
RedirectURI: redirectUri,
|
||||
SessionToken: sessionToken,
|
||||
CaptchaTs: captchaTs,
|
||||
CaptchaAttempt: captchaAttempt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/jpeg"
|
||||
"log"
|
||||
"math"
|
||||
mathrand "math/rand"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type sliderPuzzleV2 struct {
|
||||
Image image.Image
|
||||
Size int
|
||||
Swaps []int
|
||||
Attempts int
|
||||
}
|
||||
|
||||
type sliderGuessV2 struct {
|
||||
Index int
|
||||
Swaps []int
|
||||
Score int64
|
||||
ScoreRGB int64
|
||||
ScoreLuma int64
|
||||
ScoreText float64
|
||||
ConsensusRank int
|
||||
}
|
||||
|
||||
func (s *captchaV2Session) solveSliderCaptcha(
|
||||
sessionToken string,
|
||||
browserFP string,
|
||||
hash string,
|
||||
settings string,
|
||||
debugInfo string,
|
||||
) (string, error) {
|
||||
values := [][2]string{
|
||||
{"session_token", sessionToken},
|
||||
{"domain", "vk.com"},
|
||||
{"adFp", ""},
|
||||
{"access_token", ""},
|
||||
{"captcha_settings", settings},
|
||||
}
|
||||
|
||||
resp, err := s.captchaRequest("captchaNotRobot.getContent", values)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("slider getContent failed: %w", err)
|
||||
}
|
||||
puzzle, err := parseSliderPuzzleV2(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Printf("[КАПЧА] v2 slider puzzle decoded: grid=%d attempts=%d swaps=%d", puzzle.Size, puzzle.Attempts, len(puzzle.Swaps))
|
||||
|
||||
guesses, err := rankSliderGuessesV2(puzzle.Image, puzzle.Size, puzzle.Swaps)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
limit := puzzle.Attempts
|
||||
if limit > len(guesses) {
|
||||
limit = len(guesses)
|
||||
}
|
||||
if limit <= 0 {
|
||||
return "", errors.New("slider has no attempts available")
|
||||
}
|
||||
log.Printf("[КАПЧА] v2 slider guesses ranked: total=%d limit=%d", len(guesses), limit)
|
||||
|
||||
deviceJSON := captchaV2DeviceInfo
|
||||
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
|
||||
deviceJSON = s.savedProfile.DeviceJSON
|
||||
}
|
||||
if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
|
||||
{"session_token", sessionToken},
|
||||
{"domain", "vk.com"},
|
||||
{"adFp", ""},
|
||||
{"access_token", ""},
|
||||
{"browser_fp", browserFP},
|
||||
{"device", deviceJSON},
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("captcha componentDone failed: %w", err)
|
||||
}
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
log.Printf("[КАПЧА] v2 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})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
check, err := s.performCaptchaCheck(
|
||||
sessionToken,
|
||||
browserFP,
|
||||
hash,
|
||||
string(answerData),
|
||||
buildSliderCursorV2(guesses[i].Index, len(guesses)),
|
||||
debugInfo,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.EqualFold(check.Status, "ok") {
|
||||
if check.SuccessToken == "" {
|
||||
return "", errors.New("captcha success token not found")
|
||||
}
|
||||
log.Printf("[КАПЧА] v2 slider accepted on attempt %d", i+1)
|
||||
return check.SuccessToken, nil
|
||||
}
|
||||
if strings.EqualFold(check.Status, "error_limit") {
|
||||
return "", errCaptchaV2RateLimit
|
||||
}
|
||||
}
|
||||
return "", errors.New("slider guesses exhausted")
|
||||
}
|
||||
|
||||
func parseSliderPuzzleV2(raw map[string]any) (*sliderPuzzleV2, error) {
|
||||
resp, ok := raw["response"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid slider content response: %v", raw)
|
||||
}
|
||||
status := captchaV2StringifyAny(resp["status"])
|
||||
if !strings.EqualFold(status, "ok") {
|
||||
return nil, fmt.Errorf("slider getContent status: %s", status)
|
||||
}
|
||||
rawImage := captchaV2StringifyAny(resp["image"])
|
||||
if rawImage == "" {
|
||||
return nil, errors.New("slider image missing")
|
||||
}
|
||||
rawSteps, ok := resp["steps"].([]any)
|
||||
if !ok {
|
||||
return nil, errors.New("slider steps missing")
|
||||
}
|
||||
steps := make([]int, 0, len(rawSteps))
|
||||
for _, item := range rawSteps {
|
||||
switch v := item.(type) {
|
||||
case float64:
|
||||
steps = append(steps, int(v))
|
||||
case int:
|
||||
steps = append(steps, v)
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid numeric value: %v", item)
|
||||
}
|
||||
steps = append(steps, n)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid numeric value: %v", item)
|
||||
}
|
||||
}
|
||||
size, swaps, attempts, err := splitSliderStepsV2(steps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(rawImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode slider image: %w", err)
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode slider image: %w", err)
|
||||
}
|
||||
return &sliderPuzzleV2{Image: img, Size: size, Swaps: swaps, Attempts: attempts}, nil
|
||||
}
|
||||
|
||||
func splitSliderStepsV2(steps []int) (int, []int, int, error) {
|
||||
if len(steps) < 3 {
|
||||
return 0, nil, 0, errors.New("slider steps payload too short")
|
||||
}
|
||||
size := steps[0]
|
||||
if size <= 0 {
|
||||
return 0, nil, 0, fmt.Errorf("invalid slider size: %d", size)
|
||||
}
|
||||
tail := append([]int(nil), steps[1:]...)
|
||||
attempts := 4
|
||||
if len(tail)%2 != 0 {
|
||||
attempts = tail[len(tail)-1]
|
||||
tail = tail[:len(tail)-1]
|
||||
log.Printf("[КАПЧА] v2 slider payload had odd-length tail; fallback attempts=%d", attempts)
|
||||
}
|
||||
if attempts <= 0 {
|
||||
attempts = 4
|
||||
}
|
||||
if len(tail) == 0 || len(tail)%2 != 0 {
|
||||
return 0, nil, 0, errors.New("invalid slider swap payload")
|
||||
}
|
||||
return size, tail, attempts, nil
|
||||
}
|
||||
|
||||
func rankSliderGuessesV2(img image.Image, gridSize int, swaps []int) ([]sliderGuessV2, error) {
|
||||
candidateCount := len(swaps) / 2
|
||||
if candidateCount == 0 {
|
||||
return nil, errors.New("slider has no candidates")
|
||||
}
|
||||
|
||||
guesses := make([]sliderGuessV2, candidateCount)
|
||||
for idx := 1; idx <= candidateCount; idx++ {
|
||||
active := activeSwapsForIndexV2(swaps, idx)
|
||||
mapping, err := applySliderSwapsV2(gridSize, active)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guesses[idx-1] = sliderGuessV2{Index: idx, Swaps: active}
|
||||
guesses[idx-1].ScoreLuma = seamScoreLumaV2(img, gridSize, mapping)
|
||||
}
|
||||
|
||||
lumaOrder := append([]sliderGuessV2(nil), guesses...)
|
||||
sort.SliceStable(lumaOrder, func(i, j int) bool {
|
||||
if lumaOrder[i].ScoreLuma == lumaOrder[j].ScoreLuma {
|
||||
return lumaOrder[i].Index < lumaOrder[j].Index
|
||||
}
|
||||
return lumaOrder[i].ScoreLuma < lumaOrder[j].ScoreLuma
|
||||
})
|
||||
lumaRank := make(map[int]int, candidateCount)
|
||||
for rank, g := range lumaOrder {
|
||||
lumaRank[g.Index] = rank
|
||||
}
|
||||
|
||||
stage2Count := candidateCount
|
||||
if stage2Count > 12 {
|
||||
stage2Count = 12
|
||||
}
|
||||
stage2Set := make(map[int]struct{}, stage2Count)
|
||||
for i := 0; i < stage2Count; i++ {
|
||||
stage2Set[lumaOrder[i].Index] = struct{}{}
|
||||
}
|
||||
|
||||
type stage2Result struct {
|
||||
index int
|
||||
rgb int64
|
||||
text float64
|
||||
err error
|
||||
}
|
||||
jobs := make([]int, 0, stage2Count)
|
||||
for idx := range stage2Set {
|
||||
jobs = append(jobs, idx)
|
||||
}
|
||||
jobCh := make(chan int, len(jobs))
|
||||
resCh := make(chan stage2Result, len(jobs))
|
||||
|
||||
workers := runtime.NumCPU()
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
if workers > len(jobs) {
|
||||
workers = len(jobs)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for index := range jobCh {
|
||||
mapping, err := applySliderSwapsV2(gridSize, guesses[index-1].Swaps)
|
||||
if err != nil {
|
||||
resCh <- stage2Result{index: index, err: err}
|
||||
continue
|
||||
}
|
||||
rgb, text := seamScoreRGBTextV2(img, gridSize, mapping)
|
||||
resCh <- stage2Result{index: index, rgb: rgb, text: text}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, idx := range jobs {
|
||||
jobCh <- idx
|
||||
}
|
||||
close(jobCh)
|
||||
wg.Wait()
|
||||
close(resCh)
|
||||
for r := range resCh {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
g := &guesses[r.index-1]
|
||||
g.ScoreRGB = r.rgb
|
||||
g.ScoreText = r.text
|
||||
}
|
||||
|
||||
stage2 := make([]sliderGuessV2, 0, stage2Count)
|
||||
for _, g := range guesses {
|
||||
if _, ok := stage2Set[g.Index]; ok {
|
||||
stage2 = append(stage2, g)
|
||||
}
|
||||
}
|
||||
|
||||
rgbOrder := append([]sliderGuessV2(nil), stage2...)
|
||||
sort.SliceStable(rgbOrder, func(i, j int) bool {
|
||||
if rgbOrder[i].ScoreRGB == rgbOrder[j].ScoreRGB {
|
||||
return rgbOrder[i].Index < rgbOrder[j].Index
|
||||
}
|
||||
return rgbOrder[i].ScoreRGB < rgbOrder[j].ScoreRGB
|
||||
})
|
||||
rgbRank := make(map[int]int, len(rgbOrder))
|
||||
for rank, g := range rgbOrder {
|
||||
rgbRank[g.Index] = rank
|
||||
}
|
||||
|
||||
textOrder := append([]sliderGuessV2(nil), stage2...)
|
||||
sort.SliceStable(textOrder, func(i, j int) bool {
|
||||
if textOrder[i].ScoreText == textOrder[j].ScoreText {
|
||||
return textOrder[i].Index < textOrder[j].Index
|
||||
}
|
||||
return textOrder[i].ScoreText < textOrder[j].ScoreText
|
||||
})
|
||||
textRank := make(map[int]int, len(textOrder))
|
||||
for rank, g := range textOrder {
|
||||
textRank[g.Index] = rank
|
||||
}
|
||||
|
||||
for i := range guesses {
|
||||
g := &guesses[i]
|
||||
g.ConsensusRank = lumaRank[g.Index]
|
||||
if _, ok := stage2Set[g.Index]; ok {
|
||||
g.ConsensusRank += rgbRank[g.Index] + textRank[g.Index]
|
||||
} else {
|
||||
g.ConsensusRank += candidateCount
|
||||
}
|
||||
g.Score = int64(g.ConsensusRank)
|
||||
}
|
||||
|
||||
sort.SliceStable(guesses, func(i, j int) bool {
|
||||
if guesses[i].ConsensusRank == guesses[j].ConsensusRank {
|
||||
if guesses[i].ScoreLuma == guesses[j].ScoreLuma {
|
||||
return guesses[i].Index < guesses[j].Index
|
||||
}
|
||||
return guesses[i].ScoreLuma < guesses[j].ScoreLuma
|
||||
}
|
||||
return guesses[i].ConsensusRank < guesses[j].ConsensusRank
|
||||
})
|
||||
return guesses, nil
|
||||
}
|
||||
|
||||
func activeSwapsForIndexV2(swaps []int, index int) []int {
|
||||
if index <= 0 {
|
||||
return []int{}
|
||||
}
|
||||
end := index * 2
|
||||
if end > len(swaps) {
|
||||
end = len(swaps)
|
||||
}
|
||||
return append([]int(nil), swaps[:end]...)
|
||||
}
|
||||
|
||||
func applySliderSwapsV2(gridSize int, swaps []int) ([]int, error) {
|
||||
tileCount := gridSize * gridSize
|
||||
if tileCount <= 0 {
|
||||
return nil, fmt.Errorf("invalid slider tile count: %d", tileCount)
|
||||
}
|
||||
if len(swaps)%2 != 0 {
|
||||
return nil, fmt.Errorf("invalid slider swaps length: %d", len(swaps))
|
||||
}
|
||||
mapping := make([]int, tileCount)
|
||||
for i := range mapping {
|
||||
mapping[i] = i
|
||||
}
|
||||
for i := 0; i < len(swaps); i += 2 {
|
||||
left := swaps[i]
|
||||
right := swaps[i+1]
|
||||
if left < 0 || right < 0 || left >= tileCount || right >= tileCount {
|
||||
return nil, fmt.Errorf("slider step out of range: %d,%d", left, right)
|
||||
}
|
||||
mapping[left], mapping[right] = mapping[right], mapping[left]
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
|
||||
w := bounds.Dx() / gridSize
|
||||
h := bounds.Dy() / gridSize
|
||||
col := index % gridSize
|
||||
row := index / gridSize
|
||||
return image.Rect(
|
||||
bounds.Min.X+col*w,
|
||||
bounds.Min.Y+row*h,
|
||||
bounds.Min.X+(col+1)*w,
|
||||
bounds.Min.Y+(row+1)*h,
|
||||
)
|
||||
}
|
||||
|
||||
func pixelDiff(a, b color.Color) int64 {
|
||||
ar, ag, ab, _ := a.RGBA()
|
||||
br, bg, bb, _ := b.RGBA()
|
||||
dr := int64(ar>>8) - int64(br>>8)
|
||||
dg := int64(ag>>8) - int64(bg>>8)
|
||||
db := int64(ab>>8) - int64(bb>>8)
|
||||
if dr < 0 {
|
||||
dr = -dr
|
||||
}
|
||||
if dg < 0 {
|
||||
dg = -dg
|
||||
}
|
||||
if db < 0 {
|
||||
db = -db
|
||||
}
|
||||
return dr + dg + db
|
||||
}
|
||||
|
||||
func seamScoreLumaV2(img image.Image, gridSize int, mapping []int) int64 {
|
||||
bounds := img.Bounds()
|
||||
var score int64
|
||||
for row := 0; row < gridSize; row++ {
|
||||
for col := 0; col < gridSize-1; col++ {
|
||||
leftIdx := row*gridSize + col
|
||||
rightIdx := leftIdx + 1
|
||||
leftDst := sliderTileRect(bounds, gridSize, leftIdx)
|
||||
rightDst := sliderTileRect(bounds, gridSize, rightIdx)
|
||||
leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
|
||||
rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
|
||||
h := leftDst.Dy()
|
||||
if rightDst.Dy() < h {
|
||||
h = rightDst.Dy()
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
yy := leftDst.Min.Y + y
|
||||
a := sampleLumaMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
|
||||
b := sampleLumaMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy)
|
||||
score += int64(absIntV2(int(a) - int(b)))
|
||||
}
|
||||
}
|
||||
}
|
||||
for row := 0; row < gridSize-1; row++ {
|
||||
for col := 0; col < gridSize; col++ {
|
||||
topIdx := row*gridSize + col
|
||||
bottomIdx := (row+1)*gridSize + col
|
||||
topDst := sliderTileRect(bounds, gridSize, topIdx)
|
||||
bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
|
||||
topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
|
||||
bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
|
||||
w := topDst.Dx()
|
||||
if bottomDst.Dx() < w {
|
||||
w = bottomDst.Dx()
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
xx := topDst.Min.X + x
|
||||
a := sampleLumaMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1)
|
||||
b := sampleLumaMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
|
||||
score += int64(absIntV2(int(a) - int(b)))
|
||||
}
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func seamScoreRGBTextV2(img image.Image, gridSize int, mapping []int) (int64, float64) {
|
||||
bounds := img.Bounds()
|
||||
height := float64(bounds.Dy())
|
||||
textCenters := []float64{
|
||||
float64(bounds.Min.Y) + 0.2*height,
|
||||
float64(bounds.Min.Y) + 0.5*height,
|
||||
float64(bounds.Min.Y) + 0.8*height,
|
||||
}
|
||||
sigma := height * 0.14
|
||||
if sigma < 1.0 {
|
||||
sigma = 1.0
|
||||
}
|
||||
weight := func(y int) float64 {
|
||||
yf := float64(y)
|
||||
best := absFloatV2(yf - textCenters[0])
|
||||
for i := 1; i < len(textCenters); i++ {
|
||||
d := absFloatV2(yf - textCenters[i])
|
||||
if d < best {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
return 1 + 3*math.Exp(-(best*best)/(2*sigma*sigma))
|
||||
}
|
||||
|
||||
var rgbScore int64
|
||||
var textScore float64
|
||||
for row := 0; row < gridSize; row++ {
|
||||
for col := 0; col < gridSize-1; col++ {
|
||||
leftIdx := row*gridSize + col
|
||||
rightIdx := leftIdx + 1
|
||||
leftDst := sliderTileRect(bounds, gridSize, leftIdx)
|
||||
rightDst := sliderTileRect(bounds, gridSize, rightIdx)
|
||||
leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
|
||||
rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
|
||||
h := leftDst.Dy()
|
||||
if rightDst.Dy() < h {
|
||||
h = rightDst.Dy()
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
yy := leftDst.Min.Y + y
|
||||
l := sampleColorMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
|
||||
r := sampleColorMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy)
|
||||
rgbScore += pixelDiff(l, r)
|
||||
_, _, lb, _ := l.RGBA()
|
||||
_, _, rb, _ := r.RGBA()
|
||||
textScore += weight(yy) * float64(absIntV2(int(lb>>8)-int(rb>>8)))
|
||||
}
|
||||
}
|
||||
}
|
||||
for row := 0; row < gridSize-1; row++ {
|
||||
for col := 0; col < gridSize; col++ {
|
||||
topIdx := row*gridSize + col
|
||||
bottomIdx := (row+1)*gridSize + col
|
||||
topDst := sliderTileRect(bounds, gridSize, topIdx)
|
||||
bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
|
||||
topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
|
||||
bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
|
||||
w := topDst.Dx()
|
||||
if bottomDst.Dx() < w {
|
||||
w = bottomDst.Dx()
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
xx := topDst.Min.X + x
|
||||
t := sampleColorMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1)
|
||||
b := sampleColorMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
|
||||
rgbScore += pixelDiff(t, b)
|
||||
_, _, tb, _ := t.RGBA()
|
||||
_, _, bb, _ := b.RGBA()
|
||||
textScore += 0.65 * float64(absIntV2(int(tb>>8)-int(bb>>8)))
|
||||
}
|
||||
}
|
||||
}
|
||||
return rgbScore, textScore
|
||||
}
|
||||
|
||||
func sampleColorMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) color.Color {
|
||||
dx := dstRect.Dx()
|
||||
if dx < 1 {
|
||||
dx = 1
|
||||
}
|
||||
dy := dstRect.Dy()
|
||||
if dy < 1 {
|
||||
dy = 1
|
||||
}
|
||||
sx := srcRect.Min.X + (dstX-dstRect.Min.X)*srcRect.Dx()/dx
|
||||
sy := srcRect.Min.Y + (dstY-dstRect.Min.Y)*srcRect.Dy()/dy
|
||||
return img.At(sx, sy)
|
||||
}
|
||||
|
||||
func sampleLumaMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) uint8 {
|
||||
c := sampleColorMappedV2(img, dstRect, srcRect, dstX, dstY)
|
||||
r, g, b, _ := c.RGBA()
|
||||
y := (299*(r>>8) + 587*(g>>8) + 114*(b>>8)) / 1000
|
||||
return uint8(y)
|
||||
}
|
||||
|
||||
func absFloatV2(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func absIntV2(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func buildSliderCursorV2(candidateIndex int, candidateCount int) string {
|
||||
if candidateCount <= 0 {
|
||||
return "[]"
|
||||
}
|
||||
if candidateIndex < 1 {
|
||||
candidateIndex = 1
|
||||
}
|
||||
if candidateIndex > candidateCount {
|
||||
candidateIndex = candidateCount
|
||||
}
|
||||
|
||||
type cursorPoint struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
startX := 570 + mathrand.Intn(40)
|
||||
startY := 875 + mathrand.Intn(30)
|
||||
|
||||
denom := candidateCount - 1
|
||||
if denom < 1 {
|
||||
denom = 1
|
||||
}
|
||||
baseTargetX := 734 + (937-734)*(candidateIndex-1)/denom
|
||||
targetX := baseTargetX + mathrand.Intn(10) - 5
|
||||
targetY := 655 + mathrand.Intn(14)
|
||||
|
||||
points := make([]cursorPoint, 0, 28)
|
||||
|
||||
for i := 0; i < 1+mathrand.Intn(3); i++ {
|
||||
points = append(points, cursorPoint{
|
||||
X: startX + mathrand.Intn(5) - 2,
|
||||
Y: startY + mathrand.Intn(5) - 2,
|
||||
})
|
||||
}
|
||||
|
||||
transitSteps := 2 + mathrand.Intn(3)
|
||||
arcOffX := mathrand.Intn(60) - 30
|
||||
arcOffY := -(mathrand.Intn(30) + 10)
|
||||
for i := 1; i <= transitSteps; i++ {
|
||||
t := 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
|
||||
points = append(points, cursorPoint{
|
||||
X: int(math.Round(bx)) + mathrand.Intn(jitter*2+1) - jitter,
|
||||
Y: int(math.Round(by)) + mathrand.Intn(jitter*2+1) - jitter,
|
||||
})
|
||||
}
|
||||
|
||||
approachSteps := 4 + mathrand.Intn(4)
|
||||
prev := points[len(points)-1]
|
||||
for i := 1; i <= approachSteps; i++ {
|
||||
t := float64(i) / float64(approachSteps)
|
||||
ax := prev.X + int(math.Round(t*float64(targetX-prev.X))) + mathrand.Intn(5) - 2
|
||||
ay := prev.Y + int(math.Round(t*float64(targetY-prev.Y))) + mathrand.Intn(5) - 2
|
||||
points = append(points, cursorPoint{X: ax, Y: ay})
|
||||
}
|
||||
|
||||
settleCount := 3 + mathrand.Intn(5)
|
||||
for i := 0; i < settleCount; i++ {
|
||||
points = append(points, cursorPoint{
|
||||
X: targetX + mathrand.Intn(7) - 3,
|
||||
Y: targetY + mathrand.Intn(7) - 3,
|
||||
})
|
||||
}
|
||||
|
||||
data, err := json.Marshal(points)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ─── VK Credential Sets (2 stable app_id with rotating fallback) ───
|
||||
|
||||
type VKCredentials struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
var vkCredentialsList = []VKCredentials{
|
||||
{ClientID: "6287487", ClientSecret: "MuAxFaKDYDOICzGnEOhp"},
|
||||
{ClientID: "8202606", ClientSecret: "lMRsTiMCyPnp5vfoldmn"},
|
||||
}
|
||||
|
||||
const vkCredentialAttemptLimit = 4
|
||||
|
||||
// ─── Credential Caching ───
|
||||
|
||||
type TurnCredentials struct {
|
||||
Username string
|
||||
Password string
|
||||
ServerAddrs []string
|
||||
ExpiresAt time.Time
|
||||
Link string
|
||||
}
|
||||
|
||||
type StreamCredentialsCache struct {
|
||||
creds TurnCredentials
|
||||
mutex sync.RWMutex
|
||||
errorCount atomic.Int32
|
||||
lastErrorTime atomic.Int64
|
||||
}
|
||||
|
||||
const (
|
||||
credentialLifetime = 10 * time.Minute
|
||||
cacheSafetyMargin = 60 * time.Second
|
||||
maxCacheErrors = 3
|
||||
errorWindow = 10 * time.Second
|
||||
)
|
||||
|
||||
var streamsPerCache = 10
|
||||
|
||||
func getCacheID(streamID int) int {
|
||||
return streamID / streamsPerCache
|
||||
}
|
||||
|
||||
var credentialsStore = struct {
|
||||
mu sync.RWMutex
|
||||
caches map[int]*StreamCredentialsCache
|
||||
}{
|
||||
caches: make(map[int]*StreamCredentialsCache),
|
||||
}
|
||||
|
||||
func getStreamCache(streamID int) *StreamCredentialsCache {
|
||||
cacheID := getCacheID(streamID)
|
||||
|
||||
credentialsStore.mu.RLock()
|
||||
cache, exists := credentialsStore.caches[cacheID]
|
||||
credentialsStore.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return cache
|
||||
}
|
||||
|
||||
credentialsStore.mu.Lock()
|
||||
defer credentialsStore.mu.Unlock()
|
||||
|
||||
if cache, exists = credentialsStore.caches[cacheID]; exists {
|
||||
return cache
|
||||
}
|
||||
|
||||
cache = &StreamCredentialsCache{}
|
||||
credentialsStore.caches[cacheID] = cache
|
||||
return cache
|
||||
}
|
||||
|
||||
func (c *StreamCredentialsCache) invalidate(streamID int) {
|
||||
c.mutex.Lock()
|
||||
c.creds = TurnCredentials{}
|
||||
c.mutex.Unlock()
|
||||
|
||||
c.errorCount.Store(0)
|
||||
c.lastErrorTime.Store(0)
|
||||
|
||||
log.Printf("[STREAM %d] [VK Auth] Credentials cache invalidated", streamID)
|
||||
}
|
||||
|
||||
func cloneStringSlice(in []string) []string {
|
||||
out := make([]string, len(in))
|
||||
copy(out, in)
|
||||
return out
|
||||
}
|
||||
|
||||
func isAuthError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errStr := err.Error()
|
||||
return strings.Contains(errStr, "401") ||
|
||||
strings.Contains(errStr, "Unauthorized") ||
|
||||
strings.Contains(errStr, "authentication") ||
|
||||
strings.Contains(errStr, "invalid credential") ||
|
||||
strings.Contains(errStr, "stale nonce")
|
||||
}
|
||||
|
||||
func handleAuthError(streamID int) bool {
|
||||
cache := getStreamCache(streamID)
|
||||
cacheID := getCacheID(streamID)
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
if now-cache.lastErrorTime.Load() > int64(errorWindow.Seconds()) {
|
||||
cache.errorCount.Store(0)
|
||||
}
|
||||
|
||||
count := cache.errorCount.Add(1)
|
||||
cache.lastErrorTime.Store(now)
|
||||
|
||||
log.Printf("[STREAM %d] Auth error (cache=%d, count=%d/%d)", streamID, cacheID, count, maxCacheErrors)
|
||||
|
||||
if count >= maxCacheErrors {
|
||||
log.Printf("[VK Auth] Multiple auth errors detected (%d), invalidating cache %d", count, cacheID)
|
||||
cache.invalidate(streamID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Captcha lockout ───
|
||||
|
||||
var globalCaptchaLockout atomic.Int64
|
||||
|
||||
const (
|
||||
captchaAutoWebViewTimeout = 10 * time.Second
|
||||
captchaManualWebViewTimeout = 60 * time.Second
|
||||
captchaSelectedWebViewTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// ─── Random delay ───
|
||||
|
||||
func vkDelayRandom(minMs, maxMs int) {
|
||||
ms := minMs + rand.Intn(maxMs-minMs+1)
|
||||
time.Sleep(time.Duration(ms) * time.Millisecond)
|
||||
}
|
||||
|
||||
// ─── Cached credential fetcher ───
|
||||
|
||||
func getVkCredsCached(ctx context.Context, link string, streamID int) (string, string, []string, error) {
|
||||
cache := getStreamCache(streamID)
|
||||
cacheID := getCacheID(streamID)
|
||||
|
||||
cache.mutex.RLock()
|
||||
if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 {
|
||||
expires := time.Until(cache.creds.ExpiresAt)
|
||||
u, p := cache.creds.Username, cache.creds.Password
|
||||
addr := cache.creds.ServerAddrs[streamID%len(cache.creds.ServerAddrs)]
|
||||
addrs := cloneStringSlice(cache.creds.ServerAddrs)
|
||||
cache.mutex.RUnlock()
|
||||
log.Printf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v, selected=%s, urls=%d)", streamID, cacheID, expires.Truncate(time.Second), addr, len(addrs))
|
||||
return u, p, addrs, nil
|
||||
}
|
||||
cache.mutex.RUnlock()
|
||||
|
||||
cache.mutex.Lock()
|
||||
defer cache.mutex.Unlock()
|
||||
|
||||
// Double-check inside lock
|
||||
if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 {
|
||||
return cache.creds.Username, cache.creds.Password, cloneStringSlice(cache.creds.ServerAddrs), nil
|
||||
}
|
||||
|
||||
user, pass, addrs, err := fetchVkCredsSerialized(ctx, link, streamID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
cache.creds = TurnCredentials{
|
||||
Username: user,
|
||||
Password: pass,
|
||||
ServerAddrs: addrs,
|
||||
ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin),
|
||||
Link: link,
|
||||
}
|
||||
return user, pass, cloneStringSlice(addrs), nil
|
||||
}
|
||||
|
||||
// ─── Serialized (throttled) fetcher ───
|
||||
|
||||
var (
|
||||
vkRequestMu sync.Mutex
|
||||
globalLastVkFetchTime time.Time
|
||||
)
|
||||
|
||||
func fetchVkCredsSerialized(ctx context.Context, link string, streamID int) (string, string, []string, error) {
|
||||
vkRequestMu.Lock()
|
||||
defer vkRequestMu.Unlock()
|
||||
|
||||
// Throttle: 3-6 seconds between requests
|
||||
minInterval := 3*time.Second + time.Duration(rand.Intn(3000))*time.Millisecond
|
||||
elapsed := time.Since(globalLastVkFetchTime)
|
||||
|
||||
if !globalLastVkFetchTime.IsZero() && elapsed < minInterval {
|
||||
wait := minInterval - elapsed
|
||||
log.Printf("[STREAM %d] [VK Auth] Throttling: waiting %v to prevent rate limit...", streamID, wait.Truncate(time.Millisecond))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", "", nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
globalLastVkFetchTime = time.Now()
|
||||
}()
|
||||
|
||||
return fetchVkCreds(ctx, link, streamID)
|
||||
}
|
||||
|
||||
// ─── Main credential fetcher (rotates through stable credential sets) ───
|
||||
|
||||
func fetchVkCreds(ctx context.Context, link string, streamID int) (string, string, []string, error) {
|
||||
if time.Now().Unix() < globalCaptchaLockout.Load() {
|
||||
return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
jar := tlsclient.NewCookieJar()
|
||||
|
||||
for attempt := 0; attempt < vkCredentialAttemptLimit; attempt++ {
|
||||
creds := vkCredentialsList[attempt%len(vkCredentialsList)]
|
||||
log.Printf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s (attempt %d/%d)", streamID, creds.ClientID, attempt+1, vkCredentialAttemptLimit)
|
||||
|
||||
user, pass, addrs, err := getTokenChain(ctx, link, streamID, creds, jar)
|
||||
|
||||
if err == nil {
|
||||
log.Printf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID)
|
||||
return user, pass, addrs, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Printf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err)
|
||||
|
||||
if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") || strings.Contains(err.Error(), "FATAL_CAPTCHA") {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), "error_code:29") || strings.Contains(err.Error(), "error_code: 29") || strings.Contains(err.Error(), "Rate limit") {
|
||||
log.Printf("[STREAM %d] [VK Auth] Rate limit detected, trying next credentials...", streamID)
|
||||
}
|
||||
|
||||
if attempt%len(vkCredentialsList) == len(vkCredentialsList)-1 && attempt+1 < vkCredentialAttemptLimit {
|
||||
wait := time.Duration(900+rand.Intn(900)) * time.Millisecond
|
||||
log.Printf("[STREAM %d] [VK Auth] Both VK credentials failed, retrying stable pair after %v...", streamID, wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", "", nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", nil, fmt.Errorf("all VK credentials failed: %w", lastErr)
|
||||
}
|
||||
|
||||
// ─── Token chain: anon_token → getCallPreview → getAnonymousToken → OK session → joinConversation → TURN creds ───
|
||||
|
||||
func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, []string, error) {
|
||||
profile := getRandomProfile()
|
||||
|
||||
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(),
|
||||
tlsclient.WithTimeoutSeconds(20),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_146),
|
||||
tlsclient.WithCookieJar(jar),
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("failed to initialize tls_client: %w", err)
|
||||
}
|
||||
|
||||
name := generateName()
|
||||
escapedName := neturl.QueryEscape(name)
|
||||
|
||||
log.Printf("[STREAM %d] [VK Auth] Identity - Name: %s | UA: %s", streamID, name, profile.UserAgent)
|
||||
|
||||
doRequest := func(data string, url string) (resp map[string]interface{}, err error) {
|
||||
parsedURL, err := neturl.Parse(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse request URL: %w", err)
|
||||
}
|
||||
domain := parsedURL.Hostname()
|
||||
|
||||
req, err := fhttp.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Host = domain
|
||||
applyBrowserProfileFhttp(req, profile)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Origin", "https://vk.ru")
|
||||
req.Header.Set("Referer", "https://vk.ru/")
|
||||
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")
|
||||
|
||||
httpResp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := httpResp.Body.Close(); closeErr != nil {
|
||||
log.Printf("close response body: %s", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(httpResp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Step 1: get_anonym_token
|
||||
data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s", creds.ClientID, creds.ClientSecret, creds.ClientID)
|
||||
resp, err := doRequest(data, "https://login.vk.ru/?act=get_anonym_token")
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
dataMap, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", "", nil, fmt.Errorf("unexpected anon token response: %v", resp)
|
||||
}
|
||||
token1, ok := dataMap["access_token"].(string)
|
||||
if !ok {
|
||||
return "", "", nil, fmt.Errorf("missing access_token in response: %v", resp)
|
||||
}
|
||||
|
||||
vkDelayRandom(100, 150)
|
||||
|
||||
// Step 2: getCallPreview (mimics real VK client behavior)
|
||||
data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&fields=photo_200&access_token=%s", link, token1)
|
||||
_, err = doRequest(data, "https://api.vk.ru/method/calls.getCallPreview?v=5.275&client_id="+creds.ClientID)
|
||||
if err != nil {
|
||||
log.Printf("[STREAM %d] [VK Auth] Warning: getCallPreview failed: %v", streamID, err)
|
||||
}
|
||||
|
||||
vkDelayRandom(200, 400)
|
||||
|
||||
// Step 3: getAnonymousToken (with captcha handling)
|
||||
data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&access_token=%s", link, escapedName, token1)
|
||||
urlAddr := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=5.275&client_id=%s", creds.ClientID)
|
||||
|
||||
var token2 string
|
||||
var savedProfile *SavedProfile
|
||||
savedProfile, _ = LoadProfileFromDisk()
|
||||
|
||||
for attempt := 0; ; attempt++ {
|
||||
resp, err = doRequest(data, urlAddr)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
if errObj, hasErr := resp["error"].(map[string]interface{}); hasErr {
|
||||
captchaErr := parseVkCaptchaError(errObj)
|
||||
if captchaErr != nil && captchaErr.RedirectURI != "" && captchaErr.SessionToken != "" {
|
||||
if attempt >= 3 {
|
||||
log.Printf("[STREAM %d] [Captcha] Max attempts reached", streamID)
|
||||
globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix())
|
||||
return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED")
|
||||
}
|
||||
|
||||
successToken, solveErr := solveCaptchaBySelectedMode(ctx, streamID, attempt+1, captchaErr, client, profile, savedProfile)
|
||||
if solveErr != nil {
|
||||
log.Printf("[STREAM %d] [Captcha] Solve failed: %v", streamID, solveErr)
|
||||
globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix())
|
||||
return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED")
|
||||
}
|
||||
|
||||
captchaAttempt := captchaErr.CaptchaAttempt
|
||||
if captchaAttempt == "0" || captchaAttempt == "" {
|
||||
captchaAttempt = "1"
|
||||
}
|
||||
|
||||
data = fmt.Sprintf("vk_join_link=https://vk.com/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), captchaErr.CaptchaTs, captchaAttempt, token1)
|
||||
continue
|
||||
}
|
||||
return "", "", nil, fmt.Errorf("VK API error: %v", errObj)
|
||||
}
|
||||
|
||||
respMap, okLoop := resp["response"].(map[string]interface{})
|
||||
if !okLoop {
|
||||
return "", "", nil, fmt.Errorf("unexpected getAnonymousToken response: %v", resp)
|
||||
}
|
||||
token2, okLoop = respMap["token"].(string)
|
||||
if !okLoop {
|
||||
return "", "", nil, fmt.Errorf("missing token in response: %v", resp)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
vkDelayRandom(100, 150)
|
||||
|
||||
// Step 4: OK.ru anonymLogin
|
||||
sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New())
|
||||
data = fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA", neturl.QueryEscape(sessionData))
|
||||
resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do")
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
token3, ok := resp["session_key"].(string)
|
||||
if !ok {
|
||||
return "", "", nil, fmt.Errorf("missing session_key in response: %v", resp)
|
||||
}
|
||||
|
||||
vkDelayRandom(100, 150)
|
||||
|
||||
// Step 5: joinConversationByLink → TURN creds
|
||||
data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token2, token3)
|
||||
resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do")
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
tsRaw, ok := resp["turn_server"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", "", nil, fmt.Errorf("missing turn_server in response: %v", resp)
|
||||
}
|
||||
user, ok := tsRaw["username"].(string)
|
||||
if !ok {
|
||||
return "", "", nil, fmt.Errorf("missing username in turn_server")
|
||||
}
|
||||
pass, ok := tsRaw["credential"].(string)
|
||||
if !ok {
|
||||
return "", "", nil, fmt.Errorf("missing credential in turn_server")
|
||||
}
|
||||
urlsRaw, ok := tsRaw["urls"].([]interface{})
|
||||
if !ok || len(urlsRaw) == 0 {
|
||||
return "", "", nil, fmt.Errorf("missing or empty urls in turn_server")
|
||||
}
|
||||
|
||||
log.Printf("[STREAM %d] [VK Auth] TURN urls (%d total):", streamID, len(urlsRaw))
|
||||
for i, u := range urlsRaw {
|
||||
log.Printf("[STREAM %d] [VK Auth] [%d] %v", streamID, i, u)
|
||||
}
|
||||
|
||||
var addresses []string
|
||||
for _, u := range urlsRaw {
|
||||
urlStr, ok := u.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
clean := strings.Split(urlStr, "?")[0]
|
||||
address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:")
|
||||
addresses = append(addresses, address)
|
||||
}
|
||||
|
||||
if len(addresses) == 0 {
|
||||
return "", "", nil, fmt.Errorf("no valid TURN addresses found")
|
||||
}
|
||||
|
||||
return user, pass, addresses, nil
|
||||
}
|
||||
|
||||
func solveCaptchaBySelectedMode(
|
||||
ctx context.Context,
|
||||
streamID int,
|
||||
attempt int,
|
||||
captchaErr *VkCaptchaError,
|
||||
client tlsclient.HttpClient,
|
||||
profile Profile,
|
||||
savedProfile *SavedProfile,
|
||||
) (string, error) {
|
||||
switch getCaptchaMode() {
|
||||
case "wv":
|
||||
log.Printf("[STREAM %d] [КАПЧА] WBV: режим из настроек Android (attempt %d)", streamID, attempt)
|
||||
return requestWebViewCaptcha(streamID, captchaErr, "selected", captchaSelectedWebViewTimeout)
|
||||
case "rjs":
|
||||
log.Printf("[STREAM %d] [КАПЧА] RJS: Go v2 выбран в настройках (attempt %d)", streamID, attempt)
|
||||
token, solveErr := solveVkCaptchaV2Attempts(ctx, captchaErr, client, profile, savedProfile, 2)
|
||||
if solveErr == nil {
|
||||
return token, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return "", solveErr
|
||||
}
|
||||
log.Printf("[STREAM %d] [КАПЧА] RJS: ошибка, fallback на WBV Auto: %v", streamID, solveErr)
|
||||
return requestWebViewCaptcha(streamID, captchaErr, "auto", captchaAutoWebViewTimeout)
|
||||
}
|
||||
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: старт цепочки (captcha attempt %d)", streamID, attempt)
|
||||
|
||||
token, solveErr := solveVkCaptchaV2Attempts(ctx, captchaErr, client, profile, savedProfile, 2)
|
||||
if solveErr == nil {
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: Go v2 решил капчу", streamID)
|
||||
return token, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return "", solveErr
|
||||
}
|
||||
lastErr := solveErr
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: Go v2 не решил за 2 попытки: %v", streamID, solveErr)
|
||||
|
||||
for wbvAttempt := 1; wbvAttempt <= 2; wbvAttempt++ {
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: WBV Auto попытка %d/2 (timeout %s)", streamID, wbvAttempt, captchaAutoWebViewTimeout)
|
||||
token, solveErr = requestWebViewCaptcha(streamID, captchaErr, "auto", captchaAutoWebViewTimeout)
|
||||
if solveErr == nil {
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: WBV Auto решил капчу", streamID)
|
||||
return token, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return "", solveErr
|
||||
}
|
||||
lastErr = solveErr
|
||||
if isWebViewCaptchaTimeout(solveErr) {
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: WBV Auto timeout %d/2", streamID, wbvAttempt)
|
||||
} else {
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: WBV Auto ошибка %d/2: %v", streamID, wbvAttempt, solveErr)
|
||||
}
|
||||
|
||||
timer := time.NewTimer(time.Duration(250+rand.Intn(250)) * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return "", ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: финальная Go v2 попытка после WBV", streamID)
|
||||
token, solveErr = solveVkCaptchaV2Attempts(ctx, captchaErr, client, profile, savedProfile, 1)
|
||||
if solveErr == nil {
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: финальная Go v2 решила капчу", streamID)
|
||||
return token, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return "", solveErr
|
||||
}
|
||||
lastErr = solveErr
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: финальная Go v2 ошибка: %v", streamID, solveErr)
|
||||
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: автоцепочка не прошла, открыт ручной WebView", streamID)
|
||||
token, solveErr = requestWebViewCaptcha(streamID, captchaErr, "manual", captchaManualWebViewTimeout)
|
||||
if solveErr == nil {
|
||||
log.Printf("[STREAM %d] [КАПЧА] AUTO: ручной WebView решил капчу", streamID)
|
||||
return token, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", fmt.Errorf("automatic captcha chain failed: %w; manual fallback failed: %v", lastErr, solveErr)
|
||||
}
|
||||
return "", solveErr
|
||||
}
|
||||
|
||||
func requestWebViewCaptcha(streamID int, captchaErr *VkCaptchaError, mode string, timeout time.Duration) (string, error) {
|
||||
if CaptchaResultChan == nil || captchaErr == nil || captchaErr.RedirectURI == "" || captchaErr.SessionToken == "" {
|
||||
return "", fmt.Errorf("webview captcha data is incomplete")
|
||||
}
|
||||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||||
if mode != "manual" && mode != "selected" {
|
||||
mode = "auto"
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = captchaAutoWebViewTimeout
|
||||
}
|
||||
|
||||
drainCaptchaResult()
|
||||
fmt.Printf("CAPTCHA_SOLVE|%s|%s|%s\n", mode, captchaErr.RedirectURI, captchaErr.SessionToken)
|
||||
|
||||
waitCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case result := <-CaptchaResultChan:
|
||||
result = strings.TrimSpace(result)
|
||||
if result == "" {
|
||||
return "", fmt.Errorf("webview captcha returned empty result")
|
||||
}
|
||||
lowerResult := strings.ToLower(result)
|
||||
if lowerResult == "error:timeout" {
|
||||
return "", fmt.Errorf("webview captcha timed out")
|
||||
}
|
||||
if strings.HasPrefix(lowerResult, "error:") {
|
||||
return "", fmt.Errorf("webview captcha failed: %s", result)
|
||||
}
|
||||
log.Printf("[STREAM %d] [КАПЧА] WBV: %s solve succeeded", streamID, mode)
|
||||
return result, nil
|
||||
case <-waitCtx.Done():
|
||||
return "", fmt.Errorf("webview captcha timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func isWebViewCaptchaTimeout(err error) bool {
|
||||
return err != nil && strings.Contains(strings.ToLower(err.Error()), "timed out")
|
||||
}
|
||||
|
||||
// ─── GetCreds returns TURN credentials for a given stream ───
|
||||
|
||||
func GetCreds(ctx context.Context, link string, streamID int) (string, string, []string, error) {
|
||||
return getVkCredsCached(ctx, link, streamID)
|
||||
}
|
||||
|
||||
// ─── DNS dialer setup ───
|
||||
|
||||
func setupGlobalResolver() {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
yandexDNSServers := []string{"77.88.8.8:53", "77.88.8.1:53"}
|
||||
|
||||
net.DefaultResolver = &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
var lastErr error
|
||||
for _, dns := range yandexDNSServers {
|
||||
conn, err := dialer.DialContext(ctx, "udp", dns)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
conn, err = dialer.DialContext(ctx, "tcp", dns)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
address = strings.TrimSpace(address)
|
||||
if address != "" && !isYandexDNSAddress(address) {
|
||||
conn, err := dialer.DialContext(ctx, network, address)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, lastErr
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isYandexDNSAddress(address string) bool {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
host = address
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
return host == "77.88.8.8" || host == "77.88.8.1"
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
returnChBuf = 384
|
||||
|
||||
// chunkSize — количество последовательных пакетов, отправляемых в один worker
|
||||
// перед переключением на следующий.
|
||||
//
|
||||
// Зачем: при round-robin (chunk=1) каждый пакет летит через разный TURN relay
|
||||
// с разным latency, что приводит к reorder на сервере. TCP внутри WireGuard
|
||||
// интерпретирует reorder как потери → cwnd collapse → скорость single-flow
|
||||
// падает до ~8 KB/s.
|
||||
//
|
||||
// С chunk=8: пакеты в пределах одного TCP congestion window (~10 пакетов при
|
||||
// initial cwnd) уходят через один TURN relay → прилетают по порядку.
|
||||
// Reorder возможен только между chunk-границами, что покрывается WG replay
|
||||
// window (2048 пакетов).
|
||||
//
|
||||
// Агрегатная пропускная способность не меняется — все workers загружены
|
||||
// равномерно по-прежнему (каждый получает 1/N от общего трафика за время).
|
||||
chunkSize = 8
|
||||
)
|
||||
|
||||
type WorkerSlot struct {
|
||||
ID int
|
||||
SendCh chan []byte
|
||||
}
|
||||
|
||||
type Dispatcher struct {
|
||||
localConn net.PacketConn
|
||||
clientAddr atomic.Pointer[net.Addr]
|
||||
mu sync.Mutex
|
||||
workers []*WorkerSlot
|
||||
rrIndex int
|
||||
rrCount int // сколько пакетов отправлено в текущий worker (0..chunkSize-1)
|
||||
ReturnCh chan []byte
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
stats *Stats
|
||||
}
|
||||
|
||||
func NewDispatcher(ctx context.Context, localConn net.PacketConn, stats *Stats) *Dispatcher {
|
||||
dctx, dcancel := context.WithCancel(ctx)
|
||||
d := &Dispatcher{
|
||||
localConn: localConn,
|
||||
ReturnCh: make(chan []byte, returnChBuf),
|
||||
ctx: dctx,
|
||||
cancel: dcancel,
|
||||
stats: stats,
|
||||
}
|
||||
|
||||
d.wg.Add(2)
|
||||
go d.readLoop()
|
||||
go d.writeLoop()
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Shutdown() {
|
||||
d.cancel()
|
||||
d.wg.Wait()
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Register(w *WorkerSlot) {
|
||||
d.mu.Lock()
|
||||
d.workers = append(d.workers, w)
|
||||
count := len(d.workers)
|
||||
d.mu.Unlock()
|
||||
log.Printf("[ДИСП] Воркер #%d зарегистрирован (всего: %d)", w.ID, count)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Unregister(slot *WorkerSlot) {
|
||||
d.mu.Lock()
|
||||
for i, w := range d.workers {
|
||||
if w == slot {
|
||||
d.workers = append(d.workers[:i], d.workers[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
remaining := len(d.workers)
|
||||
// Подстраховка: если текущий rrIndex вылез за границу после удаления
|
||||
if d.rrIndex >= remaining && remaining > 0 {
|
||||
d.rrIndex = d.rrIndex % remaining
|
||||
}
|
||||
d.rrCount = 0
|
||||
d.mu.Unlock()
|
||||
log.Printf("[ДИСП] Воркер #%d отключён (осталось: %d)", slot.ID, remaining)
|
||||
}
|
||||
|
||||
// readLoop читает WireGuard-пакеты и распределяет по workers chunk'ами.
|
||||
//
|
||||
// Логика: отправляем chunkSize подряд пакетов в один worker, потом переходим
|
||||
// к следующему. Если текущий worker перегружен (канал полный) — немедленно
|
||||
// ищем свободный worker и начинаем новый chunk на нём. Это гарантирует:
|
||||
// - В рамках chunk пакеты идут через один TURN relay → in-order delivery
|
||||
// - Между chunks — разные relay → максимальная агрегатная скорость
|
||||
// - Нет блокировки, нет буферизации, нет дополнительного latency
|
||||
func (d *Dispatcher) readLoop() {
|
||||
defer d.wg.Done()
|
||||
|
||||
buf := make([]byte, readBufSize)
|
||||
for {
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
n, addr, err := d.localConn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if d.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
d.clientAddr.Store(&addr)
|
||||
atomic.AddInt64(&d.stats.TotalBytesUp, int64(n))
|
||||
|
||||
pkt := make([]byte, n)
|
||||
copy(pkt, buf[:n])
|
||||
|
||||
d.mu.Lock()
|
||||
nw := len(d.workers)
|
||||
if nw == 0 {
|
||||
d.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
sent := false
|
||||
idx := d.rrIndex % nw
|
||||
|
||||
// Пробуем текущий worker (chunk affinity)
|
||||
w := d.workers[idx]
|
||||
select {
|
||||
case w.SendCh <- pkt:
|
||||
sent = true
|
||||
d.rrCount++
|
||||
if d.rrCount >= chunkSize {
|
||||
d.rrIndex = (idx + 1) % nw
|
||||
d.rrCount = 0
|
||||
}
|
||||
default:
|
||||
// Текущий worker перегружен — ищем свободный, начинаем новый chunk
|
||||
for i := 1; i < nw; i++ {
|
||||
altIdx := (idx + i) % nw
|
||||
select {
|
||||
case d.workers[altIdx].SendCh <- pkt:
|
||||
sent = true
|
||||
d.rrIndex = altIdx
|
||||
d.rrCount = 1 // первый пакет нового chunk'а уже отправлен
|
||||
default:
|
||||
}
|
||||
if sent {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !sent {
|
||||
// Все workers перегружены — сдвигаем указатель, пакет дропается
|
||||
d.rrIndex = (idx + 1) % nw
|
||||
d.rrCount = 0
|
||||
}
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) writeLoop() {
|
||||
defer d.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-d.ctx.Done():
|
||||
return
|
||||
case pkt := <-d.ReturnCh:
|
||||
addrPtr := d.clientAddr.Load()
|
||||
if addrPtr == nil {
|
||||
continue
|
||||
}
|
||||
addr := *addrPtr
|
||||
if _, err := d.localConn.WriteTo(pkt, addr); err != nil {
|
||||
if d.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
atomic.AddInt64(&d.stats.TotalBytesDown, int64(len(pkt)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
module wg-turn-client
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/bogdanfinn/fhttp v0.6.8
|
||||
github.com/bogdanfinn/tls-client v1.14.0
|
||||
github.com/cbeuw/connutil v1.0.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pion/dtls/v3 v3.1.2
|
||||
github.com/pion/logging v0.2.4
|
||||
github.com/pion/turn/v5 v5.0.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/bdandy/go-errors v1.2.2 // indirect
|
||||
github.com/bdandy/go-socks4 v1.2.3 // indirect
|
||||
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
|
||||
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
|
||||
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/stun/v3 v3.1.1 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,300 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
var groupAuthMutex sync.Mutex
|
||||
|
||||
const (
|
||||
workersPerGroup = 9
|
||||
defaultCycleSecs = 36000
|
||||
)
|
||||
|
||||
// WorkerGroup:
|
||||
// Запускает 9 потоков с одними кредами. Ротации нет — работает до смерти воркеров.
|
||||
func WorkerGroup(
|
||||
ctx context.Context,
|
||||
groupID int,
|
||||
hashIndex int,
|
||||
tp *TurnParams,
|
||||
peer *net.UDPAddr,
|
||||
d *Dispatcher,
|
||||
localPort string,
|
||||
getConfig bool,
|
||||
configCh chan<- string,
|
||||
workerIDs []int,
|
||||
cycleDuration time.Duration,
|
||||
pauseFlag *int32,
|
||||
deviceID, password string,
|
||||
stats *Stats,
|
||||
waitReady <-chan struct{},
|
||||
signalReady chan<- struct{},
|
||||
) {
|
||||
// Каскадный запуск: ждем свою очередь
|
||||
if waitReady != nil {
|
||||
log.Printf("[ГРУППА #%d] Ожидание сигнала от предыдущей группы...", groupID)
|
||||
select {
|
||||
case <-waitReady:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var configSent int32
|
||||
if !getConfig {
|
||||
configSent = 1
|
||||
}
|
||||
|
||||
// Doze-mode пауза
|
||||
for atomic.LoadInt32(pauseFlag) != 0 {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
hash := tp.Hashes[hashIndex%len(tp.Hashes)]
|
||||
shortHash := hash
|
||||
if len(shortHash) > 8 {
|
||||
shortHash = shortHash[:8]
|
||||
}
|
||||
log.Printf("[ГРУППА #%d] Запрос кредов (хеш: %s...)", groupID, shortHash)
|
||||
|
||||
credStreamID := groupID * 100
|
||||
user, pass, turnURLs, err := GetCreds(ctx, hash, credStreamID)
|
||||
var creds *Credentials
|
||||
if err == nil {
|
||||
creds = &Credentials{User: user, Pass: pass, TurnURLs: turnURLs, CacheStreamID: credStreamID}
|
||||
} else {
|
||||
log.Printf("[ГРУППА #%d] Ошибка кредов: %v", groupID, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[ГРУППА #%d] Креды OK, TURN: %v, %d воркеров", groupID, creds.TurnURLs, len(workerIDs))
|
||||
|
||||
var configRequestInFlight int32
|
||||
var wg sync.WaitGroup
|
||||
var credsMu sync.RWMutex
|
||||
var refreshMu sync.Mutex
|
||||
var lastCredRefresh atomic.Int64
|
||||
|
||||
refreshCreds := func(reason string) bool {
|
||||
refreshMu.Lock()
|
||||
defer refreshMu.Unlock()
|
||||
|
||||
now := time.Now().Unix()
|
||||
last := lastCredRefresh.Load()
|
||||
if last > 0 && now-last < 15 {
|
||||
log.Printf("[TURN] Креды уже обновлялись %d сек назад, ждём следующий retry (%s)", now-last, reason)
|
||||
return true
|
||||
}
|
||||
|
||||
getStreamCache(credStreamID).invalidate(credStreamID)
|
||||
u, p, urls, refreshErr := GetCreds(ctx, hash, credStreamID)
|
||||
if refreshErr != nil {
|
||||
log.Printf("[TURN] Не удалось обновить креды после %s: %v", reason, refreshErr)
|
||||
return false
|
||||
}
|
||||
|
||||
credsMu.Lock()
|
||||
creds = &Credentials{User: u, Pass: p, TurnURLs: urls, CacheStreamID: credStreamID}
|
||||
credsMu.Unlock()
|
||||
lastCredRefresh.Store(time.Now().Unix())
|
||||
log.Printf("[TURN] Креды обновлены после %s, TURN urls=%d", reason, len(urls))
|
||||
return true
|
||||
}
|
||||
|
||||
// Сигнализируем следующей группе, что мы успешно запустились (креды получены + 2 сек форы)
|
||||
if signalReady != nil {
|
||||
go func() {
|
||||
time.Sleep(2000 * time.Millisecond)
|
||||
close(signalReady)
|
||||
log.Printf("[ГРУППА #%d] Успешный старт! Передача эстафеты следующей группе...", groupID)
|
||||
}()
|
||||
}
|
||||
|
||||
for i, wid := range workerIDs {
|
||||
wg.Add(1)
|
||||
|
||||
// Stagger: 500мс между воркерами
|
||||
workerDelay := time.Duration(i) * 500 * time.Millisecond
|
||||
|
||||
go func(wid int, delay time.Duration) {
|
||||
defer wg.Done()
|
||||
|
||||
if delay > 0 {
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
shouldGetConfig := getConfig
|
||||
attempt := 0
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
getConf := false
|
||||
if shouldGetConfig && atomic.LoadInt32(&configSent) == 0 {
|
||||
getConf = atomic.CompareAndSwapInt32(&configRequestInFlight, 0, 1)
|
||||
}
|
||||
var cc chan<- string
|
||||
if getConf {
|
||||
cc = configCh
|
||||
}
|
||||
|
||||
credsMu.RLock()
|
||||
credsSnapshot := *creds
|
||||
credsSnapshot.TurnURLs = cloneStringSlice(creds.TurnURLs)
|
||||
credsMu.RUnlock()
|
||||
|
||||
configDelivered, sessErr := RunSession(ctx, tp, peer, d, localPort,
|
||||
getConf, cc, wid, &credsSnapshot, deviceID, password, stats)
|
||||
|
||||
if getConf {
|
||||
if configDelivered {
|
||||
atomic.StoreInt32(&configSent, 1)
|
||||
} else {
|
||||
atomic.StoreInt32(&configRequestInFlight, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if sessErr != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
errStr := sessErr.Error()
|
||||
errStrLower := strings.ToLower(errStr)
|
||||
|
||||
turnAllocAttrMissing := strings.Contains(errStrLower, "turn allocate") &&
|
||||
strings.Contains(errStrLower, "attribute not found")
|
||||
turnCredRefreshNeeded := turnAllocAttrMissing ||
|
||||
strings.Contains(errStrLower, "turn allocate auth") ||
|
||||
strings.Contains(errStrLower, "invalid credential") ||
|
||||
strings.Contains(errStrLower, "stale nonce") ||
|
||||
strings.Contains(errStrLower, "allocation mismatch") ||
|
||||
strings.Contains(errStrLower, "error 508") ||
|
||||
strings.Contains(errStrLower, "turn квота") ||
|
||||
strings.Contains(errStrLower, "quota")
|
||||
|
||||
if strings.Contains(errStrLower, "rate limit") ||
|
||||
strings.Contains(errStrLower, "flood control") ||
|
||||
strings.Contains(errStrLower, "ip mismatch") ||
|
||||
strings.Contains(errStrLower, "error 29") {
|
||||
errStr += " (ошибка со стороны ВК)"
|
||||
}
|
||||
|
||||
if strings.Contains(errStr, "хеш мёртв") ||
|
||||
strings.Contains(errStr, "FATAL_AUTH") {
|
||||
log.Printf("[ВОРКЕР #%d] Фатальная ошибка: %s", wid, errStr)
|
||||
return
|
||||
}
|
||||
|
||||
attempt++
|
||||
if turnAllocAttrMissing {
|
||||
log.Printf("[ВОРКЕР #%d] [TURN] Allocate вернул неполный ответ, обновляем TURN-креды и повторяем (попытка %d): %s", wid, attempt, errStr)
|
||||
refreshCreds("TURN Allocate attribute-not-found")
|
||||
} else if turnCredRefreshNeeded {
|
||||
log.Printf("[ВОРКЕР #%d] [TURN] Ошибка allocation/кредов, обновляем TURN-креды и повторяем (попытка %d): %s", wid, attempt, errStr)
|
||||
refreshCreds("TURN allocation error")
|
||||
} else {
|
||||
log.Printf("[ВОРКЕР #%d] Ошибка (попытка %d): %s", wid, attempt, errStr)
|
||||
}
|
||||
|
||||
// Если ошибка STUN (credentials invalid), воркер не сможет переподключиться. Завершаем.
|
||||
isStunDeath := strings.Contains(errStrLower, "error 29") ||
|
||||
strings.Contains(errStrLower, "cannot create socket")
|
||||
|
||||
if isStunDeath {
|
||||
log.Printf("[ВОРКЕР #%d] Невосстановимая TURN/STUN ошибка, завершение: %s", wid, errStr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
retryDelay := time.Duration(5+rand.Intn(11)) * time.Second
|
||||
select {
|
||||
case <-time.After(retryDelay):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}(wid, workerDelay)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
log.Printf("[ГРУППА #%d] Все воркеры группы завершились.", groupID)
|
||||
}
|
||||
|
||||
// ParseHashes — парсит строку хешей
|
||||
func ParseHashes(raw string) []string {
|
||||
var result []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, h := range strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
|
||||
}) {
|
||||
h = normalizeVKJoinHash(h)
|
||||
if h != "" {
|
||||
if _, exists := seen[h]; exists {
|
||||
continue
|
||||
}
|
||||
seen[h] = struct{}{}
|
||||
result = append(result, h)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeVKJoinHash(input string) string {
|
||||
s := strings.Trim(strings.TrimSpace(input), "<>\"'")
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lower := strings.ToLower(s)
|
||||
if idx := strings.Index(lower, "/call/join/"); idx >= 0 {
|
||||
s = s[idx+len("/call/join/"):]
|
||||
} else if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
||||
return ""
|
||||
}
|
||||
|
||||
if idx := strings.IndexAny(s, "?#/"); idx != -1 {
|
||||
s = s[:idx]
|
||||
}
|
||||
return strings.Trim(strings.TrimSpace(s), "/")
|
||||
}
|
||||
|
||||
// TurnParams — конфигурация TURN
|
||||
type TurnParams struct {
|
||||
Host string
|
||||
Port string
|
||||
Hashes []string
|
||||
WrapKey []byte // Password-derived WRAP key (32 bytes), nil = disabled
|
||||
}
|
||||
|
||||
// Credentials — учетные данные TURN
|
||||
type Credentials struct {
|
||||
User string
|
||||
Pass string
|
||||
TurnURLs []string
|
||||
CacheStreamID int
|
||||
}
|
||||
|
||||
// Unused import suppressor
|
||||
var _ = fmt.Sprintf
|
||||
@@ -0,0 +1,305 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CaptchaResultChan — канал для получения токена капчи из внешнего решателя (WebView)
|
||||
var CaptchaResultChan = make(chan string, 1)
|
||||
|
||||
var captchaModeValue atomic.Value
|
||||
|
||||
func init() {
|
||||
captchaModeValue.Store("auto")
|
||||
}
|
||||
|
||||
func normalizeCaptchaMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "auto", "rjs", "wv":
|
||||
return strings.ToLower(strings.TrimSpace(mode))
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
|
||||
func setCaptchaMode(mode string) string {
|
||||
normalized := normalizeCaptchaMode(mode)
|
||||
captchaModeValue.Store(normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
func getCaptchaMode() string {
|
||||
mode, _ := captchaModeValue.Load().(string)
|
||||
if mode == "" {
|
||||
return "auto"
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// drainCaptchaResult удаляет устаревший результат капчи из канала
|
||||
func drainCaptchaResult() {
|
||||
select {
|
||||
case <-CaptchaResultChan:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
||||
|
||||
setupGlobalResolver()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Сигналы
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
|
||||
go func() {
|
||||
select {
|
||||
case s := <-sig:
|
||||
log.Printf("[КЛИЕНТ] Сигнал %v, завершаю...", s)
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s := <-sig:
|
||||
log.Printf("[КЛИЕНТ] Повторный %v, принудительный выход", s)
|
||||
os.Exit(1)
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
var pauseFlag int32
|
||||
|
||||
// STDIN для PAUSE/RESUME/STOP и CAPTCHA_RESULT
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.Contains(line, "error:tunnel stopped") {
|
||||
log.Printf("[STDIN] %s", line)
|
||||
}
|
||||
switch {
|
||||
case line == "PAUSE":
|
||||
atomic.StoreInt32(&pauseFlag, 1)
|
||||
case line == "RESUME":
|
||||
atomic.StoreInt32(&pauseFlag, 0)
|
||||
case line == "STOP":
|
||||
cancel()
|
||||
return
|
||||
case strings.HasPrefix(line, "CAPTCHA_RESULT|"):
|
||||
result := strings.TrimPrefix(line, "CAPTCHA_RESULT|")
|
||||
drainCaptchaResult()
|
||||
CaptchaResultChan <- result
|
||||
log.Printf("[КАПЧА] Результат от Kotlin записан в канал")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
host := flag.String("turn", "", "переопределить IP TURN")
|
||||
port := flag.String("port", "", "переопределить порт TURN")
|
||||
listen := flag.String("listen", "127.0.0.1:9000", "локальный адрес")
|
||||
vkHash := flag.String("vk", "", "хеши VK-звонков (через запятую)")
|
||||
peerAddr := flag.String("peer", "", "адрес:порт VPS сервера")
|
||||
numW := flag.Int("n", 24, "количество воркеров (кратно 12)")
|
||||
|
||||
deviceID := flag.String("device-id", "unknown", "уникальный ID устройства")
|
||||
connPassword := flag.String("password", "", "пароль подключения")
|
||||
captchaMode := flag.String("captcha-mode", "auto", "режим обхода капчи (auto/wv/rjs)")
|
||||
|
||||
flag.Parse()
|
||||
activeCaptchaMode := setCaptchaMode(*captchaMode)
|
||||
|
||||
if *peerAddr == "" || *vkHash == "" {
|
||||
log.Fatal("[КЛИЕНТ] Нужны -peer и -vk")
|
||||
}
|
||||
|
||||
peer, err := net.ResolveUDPAddr("udp", *peerAddr)
|
||||
if err != nil {
|
||||
log.Fatalf("[КЛИЕНТ] Ошибка разбора пира: %v", err)
|
||||
}
|
||||
|
||||
hashes := ParseHashes(*vkHash)
|
||||
if len(hashes) == 0 {
|
||||
log.Fatal("[КЛИЕНТ] Нет хешей VK")
|
||||
}
|
||||
|
||||
if *connPassword == "" {
|
||||
log.Fatal("[КЛИЕНТ] Нужен -password: WRAP ключ теперь выводится из пароля подключения")
|
||||
}
|
||||
|
||||
// WRAP key
|
||||
wrapKey, err := deriveWrapKey(*connPassword)
|
||||
if err != nil {
|
||||
log.Fatalf("[КЛИЕНТ] WRAP key derive: %v", err)
|
||||
}
|
||||
|
||||
// Лимит воркеров
|
||||
maxWorkers := 108
|
||||
if *numW > maxWorkers {
|
||||
*numW = maxWorkers
|
||||
}
|
||||
if *numW < workersPerGroup {
|
||||
*numW = workersPerGroup
|
||||
}
|
||||
*numW = (*numW / workersPerGroup) * workersPerGroup
|
||||
|
||||
tp := &TurnParams{
|
||||
Host: *host,
|
||||
Port: *port,
|
||||
Hashes: hashes,
|
||||
WrapKey: wrapKey,
|
||||
}
|
||||
|
||||
// Слушаем локально
|
||||
localConn, err := net.ListenPacket("udp", *listen)
|
||||
if err != nil {
|
||||
log.Fatalf("[КЛИЕНТ] Ошибка слушателя %s: %v", *listen, err)
|
||||
}
|
||||
if uc, ok := localConn.(*net.UDPConn); ok {
|
||||
_ = uc.SetReadBuffer(socketBufSize)
|
||||
_ = uc.SetWriteBuffer(socketBufSize)
|
||||
}
|
||||
stopLocalConn := context.AfterFunc(ctx, func() { _ = localConn.Close() })
|
||||
defer stopLocalConn()
|
||||
|
||||
_, localPort, _ := net.SplitHostPort(*listen)
|
||||
if localPort == "" {
|
||||
localPort = "9000"
|
||||
}
|
||||
|
||||
numGroups := *numW / workersPerGroup
|
||||
|
||||
wrapStatus := "OFF"
|
||||
if len(wrapKey) == wrapKeyLen {
|
||||
wrapStatus = "ON (password HKDF + RTP AEAD)"
|
||||
}
|
||||
|
||||
captchaStatus := "AUTO: Go v2 x2 -> WBV Auto x2 -> Go v2 x1 -> Manual WBV"
|
||||
switch activeCaptchaMode {
|
||||
case "wv":
|
||||
captchaStatus = "WBV selected in Android"
|
||||
case "rjs":
|
||||
captchaStatus = "RJS Go v2 with WBV Auto fallback"
|
||||
}
|
||||
|
||||
log.Println("[КЛИЕНТ] ═══════════════════════════════════════")
|
||||
log.Printf("[КЛИЕНТ] VK Creds: 2 stable app_id с циклическим fallback")
|
||||
log.Printf("[КЛИЕНТ] TLS: Chrome 146 fingerprint")
|
||||
log.Printf("[КЛИЕНТ] Воркеров: %d (групп: %d, по %d)", *numW, numGroups, workersPerGroup)
|
||||
log.Printf("[КЛИЕНТ] Хешей: %d", len(hashes))
|
||||
log.Printf("[КЛИЕНТ] Слушаю: %s | Пир: %s", *listen, *peerAddr)
|
||||
log.Printf("[КЛИЕНТ] Протокол: UDP")
|
||||
log.Printf("[КЛИЕНТ] WRAP: %s", wrapStatus)
|
||||
log.Printf("[WRAP] Ключ выведен из пароля, режим RTP AEAD активен")
|
||||
log.Printf("[КЛИЕНТ] Device ID: %s", *deviceID)
|
||||
log.Printf("[КЛИЕНТ] Captcha: %s", captchaStatus)
|
||||
log.Println("[КЛИЕНТ] ═══════════════════════════════════════")
|
||||
|
||||
stats := NewStats()
|
||||
shutdownCh := make(chan struct{})
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(shutdownCh)
|
||||
}()
|
||||
go stats.RunLoop(shutdownCh)
|
||||
|
||||
disp := NewDispatcher(ctx, localConn, stats)
|
||||
defer disp.Shutdown()
|
||||
|
||||
configCh := make(chan string, 1)
|
||||
configDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(configDone)
|
||||
select {
|
||||
case rawConf, ok := <-configCh:
|
||||
if !ok || rawConf == "" {
|
||||
return
|
||||
}
|
||||
finalConf := rawConf
|
||||
if !strings.Contains(finalConf, "MTU =") {
|
||||
lines := strings.Split(finalConf, "\n")
|
||||
var newLines []string
|
||||
for _, line := range lines {
|
||||
newLines = append(newLines, line)
|
||||
if strings.TrimSpace(line) == "[Interface]" {
|
||||
newLines = append(newLines, "MTU = 1280")
|
||||
}
|
||||
}
|
||||
finalConf = strings.Join(newLines, "\n")
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("╔══════════════ WireGuard Конфиг ══════════════╗")
|
||||
for _, line := range strings.Split(finalConf, "\n") {
|
||||
fmt.Printf("║ %-44s ║\n", line)
|
||||
}
|
||||
fmt.Println("╚══════════════════════════════════════════════╝")
|
||||
if err := os.WriteFile("wg-turn.conf", []byte(finalConf+"\n"), 0600); err != nil {
|
||||
log.Printf("[КОНФИГ] Ошибка сохранения: %v", err)
|
||||
} else {
|
||||
log.Println("[КОНФИГ] Сохранён в wg-turn.conf")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
workerIDCounter := 1
|
||||
|
||||
var prevWaitReady <-chan struct{}
|
||||
|
||||
for g := 0; g < numGroups; g++ {
|
||||
isFirst := (g == 0)
|
||||
|
||||
var myWaitReady <-chan struct{}
|
||||
var mySignalReady chan<- struct{}
|
||||
|
||||
if g > 0 {
|
||||
myWaitReady = prevWaitReady
|
||||
}
|
||||
if g < numGroups-1 {
|
||||
ch := make(chan struct{})
|
||||
mySignalReady = ch
|
||||
prevWaitReady = ch
|
||||
}
|
||||
|
||||
ids := make([]int, workersPerGroup)
|
||||
for i := range ids {
|
||||
ids[i] = workerIDCounter
|
||||
workerIDCounter++
|
||||
}
|
||||
|
||||
gID := g + 1
|
||||
cycle := time.Duration(defaultCycleSecs) * time.Second
|
||||
var cc chan<- string
|
||||
if isFirst {
|
||||
cc = configCh
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(groupID int, cycleDir time.Duration, isFirstGroup bool, configChan chan<- string, workerIds []int, startHashIndex int, waitR <-chan struct{}, sigR chan<- struct{}) {
|
||||
defer wg.Done()
|
||||
WorkerGroup(ctx, groupID, startHashIndex, tp, peer, disp, localPort,
|
||||
isFirstGroup, configChan, workerIds, cycleDir, &pauseFlag, *deviceID, *connPassword, stats, waitR, sigR)
|
||||
}(gID, cycle, isFirst, cc, ids, g, myWaitReady, mySignalReady)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(configCh)
|
||||
<-configDone
|
||||
log.Println("[КЛИЕНТ] Все воркеры завершены")
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var maleFirstNames = []string{
|
||||
"Александр", "Алексей", "Андрей", "Антон", "Арсений",
|
||||
"Артур", "Артём", "Богдан", "Валерий", "Василий",
|
||||
"Виктор", "Владислав", "Глеб", "Григорий", "Даниил",
|
||||
"Денис", "Дмитрий", "Евгений", "Егор", "Иван",
|
||||
"Игорь", "Илья", "Кирилл", "Леонид", "Максим",
|
||||
"Марк", "Матвей", "Михаил", "Никита", "Николай",
|
||||
"Олег", "Павел", "Пётр", "Роман", "Руслан",
|
||||
"Сергей", "Станислав", "Тимофей", "Фёдор",
|
||||
}
|
||||
|
||||
var femaleFirstNames = []string{
|
||||
"Алина", "Алёна", "Анастасия", "Ангелина", "Анна",
|
||||
"Вера", "Вероника", "Виктория", "Дарья", "Ева",
|
||||
"Екатерина", "Елена", "Елизавета", "Ирина", "Кира",
|
||||
"Кристина", "Ксения", "Любовь", "Маргарита", "Марина",
|
||||
"Мария", "Милана", "Надежда", "Наталья", "Ольга",
|
||||
"Полина", "Светлана", "София", "Татьяна", "Юлия", "Яна",
|
||||
}
|
||||
|
||||
var lastNames = []string{
|
||||
"Алексеев", "Андреев", "Антонов", "Баранов", "Белов",
|
||||
"Белый", "Бельский", "Беляев", "Борисов", "Васильев",
|
||||
"Великий", "Волков", "Воробьёв", "Григорьев", "Давыдов",
|
||||
"Егоров", "Жуков", "Зайцев", "Захаров", "Иванов",
|
||||
"Калинин", "Ковалёв", "Козлов", "Комаров", "Крамской",
|
||||
"Кузнецов", "Кузьмин", "Лебедев", "Макаров", "Медведев",
|
||||
"Михайлов", "Морозов", "Никитин", "Николаев", "Новиков",
|
||||
"Орлов", "Островский", "Павлов", "Петров", "Покровский",
|
||||
"Попов", "Раевский", "Романов", "Семёнов", "Сергеев",
|
||||
"Смирнов", "Соколов", "Соловьёв", "Степанов", "Тарасов",
|
||||
"Титов", "Толстой", "Трубецкой", "Филиппов", "Фролов",
|
||||
"Фёдоров", "Чайковский", "Черный", "Яковлев",
|
||||
}
|
||||
|
||||
// convertToFemaleSurname handles Russian suffix rules
|
||||
func convertToFemaleSurname(surname string) string {
|
||||
if strings.HasSuffix(surname, "ий") || strings.HasSuffix(surname, "ый") || strings.HasSuffix(surname, "ой") {
|
||||
return surname[:len(surname)-4] + "ая"
|
||||
}
|
||||
if strings.HasSuffix(surname, "ов") || strings.HasSuffix(surname, "ев") ||
|
||||
strings.HasSuffix(surname, "ин") || strings.HasSuffix(surname, "ын") ||
|
||||
strings.HasSuffix(surname, "ёв") {
|
||||
return surname + "а"
|
||||
}
|
||||
return surname
|
||||
}
|
||||
|
||||
func generateName() string {
|
||||
isFemale := rand.Intn(2) == 0
|
||||
var fn string
|
||||
if isFemale {
|
||||
fn = femaleFirstNames[rand.Intn(len(femaleFirstNames))]
|
||||
} else {
|
||||
fn = maleFirstNames[rand.Intn(len(maleFirstNames))]
|
||||
}
|
||||
// 70% chance to have a last name
|
||||
if rand.Float32() < 0.3 {
|
||||
return fn
|
||||
}
|
||||
ln := lastNames[rand.Intn(len(lastNames))]
|
||||
if isFemale {
|
||||
ln = convertToFemaleSurname(ln)
|
||||
}
|
||||
return fmt.Sprintf("%s %s", fn, ln)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// obfs.go — WebRTC SRTP-like obfuscation for DTLS traffic
|
||||
// Each UDP packet is wrapped in an RTP header making it indistinguishable
|
||||
// from a real WebRTC OPUS audio stream to DPI systems.
|
||||
//
|
||||
// Packet format:
|
||||
// [RTP Header 12 bytes][ChaCha20-Poly1305 payload+tag][Padding 0-N bytes][PadLen 1 byte]
|
||||
//
|
||||
// The RTP header fields (SSRC + SeqNum + Timestamp) form the 12-byte AEAD nonce,
|
||||
// so no separate nonce prefix is needed.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
// ─── Configuration ───
|
||||
|
||||
// ObfsConfig holds per-session obfuscation parameters.
|
||||
type ObfsConfig struct {
|
||||
SSRC uint32 // Synchronization Source — random per session
|
||||
PayloadType uint8 // RTP payload type (111 = OPUS dynamic)
|
||||
PaddingMax int // Max random padding bytes appended
|
||||
}
|
||||
|
||||
// NewObfsConfig creates a config with random SSRC and sane defaults.
|
||||
func NewObfsConfig() *ObfsConfig {
|
||||
var buf [4]byte
|
||||
rand.Read(buf[:])
|
||||
return &ObfsConfig{
|
||||
SSRC: binary.BigEndian.Uint32(buf[:]),
|
||||
PayloadType: 111, // dynamic PT for OPUS
|
||||
PaddingMax: 24,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Per-direction state (sequence + timestamp counters) ───
|
||||
|
||||
// ObfsState tracks monotonically increasing RTP sequence number and timestamp.
|
||||
type ObfsState struct {
|
||||
mu sync.Mutex
|
||||
seq uint16
|
||||
ts uint32
|
||||
}
|
||||
|
||||
// NewObfsState creates a state with random initial seq/ts.
|
||||
func NewObfsState() *ObfsState {
|
||||
var buf [6]byte
|
||||
rand.Read(buf[:])
|
||||
return &ObfsState{
|
||||
seq: binary.BigEndian.Uint16(buf[0:2]),
|
||||
ts: binary.BigEndian.Uint32(buf[2:6]),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Nonce derivation ───
|
||||
|
||||
// obfsBuildNonce deterministically builds a 12-byte AEAD nonce from RTP fields.
|
||||
//
|
||||
// [SSRC 4B][SeqNum 2B][0x00 0x00][Timestamp 4B]
|
||||
func obfsBuildNonce(ssrc uint32, seq uint16, ts uint32) []byte {
|
||||
n := make([]byte, 12)
|
||||
binary.BigEndian.PutUint32(n[0:4], ssrc)
|
||||
binary.BigEndian.PutUint16(n[4:6], seq)
|
||||
// n[6], n[7] = 0x00 — zero padding for unique nonce space
|
||||
binary.BigEndian.PutUint32(n[8:12], ts)
|
||||
return n
|
||||
}
|
||||
|
||||
// ─── Wrap (encrypt + add RTP header) ───
|
||||
|
||||
// obfsWrapPacket wraps a plaintext payload into an RTP-like packet with authenticated encryption.
|
||||
// The output looks like:
|
||||
//
|
||||
// [V=2,P=1,X=0,CC=0 | PT | SeqNum | Timestamp | SSRC | encrypted_payload | padding | padLen]
|
||||
func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]byte, error) {
|
||||
if len(key) != wrapKeyLen {
|
||||
return nil, fmt.Errorf("obfs: key must be %d bytes (got %d)", wrapKeyLen, len(key))
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("obfs: empty payload")
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
seq := state.seq
|
||||
ts := state.ts
|
||||
state.seq++
|
||||
state.ts += 960 // 20ms frame @ 48kHz (OPUS standard)
|
||||
state.mu.Unlock()
|
||||
|
||||
// Build nonce from RTP fields
|
||||
nonce := obfsBuildNonce(cfg.SSRC, seq, ts)
|
||||
|
||||
// Determine padding
|
||||
padRand := 0
|
||||
if cfg.PaddingMax > 0 {
|
||||
var rndBuf [1]byte
|
||||
rand.Read(rndBuf[:])
|
||||
padRand = int(rndBuf[0]) % cfg.PaddingMax
|
||||
}
|
||||
padTotal := padRand + 1 // +1 for the length byte itself
|
||||
|
||||
// Allocate output: 12 (header) + payload + AEAD tag + padTotal
|
||||
outLen := 12 + len(payload) + chacha20poly1305.Overhead + padTotal
|
||||
out := make([]byte, outLen)
|
||||
|
||||
// RTP Header (12 bytes)
|
||||
out[0] = 0x80 | 0x20 // V=2, P=1 (padding present)
|
||||
out[1] = cfg.PayloadType & 0x7F
|
||||
binary.BigEndian.PutUint16(out[2:4], seq)
|
||||
binary.BigEndian.PutUint32(out[4:8], ts)
|
||||
binary.BigEndian.PutUint32(out[8:12], cfg.SSRC)
|
||||
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("obfs: cipher init: %w", err)
|
||||
}
|
||||
sealed := aead.Seal(out[12:12], nonce, payload, out[:12])
|
||||
|
||||
// Random padding bytes
|
||||
padStart := 12 + len(sealed)
|
||||
if padRand > 0 {
|
||||
rand.Read(out[padStart : padStart+padRand])
|
||||
}
|
||||
|
||||
// Last byte = total padding count (RFC 3550 §5.1)
|
||||
out[outLen-1] = byte(padTotal)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ─── Unwrap (strip RTP header + decrypt) ───
|
||||
|
||||
// obfsUnwrapPacket strips the RTP header, removes padding, and decrypts the payload.
|
||||
// Returns number of plaintext bytes written to dst.
|
||||
func obfsUnwrapPacket(key, wire, dst []byte) (int, error) {
|
||||
if len(key) != wrapKeyLen {
|
||||
return 0, fmt.Errorf("obfs: key must be %d bytes (got %d)", wrapKeyLen, len(key))
|
||||
}
|
||||
if len(wire) < 13 { // 12 header + at least 1 byte
|
||||
return 0, errors.New("obfs: packet too short")
|
||||
}
|
||||
|
||||
// Validate RTP version
|
||||
if (wire[0] >> 6) != 2 {
|
||||
return 0, errors.New("obfs: not RTP v2")
|
||||
}
|
||||
|
||||
// Extract RTP fields for nonce
|
||||
seq := binary.BigEndian.Uint16(wire[2:4])
|
||||
ts := binary.BigEndian.Uint32(wire[4:8])
|
||||
ssrc := binary.BigEndian.Uint32(wire[8:12])
|
||||
|
||||
// Handle padding (P bit)
|
||||
payloadEnd := len(wire)
|
||||
if wire[0]&0x20 != 0 {
|
||||
padLen := int(wire[len(wire)-1])
|
||||
if padLen == 0 || padLen > payloadEnd-12 {
|
||||
return 0, fmt.Errorf("obfs: invalid padding length %d", padLen)
|
||||
}
|
||||
payloadEnd -= padLen
|
||||
}
|
||||
|
||||
ciphertextLen := payloadEnd - 12
|
||||
if ciphertextLen <= chacha20poly1305.Overhead {
|
||||
return 0, errors.New("obfs: no payload after stripping header/padding")
|
||||
}
|
||||
if ciphertextLen-chacha20poly1305.Overhead > len(dst) {
|
||||
return 0, errors.New("obfs: dst buffer too small")
|
||||
}
|
||||
|
||||
// Build nonce and decrypt
|
||||
nonce := obfsBuildNonce(ssrc, seq, ts)
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("obfs: cipher init: %w", err)
|
||||
}
|
||||
plain, err := aead.Open(dst[:0], nonce, wire[12:payloadEnd], wire[:12])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("obfs: auth: %w", err)
|
||||
}
|
||||
|
||||
return len(plain), nil
|
||||
}
|
||||
|
||||
// ─── Detection ───
|
||||
|
||||
// obfsIsRTPPacket checks if a raw UDP packet looks like our obfuscated RTP.
|
||||
// Used by the server and client to reject non-obfuscated packets.
|
||||
func obfsIsRTPPacket(wire []byte) bool {
|
||||
if len(wire) < 13 {
|
||||
return false
|
||||
}
|
||||
// RTP version must be 2
|
||||
if (wire[0] >> 6) != 2 {
|
||||
return false
|
||||
}
|
||||
// Our payload type = 111
|
||||
pt := wire[1] & 0x7F
|
||||
return pt == 111
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Profile holds consistent browser fingerprint headers for TLS+HTTP requests.
|
||||
type Profile struct {
|
||||
UserAgent string `json:"user_agent"`
|
||||
SecChUa string `json:"sec_ch_ua"`
|
||||
SecChUaMobile string `json:"sec_ch_ua_mobile"`
|
||||
SecChUaPlatform string `json:"sec_ch_ua_platform"`
|
||||
}
|
||||
|
||||
// SavedProfile is a saved real browser profile loaded from disk.
|
||||
type SavedProfile struct {
|
||||
Profile
|
||||
DeviceJSON string `json:"device_json"`
|
||||
BrowserFp string `json:"browser_fp"`
|
||||
}
|
||||
|
||||
const profileFile = "vk_profile.json"
|
||||
|
||||
func LoadProfileFromDisk() (*SavedProfile, error) {
|
||||
data, err := os.ReadFile(profileFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sp SavedProfile
|
||||
if err := json.Unmarshal(data, &sp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sp, nil
|
||||
}
|
||||
|
||||
func SaveProfileToDisk(sp SavedProfile) error {
|
||||
data, err := json.MarshalIndent(sp, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(profileFile, data, 0644)
|
||||
}
|
||||
|
||||
// profileList contains paired User-Agent and Client Hints strings.
|
||||
var profileList = []Profile{
|
||||
// Windows Chrome
|
||||
{
|
||||
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: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`,
|
||||
SecChUaMobile: "?0",
|
||||
SecChUaPlatform: `"Windows"`,
|
||||
},
|
||||
{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
||||
SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`,
|
||||
SecChUaMobile: "?0",
|
||||
SecChUaPlatform: `"Windows"`,
|
||||
},
|
||||
{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
|
||||
SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`,
|
||||
SecChUaMobile: "?0",
|
||||
SecChUaPlatform: `"Windows"`,
|
||||
},
|
||||
|
||||
// Windows Edge
|
||||
{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0",
|
||||
SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Microsoft Edge";v="146"`,
|
||||
SecChUaMobile: "?0",
|
||||
SecChUaPlatform: `"Windows"`,
|
||||
},
|
||||
{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0",
|
||||
SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Microsoft Edge";v="145"`,
|
||||
SecChUaMobile: "?0",
|
||||
SecChUaPlatform: `"Windows"`,
|
||||
},
|
||||
|
||||
// macOS Chrome
|
||||
{
|
||||
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
||||
SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`,
|
||||
SecChUaMobile: "?0",
|
||||
SecChUaPlatform: `"macOS"`,
|
||||
},
|
||||
{
|
||||
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
||||
SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`,
|
||||
SecChUaMobile: "?0",
|
||||
SecChUaPlatform: `"macOS"`,
|
||||
},
|
||||
|
||||
// Linux Chrome
|
||||
{
|
||||
UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
||||
SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`,
|
||||
SecChUaMobile: "?0",
|
||||
SecChUaPlatform: `"Linux"`,
|
||||
},
|
||||
{
|
||||
UserAgent: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
|
||||
SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`,
|
||||
SecChUaMobile: "?0",
|
||||
SecChUaPlatform: `"Linux"`,
|
||||
},
|
||||
}
|
||||
|
||||
// getRandomProfile returns a paired User-Agent and Client Hints profile.
|
||||
func getRandomProfile() Profile {
|
||||
return profileList[rand.Intn(len(profileList))]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RequestConfig запрашивает WireGuard конфиг через DTLS-соединение.
|
||||
func RequestConfig(conn net.Conn, localPort, deviceID, password string) (string, error) {
|
||||
payload := fmt.Sprintf("GETCONF:%s|%s|%s", localPort, deviceID, password)
|
||||
if _, err := conn.Write([]byte(payload)); err != nil {
|
||||
return "", fmt.Errorf("отправка GETCONF: %w", err)
|
||||
}
|
||||
|
||||
b := make([]byte, 4096)
|
||||
if err := conn.SetReadDeadline(time.Now().Add(15 * time.Second)); err != nil {
|
||||
return "", fmt.Errorf("установка дедлайна: %w", err)
|
||||
}
|
||||
n, err := conn.Read(b)
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("чтение ответа конфига: %w", err)
|
||||
}
|
||||
|
||||
resp := string(b[:n])
|
||||
if resp == "NOCONF" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(resp, "DENIED:") {
|
||||
reason := strings.TrimPrefix(resp, "DENIED:")
|
||||
switch reason {
|
||||
case "wrong_password":
|
||||
return "", fmt.Errorf("FATAL_AUTH: неверный пароль подключения")
|
||||
case "expired":
|
||||
return "", fmt.Errorf("FATAL_AUTH: срок действия пароля истёк")
|
||||
case "device_mismatch":
|
||||
return "", fmt.Errorf("FATAL_AUTH: пароль привязан к другому устройству")
|
||||
default:
|
||||
return "", fmt.Errorf("FATAL_AUTH: доступ запрещён (%s)", reason)
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cbeuw/connutil"
|
||||
"github.com/pion/dtls/v3"
|
||||
"github.com/pion/dtls/v3/pkg/crypto/selfsign"
|
||||
"github.com/pion/logging"
|
||||
"github.com/pion/turn/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
workerSendBuf = 128
|
||||
sessionReadTimeout = 30 * time.Minute // Increased from 60s to 30min
|
||||
readBufSize = 1600
|
||||
socketBufSize = 625 * 1024
|
||||
keepaliveByte = 0xFF // DTLS-level keepalive marker
|
||||
keepaliveInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
// Handshake semaphore: limit to 3 concurrent DTLS handshakes
|
||||
var handshakeSem = make(chan struct{}, 3)
|
||||
|
||||
// NullLoggerFactory подавляет логи pion
|
||||
type NullLoggerFactory struct{}
|
||||
|
||||
func (n *NullLoggerFactory) NewLogger(_ string) logging.LeveledLogger { return &NullLogger{} }
|
||||
|
||||
type NullLogger struct{}
|
||||
|
||||
func (n *NullLogger) Trace(_ string) {}
|
||||
func (n *NullLogger) Tracef(_ string, _ ...interface{}) {}
|
||||
func (n *NullLogger) Debug(_ string) {}
|
||||
func (n *NullLogger) Debugf(_ string, _ ...interface{}) {}
|
||||
func (n *NullLogger) Info(_ string) {}
|
||||
func (n *NullLogger) Infof(_ string, _ ...interface{}) {}
|
||||
func (n *NullLogger) Warn(_ string) {}
|
||||
func (n *NullLogger) Warnf(_ string, _ ...interface{}) {}
|
||||
func (n *NullLogger) Error(_ string) {}
|
||||
func (n *NullLogger) Errorf(_ string, _ ...interface{}) {}
|
||||
|
||||
// connectedUDPConn — обёртка для connected UDP socket → PacketConn
|
||||
type connectedUDPConn struct{ *net.UDPConn }
|
||||
|
||||
func (c *connectedUDPConn) WriteTo(p []byte, _ net.Addr) (int, error) { return c.Write(p) }
|
||||
|
||||
func RunSession(
|
||||
ctx context.Context,
|
||||
tp *TurnParams,
|
||||
peer *net.UDPAddr,
|
||||
d *Dispatcher,
|
||||
localPort string,
|
||||
getConfig bool,
|
||||
configCh chan<- string,
|
||||
sessionID int,
|
||||
creds *Credentials,
|
||||
deviceID, password string,
|
||||
stats *Stats,
|
||||
) (bool, error) {
|
||||
configDelivered := false
|
||||
|
||||
if len(creds.TurnURLs) == 0 {
|
||||
return false, fmt.Errorf("нет TURN URL в учетных данных")
|
||||
}
|
||||
selectedURL := creds.TurnURLs[sessionID%len(creds.TurnURLs)]
|
||||
|
||||
urlhost, urlport, err := net.SplitHostPort(selectedURL)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("разбор TURN URL %q: %w", selectedURL, err)
|
||||
}
|
||||
if tp.Host != "" {
|
||||
urlhost = tp.Host
|
||||
}
|
||||
if tp.Port != "" {
|
||||
urlport = tp.Port
|
||||
}
|
||||
turnAddr := net.JoinHostPort(urlhost, urlport)
|
||||
|
||||
// Транспорт: всегда UDP
|
||||
resolved, err := net.ResolveUDPAddr("udp", turnAddr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("резолв TURN: %w", err)
|
||||
}
|
||||
c, err := net.DialUDP("udp", nil, resolved)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("подключение TURN UDP: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetReadBuffer(socketBufSize)
|
||||
_ = c.SetWriteBuffer(socketBufSize)
|
||||
var turnConn net.PacketConn = &connectedUDPConn{c}
|
||||
|
||||
log.Printf("[СЕССИЯ #%d] TURN UDP (%s)", sessionID, turnAddr)
|
||||
|
||||
// RequestedAddressFamily
|
||||
var addrFamily turn.RequestedAddressFamily
|
||||
if peer.IP.To4() != nil {
|
||||
addrFamily = turn.RequestedAddressFamilyIPv4
|
||||
} else {
|
||||
addrFamily = turn.RequestedAddressFamilyIPv6
|
||||
}
|
||||
|
||||
// TURN Client (pion/turn/v5)
|
||||
tc, err := turn.NewClient(&turn.ClientConfig{
|
||||
STUNServerAddr: turnAddr,
|
||||
TURNServerAddr: turnAddr,
|
||||
Conn: turnConn,
|
||||
Username: creds.User,
|
||||
Password: creds.Pass,
|
||||
RequestedAddressFamily: addrFamily,
|
||||
LoggerFactory: &NullLoggerFactory{},
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("TURN клиент: %w", err)
|
||||
}
|
||||
defer tc.Close()
|
||||
|
||||
if err = tc.Listen(); err != nil {
|
||||
return false, fmt.Errorf("TURN Listen: %w", err)
|
||||
}
|
||||
|
||||
relay, err := tc.Allocate()
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
handleAuthError(creds.CacheStreamID)
|
||||
}
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Quota") || strings.Contains(errStr, "486") {
|
||||
return false, fmt.Errorf("TURN квота: %w", err)
|
||||
}
|
||||
return false, fmt.Errorf("TURN Allocate: %w", err)
|
||||
}
|
||||
defer relay.Close()
|
||||
|
||||
// Reset error count on successful allocation
|
||||
getStreamCache(creds.CacheStreamID).errorCount.Store(0)
|
||||
|
||||
log.Printf("[СЕССИЯ #%d] Relay: %s", sessionID, relay.LocalAddr())
|
||||
|
||||
// Pipe для DTLS ↔ TURN relay
|
||||
pipeA, pipeB := connutil.AsyncPacketPipe()
|
||||
|
||||
sessCtx, sessCancel := context.WithCancel(ctx)
|
||||
defer sessCancel()
|
||||
|
||||
// Keepalive goroutine (TURN binding request)
|
||||
var sessionWg sync.WaitGroup
|
||||
sessionWg.Add(1)
|
||||
go func() {
|
||||
defer sessionWg.Done()
|
||||
t := time.NewTicker(10 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-sessCtx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
tc.SendBindingRequest()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Relay ↔ Pipe proxy (with RTP obfuscation)
|
||||
var relayWg sync.WaitGroup
|
||||
relayWg.Add(2)
|
||||
|
||||
useWrap := len(tp.WrapKey) == wrapKeyLen
|
||||
|
||||
// Initialize obfs config per session
|
||||
var obfsCfg *ObfsConfig
|
||||
var obfsWriteState *ObfsState
|
||||
if useWrap {
|
||||
obfsCfg = NewObfsConfig()
|
||||
obfsWriteState = NewObfsState()
|
||||
}
|
||||
|
||||
stopRelay := context.AfterFunc(sessCtx, func() {
|
||||
_ = relay.SetDeadline(time.Now())
|
||||
_ = pipeA.SetDeadline(time.Now())
|
||||
})
|
||||
defer stopRelay()
|
||||
|
||||
// relay → pipeA (UNWRAP: strip RTP header + decrypt)
|
||||
go func() {
|
||||
defer relayWg.Done()
|
||||
defer sessCancel()
|
||||
// Max incoming: RTP header (12) + AEAD tag (16) + padding.
|
||||
readBufLen := readBufSize + 80
|
||||
buf := make([]byte, readBufLen)
|
||||
plain := make([]byte, readBufSize)
|
||||
for {
|
||||
n, _, readErr := relay.ReadFrom(buf)
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
payload := buf[:n]
|
||||
if useWrap {
|
||||
if !obfsIsRTPPacket(payload) {
|
||||
log.Printf("[СЕССИЯ #%d] OBFS unwrap: unexpected packet (n=%d)", sessionID, n)
|
||||
continue
|
||||
}
|
||||
m, wrapErr := obfsUnwrapPacket(tp.WrapKey, payload, plain)
|
||||
if wrapErr != nil {
|
||||
log.Printf("[СЕССИЯ #%d] OBFS unwrap: %v (n=%d)", sessionID, wrapErr, n)
|
||||
continue
|
||||
}
|
||||
payload = plain[:m]
|
||||
}
|
||||
if _, writeErr := pipeA.WriteTo(payload, peer); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// pipeA → relay (WRAP: add RTP header + encrypt)
|
||||
go func() {
|
||||
defer relayWg.Done()
|
||||
defer sessCancel()
|
||||
b := make([]byte, readBufSize)
|
||||
for {
|
||||
n, _, readErr := pipeA.ReadFrom(b)
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
out := b[:n]
|
||||
if useWrap {
|
||||
if obfsCfg != nil && obfsWriteState != nil {
|
||||
wrapped, wrapErr := obfsWrapPacket(tp.WrapKey, out, obfsCfg, obfsWriteState)
|
||||
if wrapErr != nil {
|
||||
log.Printf("[СЕССИЯ #%d] OBFS wrap: %v", sessionID, wrapErr)
|
||||
return
|
||||
}
|
||||
out = wrapped
|
||||
}
|
||||
}
|
||||
if _, writeErr := relay.WriteTo(out, peer); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// DTLS с поддержкой Connection ID (без SNI)
|
||||
cert, err := selfsign.GenerateSelfSigned()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("генерация сертификата: %w", err)
|
||||
}
|
||||
|
||||
// Acquire handshake semaphore
|
||||
select {
|
||||
case handshakeSem <- struct{}{}:
|
||||
case <-sessCtx.Done():
|
||||
return false, sessCtx.Err()
|
||||
}
|
||||
|
||||
dtlsCfg := &dtls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
InsecureSkipVerify: true,
|
||||
ExtendedMasterSecret: dtls.RequireExtendedMasterSecret,
|
||||
CipherSuites: []dtls.CipherSuiteID{dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256},
|
||||
ConnectionIDGenerator: dtls.OnlySendCIDGenerator(),
|
||||
// No ServerName (SNI) — less detectable by DPI
|
||||
}
|
||||
|
||||
dtlsConn, err := dtls.Client(pipeB, peer, dtlsCfg)
|
||||
if err != nil {
|
||||
<-handshakeSem
|
||||
return false, fmt.Errorf("DTLS клиент: %w", err)
|
||||
}
|
||||
defer dtlsConn.Close()
|
||||
|
||||
hctx, hcancel := context.WithTimeout(sessCtx, 20*time.Second)
|
||||
log.Printf("[ВОРКЕР #%d] [DTLS] Рукопожатие (Handshake)...", sessionID)
|
||||
err = dtlsConn.HandshakeContext(hctx)
|
||||
hcancel()
|
||||
<-handshakeSem // RELEASE SEMAPHORE IMMEDIATELY AFTER HANDSHAKE
|
||||
|
||||
if err != nil {
|
||||
if useWrap {
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if strings.Contains(errStr, "deadline") || strings.Contains(errStr, "timeout") {
|
||||
return false, fmt.Errorf("WRAP_AUTH_TIMEOUT: DTLS timeout, пароль/WRAP не подтверждён")
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("DTLS хендшейк: %w", err)
|
||||
}
|
||||
log.Printf("[ВОРКЕР #%d] [DTLS] Соединение установлено ✓", sessionID)
|
||||
|
||||
atomic.AddInt32(&stats.ActiveConnections, 1)
|
||||
defer atomic.AddInt32(&stats.ActiveConnections, -1)
|
||||
|
||||
// Запрос конфига
|
||||
if getConfig && configCh != nil {
|
||||
conf, confErr := RequestConfig(dtlsConn, localPort, deviceID, password)
|
||||
if confErr != nil {
|
||||
errStr := confErr.Error()
|
||||
if strings.Contains(errStr, "FATAL_AUTH") {
|
||||
return false, confErr
|
||||
}
|
||||
log.Printf("[ВОРКЕР #%d] Ошибка конфига: %v", sessionID, confErr)
|
||||
} else if conf != "" {
|
||||
select {
|
||||
case configCh <- conf:
|
||||
configDelivered = true
|
||||
log.Printf("[ВОРКЕР #%d] Конфиг получен", sessionID)
|
||||
default:
|
||||
configDelivered = true
|
||||
log.Printf("[ВОРКЕР #%d] Конфиг уже был доставлен другим воркером", sessionID)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[ВОРКЕР #%d] Сервер ещё не выдал WireGuard-конфиг, повторим позже", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[ВОРКЕР #%d] [READY] Туннель готов к работе ✓", sessionID)
|
||||
|
||||
// Регистрация в диспетчере
|
||||
slot := &WorkerSlot{
|
||||
ID: sessionID,
|
||||
SendCh: make(chan []byte, workerSendBuf),
|
||||
}
|
||||
d.Register(slot)
|
||||
defer d.Unregister(slot)
|
||||
|
||||
// Proxy DTLS ↔ Dispatcher
|
||||
var proxyWg sync.WaitGroup
|
||||
proxyWg.Add(3) // +1 for keepalive goroutine
|
||||
|
||||
stopDTLS := context.AfterFunc(sessCtx, func() {
|
||||
_ = dtlsConn.SetDeadline(time.Now())
|
||||
})
|
||||
defer stopDTLS()
|
||||
|
||||
// DTLS Keepalive: prevents TURN allocation timeout and DTLS idle disconnect
|
||||
go func() {
|
||||
defer proxyWg.Done()
|
||||
t := time.NewTicker(keepaliveInterval)
|
||||
defer t.Stop()
|
||||
ping := []byte{keepaliveByte}
|
||||
for {
|
||||
select {
|
||||
case <-sessCtx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
_ = dtlsConn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
if _, err := dtlsConn.Write(ping); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Writer: dispatcher → DTLS
|
||||
go func() {
|
||||
defer proxyWg.Done()
|
||||
defer sessCancel()
|
||||
for {
|
||||
select {
|
||||
case <-sessCtx.Done():
|
||||
return
|
||||
case pkt, ok := <-slot.SendCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = dtlsConn.SetWriteDeadline(time.Now().Add(sessionReadTimeout))
|
||||
if _, writeErr := dtlsConn.Write(pkt); writeErr != nil {
|
||||
log.Printf("[ВОРКЕР #%d] Ошибка Writer: %v", sessionID, writeErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Reader: DTLS → dispatcher
|
||||
go func() {
|
||||
defer proxyWg.Done()
|
||||
defer sessCancel()
|
||||
b := make([]byte, 2000)
|
||||
for {
|
||||
_ = dtlsConn.SetReadDeadline(time.Now().Add(sessionReadTimeout))
|
||||
n, readErr := dtlsConn.Read(b)
|
||||
if readErr != nil {
|
||||
if sessCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if ne, ok := readErr.(net.Error); ok && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
log.Printf("[ВОРКЕР #%d] Ошибка Reader: %v", sessionID, readErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip keepalive pong from server
|
||||
if n == 1 && b[0] == keepaliveByte {
|
||||
continue
|
||||
}
|
||||
|
||||
pkt := make([]byte, n)
|
||||
copy(pkt, b[:n])
|
||||
select {
|
||||
case d.ReturnCh <- pkt:
|
||||
case <-sessCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
proxyWg.Wait()
|
||||
sessCancel()
|
||||
relayWg.Wait()
|
||||
sessionWg.Wait()
|
||||
_ = pipeA.Close()
|
||||
_ = pipeB.Close()
|
||||
log.Printf("[СЕССИЯ #%d] Завершена", sessionID)
|
||||
return configDelivered, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
ActiveConnections int32
|
||||
Reconnects int64
|
||||
TotalBytesUp int64
|
||||
TotalBytesDown int64
|
||||
CredsErrors int64
|
||||
}
|
||||
|
||||
func NewStats() *Stats {
|
||||
return &Stats{}
|
||||
}
|
||||
|
||||
func (s *Stats) RunLoop(shutdown <-chan struct{}) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-shutdown:
|
||||
return
|
||||
case <-ticker.C:
|
||||
active := atomic.LoadInt32(&s.ActiveConnections)
|
||||
up := atomic.LoadInt64(&s.TotalBytesUp)
|
||||
down := atomic.LoadInt64(&s.TotalBytesDown)
|
||||
totalMB := float64(up+down) / (1024.0 * 1024.0)
|
||||
|
||||
log.Printf("[СТАТИСТИКА] Активных: %d | Трафик: %.2f МБ", active, totalMB)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
const (
|
||||
wrapKeyLen = 32
|
||||
)
|
||||
|
||||
func deriveWrapKey(password string) ([]byte, error) {
|
||||
if password == "" {
|
||||
return nil, errors.New("empty password")
|
||||
}
|
||||
key := make([]byte, wrapKeyLen)
|
||||
reader := hkdf.New(
|
||||
sha256.New,
|
||||
[]byte(password),
|
||||
[]byte("WDTT-WRAP-v1"),
|
||||
[]byte("rtp-obfs/chacha20poly1305"),
|
||||
)
|
||||
if _, err := io.ReadFull(reader, key); err != nil {
|
||||
return nil, fmt.Errorf("derive wrap key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
Reference in New Issue
Block a user