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) }