diff --git a/README.md b/README.md
index f3d2a47..a21876c 100644
--- a/README.md
+++ b/README.md
@@ -295,6 +295,10 @@ curl -L -o client https://github.com/cacggghp/vk-turn-proxy/releases/latest/down
Добавьте флаг `-n 1` для более стабильного подключения в 1 поток (ограничение 5 МБит/с для ВК)
+Для прохождения капчи вручную - `-manual-captcha`.
+
+По умолчанию капча теперь проходит так: обычная автопопытка, затем автопопытка через пазл-слайдер POC, и только потом ручной режим.
+
## Яндекс телемост
**UPD. ТЕЛЕМОСТ ЗАКРЫЛИ**
diff --git a/client/main.go b/client/main.go
index 1f0847e..9cdd3d8 100644
--- a/client/main.go
+++ b/client/main.go
@@ -8,7 +8,6 @@ import (
"context"
"crypto/md5"
"crypto/sha256"
- "crypto/tls"
"encoding/base64"
"encoding/hex"
"encoding/json"
@@ -22,7 +21,6 @@ import (
neturl "net/url"
"os"
"os/signal"
- "regexp"
"strconv"
"strings"
"sync"
@@ -68,8 +66,52 @@ var (
handshakeSem = make(chan struct{}, 3)
isDebug bool
manualCaptcha bool
+ autoCaptchaSliderPOC bool
)
+type captchaSolveMode int
+
+const (
+ captchaSolveModeAuto captchaSolveMode = iota
+ captchaSolveModeSliderPOC
+ captchaSolveModeManual
+)
+
+func captchaSolveModeForAttempt(attempt int, manualOnly bool, enableSliderPOC bool) (captchaSolveMode, bool) {
+ if manualOnly {
+ return captchaSolveModeManual, attempt == 0
+ }
+
+ switch attempt {
+ case 0:
+ return captchaSolveModeAuto, true
+ case 1:
+ if enableSliderPOC {
+ return captchaSolveModeSliderPOC, true
+ }
+ return captchaSolveModeManual, true
+ case 2:
+ if enableSliderPOC {
+ return captchaSolveModeManual, true
+ }
+ }
+
+ return 0, false
+}
+
+func captchaSolveModeLabel(mode captchaSolveMode) string {
+ switch mode {
+ case captchaSolveModeAuto:
+ return "auto captcha"
+ case captchaSolveModeSliderPOC:
+ return "auto captcha slider POC"
+ case captchaSolveModeManual:
+ return "manual captcha"
+ default:
+ return "captcha"
+ }
+}
+
type UDPPacket struct {
Data []byte
N int
@@ -337,8 +379,12 @@ func (e *VkCaptchaError) IsCaptchaError() bool {
return e.ErrorCode == 14 && e.RedirectURI != "" && e.SessionToken != ""
}
-func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID int, client tlsclient.HttpClient, profile Profile) (string, error) {
- log.Printf("[STREAM %d] [Captcha] Solving Not Robot Captcha...", streamID)
+func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID int, client tlsclient.HttpClient, profile Profile, useSliderPOC bool) (string, error) {
+ if useSliderPOC {
+ log.Printf("[STREAM %d] [Captcha] Solving captcha with slider POC...", streamID)
+ } else {
+ log.Printf("[STREAM %d] [Captcha] Solving captcha...", streamID)
+ }
if captchaErr.SessionToken == "" {
return "", fmt.Errorf("no session_token in redirect_uri for auto-solve")
@@ -347,17 +393,30 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in
return "", fmt.Errorf("no redirect_uri for auto-solve")
}
- powInput, difficulty, err := fetchPowInput(ctx, captchaErr.RedirectURI, client, profile)
+ bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile)
if err != nil {
- return "", fmt.Errorf("failed to fetch PoW input: %w", err)
+ return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err)
}
- log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, powInput, difficulty)
+ log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty)
- hash := solvePoW(powInput, difficulty)
+ hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty)
log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s", streamID, hash)
- successToken, err := callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile)
+ var successToken string
+ if useSliderPOC {
+ successToken, err = callCaptchaNotRobotWithSliderPOC(
+ ctx,
+ captchaErr.SessionToken,
+ hash,
+ streamID,
+ client,
+ profile,
+ bootstrap.Settings,
+ )
+ } else {
+ successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile)
+ }
if err != nil {
return "", fmt.Errorf("captchaNotRobot API failed: %w", err)
}
@@ -366,16 +425,16 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in
return successToken, nil
}
-func fetchPowInput(ctx context.Context, RedirectURI string, client tlsclient.HttpClient, profile Profile) (string, int, error) {
- parsedURL, err := neturl.Parse(RedirectURI)
+func fetchCaptchaBootstrap(ctx context.Context, redirectURI string, client tlsclient.HttpClient, profile Profile) (*captchaBootstrap, error) {
+ parsedURL, err := neturl.Parse(redirectURI)
if err != nil {
- return "", 0, err
+ return nil, err
}
domain := parsedURL.Hostname()
req, err := fhttp.NewRequestWithContext(ctx, "GET", RedirectURI, nil)
if err != nil {
- return "", 0, err
+ return nil, err
}
req.Host = domain
@@ -387,7 +446,7 @@ func fetchPowInput(ctx context.Context, RedirectURI string, client tlsclient.Htt
resp, err := client.Do(req)
if err != nil {
- return "", 0, err
+ return nil, err
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
@@ -395,26 +454,9 @@ func fetchPowInput(ctx context.Context, RedirectURI string, client tlsclient.Htt
body, err := io.ReadAll(resp.Body)
if err != nil {
- return "", 0, err
- }
- html := string(body)
-
- powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`)
- powInputMatch := powInputRe.FindStringSubmatch(html)
- if len(powInputMatch) < 2 {
- return "", 0, fmt.Errorf("powInput not found in captcha HTML")
- }
- powInput := powInputMatch[1]
-
- diffRe := regexp.MustCompile(`startsWith\('0'\.repeat\((\d+)\)\)`)
- diffMatch := diffRe.FindStringSubmatch(html)
- difficulty := 2
- if len(diffMatch) >= 2 {
- if d, err := strconv.Atoi(diffMatch[1]); err == nil {
- difficulty = d
- }
+ return nil, err
}
- return powInput, difficulty, nil
+ return parseCaptchaBootstrapHTML(string(body))
}
func solvePoW(powInput string, difficulty int) string {
@@ -486,7 +528,7 @@ func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamI
log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID)
browserFp := generateBrowserFp(profile)
- deviceJSON := fmt.Sprintf(`{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default","userAgent":"%s","platform":"Win32"}`, profile.UserAgent)
+ deviceJSON := buildCaptchaDeviceJSON(profile)
componentDoneData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, neturl.QueryEscape(deviceJSON))
if _, err := vkReq("captchaNotRobot.componentDone", componentDoneData); err != nil {
@@ -865,11 +907,12 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
urlAddr := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=5.275&client_id=%s", creds.ClientID)
var token2 string
- maxAutoAttempts := 2
- if manualCaptcha {
- maxAutoAttempts = 0
- }
- for attempt := 0; attempt <= maxAutoAttempts+1; attempt++ {
+ for attempt := 0; ; attempt++ {
+ solveMode, hasSolveMode := captchaSolveModeForAttempt(attempt, manualCaptcha, autoCaptchaSliderPOC)
+ if !hasSolveMode {
+ break
+ }
+
resp, err = doRequest(data, urlAddr)
if err != nil {
return "", "", "", err
@@ -882,20 +925,27 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
var captchaKey string
var solveErr error
- if attempt < maxAutoAttempts {
- // Auto Solve Attempts
+ switch solveMode {
+ case captchaSolveModeAuto:
if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" {
- successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile)
+ successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, false)
if solveErr != nil {
- log.Printf("[STREAM %d] [Captcha] Auto solve failed: %v", streamID, solveErr)
+ log.Printf("[STREAM %d] [Captcha] Auto captcha failed: %v", streamID, solveErr)
}
} else {
solveErr = fmt.Errorf("missing fields for auto solve")
}
- } else if attempt == maxAutoAttempts {
- // Manual Solve Fallback with 60s Timeout
- log.Printf("[STREAM %d] [Captcha] Auto failed %d times. Triggering MANUAL fallback...", streamID, maxAutoAttempts)
-
+ case captchaSolveModeSliderPOC:
+ if captchaErr.SessionToken != "" && captchaErr.RedirectUri != "" {
+ successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, true)
+ if solveErr != nil {
+ log.Printf("[STREAM %d] [Captcha] Auto captcha slider POC failed: %v", streamID, solveErr)
+ }
+ } else {
+ solveErr = fmt.Errorf("missing fields for slider POC auto solve")
+ }
+ case captchaSolveModeManual:
+ log.Printf("[STREAM %d] [Captcha] Triggering manual captcha fallback...", streamID)
manualCtx, manualCancel := context.WithTimeout(ctx, 60*time.Second)
type manualRes struct {
@@ -927,29 +977,15 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
solveErr = fmt.Errorf("manual captcha timed out after 60s")
}
manualCancel()
- } else {
- solveErr = fmt.Errorf("max attempts reached")
}
// If solving failed (auto or manual) or timed out
if solveErr != nil {
- log.Printf("[STREAM %d] [Captcha] Failed to solve (attempt %d): %v", streamID, attempt+1, solveErr)
+ log.Printf("[STREAM %d] [Captcha] %s failed (attempt %d): %v", streamID, captchaSolveModeLabel(solveMode), attempt+1, solveErr)
- if attempt < maxAutoAttempts-1 {
- log.Printf("[STREAM %d] [Captcha] Backing off for 10 seconds before next auto attempt...", streamID)
- select {
- case <-ctx.Done():
- return "", "", "", ctx.Err()
- case <-time.After(10 * time.Second):
- }
- continue
- } else if attempt == maxAutoAttempts-1 {
- log.Printf("[STREAM %d] [Captcha] Backing off for 2 seconds before manual fallback...", streamID)
- select {
- case <-ctx.Done():
- return "", "", "", ctx.Err()
- case <-time.After(2 * time.Second):
- }
+ nextSolveMode, hasNextSolveMode := captchaSolveModeForAttempt(attempt+1, manualCaptcha, autoCaptchaSliderPOC)
+ if hasNextSolveMode {
+ log.Printf("[STREAM %d] [Captcha] Falling back to %s...", streamID, captchaSolveModeLabel(nextSolveMode))
continue
}
@@ -1354,13 +1390,6 @@ func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net.
if err != nil {
return nil, err
}
- config := &dtls.Config{
- Certificates: []tls.Certificate{certificate},
- InsecureSkipVerify: true,
- ExtendedMasterSecret: dtls.RequireExtendedMasterSecret,
- CipherSuites: []dtls.CipherSuiteID{dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256},
- ConnectionIDGenerator: dtls.OnlySendCIDGenerator(),
- }
select {
case handshakeSem <- struct{}{}:
@@ -1371,7 +1400,15 @@ func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net.
ctx1, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
- dtlsConn, err := dtls.Client(conn, peer, config)
+ dtlsConn, err := dtls.ClientWithOptions(
+ conn,
+ peer,
+ dtls.WithCertificates(certificate),
+ dtls.WithInsecureSkipVerify(true),
+ dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
+ dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256),
+ dtls.WithConnectionIDGenerator(dtls.OnlySendCIDGenerator()),
+ )
if err != nil {
return nil, err
}
@@ -1778,6 +1815,7 @@ func main() {
isDebug = *debugFlag
manualCaptcha = *manualCaptchaFlag
+ autoCaptchaSliderPOC = !manualCaptcha
var link string
var getCreds getCredsFunc
diff --git a/client/main_test.go b/client/main_test.go
new file mode 100644
index 0000000..e0f2d77
--- /dev/null
+++ b/client/main_test.go
@@ -0,0 +1,61 @@
+package main
+
+import "testing"
+
+func TestCaptchaSolveModeForAttempt(t *testing.T) {
+ t.Parallel()
+
+ t.Run("default flow", func(t *testing.T) {
+ t.Parallel()
+
+ mode, ok := captchaSolveModeForAttempt(0, false, true)
+ if !ok || mode != captchaSolveModeAuto {
+ t.Fatalf("expected first attempt to use auto captcha, got mode=%v ok=%v", mode, ok)
+ }
+
+ mode, ok = captchaSolveModeForAttempt(1, false, true)
+ if !ok || mode != captchaSolveModeSliderPOC {
+ t.Fatalf("expected second attempt to use slider POC, got mode=%v ok=%v", mode, ok)
+ }
+
+ mode, ok = captchaSolveModeForAttempt(2, false, true)
+ if !ok || mode != captchaSolveModeManual {
+ t.Fatalf("expected third attempt to use manual captcha, got mode=%v ok=%v", mode, ok)
+ }
+
+ if _, ok = captchaSolveModeForAttempt(3, false, true); ok {
+ t.Fatal("expected no fourth captcha attempt in default flow")
+ }
+ })
+
+ t.Run("manual only flow", func(t *testing.T) {
+ t.Parallel()
+
+ mode, ok := captchaSolveModeForAttempt(0, true, true)
+ if !ok || mode != captchaSolveModeManual {
+ t.Fatalf("expected manual mode on first attempt, got mode=%v ok=%v", mode, ok)
+ }
+
+ if _, ok = captchaSolveModeForAttempt(1, true, true); ok {
+ t.Fatal("expected only one manual captcha attempt when manual mode is forced")
+ }
+ })
+
+ t.Run("flow without slider poc", func(t *testing.T) {
+ t.Parallel()
+
+ mode, ok := captchaSolveModeForAttempt(0, false, false)
+ if !ok || mode != captchaSolveModeAuto {
+ t.Fatalf("expected auto captcha first, got mode=%v ok=%v", mode, ok)
+ }
+
+ mode, ok = captchaSolveModeForAttempt(1, false, false)
+ if !ok || mode != captchaSolveModeManual {
+ t.Fatalf("expected manual captcha second when slider POC is disabled, got mode=%v ok=%v", mode, ok)
+ }
+
+ if _, ok = captchaSolveModeForAttempt(2, false, false); ok {
+ t.Fatal("expected only two attempts when slider POC is disabled")
+ }
+ })
+}
diff --git a/client/manual_captcha.go b/client/manual_captcha.go
index 09b342a..27f958d 100644
--- a/client/manual_captcha.go
+++ b/client/manual_captcha.go
@@ -74,6 +74,32 @@ func targetOrigin(targetURL *neturl.URL) string {
return targetURL.Scheme + "://" + targetURL.Host
}
+func isSafeLocalRedirectPath(raw string) bool {
+ if raw == "" || raw[0] != '/' {
+ return false
+ }
+ if len(raw) > 1 && (raw[1] == '/' || raw[1] == '\\') {
+ return false
+ }
+ return true
+}
+
+func rewriteProxyRedirectLocation(raw string, targetURL *neturl.URL) (string, bool) {
+ if isSafeLocalRedirectPath(raw) {
+ return raw, true
+ }
+
+ parsed, err := neturl.Parse(raw)
+ if err != nil {
+ return "", false
+ }
+ if !strings.EqualFold(parsed.Scheme, targetURL.Scheme) || !strings.EqualFold(parsed.Host, targetURL.Host) {
+ return "", false
+ }
+
+ return localCaptchaURLForTarget(parsed), true
+}
+
func rewriteProxyHeaderURL(raw string, targetURL *neturl.URL) string {
if raw == "" {
return raw
@@ -358,9 +384,10 @@ func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-ch
fmt.Println("\n==============================================")
fmt.Println("ACTION REQUIRED: MANUAL CAPTCHA SOLVING NEEDED")
- fmt.Println("Open this URL in your browser: " + captchaURL)
+ fmt.Println("Open this URL in your browser: " + localCaptchaOrigin())
fmt.Println("==============================================")
fmt.Println()
+
openBrowser(captchaURL)
key := <-keyCh
@@ -441,10 +468,10 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
if res.StatusCode >= 300 && res.StatusCode < 400 {
if loc := res.Header.Get("Location"); loc != "" {
- if strings.HasPrefix(loc, "/") {
- res.Header.Set("Location", loc)
- } else if strings.HasPrefix(loc, targetOrigin(targetURL)) {
- res.Header.Set("Location", strings.Replace(loc, targetOrigin(targetURL), localCaptchaOrigin(), 1))
+ if rewritten, ok := rewriteProxyRedirectLocation(loc, targetURL); ok {
+ res.Header.Set("Location", rewritten)
+ } else {
+ res.Header.Del("Location")
}
}
}
diff --git a/client/manual_captcha_test.go b/client/manual_captcha_test.go
new file mode 100644
index 0000000..8afbafd
--- /dev/null
+++ b/client/manual_captcha_test.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+ "net/url"
+ "testing"
+)
+
+func TestRewriteProxyRedirectLocation(t *testing.T) {
+ t.Parallel()
+
+ targetURL, err := url.Parse("https://id.vk.ru/captcha")
+ if err != nil {
+ t.Fatalf("failed to parse target URL: %v", err)
+ }
+
+ testCases := []struct {
+ name string
+ location string
+ want string
+ ok bool
+ }{
+ {
+ name: "keeps safe relative path",
+ location: "/captcha?step=2",
+ want: "/captcha?step=2",
+ ok: true,
+ },
+ {
+ name: "rewrites same-origin absolute URL",
+ location: "https://id.vk.ru/captcha?step=2",
+ want: "http://localhost:8765/captcha?step=2",
+ ok: true,
+ },
+ {
+ name: "blocks scheme-relative redirect",
+ location: "//evil.example/captcha",
+ ok: false,
+ },
+ {
+ name: "blocks slash-backslash redirect",
+ location: `/\evil.example/captcha`,
+ ok: false,
+ },
+ {
+ name: "blocks lookalike absolute host",
+ location: "https://id.vk.ru.evil.example/captcha",
+ ok: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ got, ok := rewriteProxyRedirectLocation(tc.location, targetURL)
+ if ok != tc.ok {
+ t.Fatalf("rewriteProxyRedirectLocation() ok = %v, want %v", ok, tc.ok)
+ }
+ if got != tc.want {
+ t.Fatalf("rewriteProxyRedirectLocation() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/client/slider_captcha.go b/client/slider_captcha.go
new file mode 100644
index 0000000..166a6ab
--- /dev/null
+++ b/client/slider_captcha.go
@@ -0,0 +1,918 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "image"
+ "image/color"
+ _ "image/jpeg"
+ "io"
+ "log"
+ neturl "net/url"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ fhttp "github.com/bogdanfinn/fhttp"
+ tlsclient "github.com/bogdanfinn/tls-client"
+)
+
+const (
+ captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c"
+ sliderCaptchaType = "slider"
+ defaultSliderAttempts = 4
+)
+
+type captchaNotRobotSession struct {
+ ctx context.Context
+ sessionToken string
+ hash string
+ streamID int
+ client tlsclient.HttpClient
+ profile Profile
+ browserFp string
+}
+
+type captchaSettingsResponse struct {
+ ShowCaptchaType string
+ SettingsByType map[string]string
+}
+
+type captchaCheckResult struct {
+ Status string
+ SuccessToken string
+ ShowCaptchaType string
+}
+
+type sliderCaptchaContent struct {
+ Image image.Image
+ Size int
+ Steps []int
+ Attempts int
+}
+
+type sliderCandidate struct {
+ Index int
+ ActiveSteps []int
+ Score int64
+}
+
+type captchaBootstrap struct {
+ PowInput string
+ Difficulty int
+ Settings *captchaSettingsResponse
+}
+
+func newCaptchaNotRobotSession(
+ ctx context.Context,
+ sessionToken string,
+ hash string,
+ streamID int,
+ client tlsclient.HttpClient,
+ profile Profile,
+) *captchaNotRobotSession {
+ return &captchaNotRobotSession{
+ ctx: ctx,
+ sessionToken: sessionToken,
+ hash: hash,
+ streamID: streamID,
+ client: client,
+ profile: profile,
+ browserFp: generateBrowserFp(profile),
+ }
+}
+
+func (s *captchaNotRobotSession) baseValues() neturl.Values {
+ values := neturl.Values{}
+ values.Set("session_token", s.sessionToken)
+ values.Set("domain", "vk.com")
+ values.Set("adFp", "")
+ values.Set("access_token", "")
+ return values
+}
+
+func (s *captchaNotRobotSession) request(method string, values neturl.Values) (map[string]interface{}, error) {
+ reqURL := "https://api.vk.ru/method/" + method + "?v=5.131"
+
+ req, err := fhttp.NewRequestWithContext(s.ctx, "POST", reqURL, strings.NewReader(values.Encode()))
+ if err != nil {
+ return nil, err
+ }
+
+ httpResp, err := s.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ _ = httpResp.Body.Close()
+ }()
+
+ body, err := io.ReadAll(httpResp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp map[string]interface{}
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return nil, err
+ }
+ return resp, nil
+}
+
+func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, error) {
+ resp, err := s.request("captchaNotRobot.settings", s.baseValues())
+ if err != nil {
+ return nil, fmt.Errorf("settings failed: %w", err)
+ }
+ return parseCaptchaSettingsResponse(resp)
+}
+
+func (s *captchaNotRobotSession) requestComponentDone() error {
+ values := s.baseValues()
+ values.Set("browser_fp", s.browserFp)
+ values.Set("device", buildCaptchaDeviceJSON(s.profile))
+
+ resp, err := s.request("captchaNotRobot.componentDone", values)
+ if err != nil {
+ return fmt.Errorf("componentDone failed: %w", err)
+ }
+
+ respObj, ok := resp["response"].(map[string]interface{})
+ if ok {
+ if status, _ := respObj["status"].(string); status != "" && status != "OK" {
+ return fmt.Errorf("componentDone status: %s", status)
+ }
+ }
+
+ return nil
+}
+
+func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, error) {
+ return s.requestCheck("[]", base64.StdEncoding.EncodeToString([]byte("{}")))
+}
+
+func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*sliderCaptchaContent, error) {
+ values := s.baseValues()
+ if sliderSettings != "" {
+ values.Set("captcha_settings", sliderSettings)
+ }
+
+ resp, err := s.request("captchaNotRobot.getContent", values)
+ if err != nil {
+ return nil, fmt.Errorf("getContent failed: %w", err)
+ }
+ return parseSliderCaptchaContentResponse(resp)
+}
+
+func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) {
+ answer, err := encodeSliderAnswer(activeSteps)
+ if err != nil {
+ return nil, err
+ }
+
+ return s.requestCheck(generateSliderCursor(candidateIndex, candidateCount), answer)
+}
+
+func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*captchaCheckResult, error) {
+ values := s.baseValues()
+ values.Set("accelerometer", "[]")
+ values.Set("gyroscope", "[]")
+ values.Set("motion", "[]")
+ values.Set("cursor", cursor)
+ values.Set("taps", "[]")
+ values.Set("connectionRtt", "[]")
+ values.Set("connectionDownlink", "[]")
+ values.Set("browser_fp", s.browserFp)
+ values.Set("hash", s.hash)
+ values.Set("answer", answer)
+ values.Set("debug_info", captchaDebugInfo)
+
+ resp, err := s.request("captchaNotRobot.check", values)
+ if err != nil {
+ return nil, fmt.Errorf("check failed: %w", err)
+ }
+ return parseCaptchaCheckResult(resp)
+}
+
+func (s *captchaNotRobotSession) requestEndSession() {
+ log.Printf("[STREAM %d] [Captcha] Step 4/4: endSession", s.streamID)
+ if _, err := s.request("captchaNotRobot.endSession", s.baseValues()); err != nil {
+ log.Printf("[STREAM %d] [Captcha] Warning: endSession failed: %v", s.streamID, err)
+ }
+}
+
+func callCaptchaNotRobotWithSliderPOC(
+ ctx context.Context,
+ sessionToken string,
+ hash string,
+ streamID int,
+ client tlsclient.HttpClient,
+ profile Profile,
+ initialSettings *captchaSettingsResponse,
+) (string, error) {
+ session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile)
+
+ log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
+ settingsResp, err := session.requestSettings()
+ if err != nil {
+ return "", err
+ }
+ settingsResp = mergeCaptchaSettings(settingsResp, initialSettings)
+
+ time.Sleep(200 * time.Millisecond)
+
+ log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID)
+ if err := session.requestComponentDone(); err != nil {
+ return "", err
+ }
+
+ time.Sleep(200 * time.Millisecond)
+
+ log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID)
+ initialCheck, err := session.requestCheckboxCheck()
+ if err != nil {
+ return "", err
+ }
+ if initialCheck.Status == "OK" {
+ if initialCheck.SuccessToken == "" {
+ return "", fmt.Errorf("success_token not found")
+ }
+ session.requestEndSession()
+ return initialCheck.SuccessToken, nil
+ }
+
+ sliderSettings, hasSlider := settingsResp.SettingsByType[sliderCaptchaType]
+ log.Printf(
+ "[STREAM %d] [Captcha] Checkbox-style check returned status=%s (settings show_type=%q, check show_type=%q, available_types=%s)",
+ streamID,
+ initialCheck.Status,
+ settingsResp.ShowCaptchaType,
+ initialCheck.ShowCaptchaType,
+ describeCaptchaTypes(settingsResp.SettingsByType),
+ )
+
+ if !hasSlider {
+ log.Printf(
+ "[STREAM %d] [Captcha] Slider settings not found in settings response. Trying getContent without captcha_settings...",
+ streamID,
+ )
+ } else {
+ log.Printf("[STREAM %d] [Captcha] Trying experimental slider solver...", streamID)
+ }
+
+ sliderContent, err := session.requestSliderContent(sliderSettings)
+ if err != nil {
+ return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err)
+ }
+
+ candidates, err := rankSliderCandidates(sliderContent.Image, sliderContent.Size, sliderContent.Steps)
+ if err != nil {
+ return "", err
+ }
+
+ log.Printf(
+ "[STREAM %d] [Captcha] Ranked %d slider positions locally; submitting top %d based on attempt budget %d",
+ streamID,
+ len(candidates),
+ minInt(sliderContent.Attempts, len(candidates)),
+ sliderContent.Attempts,
+ )
+
+ successToken, err := trySliderCaptchaCandidates(candidates, sliderContent.Attempts, func(candidate sliderCandidate) (*captchaCheckResult, error) {
+ log.Printf(
+ "[STREAM %d] [Captcha] Slider guess position=%d score=%d",
+ streamID,
+ candidate.Index,
+ candidate.Score,
+ )
+ return session.requestSliderCheck(candidate.ActiveSteps, candidate.Index, len(candidates))
+ })
+ if err != nil {
+ return "", err
+ }
+
+ session.requestEndSession()
+ return successToken, nil
+}
+
+func buildCaptchaDeviceJSON(profile Profile) string {
+ return fmt.Sprintf(
+ `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default","userAgent":"%s","platform":"Win32"}`,
+ profile.UserAgent,
+ )
+}
+
+func parseCaptchaSettingsResponse(resp map[string]interface{}) (*captchaSettingsResponse, error) {
+ respObj, ok := resp["response"].(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("invalid settings response: %v", resp)
+ }
+
+ settings := &captchaSettingsResponse{
+ SettingsByType: make(map[string]string),
+ }
+ settings.ShowCaptchaType, _ = respObj["show_captcha_type"].(string)
+
+ rawSettings, ok := expandCaptchaSettings(respObj["captcha_settings"])
+ if !ok {
+ return settings, nil
+ }
+
+ for _, rawItem := range rawSettings {
+ item, ok := rawItem.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ captchaType, _ := item["type"].(string)
+ if captchaType == "" {
+ continue
+ }
+
+ normalized, err := normalizeCaptchaSettings(item["settings"])
+ if err != nil {
+ return nil, fmt.Errorf("invalid captcha_settings for %s: %w", captchaType, err)
+ }
+
+ settings.SettingsByType[captchaType] = normalized
+ }
+
+ return settings, nil
+}
+
+func parseCaptchaBootstrapHTML(html string) (*captchaBootstrap, error) {
+ powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`)
+ powInputMatch := powInputRe.FindStringSubmatch(html)
+ if len(powInputMatch) < 2 {
+ return nil, fmt.Errorf("powInput not found in captcha HTML")
+ }
+
+ difficulty := 2
+ for _, expr := range []*regexp.Regexp{
+ regexp.MustCompile(`startsWith\('0'\.repeat\((\d+)\)\)`),
+ regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`),
+ } {
+ if match := expr.FindStringSubmatch(html); len(match) >= 2 {
+ if parsed, err := strconv.Atoi(match[1]); err == nil {
+ difficulty = parsed
+ break
+ }
+ }
+ }
+
+ settings, err := parseCaptchaSettingsFromHTML(html)
+ if err != nil {
+ return nil, err
+ }
+
+ return &captchaBootstrap{
+ PowInput: powInputMatch[1],
+ Difficulty: difficulty,
+ Settings: settings,
+ }, nil
+}
+
+func parseCaptchaSettingsFromHTML(html string) (*captchaSettingsResponse, error) {
+ initRe := regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;\s*window\.lang`)
+ initMatch := initRe.FindStringSubmatch(html)
+ if len(initMatch) < 2 {
+ return &captchaSettingsResponse{SettingsByType: make(map[string]string)}, nil
+ }
+
+ var initPayload struct {
+ Data struct {
+ ShowCaptchaType string `json:"show_captcha_type"`
+ CaptchaSettings interface{} `json:"captcha_settings"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal([]byte(initMatch[1]), &initPayload); err != nil {
+ return nil, fmt.Errorf("parse window.init captcha data: %w", err)
+ }
+
+ return parseCaptchaSettingsResponse(map[string]interface{}{
+ "response": map[string]interface{}{
+ "show_captcha_type": initPayload.Data.ShowCaptchaType,
+ "captcha_settings": initPayload.Data.CaptchaSettings,
+ },
+ })
+}
+
+func mergeCaptchaSettings(primary *captchaSettingsResponse, fallback *captchaSettingsResponse) *captchaSettingsResponse {
+ if primary == nil {
+ return cloneCaptchaSettings(fallback)
+ }
+ if primary.SettingsByType == nil {
+ primary.SettingsByType = make(map[string]string)
+ }
+ if fallback == nil {
+ return primary
+ }
+ if primary.ShowCaptchaType == "" {
+ primary.ShowCaptchaType = fallback.ShowCaptchaType
+ }
+ for captchaType, settings := range fallback.SettingsByType {
+ if _, exists := primary.SettingsByType[captchaType]; !exists {
+ primary.SettingsByType[captchaType] = settings
+ }
+ }
+ return primary
+}
+
+func cloneCaptchaSettings(src *captchaSettingsResponse) *captchaSettingsResponse {
+ if src == nil {
+ return nil
+ }
+
+ cloned := &captchaSettingsResponse{
+ ShowCaptchaType: src.ShowCaptchaType,
+ SettingsByType: make(map[string]string, len(src.SettingsByType)),
+ }
+ for captchaType, settings := range src.SettingsByType {
+ cloned.SettingsByType[captchaType] = settings
+ }
+ return cloned
+}
+
+func expandCaptchaSettings(raw interface{}) ([]interface{}, bool) {
+ switch value := raw.(type) {
+ case nil:
+ return nil, false
+ case []interface{}:
+ return value, true
+ case map[string]interface{}:
+ items := make([]interface{}, 0, len(value))
+ for captchaType, settings := range value {
+ items = append(items, map[string]interface{}{
+ "type": captchaType,
+ "settings": settings,
+ })
+ }
+ return items, true
+ case string:
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return nil, false
+ }
+
+ var items []interface{}
+ if err := json.Unmarshal([]byte(trimmed), &items); err == nil {
+ return items, true
+ }
+
+ var mapping map[string]interface{}
+ if err := json.Unmarshal([]byte(trimmed), &mapping); err == nil {
+ return expandCaptchaSettings(mapping)
+ }
+ }
+
+ return nil, false
+}
+
+func normalizeCaptchaSettings(raw interface{}) (string, error) {
+ switch value := raw.(type) {
+ case nil:
+ return "", nil
+ case string:
+ return value, nil
+ default:
+ data, err := json.Marshal(value)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+ }
+}
+
+func parseCaptchaCheckResult(resp map[string]interface{}) (*captchaCheckResult, error) {
+ respObj, ok := resp["response"].(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("invalid check response: %v", resp)
+ }
+
+ result := &captchaCheckResult{}
+ result.Status, _ = respObj["status"].(string)
+ result.SuccessToken, _ = respObj["success_token"].(string)
+ result.ShowCaptchaType, _ = respObj["show_captcha_type"].(string)
+ if result.Status == "" {
+ return nil, fmt.Errorf("check status missing: %v", resp)
+ }
+
+ return result, nil
+}
+
+func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCaptchaContent, error) {
+ respObj, ok := resp["response"].(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("invalid slider content response: %v", resp)
+ }
+
+ status, _ := respObj["status"].(string)
+ if status != "OK" {
+ return nil, fmt.Errorf("slider getContent status: %s", status)
+ }
+
+ extension, _ := respObj["extension"].(string)
+ extension = strings.ToLower(extension)
+ if extension != "jpeg" && extension != "jpg" {
+ return nil, fmt.Errorf("unsupported slider image format: %s", extension)
+ }
+
+ rawImage, _ := respObj["image"].(string)
+ if rawImage == "" {
+ return nil, fmt.Errorf("slider image missing")
+ }
+
+ rawSteps, ok := respObj["steps"].([]interface{})
+ if !ok {
+ return nil, fmt.Errorf("slider steps missing")
+ }
+
+ steps, err := parseIntSlice(rawSteps)
+ if err != nil {
+ return nil, err
+ }
+
+ size, swaps, attempts, err := parseSliderSteps(steps)
+ if err != nil {
+ return nil, err
+ }
+
+ img, err := decodeSliderImage(rawImage)
+ if err != nil {
+ return nil, err
+ }
+
+ return &sliderCaptchaContent{
+ Image: img,
+ Size: size,
+ Steps: swaps,
+ Attempts: attempts,
+ }, nil
+}
+
+func parseIntSlice(raw []interface{}) ([]int, error) {
+ values := make([]int, 0, len(raw))
+ for _, item := range raw {
+ number, err := parseIntValue(item)
+ if err != nil {
+ return nil, err
+ }
+ values = append(values, number)
+ }
+ return values, nil
+}
+
+func parseIntValue(raw interface{}) (int, error) {
+ switch value := raw.(type) {
+ case float64:
+ return int(value), nil
+ case int:
+ return value, nil
+ case string:
+ parsed, err := strconv.Atoi(strings.TrimSpace(value))
+ if err != nil {
+ return 0, fmt.Errorf("invalid numeric value: %v", raw)
+ }
+ return parsed, nil
+ default:
+ return 0, fmt.Errorf("invalid numeric value: %v", raw)
+ }
+}
+
+func parseSliderSteps(steps []int) (int, []int, int, error) {
+ if len(steps) < 3 {
+ return 0, nil, 0, fmt.Errorf("slider steps payload too short")
+ }
+
+ size := steps[0]
+ if size <= 0 {
+ return 0, nil, 0, fmt.Errorf("invalid slider size: %d", size)
+ }
+
+ remaining := append([]int(nil), steps[1:]...)
+ attempts := defaultSliderAttempts
+ if len(remaining)%2 != 0 {
+ attempts = remaining[len(remaining)-1]
+ remaining = remaining[:len(remaining)-1]
+ }
+ if attempts <= 0 {
+ attempts = defaultSliderAttempts
+ }
+ if len(remaining) == 0 || len(remaining)%2 != 0 {
+ return 0, nil, 0, fmt.Errorf("invalid slider swap payload")
+ }
+
+ return size, remaining, attempts, nil
+}
+
+func decodeSliderImage(rawImage string) (image.Image, error) {
+ decoded, err := base64.StdEncoding.DecodeString(rawImage)
+ if err != nil {
+ return nil, fmt.Errorf("decode slider image: %w", err)
+ }
+
+ img, _, err := image.Decode(bytes.NewReader(decoded))
+ if err != nil {
+ return nil, fmt.Errorf("decode slider image: %w", err)
+ }
+
+ return img, nil
+}
+
+func encodeSliderAnswer(activeSteps []int) (string, error) {
+ payload := struct {
+ Value []int `json:"value"`
+ }{
+ Value: activeSteps,
+ }
+
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return "", err
+ }
+
+ return base64.StdEncoding.EncodeToString(data), nil
+}
+
+func buildSliderActiveSteps(swaps []int, candidateIndex int) []int {
+ if candidateIndex <= 0 {
+ return []int{}
+ }
+
+ end := candidateIndex * 2
+ if end > len(swaps) {
+ end = len(swaps)
+ }
+
+ return append([]int(nil), swaps[:end]...)
+}
+
+func buildSliderTileMapping(gridSize int, activeSteps []int) ([]int, error) {
+ tileCount := gridSize * gridSize
+ if tileCount <= 0 {
+ return nil, fmt.Errorf("invalid slider tile count: %d", tileCount)
+ }
+ if len(activeSteps)%2 != 0 {
+ return nil, fmt.Errorf("invalid active steps length: %d", len(activeSteps))
+ }
+
+ mapping := make([]int, tileCount)
+ for i := range mapping {
+ mapping[i] = i
+ }
+
+ for idx := 0; idx < len(activeSteps); idx += 2 {
+ left := activeSteps[idx]
+ right := activeSteps[idx+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 rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderCandidate, error) {
+ candidateCount := len(swaps) / 2
+ if candidateCount == 0 {
+ return nil, fmt.Errorf("slider has no candidates")
+ }
+
+ candidates := make([]sliderCandidate, 0, candidateCount)
+ for idx := 1; idx <= candidateCount; idx++ {
+ activeSteps := buildSliderActiveSteps(swaps, idx)
+ mapping, err := buildSliderTileMapping(gridSize, activeSteps)
+ if err != nil {
+ return nil, err
+ }
+
+ score, err := scoreSliderCandidate(img, gridSize, mapping)
+ if err != nil {
+ return nil, err
+ }
+
+ candidates = append(candidates, sliderCandidate{
+ Index: idx,
+ ActiveSteps: activeSteps,
+ Score: score,
+ })
+ }
+
+ sort.SliceStable(candidates, func(i, j int) bool {
+ if candidates[i].Score == candidates[j].Score {
+ return candidates[i].Index < candidates[j].Index
+ }
+ return candidates[i].Score < candidates[j].Score
+ })
+
+ return candidates, nil
+}
+
+func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, error) {
+ rendered, err := renderSliderCandidate(img, gridSize, mapping)
+ if err != nil {
+ return 0, err
+ }
+
+ return scoreRenderedSliderImage(rendered, gridSize), nil
+}
+
+func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image.RGBA, error) {
+ if gridSize <= 0 {
+ return nil, fmt.Errorf("invalid grid size: %d", gridSize)
+ }
+
+ tileCount := gridSize * gridSize
+ if len(mapping) != tileCount {
+ return nil, fmt.Errorf("unexpected tile mapping length: %d", len(mapping))
+ }
+
+ bounds := img.Bounds()
+ rendered := image.NewRGBA(bounds)
+ for dstIndex, srcIndex := range mapping {
+ srcRect := sliderTileRect(bounds, gridSize, srcIndex)
+ dstRect := sliderTileRect(bounds, gridSize, dstIndex)
+ copyScaledTile(rendered, dstRect, img, srcRect)
+ }
+
+ return rendered, nil
+}
+
+func scoreRenderedSliderImage(img image.Image, gridSize int) int64 {
+ bounds := img.Bounds()
+ var score int64
+
+ for row := 0; row < gridSize; row++ {
+ for col := 0; col < gridSize-1; col++ {
+ leftRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
+ rightRect := sliderTileRect(bounds, gridSize, row*gridSize+col+1)
+ height := minInt(leftRect.Dy(), rightRect.Dy())
+ for offset := 0; offset < height; offset++ {
+ score += pixelDiff(
+ img.At(leftRect.Max.X-1, leftRect.Min.Y+offset),
+ img.At(rightRect.Min.X, rightRect.Min.Y+offset),
+ )
+ }
+ }
+ }
+
+ for row := 0; row < gridSize-1; row++ {
+ for col := 0; col < gridSize; col++ {
+ topRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
+ bottomRect := sliderTileRect(bounds, gridSize, (row+1)*gridSize+col)
+ width := minInt(topRect.Dx(), bottomRect.Dx())
+ for offset := 0; offset < width; offset++ {
+ score += pixelDiff(
+ img.At(topRect.Min.X+offset, topRect.Max.Y-1),
+ img.At(bottomRect.Min.X+offset, bottomRect.Min.Y),
+ )
+ }
+ }
+ }
+
+ return score
+}
+
+func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
+ row := index / gridSize
+ col := index % gridSize
+
+ x0 := bounds.Min.X + col*bounds.Dx()/gridSize
+ x1 := bounds.Min.X + (col+1)*bounds.Dx()/gridSize
+ y0 := bounds.Min.Y + row*bounds.Dy()/gridSize
+ y1 := bounds.Min.Y + (row+1)*bounds.Dy()/gridSize
+
+ return image.Rect(x0, y0, x1, y1)
+}
+
+func copyScaledTile(dst *image.RGBA, dstRect image.Rectangle, src image.Image, srcRect image.Rectangle) {
+ if dstRect.Empty() || srcRect.Empty() {
+ return
+ }
+
+ dstWidth := dstRect.Dx()
+ dstHeight := dstRect.Dy()
+ srcWidth := srcRect.Dx()
+ srcHeight := srcRect.Dy()
+
+ for y := 0; y < dstHeight; y++ {
+ sy := srcRect.Min.Y + y*srcHeight/dstHeight
+ for x := 0; x < dstWidth; x++ {
+ sx := srcRect.Min.X + x*srcWidth/dstWidth
+ dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, src.At(sx, sy))
+ }
+ }
+}
+
+func pixelDiff(left color.Color, right color.Color) int64 {
+ lr, lg, lb, _ := left.RGBA()
+ rr, rg, rb, _ := right.RGBA()
+
+ return absDiff(lr, rr) + absDiff(lg, rg) + absDiff(lb, rb)
+}
+
+func absDiff(left uint32, right uint32) int64 {
+ if left > right {
+ return int64(left - right)
+ }
+ return int64(right - left)
+}
+
+func generateSliderCursor(candidateIndex int, candidateCount int) string {
+ return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli())
+}
+
+func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64) string {
+ if candidateCount <= 0 {
+ return "[]"
+ }
+
+ type cursorPoint struct {
+ X int `json:"x"`
+ Y int `json:"y"`
+ T int64 `json:"t"`
+ }
+
+ startX := 140
+ endX := startX + 620*candidateIndex/candidateCount
+ startY := 430
+
+ points := make([]cursorPoint, 0, 12)
+ for step := 0; step < 12; step++ {
+ x := startX + (endX-startX)*step/11
+ y := startY + ((step % 3) - 1)
+ points = append(points, cursorPoint{
+ X: x,
+ Y: y,
+ T: startTime + int64(step*18),
+ })
+ }
+
+ data, err := json.Marshal(points)
+ if err != nil {
+ return "[]"
+ }
+ return string(data)
+}
+
+func trySliderCaptchaCandidates(
+ candidates []sliderCandidate,
+ maxAttempts int,
+ check func(candidate sliderCandidate) (*captchaCheckResult, error),
+) (string, error) {
+ if len(candidates) == 0 {
+ return "", fmt.Errorf("slider has no ranked candidates")
+ }
+
+ limit := minInt(maxAttempts, len(candidates))
+ if limit <= 0 {
+ return "", fmt.Errorf("slider has no attempts available")
+ }
+
+ for idx := 0; idx < limit; idx++ {
+ result, err := check(candidates[idx])
+ if err != nil {
+ return "", err
+ }
+
+ switch result.Status {
+ case "OK":
+ if result.SuccessToken == "" {
+ return "", fmt.Errorf("success_token not found")
+ }
+ return result.SuccessToken, nil
+ case "ERROR_LIMIT":
+ return "", fmt.Errorf("slider check status: %s", result.Status)
+ default:
+ continue
+ }
+ }
+
+ return "", fmt.Errorf("slider guesses exhausted")
+}
+
+func minInt(left int, right int) int {
+ if left < right {
+ return left
+ }
+ return right
+}
+
+func describeCaptchaTypes(settingsByType map[string]string) string {
+ if len(settingsByType) == 0 {
+ return "none"
+ }
+
+ types := make([]string, 0, len(settingsByType))
+ for captchaType := range settingsByType {
+ types = append(types, captchaType)
+ }
+ sort.Strings(types)
+ return strings.Join(types, ",")
+}
diff --git a/client/slider_captcha_test.go b/client/slider_captcha_test.go
new file mode 100644
index 0000000..19c2771
--- /dev/null
+++ b/client/slider_captcha_test.go
@@ -0,0 +1,278 @@
+package main
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "image"
+ "image/color"
+ "image/jpeg"
+ "reflect"
+ "testing"
+)
+
+func TestParseSliderSteps(t *testing.T) {
+ t.Parallel()
+
+ size, swaps, attempts, err := parseSliderSteps([]int{5, 0, 1, 2, 3, 7})
+ if err != nil {
+ t.Fatalf("parseSliderSteps returned error: %v", err)
+ }
+
+ if size != 5 {
+ t.Fatalf("expected size 5, got %d", size)
+ }
+ if attempts != 7 {
+ t.Fatalf("expected attempts 7, got %d", attempts)
+ }
+ expected := []int{0, 1, 2, 3}
+ if !reflect.DeepEqual(swaps, expected) {
+ t.Fatalf("expected swaps %v, got %v", expected, swaps)
+ }
+}
+
+func TestParseCaptchaSettingsResponseSupportsJSONStringMap(t *testing.T) {
+ t.Parallel()
+
+ resp := map[string]interface{}{
+ "response": map[string]interface{}{
+ "show_captcha_type": "checkbox",
+ "captcha_settings": `{"slider":"slider-token","sound":"sound-token"}`,
+ },
+ }
+
+ settings, err := parseCaptchaSettingsResponse(resp)
+ if err != nil {
+ t.Fatalf("parseCaptchaSettingsResponse returned error: %v", err)
+ }
+
+ if settings.ShowCaptchaType != "checkbox" {
+ t.Fatalf("expected show_captcha_type checkbox, got %q", settings.ShowCaptchaType)
+ }
+ if settings.SettingsByType["slider"] != "slider-token" {
+ t.Fatalf("expected slider settings token, got %q", settings.SettingsByType["slider"])
+ }
+ if settings.SettingsByType["sound"] != "sound-token" {
+ t.Fatalf("expected sound settings token, got %q", settings.SettingsByType["sound"])
+ }
+}
+
+func TestParseCaptchaBootstrapHTML(t *testing.T) {
+ t.Parallel()
+
+ html := `
+
+`
+
+ bootstrap, err := parseCaptchaBootstrapHTML(html)
+ if err != nil {
+ t.Fatalf("parseCaptchaBootstrapHTML returned error: %v", err)
+ }
+
+ if bootstrap.PowInput != "abc123" {
+ t.Fatalf("expected pow input abc123, got %q", bootstrap.PowInput)
+ }
+ if bootstrap.Difficulty != 3 {
+ t.Fatalf("expected difficulty 3, got %d", bootstrap.Difficulty)
+ }
+ if bootstrap.Settings == nil {
+ t.Fatal("expected bootstrap settings")
+ }
+ if bootstrap.Settings.ShowCaptchaType != "checkbox" {
+ t.Fatalf("expected show_captcha_type checkbox, got %q", bootstrap.Settings.ShowCaptchaType)
+ }
+ if bootstrap.Settings.SettingsByType["slider"] != "slider-token" {
+ t.Fatalf("expected slider token, got %q", bootstrap.Settings.SettingsByType["slider"])
+ }
+}
+
+func TestRenderSliderCandidateMatchesSwapLayout(t *testing.T) {
+ t.Parallel()
+
+ src := image.NewRGBA(image.Rect(0, 0, 20, 20))
+ fillRect(src, image.Rect(0, 0, 10, 10), color.RGBA{R: 255, A: 255})
+ fillRect(src, image.Rect(10, 0, 20, 10), color.RGBA{G: 255, A: 255})
+ fillRect(src, image.Rect(0, 10, 10, 20), color.RGBA{B: 255, A: 255})
+ fillRect(src, image.Rect(10, 10, 20, 20), color.RGBA{R: 255, G: 255, A: 255})
+
+ mapping, err := buildSliderTileMapping(2, []int{0, 1})
+ if err != nil {
+ t.Fatalf("buildSliderTileMapping returned error: %v", err)
+ }
+
+ rendered, err := renderSliderCandidate(src, 2, mapping)
+ if err != nil {
+ t.Fatalf("renderSliderCandidate returned error: %v", err)
+ }
+
+ assertPixelEquals(t, rendered.At(2, 2), color.RGBA{G: 255, A: 255})
+ assertPixelEquals(t, rendered.At(12, 2), color.RGBA{R: 255, A: 255})
+ assertPixelEquals(t, rendered.At(2, 12), color.RGBA{B: 255, A: 255})
+ assertPixelEquals(t, rendered.At(12, 12), color.RGBA{R: 255, G: 255, A: 255})
+}
+
+func TestRankSliderCandidatesPrefersMostCoherentImage(t *testing.T) {
+ t.Parallel()
+
+ src := image.NewRGBA(image.Rect(0, 0, 30, 30))
+ for y := 0; y < 30; y++ {
+ for x := 0; x < 30; x++ {
+ src.Set(x, y, color.RGBA{
+ R: uint8(x * 5),
+ G: uint8(y * 5),
+ B: uint8((x + y) * 3),
+ A: 255,
+ })
+ }
+ }
+
+ candidates, err := rankSliderCandidates(src, 3, []int{0, 1, 0, 1})
+ if err != nil {
+ t.Fatalf("rankSliderCandidates returned error: %v", err)
+ }
+
+ if len(candidates) != 2 {
+ t.Fatalf("expected 2 candidates, got %d", len(candidates))
+ }
+ if candidates[0].Index != 2 {
+ t.Fatalf("expected solved candidate to rank first, got candidate %d", candidates[0].Index)
+ }
+}
+
+func TestEncodeSliderAnswer(t *testing.T) {
+ t.Parallel()
+
+ encoded, err := encodeSliderAnswer([]int{9, 10, 2})
+ if err != nil {
+ t.Fatalf("encodeSliderAnswer returned error: %v", err)
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ t.Fatalf("failed to decode answer: %v", err)
+ }
+
+ var payload struct {
+ Value []int `json:"value"`
+ }
+ if err := json.Unmarshal(decoded, &payload); err != nil {
+ t.Fatalf("failed to unmarshal answer payload: %v", err)
+ }
+
+ expected := []int{9, 10, 2}
+ if !reflect.DeepEqual(payload.Value, expected) {
+ t.Fatalf("expected payload %v, got %v", expected, payload.Value)
+ }
+}
+
+func TestTrySliderCaptchaCandidates(t *testing.T) {
+ t.Parallel()
+
+ candidates := []sliderCandidate{
+ {Index: 1, ActiveSteps: []int{0, 1}, Score: 10},
+ {Index: 2, ActiveSteps: []int{0, 1, 0, 1}, Score: 20},
+ }
+
+ t.Run("success on first candidate", func(t *testing.T) {
+ token, err := trySliderCaptchaCandidates(candidates, 2, func(candidate sliderCandidate) (*captchaCheckResult, error) {
+ if candidate.Index != 1 {
+ t.Fatalf("unexpected candidate index %d", candidate.Index)
+ }
+ return &captchaCheckResult{Status: "OK", SuccessToken: "token-1"}, nil
+ })
+ if err != nil {
+ t.Fatalf("trySliderCaptchaCandidates returned error: %v", err)
+ }
+ if token != "token-1" {
+ t.Fatalf("expected token-1, got %s", token)
+ }
+ })
+
+ t.Run("success on later candidate", func(t *testing.T) {
+ calls := 0
+ token, err := trySliderCaptchaCandidates(candidates, 2, func(candidate sliderCandidate) (*captchaCheckResult, error) {
+ calls++
+ if candidate.Index == 1 {
+ return &captchaCheckResult{Status: "BOT"}, nil
+ }
+ return &captchaCheckResult{Status: "OK", SuccessToken: "token-2"}, nil
+ })
+ if err != nil {
+ t.Fatalf("trySliderCaptchaCandidates returned error: %v", err)
+ }
+ if calls != 2 {
+ t.Fatalf("expected 2 calls, got %d", calls)
+ }
+ if token != "token-2" {
+ t.Fatalf("expected token-2, got %s", token)
+ }
+ })
+
+ t.Run("exhausted candidates", func(t *testing.T) {
+ _, err := trySliderCaptchaCandidates(candidates, 1, func(candidate sliderCandidate) (*captchaCheckResult, error) {
+ return &captchaCheckResult{Status: "BOT"}, nil
+ })
+ if err == nil {
+ t.Fatal("expected error after exhausting ranked candidates")
+ }
+ })
+}
+
+func TestParseSliderCaptchaContentResponse(t *testing.T) {
+ t.Parallel()
+
+ src := image.NewRGBA(image.Rect(0, 0, 20, 20))
+ fillRect(src, src.Bounds(), color.RGBA{R: 12, G: 34, B: 56, A: 255})
+
+ var buf bytes.Buffer
+ if err := jpeg.Encode(&buf, src, nil); err != nil {
+ t.Fatalf("failed to encode jpeg fixture: %v", err)
+ }
+
+ resp := map[string]interface{}{
+ "response": map[string]interface{}{
+ "status": "OK",
+ "extension": "jpeg",
+ "image": base64.StdEncoding.EncodeToString(buf.Bytes()),
+ "steps": []interface{}{float64(5), float64(0), float64(1), float64(2), float64(3), float64(6)},
+ },
+ }
+
+ content, err := parseSliderCaptchaContentResponse(resp)
+ if err != nil {
+ t.Fatalf("parseSliderCaptchaContentResponse returned error: %v", err)
+ }
+
+ if content.Size != 5 {
+ t.Fatalf("expected size 5, got %d", content.Size)
+ }
+ if content.Attempts != 6 {
+ t.Fatalf("expected attempts 6, got %d", content.Attempts)
+ }
+ if len(content.Steps) != 4 {
+ t.Fatalf("expected 4 swap entries, got %d", len(content.Steps))
+ }
+}
+
+func fillRect(img *image.RGBA, rect image.Rectangle, c color.Color) {
+ for y := rect.Min.Y; y < rect.Max.Y; y++ {
+ for x := rect.Min.X; x < rect.Max.X; x++ {
+ img.Set(x, y, c)
+ }
+ }
+}
+
+func assertPixelEquals(t *testing.T, actual color.Color, expected color.RGBA) {
+ t.Helper()
+
+ ar, ag, ab, aa := actual.RGBA()
+ if ar != uint32(expected.R)*0x101 || ag != uint32(expected.G)*0x101 || ab != uint32(expected.B)*0x101 || aa != uint32(expected.A)*0x101 {
+ t.Fatalf("expected pixel %+v, got rgba(%d,%d,%d,%d)", expected, ar, ag, ab, aa)
+ }
+}
diff --git a/go.mod b/go.mod
index eb4b4e3..6a797f8 100644
--- a/go.mod
+++ b/go.mod
@@ -9,16 +9,16 @@ require (
github.com/cbeuw/connutil v1.0.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
- github.com/pion/dtls/v3 v3.0.11
+ github.com/pion/dtls/v3 v3.1.2
github.com/pion/logging v0.2.4
github.com/pion/transport/v4 v4.0.1
- github.com/pion/turn/v5 v5.0.2
+ github.com/pion/turn/v5 v5.0.3
github.com/xtaci/kcp-go/v5 v5.6.18
github.com/xtaci/smux v1.5.34
)
require (
- github.com/andybalholm/brotli v1.2.0 // indirect
+ github.com/andybalholm/brotli v1.2.1 // 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
@@ -26,12 +26,12 @@ require (
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
- github.com/klauspost/compress v1.18.2 // indirect
+ github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/klauspost/reedsolomon v1.12.4 // indirect
- github.com/miekg/dns v1.1.69 // indirect
+ github.com/miekg/dns v1.1.72 // indirect
github.com/pion/randutil v0.1.0 // indirect
- github.com/pion/stun/v3 v3.1.1 // indirect
+ github.com/pion/stun/v3 v3.1.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
@@ -39,12 +39,11 @@ require (
github.com/templexxx/xorsimd v0.4.3 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
- golang.org/x/crypto v0.47.0 // indirect
- golang.org/x/mod v0.31.0 // indirect
- golang.org/x/net v0.48.0 // indirect
- golang.org/x/sync v0.19.0 // indirect
- golang.org/x/sys v0.40.0 // indirect
- golang.org/x/text v0.33.0 // indirect
- golang.org/x/tools v0.40.0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
+ golang.org/x/crypto v0.49.0 // indirect
+ golang.org/x/mod v0.34.0 // indirect
+ golang.org/x/net v0.52.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+ golang.org/x/tools v0.43.0 // indirect
)
diff --git a/go.sum b/go.sum
index f7b2d40..aef2d97 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,7 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
-github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
+github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
@@ -51,26 +51,26 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
-github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
-github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
+github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/klauspost/reedsolomon v1.12.4 h1:5aDr3ZGoJbgu/8+j45KtUJxzYm8k08JGtB9Wx1VQ4OA=
github.com/klauspost/reedsolomon v1.12.4/go.mod h1:d3CzOMOt0JXGIFZm1StgkyF14EYr3xneR2rNWo7NcMU=
-github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
-github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
-github.com/pion/dtls/v3 v3.0.11 h1:zqn8YhoAU7d9whsWLhNiQlbB8QdpJj8XQVSc5ImUons=
-github.com/pion/dtls/v3 v3.0.11/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
+github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
+github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
+github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
+github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
-github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
-github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
+github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY=
+github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
-github.com/pion/turn/v5 v5.0.2 h1:GHlDk+fiegz+yibb3ch+tK+iPFokoVWiM+aVJakySqA=
-github.com/pion/turn/v5 v5.0.2/go.mod h1:cumcsSEF2ytAtDhDwkYgYhv1uJ3AOP7a4pFt0NL/snY=
+github.com/pion/turn/v5 v5.0.3 h1:I+Nw0fQgdPWF1SXDj0egWDhCkcff7gWiigdQpOK52Ak=
+github.com/pion/turn/v5 v5.0.3/go.mod h1:fs4SogUh/aRGQzonc4Lx3Jp4EU3j3t0PfNDEd9KcD/w=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -103,14 +103,14 @@ go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
-golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
-golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -118,58 +118,58 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
-golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
-golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
-golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
-golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
-golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
-google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
+google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
+google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
-google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/server/main.go b/server/main.go
index 729262b..cdf91d4 100644
--- a/server/main.go
+++ b/server/main.go
@@ -2,7 +2,6 @@ package main
import (
"context"
- "crypto/tls"
"flag"
"fmt"
"io"
@@ -51,16 +50,19 @@ func main() {
panic(genErr)
}
- // Prepare the configuration of the DTLS connection
- config := &dtls.Config{
- Certificates: []tls.Certificate{certificate},
- ExtendedMasterSecret: dtls.RequireExtendedMasterSecret,
- CipherSuites: []dtls.CipherSuiteID{dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256},
- ConnectionIDGenerator: dtls.RandomCIDGenerator(8),
- }
+ //
+ // Everything below is the pion-DTLS API! Thanks for using it ❤️.
+ //
- // Listen for DTLS connections
- listener, err := dtls.Listen("udp", addr, config)
+ // Connect to a DTLS server
+ listener, err := dtls.ListenWithOptions(
+ "udp",
+ addr,
+ dtls.WithCertificates(certificate),
+ dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
+ dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256),
+ dtls.WithConnectionIDGenerator(dtls.RandomCIDGenerator(8)),
+ )
if err != nil {
panic(err)
}