Initial v1.1.8 Commits

This commit is contained in:
amurcanov
2026-05-23 22:18:08 +03:00
commit ac86caaf8b
76 changed files with 15693 additions and 0 deletions
+655
View File
@@ -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,
}
}
+637
View File
@@ -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)
}
+675
View File
@@ -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"
}
+198
View File
@@ -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)))
}
}
}
+33
View File
@@ -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
)
+300
View File
@@ -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
+305
View File
@@ -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("[КЛИЕНТ] Все воркеры завершены")
}
+74
View File
@@ -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)
}
+208
View File
@@ -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
}
+114
View File
@@ -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))]
}
+49
View File
@@ -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
}
+424
View File
@@ -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
}
+38
View File
@@ -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)
}
}
}
+33
View File
@@ -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
}