Browse Source

Merge b8c4ffab11 into e8a96967dc

pull/183/merge
NikKuz99 2 days ago
committed by GitHub
parent
commit
2d2c6c0276
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 0
      .github/workflows/.golangci.yml
  2. 0
      .github/workflows/ci.yml
  3. 0
      .github/workflows/issues.yml
  4. 0
      .github/workflows/release.yml
  5. 0
      Dockerfile
  6. 0
      LICENSE
  7. 0
      README.md
  8. 82
      client/ca_bundle.go
  9. 146
      client/captcha_parse_test.go
  10. 2957
      client/certs/cacert.pem
  11. 448
      client/main.go
  12. 0
      client/main_test.go
  13. 79
      client/manual_captcha.go
  14. 0
      client/manual_captcha_test.go
  15. 0
      client/namegen.go
  16. 0
      client/profiles.go
  17. 42
      client/slider_captcha.go
  18. 0
      client/slider_captcha_test.go
  19. 171
      client/wrap.go
  20. 201
      client/wrap_test.go
  21. 17
      deploy/vk-captcha-solver.service
  22. 0
      docker-entrypoint.sh
  23. 0
      go.mod
  24. 0
      go.sum
  25. 0
      routes-macos.sh
  26. 0
      routes.ps1
  27. 0
      routes.sh
  28. 204
      scripts/captcha_solver.py
  29. 101
      server/main.go
  30. 198
      server/wrap.go
  31. 0
      tcputil/tcputil.go

0
.github/workflows/.golangci.yml

0
.github/workflows/ci.yml

0
.github/workflows/issues.yml

0
.github/workflows/release.yml

0
Dockerfile

0
LICENSE

0
README.md

82
client/ca_bundle.go

@ -0,0 +1,82 @@
package main
import (
"crypto/x509"
_ "embed"
"log"
"os"
"strings"
)
//go:embed certs/cacert.pem
var mozillaCABundlePEM []byte
// loadCABundle returns a *x509.CertPool that can verify VK (and any other
// TLS site) even on platforms where the system certificate store is empty
// or unavailable — most notably Android apps that ship without /etc/ssl/certs
// and have no access to the OS cert store.
//
// Strategy:
// 1. Try x509.SystemCertPool(). If it returns a non-empty pool, use it
// (this is the normal case on Linux/macOS/Windows desktops and servers).
// 2. If SystemCertPool is unavailable or empty, fall back to the embedded
// Mozilla CA bundle (cacert.pem, the same one curl ships). This contains
// every CA in the Mozilla trust program, including HARICA TLS RSA/ECC
// Root CA 2021 which VK uses for *.vk.com since mid-2026.
// 3. Also honor the SSL_CERT_FILE / SSL_CERT_DIR env vars if set, so users
// can override on unusual systems.
//
// Without this, on Android the tls-client (bogdanfinn) leaves RootCAs == nil,
// which makes Go fall back to SystemCertPool — and on Android that pool is
// empty, so every HTTPS request fails with:
//
// tls: failed to verify certificate: x509: certificate signed by unknown authority
//
// See: https://github.com/cacggghp/vk-turn-proxy/issues/182 (and follow-ups).
func loadCABundle() *x509.CertPool {
// 1. Honor explicit env overrides first (mirrors crypto/x509 behavior).
if file := os.Getenv("SSL_CERT_FILE"); file != "" {
if p, err := loadPoolFromFile(file); err == nil && p != nil {
log.Printf("[CABundle] using SSL_CERT_FILE=%s", file)
return p
}
}
// 2. Try the system pool.
if sys, err := x509.SystemCertPool(); err == nil && sys != nil && len(sys.Subjects()) > 0 {
log.Printf("[CABundle] using system cert pool (%d roots)", len(sys.Subjects()))
return sys
}
// 3. Fall back to the embedded Mozilla bundle.
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(mozillaCABundlePEM) {
log.Printf("[CABundle] WARNING: embedded Mozilla bundle did not parse; HTTPS will likely fail")
} else {
log.Printf("[CABundle] using embedded Mozilla CA bundle (%d bytes)", len(mozillaCABundlePEM))
}
return pool
}
// loadPoolFromFile loads a PEM file with one or more certificates into a fresh
// CertPool. Returns nil if the file contained no usable certificates.
func loadPoolFromFile(path string) (*x509.CertPool, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(data) {
return nil, nil
}
return pool, nil
}
// hasHTTPSInsecureEnv returns true if the user explicitly requested skipping
// TLS verification (useful for debugging MITM proxies or stale CA bundles).
// This is intentionally NOT exposed as a CLI flag — production should always
// verify.
func hasHTTPSInsecureEnv() bool {
v := strings.ToLower(os.Getenv("VKTURN_INSECURE_TLS"))
return v == "1" || v == "true" || v == "yes"
}

146
client/captcha_parse_test.go

@ -0,0 +1,146 @@
package main
import "testing"
// TestParseVkCaptchaError_NewFormatWithoutCaptchaSid reproduces issue #182.
//
// VK started returning a captcha-required error (error_code 14) at the
// calls.getAnonymousToken step in a NEW shape that only carries:
//
// error_code, error_msg, is_enabled_captcha, redirect_uri
//
// and notably NO captcha_sid and NO captcha_img. The previous parser
// rejected this payload with "missing captcha_sid in captcha error data"
// and returned nil, so the auth loop never reached the captcha solver and
// just fell through to the next client_id.
//
// After the fix, ParseVkCaptchaError must return a non-nil *VkCaptchaError
// with RedirectURI + SessionToken populated so both the auto solver and the
// manual (browser) solver can run.
func TestParseVkCaptchaError_NewFormatWithoutCaptchaSid(t *testing.T) {
t.Parallel()
// Shape taken verbatim from the issue #182 log (session_token truncated
// for readability but structurally identical to the real payload).
errData := map[string]interface{}{
"error_code": float64(14),
"error_msg": "Captcha need",
"is_enabled_captcha": true,
"redirect_uri": "https://id.vk.ru/not_robot_captcha?domain=vk.com&session_token=eyJhbGciOiJBMjU2R0NNS1ci.payload.signature&variant=popup&blank=1",
"captcha_stid": "some-stid", // unrelated field, must be ignored
}
got := ParseVkCaptchaError(errData)
if got == nil {
t.Fatal("expected non-nil VkCaptchaError for new captcha format, got nil")
}
if got.ErrorCode != 14 {
t.Fatalf("ErrorCode = %d, want 14", got.ErrorCode)
}
if got.ErrorMsg != "Captcha need" {
t.Fatalf("ErrorMsg = %q, want %q", got.ErrorMsg, "Captcha need")
}
if got.RedirectURI == "" {
t.Fatal("RedirectURI is empty, expected the not_robot_captcha URL")
}
if got.SessionToken == "" {
t.Fatal("SessionToken is empty, expected it to be parsed from redirect_uri query")
}
if got.SessionToken != "eyJhbGciOiJBMjU2R0NNS1ci.payload.signature" {
t.Fatalf("SessionToken = %q, want the session_token query value", got.SessionToken)
}
// captcha_sid / captcha_img are absent in the new format and must default
// to empty strings (NOT cause the parser to bail out).
if got.CaptchaSid != "" {
t.Fatalf("CaptchaSid = %q, want empty (not present in new format)", got.CaptchaSid)
}
if got.CaptchaImg != "" {
t.Fatalf("CaptchaImg = %q, want empty (not present in new format)", got.CaptchaImg)
}
if !got.IsCaptchaError() {
t.Fatal("IsCaptchaError() = false, want true (new flow is solvable via redirect_uri + session_token)")
}
}
// TestParseVkCaptchaError_LegacyFormatStillWorks ensures the legacy captcha
// payload (captcha_sid + captcha_img, no redirect_uri) still parses so the
// manual HTTP captcha path keeps working.
func TestParseVkCaptchaError_LegacyFormatStillWorks(t *testing.T) {
t.Parallel()
errData := map[string]interface{}{
"error_code": float64(14),
"error_msg": "Captcha need",
"captcha_sid": "1234567890",
"captcha_img": "https://api.vk.com/captcha.php?sid=1234567890&s=1",
}
got := ParseVkCaptchaError(errData)
if got == nil {
t.Fatal("expected non-nil VkCaptchaError for legacy captcha format, got nil")
}
if got.CaptchaSid != "1234567890" {
t.Fatalf("CaptchaSid = %q, want 1234567890", got.CaptchaSid)
}
if got.CaptchaImg == "" {
t.Fatal("CaptchaImg is empty, expected the captcha image URL")
}
if got.RedirectURI != "" {
t.Fatalf("RedirectURI = %q, want empty in legacy flow", got.RedirectURI)
}
}
// TestParseVkCaptchaError_NumericCaptchaSid covers the case where VK returns
// captcha_sid as a JSON number (unmarshalled into float64).
func TestParseVkCaptchaError_NumericCaptchaSid(t *testing.T) {
t.Parallel()
errData := map[string]interface{}{
"error_code": float64(14),
"error_msg": "Captcha need",
"captcha_sid": float64(9876543210),
"captcha_img": "https://api.vk.com/captcha.php?sid=9876543210",
}
got := ParseVkCaptchaError(errData)
if got == nil {
t.Fatal("expected non-nil VkCaptchaError, got nil")
}
if got.CaptchaSid != "9876543210" {
t.Fatalf("CaptchaSid = %q, want 9876543210", got.CaptchaSid)
}
}
// TestParseVkCaptchaError_UnsolvableReturnsNil verifies that when none of the
// solvable fields are present, the parser returns nil so the caller surfaces
// the raw VK error instead of pretending it can solve a captcha it cannot.
func TestParseVkCaptchaError_UnsolvableReturnsNil(t *testing.T) {
t.Parallel()
errData := map[string]interface{}{
"error_code": float64(14),
"error_msg": "Captcha need",
// no redirect_uri, no captcha_sid, no captcha_img
}
if got := ParseVkCaptchaError(errData); got != nil {
t.Fatalf("expected nil for unsolvable captcha payload, got %+v", got)
}
}
// TestParseVkCaptchaError_MissingErrorCodeReturnsNil verifies the parser still
// rejects payloads that don't even identify as a VK API error object.
func TestParseVkCaptchaError_MissingErrorCodeReturnsNil(t *testing.T) {
t.Parallel()
errData := map[string]interface{}{
"error_msg": "Captcha need",
"redirect_uri": "https://id.vk.ru/not_robot_captcha?session_token=x",
}
if got := ParseVkCaptchaError(errData); got != nil {
t.Fatalf("expected nil when error_code is missing, got %+v", got)
}
}

2957
client/certs/cacert.pem

File diff suppressed because it is too large

448
client/main.go

@ -21,6 +21,7 @@ import (
neturl "net/url" neturl "net/url"
"os" "os"
"os/signal" "os/signal"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -67,6 +68,7 @@ var (
isDebug bool isDebug bool
manualCaptcha bool manualCaptcha bool
autoCaptchaSliderPOC bool autoCaptchaSliderPOC bool
captchaSolverURL string // e.g. "http://127.0.0.1:8766" — Playwright-based solver
) )
type captchaSolveMode int type captchaSolveMode int
@ -75,9 +77,33 @@ const (
captchaSolveModeAuto captchaSolveMode = iota captchaSolveModeAuto captchaSolveMode = iota
captchaSolveModeSliderPOC captchaSolveModeSliderPOC
captchaSolveModeManual captchaSolveModeManual
captchaSolveModePlaywright
) )
func captchaSolveModeForAttempt(attempt int, manualOnly bool, enableSliderPOC bool) (captchaSolveMode, bool) { func captchaSolveModeForAttempt(attempt int, manualOnly bool, enableSliderPOC bool) (captchaSolveMode, bool) {
// If a Playwright solver URL is configured, try it FIRST — it's the only
// mode that reliably passes VK's bot detection (because the check request
// comes from a real Chromium with a real FingerprintJS visitor_id).
// Fall back to the legacy modes if the solver is unreachable / fails.
if captchaSolverURL != "" && !manualOnly {
switch attempt {
case 0:
return captchaSolveModePlaywright, true
case 1:
return captchaSolveModeAuto, true
case 2:
if enableSliderPOC {
return captchaSolveModeSliderPOC, true
}
return captchaSolveModeManual, true
case 3:
if enableSliderPOC {
return captchaSolveModeManual, true
}
}
return 0, false
}
if manualOnly { if manualOnly {
return captchaSolveModeManual, attempt == 0 return captchaSolveModeManual, attempt == 0
} }
@ -107,6 +133,8 @@ func captchaSolveModeLabel(mode captchaSolveMode) string {
return "auto captcha slider POC" return "auto captcha slider POC"
case captchaSolveModeManual: case captchaSolveModeManual:
return "manual captcha" return "manual captcha"
case captchaSolveModePlaywright:
return "playwright solver"
default: default:
return "captcha" return "captcha"
} }
@ -247,6 +275,53 @@ func generateFakeCursor() string {
return "[" + strings.Join(points, ",") + "]" return "[" + strings.Join(points, ",") + "]"
} }
// generateConnectionRtt produces a realistic Navigator.connection.rtt sample
// array. Real browsers observe slight per-sample variation around a baseline
// (e.g. 50ms +/- 5-15ms jitter, occasional spikes). Sending the constant
// [50,50,50,...] is a known bot fingerprint.
func generateConnectionRtt(n int) string {
if n <= 0 {
n = 10
}
base := 45 + rand.Intn(40) // 45..84 ms baseline
parts := make([]string, 0, n)
for i := 0; i < n; i++ {
// jitter: -10..+30 ms, with occasional spike (+30..+90)
jitter := rand.Intn(41) - 10
if rand.Intn(8) == 0 { // ~12% chance of a spike
jitter += 30 + rand.Intn(60)
}
val := base + jitter
if val < 5 {
val = 5
}
parts = append(parts, strconv.Itoa(val))
}
return "[" + strings.Join(parts, ",") + "]"
}
// generateConnectionDownlink produces a realistic Navigator.connection.downlink
// sample array. Real values are MBps floats with mild variation (e.g. 9.5, 9.6,
// 9.4, 10.1, ...). Constant [9.5,9.5,...] is a known bot fingerprint.
func generateConnectionDownlink(n int) string {
if n <= 0 {
n = 16
}
// baseline 5.0..15.0 Mbps, one decimal place
baseTenths := 50 + rand.Intn(100) // 5.0..14.9
parts := make([]string, 0, n)
for i := 0; i < n; i++ {
// jitter -3..+3 tenths
jitter := rand.Intn(7) - 3
t := baseTenths + jitter
if t < 10 {
t = 10 // floor at 1.0 Mbps
}
parts = append(parts, fmt.Sprintf("%.1f", float64(t)/10.0))
}
return "[" + strings.Join(parts, ",") + "]"
}
func getCustomNetDialer() net.Dialer { func getCustomNetDialer() net.Dialer {
return net.Dialer{ return net.Dialer{
Timeout: 20 * time.Second, Timeout: 20 * time.Second,
@ -270,6 +345,56 @@ func getCustomNetDialer() net.Dialer {
} }
} }
// customResolver is a net.Resolver that uses public DNS servers as fallback.
// On Android (CGO_ENABLED=0) the system resolver may not work, so we provide
// a Go-native resolver that queries multiple public DNS servers directly.
var customResolver = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
var d net.Dialer
dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"}
var lastErr error
for _, dns := range dnsServers {
conn, err := d.DialContext(ctx, "udp", dns)
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, lastErr
},
}
// resolveUDPAddr resolves a host:port string to *net.UDPAddr.
// Works with both IP addresses and domain names.
// Uses the custom DNS resolver (public DNS fallback) so domain resolution
// works on Android where the system resolver may be unavailable.
func resolveUDPAddr(network, address string) (*net.UDPAddr, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("split host:port %q: %w", address, err)
}
// If host is already an IP address, skip DNS resolution
if ip := net.ParseIP(host); ip != nil {
return net.ResolveUDPAddr(network, address)
}
// Resolve domain name using custom resolver (public DNS fallback)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ips, err := customResolver.LookupIP(ctx, "ip4", host)
if err != nil || len(ips) == 0 {
// Fallback: try system resolver
return net.ResolveUDPAddr(network, address)
}
resolved := net.JoinHostPort(ips[0].String(), port)
log.Printf("[DNS] resolved %s → %s", host, ips[0].String())
return net.ResolveUDPAddr(network, resolved)
}
// endregion // endregion
// region Automatic Captcha Solver & Authentication // region Automatic Captcha Solver & Authentication
@ -287,7 +412,7 @@ type VkCaptchaError struct {
} }
func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
// Extract error_code // Extract error_code (required — identifies this as a VK API error object)
codeFloat, ok := errData["error_code"].(float64) codeFloat, ok := errData["error_code"].(float64)
if !ok { if !ok {
log.Printf("missing error_code in captcha error data") log.Printf("missing error_code in captcha error data")
@ -295,48 +420,45 @@ func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
} }
code := int(codeFloat) code := int(codeFloat)
// Extract redirect_uri // Extract redirect_uri (optional). Present in the NEW VK captcha flow where
RedirectURI, ok := errData["redirect_uri"].(string) // the user/automaton is redirected to https://id.vk.ru/not_robot_captcha?...
if !ok { // Absent in the legacy captcha flow (which uses captcha_sid + captcha_img).
log.Printf("missing redirect_uri in captcha error data") RedirectURI, _ := errData["redirect_uri"].(string)
return nil
}
// Extract captcha_sid // Extract captcha_sid (optional in the new flow, required in the legacy flow).
captchaSid, ok := errData["captcha_sid"].(string) captchaSid, ok := errData["captcha_sid"].(string)
if !ok { if !ok {
// try numeric // try numeric
if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 { if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 {
captchaSid = fmt.Sprintf("%.0f", sidNum) captchaSid = fmt.Sprintf("%.0f", sidNum)
} else {
log.Printf("missing captcha_sid in captcha error data")
return nil
} }
} }
// Extract captcha_img // Extract captcha_img (optional in the new flow, required in the legacy flow).
captchaImg, ok := errData["captcha_img"].(string) captchaImg, _ := errData["captcha_img"].(string)
if !ok {
log.Printf("missing captcha_img in captcha error data")
return nil
}
// Extract error_msg // Extract error_msg (optional — only used for logging/diagnostics).
errorMsg, ok := errData["error_msg"].(string) errorMsg, _ := errData["error_msg"].(string)
if !ok {
log.Printf("missing error_msg in captcha error data")
return nil
}
// Extract session token if redirect_uri present // Extract session token if redirect_uri present
var sessionToken string var sessionToken string
if RedirectURI != "" { if RedirectURI != "" {
if parsed, err := neturl.Parse(RedirectURI); err == nil { parsed, err := neturl.Parse(RedirectURI)
sessionToken = parsed.Query().Get("session_token") if err != nil {
} else {
log.Printf("failed to parse redirect_uri: %v", err) log.Printf("failed to parse redirect_uri: %v", err)
return nil return nil
} }
sessionToken = parsed.Query().Get("session_token")
}
// Require at least one solvable captcha path before returning a result:
// - new flow: redirect_uri + session_token (solved via not_robot_captcha)
// - legacy flow: captcha_img (manual HTTP) typically paired with captcha_sid
// Without any of these, there is nothing the caller can do, so return nil
// and let the auth loop surface the raw VK API error.
if RedirectURI == "" && sessionToken == "" && captchaImg == "" && captchaSid == "" {
log.Printf("no solvable captcha path in error data: %v", errData)
return nil
} }
// Extract is_sound_captcha_available // Extract is_sound_captcha_available
@ -400,22 +522,26 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in
log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty) log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty)
hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty) hexHash, nonce, ok := solvePoW(bootstrap.PowInput, bootstrap.Difficulty)
log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s", streamID, hash) if !ok {
return "", fmt.Errorf("PoW solve exhausted nonce budget (input=%s, difficulty=%d)", bootstrap.PowInput, bootstrap.Difficulty)
}
powResult := formatPoWResult(hexHash, nonce)
log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s nonce=%d (v2-wrapped, len=%d)", streamID, hexHash, nonce, len(powResult))
var successToken string var successToken string
if useSliderPOC { if useSliderPOC {
successToken, err = callCaptchaNotRobotWithSliderPOC( successToken, err = callCaptchaNotRobotWithSliderPOC(
ctx, ctx,
captchaErr.SessionToken, captchaErr.SessionToken,
hash, powResult,
streamID, streamID,
client, client,
profile, profile,
bootstrap.Settings, bootstrap.Settings,
) )
} else { } else {
successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile) successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, powResult, streamID, client, profile)
} }
if err != nil { if err != nil {
return "", fmt.Errorf("captchaNotRobot API failed: %w", err) return "", fmt.Errorf("captchaNotRobot API failed: %w", err)
@ -459,17 +585,64 @@ func fetchCaptchaBootstrap(ctx context.Context, redirectURI string, client tlscl
return parseCaptchaBootstrapHTML(string(body)) return parseCaptchaBootstrapHTML(string(body))
} }
func solvePoW(powInput string, difficulty int) string { // solvePoW finds a nonce so that sha256(powInput + str(nonce)) starts with
// `difficulty` zero characters. Returns (hexHash, nonce, ok).
//
// VK's not_robot_captcha.js stores the result as:
//
// window.captchaPowResult = "v2." + btoa(JSON.stringify({ hash, nonce }))
//
// so callers must wrap the return value via formatPoWResult() before sending
// it as the `hash` field of captchaNotRobot.check — sending the bare hex hash
// is the #1 reason VK's bot detector flags the request as "BOT".
func solvePoW(powInput string, difficulty int) (string, int, bool) {
target := strings.Repeat("0", difficulty) target := strings.Repeat("0", difficulty)
for nonce := 1; nonce <= 10000000; nonce++ { for nonce := 1; nonce <= 10000000; nonce++ {
data := powInput + strconv.Itoa(nonce) data := powInput + strconv.Itoa(nonce)
hash := sha256.Sum256([]byte(data)) hash := sha256.Sum256([]byte(data))
hexHash := hex.EncodeToString(hash[:]) hexHash := hex.EncodeToString(hash[:])
if strings.HasPrefix(hexHash, target) { if strings.HasPrefix(hexHash, target) {
return hexHash return hexHash, nonce, true
} }
} }
return "" return "", 0, false
}
// formatPoWResult wraps (hash, nonce) into the v2.<base64(JSON)> format that
// VK's not_robot_captcha.js produces. Mirrors:
//
// "v2." + btoa(JSON.stringify({ hash, nonce }))
//
// where btoa is RFC 4648 standard base64 (no padding stripping).
func formatPoWResult(hexHash string, nonce int) string {
payload := fmt.Sprintf(`{"hash":%q,"nonce":%d}`, hexHash, nonce)
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
return "v2." + encoded
}
// captchaDebugInfoHardcoded is the fallback value VK's not_robot_captcha.js
// sends when window.vk.brlefapmjnpg is undefined:
//
// debug_info: window.vk?.brlefapmjnpg || "4045edac1cb2c18eff209dc09cd5e2e56475a13ed4615413a87618b3c6813f9a"
//
// Sending a random MD5 here (the previous behavior) is one of the strongest
// bot signals VK checks.
const captchaDebugInfoHardcoded = "4045edac1cb2c18eff209dc09cd5e2e56475a13ed4615413a87618b3c6813f9a"
// formatCaptchaAnswer mirrors VK's not_robot_captcha.js:
//
// answer: $w(JSON.stringify({ value: t }))
//
// where $w(s) = btoa(unescape(encodeURIComponent(s))) — i.e. standard base64
// of the UTF-8 bytes. For a checkbox-style check, t is the user-event value;
// passing an empty object matches what the JS sends for a synthetic click.
func formatCaptchaAnswer(valueJSON string) string {
if valueJSON == "" {
valueJSON = "{}"
}
// JSON.stringify({value: t}) — t is already JSON-encoded by caller when needed
payload := `{"value":` + valueJSON + `}`
return base64.StdEncoding.EncodeToString([]byte(payload))
} }
func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile) (string, error) { func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile) (string, error) {
@ -519,6 +692,21 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=&access_token=", neturl.QueryEscape(sessionToken)) baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=&access_token=", neturl.QueryEscape(sessionToken))
// Step 0: initSession — VK's not_robot_captcha.js calls this before
// settings. Without it the server-side session state is not fully
// initialized, which is one of the signals that flags the request as
// "BOT" even when everything else looks legitimate.
log.Printf("[STREAM %d] [Captcha] Step 0/4: initSession", streamID)
if initResp, err := vkReq("captchaNotRobot.initSession", baseParams); err != nil {
log.Printf("[STREAM %d] [Captcha] Warning: initSession failed: %v (continuing)", streamID, err)
} else if respObj, ok := initResp["response"].(map[string]interface{}); ok {
if showType, _ := respObj["show_captcha_type"].(string); showType != "" {
log.Printf("[STREAM %d] [Captcha] initSession: show_captcha_type=%s", streamID, showType)
}
}
time.Sleep(200 * time.Millisecond)
log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID) log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
if _, err := vkReq("captchaNotRobot.settings", baseParams); err != nil { if _, err := vkReq("captchaNotRobot.settings", baseParams); err != nil {
return "", fmt.Errorf("settings failed: %w", err) return "", fmt.Errorf("settings failed: %w", err)
@ -539,14 +727,19 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID) log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID)
cursorJSON := generateFakeCursor() cursorJSON := generateFakeCursor()
answer := base64.StdEncoding.EncodeToString([]byte("{}")) // answer = $w(JSON.stringify({value: t})). For checkbox-style checks
// t is an empty object, so we wrap {} under "value".
answer := formatCaptchaAnswer("{}")
// Dynamically generate debug_info to avoid static fingerprint bans // debug_info: VK's not_robot_captcha.js sends either window.vk.brlefapmjnpg
debugInfoBytes := md5.Sum([]byte(profile.UserAgent + strconv.FormatInt(time.Now().UnixNano(), 10))) // (which we don't have, since we're not running inside the page) or the
debugInfo := hex.EncodeToString(debugInfoBytes[:]) // hardcoded fallback. Sending a random MD5 here is a strong bot signal.
debugInfo := captchaDebugInfoHardcoded
connectionRtt := "[50,50,50,50,50,50,50,50,50,50]" // Realistic connection telemetry — slight per-sample jitter so the
connectionDownlink := "[9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5]" // array doesn't look like a constant signature.
connectionRtt := generateConnectionRtt(10)
connectionDownlink := generateConnectionDownlink(16)
checkData := baseParams + fmt.Sprintf( checkData := baseParams + fmt.Sprintf(
"&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s&browser_fp=%s&hash=%s&answer=%s&debug_info=%s", "&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s&browser_fp=%s&hash=%s&answer=%s&debug_info=%s",
@ -561,6 +754,16 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
return "", fmt.Errorf("check failed: %w", err) return "", fmt.Errorf("check failed: %w", err)
} }
// Log the raw check response so future debugging is easier. VK returns
// {status:"OK|BOT", success_token, show_captcha_type, redirect}.
if respObj, ok := checkResp["response"].(map[string]interface{}); ok {
status, _ := respObj["status"].(string)
showType, _ := respObj["show_captcha_type"].(string)
redirect, _ := respObj["redirect"].(string)
log.Printf("[STREAM %d] [Captcha] check response: status=%s show_captcha_type=%q redirect=%q",
streamID, status, showType, redirect)
}
respObj, ok := checkResp["response"].(map[string]interface{}) respObj, ok := checkResp["response"].(map[string]interface{})
if !ok { if !ok {
return "", fmt.Errorf("invalid check response: %v", checkResp) return "", fmt.Errorf("invalid check response: %v", checkResp)
@ -816,12 +1019,27 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
SecChUaPlatform: `"Windows"`, SecChUaPlatform: `"Windows"`,
} }
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), clientOpts := []tlsclient.HttpClientOption{
tlsclient.WithTimeoutSeconds(20), tlsclient.WithTimeoutSeconds(20),
tlsclient.WithClientProfile(profiles.Chrome_146), tlsclient.WithClientProfile(profiles.Chrome_146),
tlsclient.WithCookieJar(jar), tlsclient.WithCookieJar(jar),
tlsclient.WithDialer(getCustomNetDialer()), tlsclient.WithDialer(getCustomNetDialer()),
) }
// Provide an explicit RootCAs pool. Without it, tls-client falls back
// to x509.SystemCertPool() which is empty on Android apps and causes
// every HTTPS request to fail with "x509: certificate signed by
// unknown authority". loadCABundle() handles system + embedded
// Mozilla fallback transparently.
if rootPool := loadCABundle(); rootPool != nil {
clientOpts = append(clientOpts, tlsclient.WithTransportOptions(
&tlsclient.TransportOptions{RootCAs: rootPool},
))
}
if hasHTTPSInsecureEnv() {
log.Printf("[STREAM %d] [VK Auth] WARNING: VKTURN_INSECURE_TLS set — skipping TLS verification (debug only!)", streamID)
clientOpts = append(clientOpts, tlsclient.WithInsecureSkipVerify())
}
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), clientOpts...)
if err != nil { if err != nil {
return "", "", "", fmt.Errorf("failed to initialize tls_client: %w", err) return "", "", "", fmt.Errorf("failed to initialize tls_client: %w", err)
} }
@ -936,6 +1154,16 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
var solveErr error var solveErr error
switch solveMode { switch solveMode {
case captchaSolveModePlaywright:
log.Printf("[STREAM %d] [Captcha] Using Playwright solver (real Chromium)...", streamID)
if captchaErr.RedirectURI != "" {
successToken, solveErr = solveCaptchaViaPlaywright(captchaErr.RedirectURI, streamID)
if solveErr != nil {
log.Printf("[STREAM %d] [Captcha] Playwright solver failed: %v", streamID, solveErr)
}
} else {
solveErr = fmt.Errorf("no redirect_uri for playwright solver")
}
case captchaSolveModeAuto: case captchaSolveModeAuto:
if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" { if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" {
successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, false) successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, false)
@ -1019,8 +1247,16 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=%s&captcha_sid=%s&access_token=%s", data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=%s&captcha_sid=%s&access_token=%s",
link, escapedName, neturl.QueryEscape(captchaKey), captchaErr.CaptchaSid, token1) link, escapedName, neturl.QueryEscape(captchaKey), captchaErr.CaptchaSid, token1)
} else { } else {
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", // New VK captcha flow: success_token was obtained from
link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaErr.CaptchaAttempt, token1) // id.vk.ru/not_robot_captcha. captcha_sid is absent in this
// flow's error payload, so only include it when present to
// avoid sending an empty legacy field that could confuse VK.
sidPart := ""
if captchaErr.CaptchaSid != "" {
sidPart = "&captcha_sid=" + captchaErr.CaptchaSid
}
data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s%s&access_token=%s",
link, escapedName, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaErr.CaptchaAttempt, sidPart, token1)
} }
continue continue
} }
@ -1529,6 +1765,7 @@ type turnParams struct {
link string link string
udp bool udp bool
getCreds getCredsFunc getCreds getCredsFunc
wrapKey []byte
} }
func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, conn2 net.PacketConn, streamID int, c chan<- error) { func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, conn2 net.PacketConn, streamID int, c chan<- error) {
@ -1553,7 +1790,7 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
} }
var turnServerAddr string var turnServerAddr string
turnServerAddr = net.JoinHostPort(urlhost, urlport) turnServerAddr = net.JoinHostPort(urlhost, urlport)
turnServerUDPAddr, err1 := net.ResolveUDPAddr("udp", turnServerAddr) turnServerUDPAddr, err1 := resolveUDPAddr("udp", turnServerAddr)
if err1 != nil { if err1 != nil {
err = fmt.Errorf("failed to resolve TURN server address: %s", err1) err = fmt.Errorf("failed to resolve TURN server address: %s", err1)
return return
@ -1659,9 +1896,24 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
}) })
var internalPipeAddr atomic.Value var internalPipeAddr atomic.Value
var wc *wrapConn
if len(turnParams.wrapKey) == wrapKeyLen {
var wcErr error
wc, wcErr = newWrapConn(turnParams.wrapKey, false)
if wcErr != nil {
log.Printf("[STREAM %d] WRAP init failed: %v", streamID, wcErr)
turncancel()
return
}
}
go func() { go func() {
defer turncancel() defer turncancel()
buf := make([]byte, 1600) buf := make([]byte, 1600)
var wireBuf []byte
if wc != nil {
wireBuf = make([]byte, wrapMaxWire(len(buf)))
}
for { for {
if turnctx.Err() != nil { if turnctx.Err() != nil {
return return
@ -1676,7 +1928,17 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
internalPipeAddr.Store(addr1) internalPipeAddr.Store(addr1)
_, err1 = relayConn.WriteTo(buf[:n], peer) out := buf[:n]
if wc != nil {
written, wrapErr := wc.wrapInto(wireBuf, out)
if wrapErr != nil {
log.Printf("[STREAM %d] WRAP failed: %v", streamID, wrapErr)
return
}
out = wireBuf[:written]
}
_, err1 = relayConn.WriteTo(out, peer)
if err1 != nil { if err1 != nil {
return return
} }
@ -1686,7 +1948,12 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
go func() { go func() {
defer wg.Done() defer wg.Done()
defer turncancel() defer turncancel()
buf := make([]byte, 1600) readBufLen := 1600
if wc != nil {
readBufLen = wrapMaxWire(1600)
}
buf := make([]byte, readBufLen)
plain := make([]byte, 1600)
for { for {
n, _, err1 := relayConn.ReadFrom(buf) n, _, err1 := relayConn.ReadFrom(buf)
if err1 != nil { if err1 != nil {
@ -1698,7 +1965,16 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD
} }
if addr, ok := addr1.(net.Addr); ok { if addr, ok := addr1.(net.Addr); ok {
if _, err := conn2.WriteTo(buf[:n], addr); err != nil { payload := buf[:n]
if wc != nil {
m, wrapErr := wc.unwrapPacket(payload, plain)
if wrapErr != nil {
log.Printf("[STREAM %d] UNWRAP failed: %v (n=%d)", streamID, wrapErr, n)
continue
}
payload = plain[:m]
}
if _, err := conn2.WriteTo(payload, addr); err != nil {
return return
} }
} }
@ -1784,6 +2060,13 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne
} }
func main() { func main() {
// Build banner — printed as the very first line so users can confirm
// they are running the patched binary (issue #182 captcha fix + Android
// TLS/CA-bundle fix). If this line is missing from the log, an older
// binary is being launched by the wrapper/app.
log.Printf("vk-turn-proxy build: captcha-fix=v2 tls-ca-fix=v1 go=%s (issue#182 + HARICA root CA bundle embedded)",
runtime.Version())
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
globalAppCancel = cancel globalAppCancel = cancel
defer cancel() defer cancel()
@ -1812,11 +2095,24 @@ func main() {
vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets") vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets")
debugFlag := flag.Bool("debug", false, "enable debug logging") debugFlag := flag.Bool("debug", false, "enable debug logging")
manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately") manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately")
captchaSolverFlag := flag.String("captcha-solver", "", "URL of Playwright-based captcha solver (e.g. http://127.0.0.1:8766). When set, captcha is solved via real headless Chromium, bypassing VK's bot detection. Requires captcha_solver.py running on the host.")
wrapMode := flag.Bool("wrap", false, "WRAP mode: SRTP-mimicry AEAD wrap DTLS packets. Peer server must use matching -wrap-key.")
wrapKeyHex := flag.String("wrap-key", "", "32-byte hex-encoded shared key for -wrap (64 hex chars)")
genWrapKey := flag.Bool("gen-wrap-key", false, "print a fresh 64-character hex key for -wrap-key and exit")
flag.Parse() flag.Parse()
if *genWrapKey {
key, err := genWrapKeyHex()
if err != nil {
log.Panicf("gen-wrap-key: %v", err)
}
fmt.Println(key)
return
}
if *peerAddr == "" { if *peerAddr == "" {
log.Panicf("Need peer address!") log.Panicf("Need peer address!")
} }
peer, err := net.ResolveUDPAddr("udp", *peerAddr) peer, err := resolveUDPAddr("udp", *peerAddr)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -1827,6 +2123,21 @@ func main() {
isDebug = *debugFlag isDebug = *debugFlag
manualCaptcha = *manualCaptchaFlag manualCaptcha = *manualCaptchaFlag
autoCaptchaSliderPOC = !manualCaptcha autoCaptchaSliderPOC = !manualCaptcha
captchaSolverURL = *captchaSolverFlag
if captchaSolverURL != "" {
log.Printf("[Captcha] Playwright solver enabled: %s (will be tried first, then fallback to auto/manual)", captchaSolverURL)
}
if *wrapMode && *direct {
log.Panicf("-wrap requires DTLS; remove -no-dtls")
}
wrapKey, err := decodeWrapKey(*wrapMode, *wrapKeyHex)
if err != nil {
log.Panicf("%v", err)
}
if *wrapMode {
log.Printf("WRAP mode enabled: SRTP-mimicry AEAD. Peer server must use matching -wrap-key.")
}
var link string var link string
var getCreds getCredsFunc var getCreds getCredsFunc
@ -1866,6 +2177,7 @@ func main() {
link: link, link: link,
udp: *udp, udp: *udp,
getCreds: getCreds, getCreds: getCreds,
wrapKey: wrapKey,
} }
if *vlessMode { if *vlessMode {
@ -2158,7 +2470,7 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i
urlport = tp.port urlport = tp.port
} }
turnServerAddr := net.JoinHostPort(urlhost, urlport) turnServerAddr := net.JoinHostPort(urlhost, urlport)
turnServerUDPAddr, err := net.ResolveUDPAddr("udp", turnServerAddr) turnServerUDPAddr, err := resolveUDPAddr("udp", turnServerAddr)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("resolve TURN addr: %w", err) return nil, nil, fmt.Errorf("resolve TURN addr: %w", err)
} }
@ -2227,7 +2539,15 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i
cleanup() cleanup()
return nil, nil, fmt.Errorf("generate cert: %w", err) return nil, nil, fmt.Errorf("generate cert: %w", err)
} }
dtlsPC := &relayPacketConn{relay: relayConn, peer: peer} var relayWC *wrapConn
if len(tp.wrapKey) == wrapKeyLen {
relayWC, err = newWrapConn(tp.wrapKey, false)
if err != nil {
cleanup()
return nil, nil, fmt.Errorf("wrap init: %w", err)
}
}
dtlsPC := &relayPacketConn{relay: relayConn, peer: peer, wc: relayWC}
dtlsConn, err := dtls.ClientWithOptions(dtlsPC, peer, dtlsConn, err := dtls.ClientWithOptions(dtlsPC, peer,
dtls.WithCertificates(certificate), dtls.WithCertificates(certificate),
dtls.WithInsecureSkipVerify(true), dtls.WithInsecureSkipVerify(true),
@ -2274,14 +2594,38 @@ func createSmuxSession(ctx context.Context, tp *turnParams, peer *net.UDPAddr, i
type relayPacketConn struct { type relayPacketConn struct {
relay net.PacketConn relay net.PacketConn
peer net.Addr peer net.Addr
wc *wrapConn
} }
func (r *relayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { func (r *relayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
return r.relay.ReadFrom(b) if r.wc == nil {
return r.relay.ReadFrom(b)
}
buf := make([]byte, wrapMaxWire(len(b)))
n, addr, err := r.relay.ReadFrom(buf)
if err != nil {
return 0, addr, err
}
m, err := r.wc.unwrapPacket(buf[:n], b)
if err != nil {
return 0, addr, err
}
return m, addr, nil
} }
func (r *relayPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) { func (r *relayPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
return r.relay.WriteTo(b, r.peer) if r.wc == nil {
return r.relay.WriteTo(b, r.peer)
}
out := make([]byte, wrapMaxWire(len(b)))
n, err := r.wc.wrapInto(out, b)
if err != nil {
return 0, err
}
if _, err = r.relay.WriteTo(out[:n], r.peer); err != nil {
return 0, err
}
return len(b), nil
} }
func (r *relayPacketConn) Close() error { return r.relay.Close() } func (r *relayPacketConn) Close() error { return r.relay.Close() }

0
client/main_test.go

79
client/manual_captcha.go

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -343,6 +344,12 @@ func newCaptchaProxyTransport(dialer *dnsdialer.Dialer) *http.Transport {
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second, ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: false, ForceAttemptHTTP2: false,
// Use the same CA bundle as the main tls-client (system pool →
// embedded Mozilla bundle). Without this, the captcha proxy fails
// on Android with "x509: certificate signed by unknown authority"
// because HARICA TLS RSA Root CA 2021 is not in the empty Android
// system cert pool.
TLSClientConfig: &tls.Config{RootCAs: loadCABundle()},
} }
if dialer != nil { if dialer != nil {
transport.DialContext = dialer.DialContext transport.DialContext = dialer.DialContext
@ -615,3 +622,75 @@ func browserOpenCommands(goos string, url string) []browserCommand {
} }
return nil return nil
} }
// solveCaptchaViaPlaywright delegates the captcha solving to an external
// HTTP service (typically the captcha_solver.py Python script shipped in
// /home/z/my-project/scripts/) that drives a real headless Chromium via
// Playwright. The Chromium instance produces a real FingerprintJS visitor_id
// and real behavioral telemetry, which is the only reliable way to pass
// VK's not_robot_captcha bot detection.
//
// API contract:
//
// POST <solverURL>/solve
// Body: {"redirect_uri": "https://id.vk.ru/not_robot_captcha?..."}
// Response: {"success_token": "...", "elapsed": 8.3}
// or {"error": "...", "elapsed": 8.3}
//
// The solver must already be running (e.g. `python3 captcha_solver.py`).
// Configure via `-captcha-solver http://127.0.0.1:8766` CLI flag.
func solveCaptchaViaPlaywright(redirectURI string, streamID int) (string, error) {
if captchaSolverURL == "" {
return "", fmt.Errorf("no captcha solver URL configured")
}
payload := map[string]string{"redirect_uri": redirectURI}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal solver request: %w", err)
}
solveURL := strings.TrimRight(captchaSolverURL, "/") + "/solve"
log.Printf("[STREAM %d] [Captcha] Calling Playwright solver at %s", streamID, solveURL)
httpClient := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest("POST", solveURL, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("build solver request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("solver HTTP call failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read solver response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("solver returned HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
SuccessToken string `json:"success_token"`
Elapsed float64 `json:"elapsed"`
Error string `json:"error"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("parse solver response: %w (body: %s)", err, string(respBody))
}
if result.Error != "" {
return "", fmt.Errorf("solver error: %s", result.Error)
}
if result.SuccessToken == "" {
return "", fmt.Errorf("solver returned empty success_token")
}
log.Printf("[STREAM %d] [Captcha] Playwright solver returned success_token (elapsed=%.1fs)", streamID, result.Elapsed)
return result.SuccessToken, nil
}

0
client/manual_captcha_test.go

0
client/namegen.go

0
client/profiles.go

42
client/slider_captcha.go

@ -23,7 +23,6 @@ import (
) )
const ( const (
captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c"
sliderCaptchaType = "slider" sliderCaptchaType = "slider"
defaultSliderAttempts = 4 defaultSliderAttempts = 4
) )
@ -132,6 +131,23 @@ func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, er
return parseCaptchaSettingsResponse(resp) return parseCaptchaSettingsResponse(resp)
} }
// requestInitSession mirrors VK's not_robot_captcha.js which calls
// captchaNotRobot.initSession before settings. The response carries captcha_id
// and content_settings (encrypted per-type config). Calling it sets up
// server-side session state that the subsequent check expects.
func (s *captchaNotRobotSession) requestInitSession() {
resp, err := s.request("captchaNotRobot.initSession", s.baseValues())
if err != nil {
log.Printf("[STREAM %d] [Captcha] Warning: initSession failed: %v (continuing)", s.streamID, err)
return
}
if respObj, ok := resp["response"].(map[string]interface{}); ok {
if showType, _ := respObj["show_captcha_type"].(string); showType != "" {
log.Printf("[STREAM %d] [Captcha] initSession: show_captcha_type=%s", s.streamID, showType)
}
}
}
func (s *captchaNotRobotSession) requestComponentDone() error { func (s *captchaNotRobotSession) requestComponentDone() error {
values := s.baseValues() values := s.baseValues()
values.Set("browser_fp", s.browserFp) values.Set("browser_fp", s.browserFp)
@ -185,17 +201,31 @@ func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*ca
values.Set("motion", "[]") values.Set("motion", "[]")
values.Set("cursor", cursor) values.Set("cursor", cursor)
values.Set("taps", "[]") values.Set("taps", "[]")
values.Set("connectionRtt", "[]") // Realistic connection telemetry with jitter (constant arrays are a bot fingerprint).
values.Set("connectionDownlink", "[]") values.Set("connectionRtt", generateConnectionRtt(10))
values.Set("connectionDownlink", generateConnectionDownlink(16))
values.Set("browser_fp", s.browserFp) values.Set("browser_fp", s.browserFp)
values.Set("hash", s.hash) values.Set("hash", s.hash)
values.Set("answer", answer) values.Set("answer", answer)
values.Set("debug_info", captchaDebugInfo) // VK's not_robot_captcha.js sends either window.vk.brlefapmjnpg or the
// hardcoded fallback. The previous captchaDebugInfo constant here was a
// different hardcoded MD5, which VK treats as a strong bot signal.
values.Set("debug_info", captchaDebugInfoHardcoded)
resp, err := s.request("captchaNotRobot.check", values) resp, err := s.request("captchaNotRobot.check", values)
if err != nil { if err != nil {
return nil, fmt.Errorf("check failed: %w", err) return nil, fmt.Errorf("check failed: %w", err)
} }
// Log the raw check response so future debugging is easier.
if respObj, ok := resp["response"].(map[string]interface{}); ok {
status, _ := respObj["status"].(string)
showType, _ := respObj["show_captcha_type"].(string)
redirect, _ := respObj["redirect"].(string)
log.Printf("[STREAM %d] [Captcha] check response: status=%s show_captcha_type=%q redirect=%q",
s.streamID, status, showType, redirect)
}
return parseCaptchaCheckResult(resp) return parseCaptchaCheckResult(resp)
} }
@ -217,6 +247,10 @@ func callCaptchaNotRobotWithSliderPOC(
) (string, error) { ) (string, error) {
session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile) session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile)
log.Printf("[STREAM %d] [Captcha] Step 0/4: initSession", streamID)
session.requestInitSession()
time.Sleep(200 * time.Millisecond)
log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID) log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
settingsResp, err := session.requestSettings() settingsResp, err := session.requestSettings()
if err != nil { if err != nil {

0
client/slider_captcha_test.go

171
client/wrap.go

@ -0,0 +1,171 @@
// SPDX-License-Identifier: MIT
package main
import (
"crypto/cipher"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"sync/atomic"
"golang.org/x/crypto/chacha20poly1305"
)
// Wire format — SRTP-like mimicry:
//
// [12B RTP header | 12B explicit nonce | AEAD ciphertext | 16B tag]
//
// RTP header (RFC 3550):
//
// byte 0: 0x80 V=2, P=0, X=0, CC=0
// byte 1: 0x6F M=0, PT=111 (opus, typical voice PT)
// byte 2-3: seq16 BE monotonic, init random
// byte 4-7: ts32 BE monotonic, init random, increments by 960 (20ms @ 48kHz)
// byte 8-11: SSRC random per conn, MSB encodes direction
//
// 12B explicit nonce = 4B sessionID || 8B counter (BE). sessionID MSB
// matches SSRC MSB (direction bit). counter starts at a random uint64.
// AAD = first 24 bytes (RTP header || nonce).
//
// VK TURN appears to forward SRTP-shaped ChannelData on a fast path and
// drop anomalous payloads. AEAD ciphertext + 16B tag is plausible as
// AES-GCM SRTP per RFC 7714.
const (
wrapKeyLen = 32
wrapRTPHdrLen = 12
wrapNonceLen = 12
wrapTagLen = 16
wrapHeaderLen = wrapRTPHdrLen + wrapNonceLen // 24
wrapOverhead = wrapHeaderLen + wrapTagLen // 40
wrapRTPVersion = 0x80 // V=2, P=0, X=0, CC=0
wrapRTPPT = 0x6F // M=0, PT=111 (opus)
wrapTSStep = 960 // 20ms @ 48kHz
)
type wrapConn struct {
aead cipher.AEAD
sessionID [4]byte // 4B prefix for nonce; MSB encodes direction
ssrc [4]byte // SSRC for RTP header; MSB encodes direction
counter atomic.Uint64
seq atomic.Uint32 // RTP sequence (used as uint16)
timestamp atomic.Uint32 // RTP timestamp
}
func newWrapConn(key []byte, isServer bool) (*wrapConn, error) {
if len(key) != wrapKeyLen {
return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key))
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, fmt.Errorf("wrap: aead init: %w", err)
}
w := &wrapConn{aead: aead}
var rnd [16]byte
if _, err := rand.Read(rnd[:]); err != nil {
return nil, fmt.Errorf("wrap: rand init: %w", err)
}
copy(w.sessionID[:], rnd[0:4])
copy(w.ssrc[:], rnd[4:8])
if isServer {
w.sessionID[0] |= 0x80
w.ssrc[0] |= 0x80
} else {
w.sessionID[0] &^= 0x80
w.ssrc[0] &^= 0x80
}
w.seq.Store(uint32(binary.BigEndian.Uint16(rnd[8:10])))
w.timestamp.Store(binary.BigEndian.Uint32(rnd[10:14]))
var cb [8]byte
if _, err := rand.Read(cb[:]); err != nil {
return nil, fmt.Errorf("wrap: counter rand: %w", err)
}
w.counter.Store(binary.BigEndian.Uint64(cb[:]))
return w, nil
}
// wrapMaxWire returns max wire bytes for a given payload size.
func wrapMaxWire(payloadLen int) int {
return wrapOverhead + payloadLen
}
func (w *wrapConn) wrapInto(dst, payload []byte) (int, error) {
wireLen := wrapOverhead + len(payload)
if len(dst) < wireLen {
return 0, errors.New("wrap: dst buffer too small")
}
// RTP header.
dst[0] = wrapRTPVersion
dst[1] = wrapRTPPT
seq := uint16(w.seq.Add(1) - 1)
binary.BigEndian.PutUint16(dst[2:4], seq)
ts := w.timestamp.Add(wrapTSStep) - wrapTSStep
binary.BigEndian.PutUint32(dst[4:8], ts)
copy(dst[8:12], w.ssrc[:])
// Explicit nonce.
noncePos := wrapRTPHdrLen
copy(dst[noncePos:noncePos+4], w.sessionID[:])
ctr := w.counter.Add(1) - 1
binary.BigEndian.PutUint64(dst[noncePos+4:noncePos+wrapNonceLen], ctr)
nonce := dst[noncePos : noncePos+wrapNonceLen]
aad := dst[:wrapHeaderLen]
ctPos := wrapHeaderLen
copy(dst[ctPos:], payload)
w.aead.Seal(dst[ctPos:ctPos], nonce, dst[ctPos:ctPos+len(payload)], aad)
return wireLen, nil
}
func (w *wrapConn) unwrapPacket(wire, dst []byte) (int, error) {
if len(wire) < wrapOverhead {
return 0, errors.New("wrap: packet too short")
}
nonce := wire[wrapRTPHdrLen : wrapRTPHdrLen+wrapNonceLen]
aad := wire[:wrapHeaderLen]
ct := wire[wrapHeaderLen:]
plain, err := w.aead.Open(ct[:0], nonce, ct, aad)
if err != nil {
return 0, fmt.Errorf("wrap: AEAD open: %w", err)
}
if len(plain) > len(dst) {
return 0, errors.New("wrap: dst buffer too small")
}
copy(dst[:len(plain)], plain)
return len(plain), nil
}
// --- Helpers ---
func genWrapKeyHex() (string, error) {
key := make([]byte, wrapKeyLen)
if _, err := rand.Read(key); err != nil {
return "", fmt.Errorf("wrap: key gen: %w", err)
}
return hex.EncodeToString(key), nil
}
func decodeWrapKey(enabled bool, raw string) ([]byte, error) {
if !enabled {
return nil, nil
}
if raw == "" {
return nil, errors.New("-wrap requires -wrap-key")
}
key, err := hex.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("-wrap-key invalid hex: %w", err)
}
if len(key) != wrapKeyLen {
return nil, fmt.Errorf("-wrap-key must decode to %d bytes (got %d)", wrapKeyLen, len(key))
}
return key, nil
}

201
client/wrap_test.go

@ -0,0 +1,201 @@
package main
import (
"bytes"
"encoding/binary"
"strings"
"testing"
)
func TestWrapConnRoundTrip(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
payload := []byte("dtls record bytes")
client, err := newWrapConn(key, false)
if err != nil {
t.Fatalf("newWrapConn(client): %v", err)
}
server, err := newWrapConn(key, true)
if err != nil {
t.Fatalf("newWrapConn(server): %v", err)
}
wire := make([]byte, wrapMaxWire(len(payload)))
n, err := client.wrapInto(wire, payload)
if err != nil {
t.Fatalf("wrapInto: %v", err)
}
wire = wire[:n]
if wire[0] != wrapRTPVersion {
t.Fatalf("RTP byte0 = 0x%02X, want 0x%02X", wire[0], wrapRTPVersion)
}
if wire[1] != wrapRTPPT {
t.Fatalf("RTP byte1 (PT) = 0x%02X, want 0x%02X", wire[1], wrapRTPPT)
}
if bytes.Contains(wire, payload) {
t.Fatalf("wrapped packet contains plaintext payload")
}
dst := make([]byte, 1600)
m, err := server.unwrapPacket(wire, dst)
if err != nil {
t.Fatalf("unwrapPacket: %v", err)
}
if m != len(payload) {
t.Fatalf("unwrapped len = %d, want %d", m, len(payload))
}
if !bytes.Equal(dst[:m], payload) {
t.Fatalf("round trip mismatch: got %q want %q", dst[:m], payload)
}
// Server → Client
wire2 := make([]byte, wrapMaxWire(len(payload)))
n2, err := server.wrapInto(wire2, payload)
if err != nil {
t.Fatalf("server wrapInto: %v", err)
}
m2, err := client.unwrapPacket(wire2[:n2], dst)
if err != nil {
t.Fatalf("client unwrapPacket: %v", err)
}
if !bytes.Equal(dst[:m2], payload) {
t.Fatalf("server→client round trip mismatch")
}
}
func TestWrapRTPHeaderProgression(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
wc, err := newWrapConn(key, false)
if err != nil {
t.Fatalf("newWrapConn: %v", err)
}
payload := []byte("x")
wire1 := make([]byte, wrapMaxWire(len(payload)))
n1, err := wc.wrapInto(wire1, payload)
if err != nil {
t.Fatalf("wrapInto 1: %v", err)
}
wire2 := make([]byte, wrapMaxWire(len(payload)))
n2, err := wc.wrapInto(wire2, payload)
if err != nil {
t.Fatalf("wrapInto 2: %v", err)
}
if n1 != n2 {
t.Fatalf("wire size variance: %d vs %d", n1, n2)
}
seq1 := binary.BigEndian.Uint16(wire1[2:4])
seq2 := binary.BigEndian.Uint16(wire2[2:4])
if seq2 != seq1+1 {
t.Fatalf("seq did not increment: %d → %d", seq1, seq2)
}
ts1 := binary.BigEndian.Uint32(wire1[4:8])
ts2 := binary.BigEndian.Uint32(wire2[4:8])
if ts2-ts1 != wrapTSStep {
t.Fatalf("timestamp step = %d, want %d", ts2-ts1, wrapTSStep)
}
// SSRC stable across packets.
if !bytes.Equal(wire1[8:12], wire2[8:12]) {
t.Fatalf("SSRC changed between packets")
}
}
func TestWrapDirectionBit(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
client, err := newWrapConn(key, false)
if err != nil {
t.Fatalf("newWrapConn(client): %v", err)
}
server, err := newWrapConn(key, true)
if err != nil {
t.Fatalf("newWrapConn(server): %v", err)
}
if client.sessionID[0]&0x80 != 0 {
t.Fatalf("client sessionID MSB should be 0, got 0x%02X", client.sessionID[0])
}
if server.sessionID[0]&0x80 == 0 {
t.Fatalf("server sessionID MSB should be 1, got 0x%02X", server.sessionID[0])
}
if client.ssrc[0]&0x80 != 0 {
t.Fatalf("client SSRC MSB should be 0, got 0x%02X", client.ssrc[0])
}
if server.ssrc[0]&0x80 == 0 {
t.Fatalf("server SSRC MSB should be 1, got 0x%02X", server.ssrc[0])
}
}
func TestDecodeWrapKeyRequiresValidKeyWhenEnabled(t *testing.T) {
if key, err := decodeWrapKey(false, ""); err != nil || key != nil {
t.Fatalf("disabled decodeWrapKey = (%v, %v), want (nil, nil)", key, err)
}
if _, err := decodeWrapKey(true, ""); err == nil {
t.Fatalf("decodeWrapKey accepted empty key")
}
shortHex := strings.Repeat("ab", wrapKeyLen-1)
if _, err := decodeWrapKey(true, shortHex); err == nil {
t.Fatalf("decodeWrapKey accepted short key")
}
fullHex := strings.Repeat("ab", wrapKeyLen)
key, err := decodeWrapKey(true, fullHex)
if err != nil {
t.Fatalf("decodeWrapKey returned error: %v", err)
}
if len(key) != wrapKeyLen {
t.Fatalf("decoded key len = %d, want %d", len(key), wrapKeyLen)
}
}
func TestUnwrapRejectsShortPacket(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
wc, err := newWrapConn(key, false)
if err != nil {
t.Fatalf("newWrapConn: %v", err)
}
if _, err := wc.unwrapPacket([]byte("short"), make([]byte, 16)); err == nil {
t.Fatalf("unwrapPacket accepted short packet")
}
}
func TestUnwrapRejectsTamperedPacket(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
client, err := newWrapConn(key, false)
if err != nil {
t.Fatalf("newWrapConn(client): %v", err)
}
server, err := newWrapConn(key, true)
if err != nil {
t.Fatalf("newWrapConn(server): %v", err)
}
payload := []byte("integrity test")
wire := make([]byte, wrapMaxWire(len(payload)))
n, err := client.wrapInto(wire, payload)
if err != nil {
t.Fatalf("wrapInto: %v", err)
}
wire = wire[:n]
// Flip a bit in the ciphertext.
wire[wrapHeaderLen+1] ^= 0xFF
dst := make([]byte, 1600)
if _, err := server.unwrapPacket(wire, dst); err == nil {
t.Fatalf("unwrapPacket accepted tampered ciphertext")
}
// Re-wrap and tamper RTP header (AAD).
n2, _ := client.wrapInto(wire, payload)
wire = wire[:n2]
wire[8] ^= 0x01 // flip a bit in SSRC
if _, err := server.unwrapPacket(wire, dst); err == nil {
t.Fatalf("unwrapPacket accepted tampered AAD")
}
}

17
deploy/vk-captcha-solver.service

@ -0,0 +1,17 @@
[Unit]
Description=vk-turn-proxy captcha solver (Playwright + headless Chromium)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /root/captcha_solver.py --listen 127.0.0.1:8766
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
TasksMax=256
LimitNOFILE=4096
[Install]
WantedBy=multi-user.target

0
docker-entrypoint.sh

0
go.mod

0
go.sum

0
routes-macos.sh

0
routes.ps1

0
routes.sh

204
scripts/captcha_solver.py

@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
vk-turn-proxy captcha solver local HTTP server that uses Playwright + headless
Chromium to solve VK's not_robot_captcha and return a success_token.
Run:
pip install playwright requests
playwright install chromium
python3 captcha_solver.py [--listen 127.0.0.1:8766]
HTTP API:
POST /solve
Body (JSON): {"redirect_uri": "https://id.vk.ru/not_robot_captcha?..."}
Response (JSON): {"success_token": "...", "elapsed": 8.3}
or {"error": "...", "elapsed": 8.3}
The Go vk-turn-proxy client can be configured (via -captcha-solver flag) to
call this server instead of trying to solve captcha inline. This bypasses
VK's bot detection because the captchaNotRobot.check request comes from a
real Chromium browser with a real FingerprintJS visitor_id.
Note: Chromium must be installed via `playwright install chromium`.
"""
import argparse
import json
import re
import random
import time
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from playwright.sync_api import sync_playwright
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
# Reuse a single browser instance across requests (Chromium startup is ~500ms)
_browser_lock = threading.Lock()
_browser = None
_playwright_ctx = None
def get_browser():
global _browser, _playwright_ctx
with _browser_lock:
if _browser is None:
_playwright_ctx = sync_playwright().start()
_browser = _playwright_ctx.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-blink-features=AutomationControlled"],
)
return _browser
def solve_captcha(redirect_uri: str, timeout: float = 30.0) -> dict:
"""Load the captcha page in Chromium, click the checkbox, return success_token."""
start = time.time()
success_token = {"value": None, "error": None}
browser = get_browser()
context = browser.new_context(
user_agent=UA,
viewport={"width": 1920, "height": 1080},
locale="en-US",
extra_http_headers={
"sec-ch-ua": '"Chromium";v="146", "Not A(Brand";v="24", "Google Chrome";v="146"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
},
)
context.add_init_script("""
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
Object.defineProperty(navigator, 'languages', {get: () => ['en-US', 'en']});
Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]});
window.chrome = {runtime: {}};
""")
page = context.new_page()
def on_response(response):
if "captchaNotRobot.check" in response.url:
try:
data = response.json()
if data.get("response", {}).get("success_token"):
success_token["value"] = data["response"]["success_token"]
except Exception as e:
success_token["error"] = f"response parse: {e}"
page.on("response", on_response)
try:
page.goto(redirect_uri, wait_until="networkidle", timeout=20000)
checkbox = page.wait_for_selector("#not-robot-captcha-checkbox", timeout=10000)
box = checkbox.bounding_box()
target_x = box["x"] + box["width"] / 2
target_y = box["y"] + box["height"] / 2
# Realistic mouse path (cubic bezier)
start_x = random.randint(100, 400)
start_y = random.randint(100, 400)
steps = 40
for i in range(steps):
t = i / (steps - 1)
cp1x = start_x + random.uniform(50, 200)
cp1y = start_y + random.uniform(50, 200)
cp2x = target_x - random.uniform(20, 100)
cp2y = target_y - random.uniform(20, 100)
x = (1-t)**3 * start_x + 3*(1-t)**2*t * cp1x + 3*(1-t)*t**2 * cp2x + t**3 * target_x
y = (1-t)**3 * start_y + 3*(1-t)**2*t * cp1y + 3*(1-t)*t**2 * cp2y + t**3 * target_y
page.mouse.move(x, y)
page.wait_for_timeout(20)
page.wait_for_timeout(300)
page.mouse.click(target_x, target_y)
# Wait for success_token
deadline = time.time() + timeout
while time.time() < deadline and not success_token["value"] and not success_token["error"]:
page.wait_for_timeout(100)
except Exception as e:
success_token["error"] = str(e)
finally:
context.close()
elapsed = time.time() - start
if success_token["value"]:
return {"success_token": success_token["value"], "elapsed": round(elapsed, 1)}
return {"error": success_token["error"] or "no success_token received", "elapsed": round(elapsed, 1)}
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
# Log to stdout with timestamp
print(f"[{time.strftime('%H:%M:%S')}] {args[0]}")
def do_GET(self):
if self.path == "/health":
self._json(200, {"ok": True, "engine": "playwright"})
else:
self._json(404, {"error": "not found"})
def do_POST(self):
if self.path != "/solve":
self._json(404, {"error": "not found"})
return
try:
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode("utf-8")
req = json.loads(body) if body else {}
except Exception as e:
self._json(400, {"error": f"bad request: {e}"})
return
redirect_uri = req.get("redirect_uri", "")
if not redirect_uri:
self._json(400, {"error": "missing redirect_uri"})
return
print(f"[/solve] redirect_uri={redirect_uri[:80]}...")
result = solve_captcha(redirect_uri)
print(f"[/solve] result: token={'OK' if result.get('success_token') else 'FAIL'} elapsed={result.get('elapsed')}s")
self._json(200, result)
def _json(self, code, obj):
body = json.dumps(obj).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--listen", default="127.0.0.1:8766", help="listen address")
args = ap.parse_args()
# Warm up the browser instance
print("[init] warming up Chromium...")
get_browser()
print("[init] ready")
host, port = args.listen.split(":")
# IMPORTANT: use plain HTTPServer (single-threaded), NOT ThreadingHTTPServer.
# Playwright's sync API uses greenlets that are pinned to a single thread —
# calling browser.new_context() from a worker thread raises
# "greenlet.error: Cannot switch to a different thread". Serializing all
# requests on the main thread is fine because captcha solving is rare
# (once per ~10 minutes per stream) and takes ~8s.
from http.server import HTTPServer
srv = HTTPServer((host, int(port)), Handler)
srv.daemon_threads = False
print(f"[listen] http://{args.listen} (single-threaded)")
print("[listen] POST /solve with {\"redirect_uri\": \"...\"} to solve captcha")
try:
srv.serve_forever()
except KeyboardInterrupt:
print("\n[shutdown]")
if _browser:
_browser.close()
if _playwright_ctx:
_playwright_ctx.stop()
if __name__ == "__main__":
main()

101
server/main.go

@ -2,6 +2,8 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@ -19,12 +21,70 @@ import (
"github.com/xtaci/smux" "github.com/xtaci/smux"
) )
// customResolver uses public DNS servers as fallback for domain resolution.
var customResolver = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
var d net.Dialer
dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"}
var lastErr error
for _, dns := range dnsServers {
conn, err := d.DialContext(ctx, "udp", dns)
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, lastErr
},
}
// resolveUDPAddr resolves a host:port string to *net.UDPAddr.
// Works with both IP addresses and domain names.
func resolveUDPAddr(network, address string) (*net.UDPAddr, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("split host:port %q: %w", address, err)
}
// If host is already an IP address, skip DNS resolution
if ip := net.ParseIP(host); ip != nil {
return net.ResolveUDPAddr(network, address)
}
// Resolve domain name using custom resolver (public DNS fallback)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ips, err := customResolver.LookupIP(ctx, "ip4", host)
if err != nil || len(ips) == 0 {
// Fallback: try system resolver
return net.ResolveUDPAddr(network, address)
}
resolved := net.JoinHostPort(ips[0].String(), port)
log.Printf("[DNS] resolved %s → %s", host, ips[0].String())
return net.ResolveUDPAddr(network, resolved)
}
func main() { func main() {
listen := flag.String("listen", "0.0.0.0:56000", "listen on ip:port") listen := flag.String("listen", "0.0.0.0:56000", "listen on ip:port")
connect := flag.String("connect", "", "connect to ip:port") connect := flag.String("connect", "", "connect to ip:port")
vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets") vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets")
wrapMode := flag.Bool("wrap", false, "WRAP mode: SRTP-mimicry AEAD wrap. Required when client uses -wrap.")
wrapKeyHex := flag.String("wrap-key", "", "32-byte hex-encoded shared key for -wrap (64 hex chars)")
genWrapKey := flag.Bool("gen-wrap-key", false, "print a fresh 64-character hex key for -wrap-key and exit")
flag.Parse() flag.Parse()
if *genWrapKey {
key := make([]byte, wrapKeyLen)
if _, err := rand.Read(key); err != nil {
log.Panicf("gen-wrap-key: %v", err)
}
fmt.Println(hex.EncodeToString(key))
return
}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
signalChan := make(chan os.Signal, 1) signalChan := make(chan os.Signal, 1)
@ -37,32 +97,53 @@ func main() {
log.Fatalf("Exit...\n") log.Fatalf("Exit...\n")
}() }()
addr, err := net.ResolveUDPAddr("udp", *listen) addr, err := resolveUDPAddr("udp", *listen)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if len(*connect) == 0 { if len(*connect) == 0 {
log.Panicf("server address is required") log.Panicf("server address is required")
} }
var wrapKey []byte
if *wrapMode {
if *wrapKeyHex == "" {
log.Panicf("-wrap requires -wrap-key")
}
wrapKey, err = hex.DecodeString(*wrapKeyHex)
if err != nil {
log.Panicf("-wrap-key invalid hex: %v", err)
}
if len(wrapKey) != wrapKeyLen {
log.Panicf("-wrap-key must decode to %d bytes (got %d)", wrapKeyLen, len(wrapKey))
}
}
log.Printf("Starting server listen=%s connect=%s vless=%t wrap=%t", *listen, *connect, *vlessMode, *wrapMode)
// Generate a certificate and private key to secure the connection // Generate a certificate and private key to secure the connection
certificate, genErr := selfsign.GenerateSelfSigned() certificate, genErr := selfsign.GenerateSelfSigned()
if genErr != nil { if genErr != nil {
panic(genErr) panic(genErr)
} }
// dtlsOpts := []dtls.ServerOption{
// Everything below is the pion-DTLS API! Thanks for using it ❤️.
//
// Connect to a DTLS server
listener, err := dtls.ListenWithOptions(
"udp",
addr,
dtls.WithCertificates(certificate), dtls.WithCertificates(certificate),
dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret), dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256),
dtls.WithConnectionIDGenerator(dtls.RandomCIDGenerator(8)), dtls.WithConnectionIDGenerator(dtls.RandomCIDGenerator(8)),
) }
var listener net.Listener
if *wrapMode {
log.Printf("WRAP mode enabled: listener only accepts clients with matching -wrap-key")
wrapListener, werr := listenWrapped(addr, wrapKey)
if werr != nil {
panic(werr)
}
listener, err = dtls.NewListenerWithOptions(wrapListener, dtlsOpts...)
} else {
listener, err = dtls.ListenWithOptions("udp", addr, dtlsOpts...)
}
if err != nil { if err != nil {
panic(err) panic(err)
} }

198
server/wrap.go

@ -0,0 +1,198 @@
// SPDX-License-Identifier: MIT
package main
import (
"crypto/cipher"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"
"time"
dtlsnet "github.com/pion/dtls/v3/pkg/net"
pionudp "github.com/pion/transport/v4/udp"
"golang.org/x/crypto/chacha20poly1305"
)
// Wire format is identical to client — see client/wrap.go. Server sets the
// MSB of sessionID/SSRC; client clears it. RTP header fields are per-conn.
const (
wrapKeyLen = 32
wrapRTPHdrLen = 12
wrapNonceLen = 12
wrapTagLen = 16
wrapHeaderLen = wrapRTPHdrLen + wrapNonceLen
wrapOverhead = wrapHeaderLen + wrapTagLen
wrapRTPVersion = 0x80
wrapRTPPT = 0x6F
wrapTSStep = 960
)
// bufPool eliminates per-packet heap allocation on the hot read/write paths.
var bufPool = sync.Pool{
New: func() any {
b := make([]byte, 1600+wrapOverhead)
return &b
},
}
// wrapState holds the AEAD instance shared by all connections under one key.
type wrapState struct {
aead cipher.AEAD
}
func newWrapState(key []byte) (*wrapState, error) {
if len(key) != wrapKeyLen {
return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key))
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, fmt.Errorf("wrap: aead init: %w", err)
}
return &wrapState{aead: aead}, nil
}
// --- Listener ---
func listenWrapped(addr *net.UDPAddr, key []byte) (dtlsnet.PacketListener, error) {
ws, err := newWrapState(key)
if err != nil {
return nil, err
}
inner, err := pionudp.Listen("udp", addr)
if err != nil {
return nil, fmt.Errorf("wrap: udp listen: %w", err)
}
return &wrapPacketListener{
inner: dtlsnet.PacketListenerFromListener(inner),
ws: ws,
}, nil
}
type wrapPacketListener struct {
inner dtlsnet.PacketListener
ws *wrapState
}
func (l *wrapPacketListener) Accept() (net.PacketConn, net.Addr, error) {
pc, addr, err := l.inner.Accept()
if err != nil {
return pc, addr, err
}
c := &wrapPacketConn{inner: pc, ws: l.ws}
var rnd [16]byte
if _, err := rand.Read(rnd[:]); err != nil {
return nil, addr, fmt.Errorf("wrap: rand init: %w", err)
}
copy(c.sessionID[:], rnd[0:4])
copy(c.ssrc[:], rnd[4:8])
c.sessionID[0] |= 0x80
c.ssrc[0] |= 0x80
c.seq.Store(uint32(binary.BigEndian.Uint16(rnd[8:10])))
c.timestamp.Store(binary.BigEndian.Uint32(rnd[10:14]))
var cb [8]byte
if _, err := rand.Read(cb[:]); err != nil {
return nil, addr, fmt.Errorf("wrap: counter rand: %w", err)
}
c.counter.Store(binary.BigEndian.Uint64(cb[:]))
return c, addr, nil
}
func (l *wrapPacketListener) Close() error { return l.inner.Close() }
func (l *wrapPacketListener) Addr() net.Addr { return l.inner.Addr() }
// --- Per-peer PacketConn ---
type wrapPacketConn struct {
inner net.PacketConn
ws *wrapState
sessionID [4]byte
ssrc [4]byte
counter atomic.Uint64
seq atomic.Uint32
timestamp atomic.Uint32
}
func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
bp := bufPool.Get().(*[]byte) //nolint:errcheck // pool New always returns *[]byte
buf := *bp
need := len(p) + wrapOverhead
if cap(buf) < need {
buf = make([]byte, need)
*bp = buf
}
defer bufPool.Put(bp)
n, addr, err := c.inner.ReadFrom(buf[:cap(buf)])
if err != nil {
return 0, addr, err
}
wire := buf[:n]
if len(wire) < wrapOverhead {
return 0, addr, errors.New("wrap: packet too short")
}
nonce := wire[wrapRTPHdrLen : wrapRTPHdrLen+wrapNonceLen]
aad := wire[:wrapHeaderLen]
ct := wire[wrapHeaderLen:]
plain, err := c.ws.aead.Open(ct[:0], nonce, ct, aad)
if err != nil {
return 0, addr, fmt.Errorf("wrap: AEAD open: %w", err)
}
if len(plain) > len(p) {
return 0, addr, errors.New("wrap: dst buffer too small")
}
copy(p[:len(plain)], plain)
return len(plain), addr, nil
}
func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
wireLen := wrapOverhead + len(p)
bp := bufPool.Get().(*[]byte) //nolint:errcheck // pool New always returns *[]byte
out := *bp
if cap(out) < wireLen {
out = make([]byte, wireLen)
*bp = out
}
out = out[:wireLen]
defer bufPool.Put(bp)
// RTP header.
out[0] = wrapRTPVersion
out[1] = wrapRTPPT
seq := uint16(c.seq.Add(1) - 1)
binary.BigEndian.PutUint16(out[2:4], seq)
ts := c.timestamp.Add(wrapTSStep) - wrapTSStep
binary.BigEndian.PutUint32(out[4:8], ts)
copy(out[8:12], c.ssrc[:])
noncePos := wrapRTPHdrLen
copy(out[noncePos:noncePos+4], c.sessionID[:])
ctr := c.counter.Add(1) - 1
binary.BigEndian.PutUint64(out[noncePos+4:noncePos+wrapNonceLen], ctr)
nonce := out[noncePos : noncePos+wrapNonceLen]
aad := out[:wrapHeaderLen]
ctPos := wrapHeaderLen
copy(out[ctPos:], p)
c.ws.aead.Seal(out[ctPos:ctPos], nonce, out[ctPos:ctPos+len(p)], aad)
if _, err := c.inner.WriteTo(out, addr); err != nil {
return 0, err
}
return len(p), nil
}
func (c *wrapPacketConn) Close() error { return c.inner.Close() }
func (c *wrapPacketConn) LocalAddr() net.Addr { return c.inner.LocalAddr() }
func (c *wrapPacketConn) SetDeadline(t time.Time) error { return c.inner.SetDeadline(t) }
func (c *wrapPacketConn) SetReadDeadline(t time.Time) error { return c.inner.SetReadDeadline(t) }
func (c *wrapPacketConn) SetWriteDeadline(t time.Time) error { return c.inner.SetWriteDeadline(t) }

0
tcputil/tcputil.go

Loading…
Cancel
Save