diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c913016..abf2d15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,12 +139,12 @@ jobs: GOOS=$GOOS GOARCH=$GOARCH \ go build -ldflags "-s -w -checklinkname=0" -trimpath \ -o "dist/client-${GOOS}-${GOARCH}${ext}" \ - ./client - + ./cmd/vk-turn-client + GOOS=$GOOS GOARCH=$GOARCH \ go build -ldflags "-s -w -checklinkname=0" -trimpath \ -o "dist/server-${GOOS}-${GOARCH}${ext}" \ - ./server + ./cmd/vk-turn-server - name: Upload artifact uses: actions/upload-artifact@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7f9ab6..1d95616 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,12 +134,12 @@ jobs: GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM GOMIPS=$GOMIPS \ go build -ldflags "-s -w -checklinkname=0" -trimpath \ -o "dist/client-${GOOS}-${GOARCH}${EXT}" \ - ./client + ./cmd/vk-turn-client GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM GOMIPS=$GOMIPS \ go build -ldflags "-s -w -checklinkname=0" -trimpath \ -o "dist/server-${GOOS}-${GOARCH}${EXT}" \ - ./server + ./cmd/vk-turn-server - name: Upload artifact uses: actions/upload-artifact@v6 diff --git a/Dockerfile b/Dockerfile index 9ba25b1..a257809 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM golang:1.25-alpine AS builder WORKDIR /build COPY . . -RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o vk-turn-proxy ./server +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o vk-turn-proxy ./cmd/vk-turn-server FROM alpine:3.23 diff --git a/README.md b/README.md index 6f1d6d9..6d6b0ea 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ docker build -t vk-turn-proxy . С bonding: ```bash -./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -vless -vless-bond +./server -listen 0.0.0.0:56000 -connect 127.0.0.1:443 -vless ./client -listen 127.0.0.1:9000 -peer :56000 -vk-link "" -vless -vless-bond -n 4 ``` @@ -343,18 +343,45 @@ docker build -t vk-turn-proxy . Нужен Go 1.25.x. ```bash -go build -o client ./client -go build -o server ./server +go build -o client ./cmd/vk-turn-client +go build -o server ./cmd/vk-turn-server go test ./... ``` Кросс-сборка примера для Linux amd64: ```bash -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o server-linux-amd64 ./server -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o client-linux-amd64 ./client +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o server-linux-amd64 ./cmd/vk-turn-server +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o client-linux-amd64 ./cmd/vk-turn-client ``` +Android arm64 клиент как `.so` для упаковки в приложение (PowerShell): + +```powershell +$env:GOOS="android"; $env:GOARCH="arm64"; $env:CGO_ENABLED="0"; go build -ldflags="-s -w -checklinkname=0" -trimpath -o libvkturn.so .\cmd\vk-turn-client +``` + +То же из bash: + +```bash +GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w -checklinkname=0" -trimpath -o libvkturn.so ./cmd/vk-turn-client +``` + +Linux arm64 клиент как `.so` (для упаковки в Android-приложение в обход NDK; ABI совпадает): + +```bash +GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w -checklinkname=0" -trimpath -o libvkturn.so ./cmd/vk-turn-client +``` + +Linux amd64 клиент/сервер как `.so`: + +```bash +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -checklinkname=0" -trimpath -o libvkturn-client.so ./cmd/vk-turn-client +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -checklinkname=0" -trimpath -o libvkturn-server.so ./cmd/vk-turn-server +``` + +Все `.so` выше — обычные ELF-бинари под `.so`-именем (`CGO_ENABLED=0`, не настоящий shared library). Если нужен настоящий `c-shared` — `CGO_ENABLED=1` + NDK toolchain (см. `.github/workflows/release.yml`). + ## Решение Проблем - Сначала запускайте VK TURN Proxy client, потом включайте WireGuard. diff --git a/client/main.go b/client/main.go deleted file mode 100644 index 8d6b341..0000000 --- a/client/main.go +++ /dev/null @@ -1,272 +0,0 @@ -// 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" - - "github.com/cacggghp/vk-turn-proxy/client/internal/captcha" - "github.com/cacggghp/vk-turn-proxy/client/internal/dnsdial" - "github.com/cacggghp/vk-turn-proxy/client/internal/vkauth" - bondclient "github.com/cacggghp/vk-turn-proxy/internal/bond/client" - "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 (see manual_captcha.go). -func manualCaptchaSolver(_ context.Context, e *captcha.Error, d net.Dialer) (string, string, error) { - if e.RedirectURI != "" { - t, err := solveCaptchaViaProxy(e.RedirectURI, d) - return t, "", err - } - if e.CaptchaImg != "" { - k, err := solveCaptchaViaHTTP(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 - - 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) - - if cfg.Direct { - log.Panicf("Direct mode not supported with dispatcher") - } - - 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/client/manual_captcha.go b/client/manual_captcha.go deleted file mode 100644 index 6bf1bd5..0000000 --- a/client/manual_captcha.go +++ /dev/null @@ -1,875 +0,0 @@ -package main - -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/client/internal/browserprofile" - "github.com/cacggghp/vk-turn-proxy/internal/ish" -) - -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