From 405bdcb9a3be5ceb5522df778712318a190d2ecc Mon Sep 17 00:00:00 2001 From: Moroka8 Date: Sat, 18 Apr 2026 15:44:33 +0700 Subject: [PATCH] fix: improve manual captcha reliability on iOS/iSH Implement navigator.sendBeacon for more reliable token delivery. Add server-side success_token extraction in generic_proxy. Make HTTP server shutdown non-fatal on iSH (ignores context deadline exceeded during cleanup) Increase manual captcha timeout to 3 minutes using an independent context Increase global DNS resolver timeout from 5s to 10s to improve stability --- client/main.go | 24 ++++++++++-- client/manual_captcha.go | 85 +++++++++++++++++++++++++++++++++++----- 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/client/main.go b/client/main.go index 9219f52..41d90ae 100644 --- a/client/main.go +++ b/client/main.go @@ -962,7 +962,9 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede } case captchaSolveModeManual: log.Printf("[STREAM %d] [Captcha] Triggering manual captcha fallback...", streamID) - manualCtx, manualCancel := context.WithTimeout(ctx, 60*time.Second) + // Use context.Background() so that a short deadline on the parent ctx + // (e.g. the overall auth timeout) doesn't cut the user's solve time short. + manualCtx, manualCancel := context.WithTimeout(context.Background(), 3*time.Minute) type manualRes struct { token string @@ -989,8 +991,24 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede successToken = res.token captchaKey = res.key solveErr = res.err + // Token may be present even when err != nil (e.g. srv.Shutdown + // timed out on iSH after the token was already received). + // Treat a non-empty token as success regardless of the error. + if successToken != "" || captchaKey != "" { + if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] Token received (ignoring cleanup error: %v)", streamID, solveErr) + solveErr = nil + } + log.Printf("[STREAM %d] [Captcha] Successfully got token from browser", streamID) + } else if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] solveCaptchaViaProxy returned error: %v", streamID, solveErr) + } case <-manualCtx.Done(): - solveErr = fmt.Errorf("manual captcha timed out after 60s") + if manualCtx.Err() == context.DeadlineExceeded { + solveErr = fmt.Errorf("manual captcha timed out after 3m") + } else { + solveErr = fmt.Errorf("manual captcha interrupted: %w", manualCtx.Err()) + } } manualCancel() } @@ -1791,7 +1809,7 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne func setupGlobalResolver() { dialer := &net.Dialer{ - Timeout: 5 * time.Second, + Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, } dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"} diff --git a/client/manual_captcha.go b/client/manual_captcha.go index d41e572..cddc1fc 100644 --- a/client/manual_captcha.go +++ b/client/manual_captcha.go @@ -265,14 +265,52 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string { function handleSuccessToken(token) { if (!token) return; + console.log('Captcha solved, sending token to proxy...'); + var body = 'token=' + encodeURIComponent(token); + + // sendBeacon is the most reliable on mobile Safari: + // it's fire-and-forget and works even if the page navigates away. + if (navigator && navigator.sendBeacon) { + var blob = new Blob([body], {type: 'application/x-www-form-urlencoded'}); + var sent = navigator.sendBeacon('/local-captcha-result', blob); + if (sent) { + console.log('Token sent via sendBeacon'); + showDone(); + return; + } + } + + // Fallback: fetch fetch('/local-captcha-result', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - body: 'token=' + encodeURIComponent(token) - }).then(function() { - document.body.innerHTML = '

Done! You can close the page.

'; - setTimeout(function() { window.close(); }, 300); - }).catch(function() {}); + body: body + }).then(function(r) { + console.log('Proxy acknowledged token'); + showDone(); + }).catch(function(e) { + console.error('Fetch failed, trying form submit...', e); + // Last resort: form POST (navigates the page) + var form = document.createElement('form'); + form.method = 'POST'; + form.action = '/local-captcha-result'; + var input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'token'; + input.value = token; + form.appendChild(input); + document.body.appendChild(form); + form.submit(); + }); + } + + function showDone() { + document.body.innerHTML = '
' + + '

✔ Done!

' + + '

Captcha solved successfully. You can close this tab now.

' + + '
'; + // On iOS, window.close() often doesn't work, so we just let the user know they are done. + setTimeout(function() { window.close(); }, 1000); } var origOpen = XMLHttpRequest.prototype.open; @@ -462,10 +500,14 @@ func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-ch key := <-keyCh - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if err := srv.Shutdown(ctx); err != nil { - return "", err + // Best-effort shutdown: the token is already received, so even if + // Shutdown times out (e.g. because ishConn.SetDeadline is a no-op + // on iSH and active connections can't be force-closed), we still + // return the token successfully. + shutCtx, shutCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer shutCancel() + if err := srv.Shutdown(shutCtx); err != nil { + log.Printf("%s: shutdown warning (token already received): %v", logPrefix, err) } return key, nil @@ -614,8 +656,15 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, mux := http.NewServeMux() mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) { - notifyKey(keyCh, r.FormValue("token")) // r.FormValue automatically parses the form + token := r.FormValue("token") + if token != "" { + log.Printf("[Captcha] Received success token from browser (%d bytes)", len(token)) + notifyKey(keyCh, token) + } else { + log.Printf("[Captcha] Received empty token from browser") + } w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "text/plain") _, _ = fmt.Fprint(w, "ok") }) @@ -651,6 +700,22 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, } // Allow the browser to use the resource cross-origin res.Header.Set("Access-Control-Allow-Origin", "*") + + // captchaNotRobot.check goes to api.vk.ru (different host from the main + // proxy upstream vk.com), so it is routed through /generic_proxy. + // Extract the success_token here so the server path works on iOS + // even if the browser-side JS callback never fires. + if strings.Contains(targetAuthURL, "captchaNotRobot.check") { + bodyBytes, readErr := io.ReadAll(res.Body) + if readErr == nil { + _ = res.Body.Close() + res.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + res.ContentLength = int64(len(bodyBytes)) + res.Header.Set("Content-Length", fmt.Sprint(len(bodyBytes))) + notifyKey(keyCh, extractSuccessToken(bodyBytes)) + } + } + return nil }, }