diff --git a/cmd/vk-turn-client/main.go b/cmd/vk-turn-client/main.go new file mode 100644 index 0000000..b727f4f --- /dev/null +++ b/cmd/vk-turn-client/main.go @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: 2023 The Pion community +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "fmt" + "log" + "net" + "os" + "os/signal" + "sync" + "sync/atomic" + "syscall" + "time" + + bondclient "github.com/cacggghp/vk-turn-proxy/internal/bond/client" + "github.com/cacggghp/vk-turn-proxy/internal/client/captcha" + manualcaptcha "github.com/cacggghp/vk-turn-proxy/internal/client/captcha/manual" + "github.com/cacggghp/vk-turn-proxy/internal/client/dnsdial" + "github.com/cacggghp/vk-turn-proxy/internal/client/vkauth" + "github.com/cacggghp/vk-turn-proxy/internal/config" + "github.com/cacggghp/vk-turn-proxy/internal/dtlsdial" + udpproxy "github.com/cacggghp/vk-turn-proxy/internal/proxy/udp" + "github.com/cacggghp/vk-turn-proxy/internal/proxy/vless" + "github.com/cacggghp/vk-turn-proxy/internal/wrap" +) + +type getCredsFunc func(ctx context.Context, link string, streamID int) (string, string, string, error) + +// Global state trackers +var ( + activeLocalPeer atomic.Value + connectedStreams atomic.Int32 + udpDtlsDialer = &dtlsdial.Dialer{ + HandshakeTimeout: 20 * time.Second, + HandshakeSem: make(chan struct{}, 3), + } + vlessDtlsDialer = &dtlsdial.Dialer{HandshakeTimeout: 30 * time.Second} + isDebug bool +) + +var appDialer net.Dialer + +// vkAuth is the lazily-initialized VK auth client. Set once in main(). +var vkAuth *vkauth.Client + +func debugf(format string, v ...any) { + if isDebug { + log.Printf(format, v...) + } +} + +// manualCaptchaSolver bridges the vkauth.ManualSolveFunc contract to the +// local captcha bouncer (internal/client/captcha/manual). +func manualCaptchaSolver(_ context.Context, e *captcha.Error, d net.Dialer) (string, string, error) { + if e.RedirectURI != "" { + t, err := manualcaptcha.SolveViaProxy(e.RedirectURI, d) + return t, "", err + } + if e.CaptchaImg != "" { + k, err := manualcaptcha.SolveViaHTTP(e.CaptchaImg) + return "", k, err + } + return "", "", fmt.Errorf("no redirect_uri or captcha_img") +} + +type turnParams struct { + host string + port string + link string + udp bool + wrapKey []byte + getCreds getCredsFunc +} + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-signalChan + log.Printf("Terminating...\n") + cancel() + select { + case <-signalChan: + case <-time.After(5 * time.Second): + } + log.Fatalf("Exit...\n") + }() + + cfg, err := config.ParseClient(os.Args[1:], os.Stderr) + if err != nil { + log.Panicf("%v", err) + } + + if cfg.DNSServers != nil { + dnsdial.SetUDPDNSServers(cfg.DNSServers) + log.Printf("[DNS] using custom UDP servers: %v", cfg.DNSServers) + } + appDialer = dnsdial.AppDialer(cfg.DNSMode) + dnsdial.InstallGlobalResolver(cfg.DNSMode) + if cfg.GenWrapKey { + key, gerr := wrap.GenKeyHex() + if gerr != nil { + log.Panicf("%v", gerr) + } + fmt.Println(key) + return + } + peer, err := net.ResolveUDPAddr("udp", cfg.Peer) + if err != nil { + panic(err) + } + if cfg.WrapMode { + log.Printf("WRAP mode enabled: peer server must use matching -wrap-key") + } + + isDebug = cfg.Debug + manualcaptcha.Debug = cfg.Debug + + vkAuth = vkauth.New(vkauth.Config{ + Dialer: appDialer, + ManualOnly: cfg.ManualCaptcha, + StreamsPerCache: cfg.StreamsPerCred, + StreamsAlive: func() int32 { return connectedStreams.Load() }, + ManualSolver: manualCaptchaSolver, + Debugf: debugf, + }) + + getCreds := getCredsFunc(vkAuth.GetCredentials) + + params := &turnParams{ + host: cfg.Host, + port: cfg.Port, + link: cfg.VKLink, + udp: cfg.UDP, + wrapKey: cfg.WrapKey, + getCreds: getCreds, + } + + if cfg.VLESSMode { + bondH := &bondclient.Handler{Deps: bondclient.Deps{Debug: isDebug, Debugf: debugf}} + vlessDeps := &vless.Deps{ + DTLSDialer: vlessDtlsDialer, + Debug: isDebug, + Debugf: debugf, + BondHandler: bondH.Handle, + } + vlessParams := &vless.Params{ + Host: params.host, + Port: params.port, + Link: params.link, + UDP: params.udp, + WrapKey: params.wrapKey, + GetCreds: vless.GetCredsFunc(params.getCreds), + } + vless.Run(ctx, vlessDeps, vlessParams, peer, cfg.Listen, cfg.N, cfg.VLESSBond) + return + } + + listenConn, err := net.ListenPacket("udp", cfg.Listen) + if err != nil { + log.Panicf("Failed to listen: %s", err) + } + context.AfterFunc(ctx, func() { + if closeErr := listenConn.Close(); closeErr != nil { + log.Printf("Failed to close local connection: %s", closeErr) + } + }) + + numStreams := cfg.N + if numStreams <= 0 { + numStreams = 1 + } + + // Shared Worker Pool Queue for Aggregation + inboundChan := make(chan *udpproxy.Packet, 2000) + + go func() { + // Pointer-cache for the last seen local peer addr. Avoids the + // per-packet addr.String() allocation pair on the hot WG ingest path: + // most packets come from the same UDPAddr instance, so a pointer + // equality check covers the fast path. The slow path (new instance + // from ReadFrom for the same ip:port) does one String compare and + // then refreshes the cache. + var lastAddr net.Addr + var lastAddrStr string + for { + pktIface := udpproxy.Pool.Get() + pkt, ok := pktIface.(*udpproxy.Packet) + if !ok { + log.Printf("packetPool returned unexpected type: %T", pktIface) + continue + } + nRead, addr, err := listenConn.ReadFrom(pkt.Data) + if err != nil { + return + } + + if addr != lastAddr { + s := addr.String() + if s != lastAddrStr { + activeLocalPeer.Store(addr) + lastAddrStr = s + } + lastAddr = addr + } + + pkt.N = nRead + + select { + case inboundChan <- pkt: + default: + // Drop the packet only if the global queue is completely full + udpproxy.Pool.Put(pkt) + } + } + }() + + wg1 := sync.WaitGroup{} + t := time.Tick(200 * time.Millisecond) + + udpDeps := &udpproxy.Deps{ + DTLSDialer: udpDtlsDialer, + Auth: vkAuth, + Debug: isDebug, + Debugf: debugf, + ActiveLocalPeer: &activeLocalPeer, + ConnectedStreams: &connectedStreams, + AppCancel: cancel, + } + udpParams := &udpproxy.Params{ + Host: params.host, + Port: params.port, + Link: params.link, + UDP: params.udp, + WrapKey: params.wrapKey, + GetCreds: udpproxy.GetCredsFunc(params.getCreds), + } + + okchan := make(chan struct{}) + connchan := make(chan net.PacketConn) + wg1.Go(func() { + udpproxy.DTLSLoop(ctx, udpDeps, peer, listenConn, inboundChan, connchan, okchan, 1) + }) + wg1.Go(func() { + udpproxy.TURNLoop(ctx, udpDeps, udpParams, peer, connchan, t, 1) + }) + + select { + case <-okchan: + case <-ctx.Done(): + } + + for i := 1; i < numStreams; i++ { + cchan := make(chan net.PacketConn) + streamID := i + wg1.Go(func() { + udpproxy.DTLSLoop(ctx, udpDeps, peer, listenConn, inboundChan, cchan, nil, streamID) + }) + wg1.Go(func() { + udpproxy.TURNLoop(ctx, udpDeps, udpParams, peer, cchan, t, streamID) + }) + } + + wg1.Wait() +} diff --git a/internal/client/captcha/manual/manual.go b/internal/client/captcha/manual/manual.go new file mode 100644 index 0000000..2005e59 --- /dev/null +++ b/internal/client/captcha/manual/manual.go @@ -0,0 +1,892 @@ +// Package manual implements the manual CAPTCHA solving flow used as a +// fallback when the auto solver (internal/client/captcha) fails or when +// the user passes -manual-captcha. It runs a local HTTP server on +// 127.0.0.1:8765 that proxies the VK CAPTCHA page (rewriting absolute +// URLs and gzipping), opens it in the user's browser, and waits for the +// token/key produced after the user completes the puzzle. +package manual + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/http/httputil" + neturl "net/url" + "os/exec" + "regexp" + "runtime" + "strings" + "time" + + "github.com/cacggghp/vk-turn-proxy/internal/client/browserprofile" + "github.com/cacggghp/vk-turn-proxy/internal/ish" +) + +// Debug toggles verbose request/response logging for the proxied browser +// traffic. Set from main once cfg is parsed. +var Debug bool + +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 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 + } + 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") + req.Header.Del("TE") // Disable transfer encoding compression + for _, h := range []string{ + "X-Requested-With", + "X-Android-Package", + "X-Android-Cert", + "X-Client-Data", + "X-Discord-Locale", + "X-Discord-Timezone", + "Save-Data", + "Purpose", + "Sec-Purpose", + } { + req.Header.Del(h) + } + 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()) + } +} + +// htmlURLAttrDoubleRe matches src/href/action attributes with double-quoted absolute or protocol-relative URLs. +var htmlURLAttrDoubleRe = regexp.MustCompile(`(?i)((?:src|href|action)\s*=\s*)"((?:https?:)?//[^"]+)"`) + +// htmlURLAttrSingleRe matches src/href/action attributes with single-quoted absolute or protocol-relative URLs. +var htmlURLAttrSingleRe = regexp.MustCompile(`(?i)((?:src|href|action)\s*=\s*)'((?:https?:)?//[^']+)'`) + +// htmlScriptContentRe matches )`) + +// htmlStyleContentRe matches )`) + +// rewriteHTMLAttrsServerSide rewrites absolute and protocol-relative URLs in src/href/action +// attributes of raw HTML. URLs matching the upstream origin are redirected to localhost; +// all other absolute URLs are routed through /generic_proxy so that cross-domain resources +// (st.vk.com, userapi.com, etc.) load correctly through the proxy. +func rewriteHTMLAttrsServerSide(html string, targetURL *neturl.URL) string { + localOrigin := localCaptchaOrigin() + upstreamOrigin := targetOrigin(targetURL) + + rewriteURL := func(rawURL string) string { + // Normalise protocol-relative URL to absolute using the upstream scheme + absURL := rawURL + if strings.HasPrefix(rawURL, "//") { + absURL = targetURL.Scheme + ":" + rawURL + } + if strings.HasPrefix(absURL, upstreamOrigin) { + return localOrigin + absURL[len(upstreamOrigin):] + } + // Already points to local proxy — leave as-is + if strings.HasPrefix(absURL, localOrigin) { + return rawURL + } + // Any other absolute URL → route through generic_proxy + return "/generic_proxy?proxy_url=" + neturl.QueryEscape(absURL) + } + + var placeholders = make(map[string]string) + + html = htmlScriptContentRe.ReplaceAllStringFunc(html, func(match string) string { + groups := htmlScriptContentRe.FindStringSubmatch(match) + if len(groups) < 4 { + return match + } + id := fmt.Sprintf("@@CONTENT_%d@@", len(placeholders)) + placeholders[id] = groups[2] + return groups[1] + id + groups[3] + }) + + html = htmlStyleContentRe.ReplaceAllStringFunc(html, func(match string) string { + groups := htmlStyleContentRe.FindStringSubmatch(match) + if len(groups) < 4 { + return match + } + id := fmt.Sprintf("@@CONTENT_%d@@", len(placeholders)) + placeholders[id] = groups[2] + return groups[1] + id + groups[3] + }) + + html = htmlURLAttrDoubleRe.ReplaceAllStringFunc(html, func(match string) string { + groups := htmlURLAttrDoubleRe.FindStringSubmatch(match) + if len(groups) < 3 { + return match + } + return groups[1] + `"` + rewriteURL(groups[2]) + `"` + }) + + html = htmlURLAttrSingleRe.ReplaceAllStringFunc(html, func(match string) string { + groups := htmlURLAttrSingleRe.FindStringSubmatch(match) + if len(groups) < 3 { + return match + } + return groups[1] + `'` + rewriteURL(groups[2]) + `'` + }) + + for id, content := range placeholders { + html = strings.Replace(html, id, content, 1) + } + + return html +} + +func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string { + localOrigin := localCaptchaOrigin() + upstreamOrigin := targetOrigin(targetURL) + + // Step 1: plain text replacement for the primary upstream origin + html = strings.ReplaceAll(html, upstreamOrigin, localOrigin) + + // Step 2: rewrite all other absolute URLs in HTML attributes server-side. + // This is critical: the browser begins downloading +`, localOrigin, upstreamOrigin) + + // Step 3: inject the client-side script as early as possible — at the opening tag + // so that XHR/fetch overrides are active before any inline