package main import ( "bytes" "compress/gzip" "context" "encoding/json" "errors" "fmt" "io" "log" "net" "net/http" "net/http/httputil" neturl "net/url" "os/exec" "runtime" "strings" "time" "github.com/bschaatsbergen/dnsdialer" ) const captchaListenPort = "8765" type browserCommand struct { name string args []string } func localCaptchaOrigin() string { return "http://localhost:" + captchaListenPort } func localCaptchaListenAddrs() []string { return []string{ "127.0.0.1:" + captchaListenPort, "[::1]:" + captchaListenPort, } } func localCaptchaHosts() []string { return []string{ "localhost:" + captchaListenPort, "127.0.0.1:" + captchaListenPort, "[::1]:" + captchaListenPort, } } func isLocalCaptchaHost(host string) bool { for _, localHost := range localCaptchaHosts() { if strings.EqualFold(host, localHost) { return true } } return false } func localCaptchaURLForTarget(targetURL *neturl.URL) string { localURL := &neturl.URL{ Scheme: "http", Host: "localhost:" + captchaListenPort, Path: targetURL.Path, RawPath: targetURL.RawPath, RawQuery: targetURL.RawQuery, } if localURL.Path == "" { localURL.Path = "/" } return localURL.String() } func targetOrigin(targetURL *neturl.URL) string { return targetURL.Scheme + "://" + targetURL.Host } func rewriteProxyHeaderURL(raw string, targetURL *neturl.URL) string { if raw == "" { return raw } parsed, err := neturl.Parse(raw) if err != nil { return raw } if parsed.Scheme != "http" || !isLocalCaptchaHost(parsed.Host) { return raw } parsed.Scheme = targetURL.Scheme parsed.Host = targetURL.Host return parsed.String() } func rewriteProxyRequest(req *http.Request, targetURL *neturl.URL) { req.URL.Scheme = targetURL.Scheme req.URL.Host = targetURL.Host if req.URL.Path == "" { req.URL.Path = targetURL.Path } req.Host = targetURL.Host req.Header.Del("Accept-Encoding") for _, headerName := range []string{"Origin", "Referer"} { if rewritten := rewriteProxyHeaderURL(req.Header.Get(headerName), targetURL); rewritten != "" { req.Header.Set(headerName, rewritten) } else { req.Header.Del(headerName) } } } func extractSuccessToken(body []byte) string { var payload struct { Response struct { SuccessToken string `json:"success_token"` } `json:"response"` } if err := json.Unmarshal(body, &payload); err != nil { return "" } return payload.Response.SuccessToken } func rewriteProxyCookies(header http.Header) { cookies := (&http.Response{Header: header}).Cookies() if len(cookies) == 0 { return } header.Del("Set-Cookie") for _, cookie := range cookies { cookie.Domain = "" cookie.Secure = false cookie.Partitioned = false if cookie.SameSite == http.SameSiteNoneMode || cookie.SameSite == http.SameSiteStrictMode { cookie.SameSite = http.SameSiteLaxMode } header.Add("Set-Cookie", cookie.String()) } } func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string { localOrigin := localCaptchaOrigin() upstreamOrigin := targetOrigin(targetURL) html = strings.ReplaceAll(html, upstreamOrigin, localOrigin) script := fmt.Sprintf(` `, localOrigin, upstreamOrigin) switch { case strings.Contains(html, ""): return strings.Replace(html, "", script+"", 1) case strings.Contains(html, ""): return strings.Replace(html, "", script+"", 1) default: return html + script } } func newCaptchaProxyTransport(dialer *dnsdialer.Dialer) *http.Transport { transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ForceAttemptHTTP2: true, } if dialer != nil { transport.DialContext = dialer.DialContext } return transport } func startCaptchaServer(srv *http.Server, logPrefix string) error { var listenErrs []string var listening bool for _, addr := range localCaptchaListenAddrs() { listener, err := net.Listen("tcp", addr) if err != nil { listenErrs = append(listenErrs, fmt.Sprintf("%s (%v)", addr, err)) continue } listening = true go func(listener net.Listener) { if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Printf("%s: %s", logPrefix, err) } }(listener) } if listening { return nil } return fmt.Errorf("captcha listeners failed: %s", strings.Join(listenErrs, "; ")) } // runCaptchaServerAndWait triggers the browser, and waiting gracefully for the solution token. func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-chan string, logPrefix string) (string, error) { srv := &http.Server{Handler: handler} if err := startCaptchaServer(srv, logPrefix); err != nil { return "", err } fmt.Println("\n==============================================") fmt.Println("ACTION REQUIRED: MANUAL CAPTCHA SOLVING NEEDED") fmt.Println("Open this URL in your browser: " + captchaURL) fmt.Println("==============================================") fmt.Println() openBrowser(captchaURL) key := <-keyCh ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { return "", err } return key, nil } // notifyKey pushes the key string to the given channel without blocking func notifyKey(keyCh chan<- string, key string) { if key != "" { select { case keyCh <- key: default: } } } func solveCaptchaViaHTTP(captchaImg string) (string, error) { keyCh := make(chan string, 1) mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = fmt.Fprintf(w, `

Solve the Captcha

captcha


`, captchaImg) }) mux.HandleFunc("/solve", func(w http.ResponseWriter, r *http.Request) { notifyKey(keyCh, r.URL.Query().Get("key")) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = fmt.Fprint(w, `

Done!

`) }) return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error") } func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, error) { keyCh := make(chan string, 1) targetURL, err := neturl.Parse(redirectURI) if err != nil { return "", fmt.Errorf("invalid redirect URI: %v", err) } transport := newCaptchaProxyTransport(dialer) proxy := &httputil.ReverseProxy{ Transport: transport, Rewrite: func(req *httputil.ProxyRequest) { rewriteProxyRequest(req.Out, targetURL) }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { log.Printf("captcha proxy error for %s: %v", r.URL.String(), err) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadGateway) _, _ = fmt.Fprintf(w, `

Captcha proxy error

%v

`, err) }, ModifyResponse: func(res *http.Response) error { rewriteProxyCookies(res.Header) 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)) } } } contentType := res.Header.Get("Content-Type") shouldInspectBody := strings.Contains(contentType, "text/html") || strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") if !shouldInspectBody { return nil } reader := res.Body if res.Header.Get("Content-Encoding") == "gzip" { gzReader, err := gzip.NewReader(res.Body) if err == nil { reader = gzReader defer func() { if err := gzReader.Close(); err != nil { log.Printf("failed to close gzip reader: %v", err) } }() } } bodyBytes, err := io.ReadAll(reader) if err != nil { return err } if err := res.Body.Close(); err != nil { return err } if strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") { notifyKey(keyCh, extractSuccessToken(bodyBytes)) } if strings.Contains(contentType, "text/html") { for _, headerName := range []string{ "Content-Security-Policy", "Content-Security-Policy-Report-Only", "X-Content-Security-Policy", "X-WebKit-CSP", "Cross-Origin-Opener-Policy", "Cross-Origin-Embedder-Policy", "Cross-Origin-Resource-Policy", "X-Frame-Options", } { res.Header.Del(headerName) } bodyBytes = []byte(rewriteCaptchaHTML(string(bodyBytes), targetURL)) res.Header.Del("Content-Encoding") } res.Body = io.NopCloser(bytes.NewReader(bodyBytes)) res.ContentLength = int64(len(bodyBytes)) res.Header.Set("Content-Length", fmt.Sprint(len(bodyBytes))) return nil }, } 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 w.Header().Set("Access-Control-Allow-Origin", "*") _, _ = fmt.Fprint(w, "ok") }) mux.HandleFunc("/generic_proxy", func(w http.ResponseWriter, r *http.Request) { targetAuthUrl := r.URL.Query().Get("proxy_url") targetParsed, err := neturl.Parse(targetAuthUrl) if err != nil || targetParsed.Host == "" { http.Error(w, "Bad URL", http.StatusBadRequest) return } genericReverse := &httputil.ReverseProxy{ Transport: transport, Rewrite: func(req *httputil.ProxyRequest) { req.Out.URL.Path = targetParsed.Path req.Out.URL.RawQuery = targetParsed.RawQuery rewriteProxyRequest(req.Out, targetParsed) }, } genericReverse.ServeHTTP(w, r) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" { http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect) return } proxy.ServeHTTP(w, r) }) return runCaptchaServerAndWait(mux, localCaptchaURLForTarget(targetURL), keyCh, "proxy HTTP server error") } func openBrowser(url string) { for _, cmd := range browserOpenCommands(runtime.GOOS, url) { if err := exec.Command(cmd.name, cmd.args...).Start(); err == nil { return } } } func browserOpenCommands(goos string, url string) []browserCommand { switch goos { case "windows": return []browserCommand{{name: "cmd", args: []string{"/c", "start", url}}} case "darwin": return []browserCommand{{name: "open", args: []string{url}}} case "linux": return []browserCommand{ {name: "xdg-open", args: []string{url}}, {name: "gio", args: []string{"open", url}}, } case "android": return []browserCommand{ {name: "termux-open-url", args: []string{url}}, {name: "/system/bin/am", args: []string{"start", "-a", "android.intent.action.VIEW", "-d", url}}, {name: "am", args: []string{"start", "-a", "android.intent.action.VIEW", "-d", url}}, {name: "xdg-open", args: []string{url}}, } case "ios": return []browserCommand{ {name: "open", args: []string{url}}, {name: "uiopen", args: []string{url}}, } } return nil }