mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
refactor(layout): переезд в cmd/, свернуть client/internal в internal/client
- server/main.go → cmd/vk-turn-server/main.go. Несёт остаточные правки сервера
(drop параметра useBond, убрано поле startup-лога).
- client/internal/{browserprofile,captcha,dnsdial,namegen,vkauth} →
internal/client/* (единая иерархия internal/; параллельное дерево
client/internal/ убрано). Все Go import-пути обновлены.
- .github/workflows/{ci,release}.yml, Dockerfile, README.md — пути сборки
переключены на ./cmd/vk-turn-client и ./cmd/vk-turn-server.
- README: убран устаревший пример "server -vless-bond" (флаг удалён ранее);
добавлены рецепты сборки .so для Android arm64 + Linux arm64/amd64 под
ручную упаковку для мобилок.
- internal/proxy/udp/udp.go, internal/config/config.go: обновлены устаревшие
ссылки client/internal/* в doc-комментариях.
- notes/GLOSSARY.md (новый, ~20 терминов): фиксирует DTLS-как-обфускацию,
wrap=AEAD-не-XOR, четыре значения "client", три значения "stream", пять
ортогональных переключателей "mode", заметки по сборке libvkturn.so, iSH,
хрупкость init globalBondRegistry и удаления флагов.
cmd/vk-turn-client/ намеренно оставлен вне этого коммита; сворачивается вместе
с извлечением ручной captcha в следующем коммите, т.к. переименование и правки
импортов manualcaptcha.* попадают в один файл.
Wire-форматы не тронуты. Поведение не изменено кроме уже закоммиченных
удалений CLI.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 <ip-vps>:56000 -vk-link "<vk-call-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.
|
||||
|
||||
-272
@@ -1,272 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||
// 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()
|
||||
}
|
||||
@@ -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 <script> tags to extract their content.
|
||||
var htmlScriptContentRe = regexp.MustCompile(`(?is)(<script[^>]*>)(.*?)(</script>)`)
|
||||
|
||||
// htmlStyleContentRe matches <style> tags to extract their content.
|
||||
var htmlStyleContentRe = regexp.MustCompile(`(?is)(<style[^>]*>)(.*?)(</style>)`)
|
||||
|
||||
// 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 <script src> / <link href> / <img src>
|
||||
// resources immediately as it parses HTML — before any injected JS can intercept them.
|
||||
html = rewriteHTMLAttrsServerSide(html, targetURL)
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
<script>
|
||||
(function() {
|
||||
var localOrigin = %q;
|
||||
var upstreamOrigin = %q;
|
||||
|
||||
function rewriteUrl(urlStr) {
|
||||
if (!urlStr || typeof urlStr !== 'string') return urlStr;
|
||||
if (urlStr.indexOf(localOrigin) === 0) return urlStr;
|
||||
if (urlStr.indexOf(upstreamOrigin) === 0) return localOrigin + urlStr.slice(upstreamOrigin.length);
|
||||
if (urlStr.indexOf('//') === 0) {
|
||||
return '/generic_proxy?proxy_url=' + encodeURIComponent(window.location.protocol + urlStr);
|
||||
}
|
||||
if (urlStr.indexOf('http://') === 0 || urlStr.indexOf('https://') === 0) {
|
||||
return '/generic_proxy?proxy_url=' + encodeURIComponent(urlStr);
|
||||
}
|
||||
return urlStr;
|
||||
}
|
||||
|
||||
function rewriteElementAttr(el, attr) {
|
||||
if (!el || !el.getAttribute) return;
|
||||
var value = el.getAttribute(attr);
|
||||
if (!value) return;
|
||||
var rewritten = rewriteUrl(value);
|
||||
if (rewritten !== value) {
|
||||
el.setAttribute(attr, rewritten);
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteDocument(root) {
|
||||
if (!root || !root.querySelectorAll) return;
|
||||
root.querySelectorAll('[href]').forEach(function(el) { rewriteElementAttr(el, 'href'); });
|
||||
root.querySelectorAll('[src]').forEach(function(el) { rewriteElementAttr(el, 'src'); });
|
||||
root.querySelectorAll('form[action]').forEach(function(el) { rewriteElementAttr(el, 'action'); });
|
||||
}
|
||||
|
||||
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: 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 = '<div style="text-align:center;margin-top:20vh;font-family:sans-serif">' +
|
||||
'<h2 style="color:#4caf50">✔ Done!</h2>' +
|
||||
'<p>Captcha solved successfully. You can close this tab now.</p>' +
|
||||
'</div>';
|
||||
// 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;
|
||||
XMLHttpRequest.prototype.open = function() {
|
||||
if (arguments[1] && typeof arguments[1] === 'string') {
|
||||
this._origUrl = arguments[1];
|
||||
arguments[1] = rewriteUrl(arguments[1]);
|
||||
}
|
||||
return origOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
var origSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.send = function() {
|
||||
var xhr = this;
|
||||
if (this._origUrl && this._origUrl.indexOf('captchaNotRobot.check') !== -1) {
|
||||
xhr.addEventListener('load', function() {
|
||||
try {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
if (data.response && data.response.success_token) {
|
||||
handleSuccessToken(data.response.success_token);
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
return origSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
var origFetch = window.fetch;
|
||||
if (origFetch) {
|
||||
window.fetch = function() {
|
||||
var url = arguments[0];
|
||||
var isObj = (typeof url === 'object' && url && url.url);
|
||||
var urlStr = isObj ? url.url : url;
|
||||
var origUrlStr = urlStr;
|
||||
|
||||
if (typeof urlStr === 'string') {
|
||||
urlStr = rewriteUrl(urlStr);
|
||||
arguments[0] = urlStr;
|
||||
}
|
||||
|
||||
var p = origFetch.apply(this, arguments);
|
||||
if (typeof origUrlStr === 'string' && origUrlStr.indexOf('captchaNotRobot.check') !== -1) {
|
||||
p.then(function(response) {
|
||||
return response.clone().json();
|
||||
}).then(function(data) {
|
||||
if (data.response && data.response.success_token) {
|
||||
handleSuccessToken(data.response.success_token);
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
return p;
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('submit', function(event) {
|
||||
if (event.target && event.target.action) {
|
||||
event.target.action = rewriteUrl(event.target.action);
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
var target = event.target && event.target.closest ? event.target.closest('a[href]') : null;
|
||||
if (target && target.href) {
|
||||
target.href = rewriteUrl(target.href);
|
||||
}
|
||||
}, true);
|
||||
|
||||
var origFormSubmit = HTMLFormElement.prototype.submit;
|
||||
HTMLFormElement.prototype.submit = function() {
|
||||
if (this.action) {
|
||||
this.action = rewriteUrl(this.action);
|
||||
}
|
||||
return origFormSubmit.apply(this, arguments);
|
||||
};
|
||||
|
||||
var origWindowOpen = window.open;
|
||||
if (origWindowOpen) {
|
||||
window.open = function(url) {
|
||||
if (typeof url === 'string') {
|
||||
arguments[0] = rewriteUrl(url);
|
||||
}
|
||||
return origWindowOpen.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
rewriteDocument(document);
|
||||
if (document.documentElement && window.MutationObserver) {
|
||||
new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === 'attributes' && mutation.target) {
|
||||
rewriteElementAttr(mutation.target, mutation.attributeName);
|
||||
return;
|
||||
}
|
||||
mutation.addedNodes.forEach(function(node) {
|
||||
if (node.nodeType === 1) {
|
||||
rewriteDocument(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
}).observe(document.documentElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['href', 'src', 'action']
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
`, localOrigin, upstreamOrigin)
|
||||
|
||||
// Step 3: inject the client-side script as early as possible — at the opening <head> tag
|
||||
// so that XHR/fetch overrides are active before any inline <script> block in <head> runs.
|
||||
switch {
|
||||
case strings.Contains(html, "<head>"):
|
||||
return strings.Replace(html, "<head>", "<head>"+script, 1)
|
||||
case strings.Contains(html, "</head>"):
|
||||
return strings.Replace(html, "</head>", script+"</head>", 1)
|
||||
case strings.Contains(html, "</body>"):
|
||||
return strings.Replace(html, "</body>", script+"</body>", 1)
|
||||
default:
|
||||
return html + script
|
||||
}
|
||||
}
|
||||
|
||||
func newCaptchaProxyTransport(dialer net.Dialer) *http.Transport {
|
||||
return &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ForceAttemptHTTP2: false,
|
||||
DialContext: dialer.DialContext,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
wrappedListener, err := ish.WrapListener(listener)
|
||||
if err != nil {
|
||||
log.Printf("%s: failed to wrap listener for iSH: %v", logPrefix, err)
|
||||
wrappedListener = listener
|
||||
}
|
||||
go func(listener net.Listener) {
|
||||
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("%s: %s", logPrefix, err)
|
||||
}
|
||||
}(wrappedListener)
|
||||
}
|
||||
|
||||
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("If your browser didn't open automatically,")
|
||||
fmt.Println("manually open this URL: " + localCaptchaOrigin())
|
||||
fmt.Println("==============================================")
|
||||
fmt.Println()
|
||||
|
||||
log.Printf("[%s] Opening browser...", logPrefix)
|
||||
openBrowser(captchaURL)
|
||||
|
||||
key := <-keyCh
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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, `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>body{font-family:sans-serif;text-align:center;padding:20px}
|
||||
img{max-width:100%%;margin:16px 0}
|
||||
input{font-size:24px;padding:12px;width:80%%;box-sizing:border-box}
|
||||
button{font-size:24px;padding:12px 32px;margin-top:12px;cursor:pointer}</style>
|
||||
</head><body>
|
||||
<h2>Solve the Captcha</h2>
|
||||
<img src="%s" alt="captcha"/>
|
||||
<form onsubmit="fetch('/solve?key='+encodeURIComponent(document.getElementById('k').value)).then(()=>{document.body.innerHTML='<h2>Done!</h2>';setTimeout(function(){window.close();}, 300);});return false;">
|
||||
<br><input id="k" type="text" autofocus placeholder="Text from image"/>
|
||||
<br><button type="submit">Submit</button>
|
||||
</form></body></html>`, 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, `<!DOCTYPE html><html><body><h2>Done!</h2></body></html>`)
|
||||
})
|
||||
|
||||
return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error")
|
||||
}
|
||||
|
||||
type loggingTransport struct {
|
||||
rt http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
isCaptchaRequest := req.Body != nil && (strings.Contains(req.URL.Path, "captchaNotRobot.check") || strings.Contains(req.URL.Path, "captchaNotRobot.componentDone"))
|
||||
|
||||
if isCaptchaRequest {
|
||||
b, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
log.Printf("[Captcha Proxy] Failed to read request body: %v", err)
|
||||
b = nil
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(b))
|
||||
|
||||
if isDebug {
|
||||
log.Printf("[Captcha Proxy] Real browser sent %s data: %s", req.URL.Path, string(b))
|
||||
for k, v := range req.Header {
|
||||
log.Printf("[Captcha Proxy] Header (%s): %s = %s", req.URL.Path, k, strings.Join(v, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(req.URL.Path, "captchaNotRobot.componentDone") || strings.Contains(req.URL.Path, "captchaNotRobot.check") {
|
||||
parsedBody, err := neturl.ParseQuery(string(b))
|
||||
if err != nil {
|
||||
log.Printf("[Captcha Proxy] Failed to parse request body: %v", err)
|
||||
}
|
||||
device := parsedBody.Get("device")
|
||||
browserFp := parsedBody.Get("browser_fp")
|
||||
|
||||
// We only save it if device is present. componentDone usually has it.
|
||||
if device != "" && browserFp != "" {
|
||||
sp := browserprofile.Saved{
|
||||
Profile: browserprofile.Profile{
|
||||
UserAgent: req.Header.Get("User-Agent"),
|
||||
SecChUa: req.Header.Get("Sec-Ch-Ua"),
|
||||
SecChUaMobile: req.Header.Get("Sec-Ch-Ua-Mobile"),
|
||||
SecChUaPlatform: req.Header.Get("Sec-Ch-Ua-Platform"),
|
||||
},
|
||||
DeviceJSON: device,
|
||||
BrowserFp: browserFp,
|
||||
}
|
||||
if err := browserprofile.Save(sp); err != nil {
|
||||
log.Printf("[Captcha Proxy] Failed to save browser profile: %v", err)
|
||||
} else {
|
||||
log.Printf("[Captcha Proxy] Successfully intercepted and saved real browser profile!")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return t.rt.RoundTrip(req)
|
||||
}
|
||||
|
||||
func solveCaptchaViaProxy(redirectURI string, dialer net.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 := &loggingTransport{rt: 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 %s: %v", r.Method, r.URL.String(), err)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = fmt.Fprintf(w, `<!DOCTYPE html><html><body style="font-family:sans-serif;padding:20px"><h2>Captcha proxy error</h2><p>%s %s</p><p>%v</p></body></html>`, r.Method, r.URL.String(), err)
|
||||
},
|
||||
ModifyResponse: func(res *http.Response) error {
|
||||
rewriteProxyCookies(res.Header)
|
||||
|
||||
if res.StatusCode >= 300 && res.StatusCode < 400 {
|
||||
if loc := res.Header.Get("Location"); loc != "" {
|
||||
// Don't log the full redirect URL to keep console clean
|
||||
if rewritten, ok := rewriteProxyRedirectLocation(loc, targetURL); ok {
|
||||
res.Header.Set("Location", rewritten)
|
||||
} else {
|
||||
res.Header.Del("Location")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentType := res.Header.Get("Content-Type")
|
||||
contentEncoding := res.Header.Get("Content-Encoding")
|
||||
if isDebug {
|
||||
log.Printf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding)
|
||||
}
|
||||
|
||||
shouldInspectBody := strings.Contains(contentType, "text/html") ||
|
||||
strings.Contains(contentType, "application/xhtml+xml") ||
|
||||
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",
|
||||
"Strict-Transport-Security",
|
||||
"Alt-Svc",
|
||||
} {
|
||||
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) {
|
||||
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")
|
||||
})
|
||||
|
||||
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)
|
||||
},
|
||||
ModifyResponse: func(res *http.Response) error {
|
||||
// Strip security headers that can block cross-origin resource loading
|
||||
// when static assets (JS/CSS) are served through the proxy.
|
||||
for _, h := 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",
|
||||
"Strict-Transport-Security",
|
||||
} {
|
||||
res.Header.Del(h)
|
||||
}
|
||||
// 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
|
||||
},
|
||||
}
|
||||
genericReverse.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.Path)
|
||||
if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" {
|
||||
// Don't log the full redirect URL to keep console clean
|
||||
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":
|
||||
// 'rundll32 url.dll,FileProtocolHandler' is more reliable than 'cmd /c start'
|
||||
// because it doesn't involve the shell (cmd.exe), avoiding issues with '&' and other special characters.
|
||||
return []browserCommand{
|
||||
{name: "rundll32", args: []string{"url.dll,FileProtocolHandler", url}},
|
||||
// Fallback with empty title argument for 'start' to handle potential quoting issues
|
||||
{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
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func main() {
|
||||
panic(err)
|
||||
}
|
||||
wrapKey := cfg.WrapKey
|
||||
log.Printf("Starting server listen=%s connect=%s vless=%t vless-bond=%t wrap=%t bond-autodetect=true", cfg.Listen, cfg.Connect, cfg.VLESSMode, cfg.VLESSBond, cfg.WrapMode)
|
||||
log.Printf("Starting server listen=%s connect=%s vless=%t wrap=%t bond-autodetect=true", cfg.Listen, cfg.Connect, cfg.VLESSMode, cfg.WrapMode)
|
||||
// Generate a certificate and private key to secure the connection
|
||||
certificate, genErr := selfsign.GenerateSelfSigned()
|
||||
if genErr != nil {
|
||||
@@ -142,7 +142,7 @@ func main() {
|
||||
debugf("Handshake done")
|
||||
|
||||
if cfg.VLESSMode {
|
||||
handleVLESSConnection(ctx, dtlsConn, cfg.Connect, cfg.VLESSBond)
|
||||
handleVLESSConnection(ctx, dtlsConn, cfg.Connect)
|
||||
} else {
|
||||
handleUDPConnection(ctx, conn, cfg.Connect)
|
||||
}
|
||||
@@ -267,7 +267,7 @@ func handleUDPConnection(ctx context.Context, conn net.Conn, connectAddr string)
|
||||
|
||||
// handleVLESSConnection creates a KCP+smux session over DTLS and forwards
|
||||
// each smux stream as a TCP connection to the backend (Xray/VLESS).
|
||||
func handleVLESSConnection(ctx context.Context, dtlsConn net.Conn, connectAddr string, useBond bool) {
|
||||
func handleVLESSConnection(ctx context.Context, dtlsConn net.Conn, connectAddr string) {
|
||||
// 1. Create KCP session over DTLS
|
||||
statsCtx, statsCancel := context.WithCancel(ctx)
|
||||
defer statsCancel()
|
||||
@@ -333,9 +333,6 @@ func handleVLESSConnection(ctx context.Context, dtlsConn net.Conn, connectAddr s
|
||||
globalBondRegistry.HandleStreamAfterMagic(ctx, s, connectAddr, prefix)
|
||||
return
|
||||
}
|
||||
if useBond {
|
||||
log.Printf("non-bond smux stream accepted while -vless-bond is enabled")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := s.Close(); err != nil && err != smux.ErrGoAway {
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
|
||||
"github.com/cacggghp/vk-turn-proxy/client/internal/browserprofile"
|
||||
"github.com/cacggghp/vk-turn-proxy/internal/client/browserprofile"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/cacggghp/vk-turn-proxy/client/internal/browserprofile"
|
||||
"github.com/cacggghp/vk-turn-proxy/client/internal/captcha"
|
||||
"github.com/cacggghp/vk-turn-proxy/internal/client/browserprofile"
|
||||
"github.com/cacggghp/vk-turn-proxy/internal/client/captcha"
|
||||
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/cacggghp/vk-turn-proxy/client/internal/browserprofile"
|
||||
"github.com/cacggghp/vk-turn-proxy/client/internal/captcha"
|
||||
"github.com/cacggghp/vk-turn-proxy/internal/client/browserprofile"
|
||||
"github.com/cacggghp/vk-turn-proxy/internal/client/captcha"
|
||||
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cacggghp/vk-turn-proxy/client/internal/browserprofile"
|
||||
"github.com/cacggghp/vk-turn-proxy/client/internal/captcha"
|
||||
"github.com/cacggghp/vk-turn-proxy/client/internal/namegen"
|
||||
"github.com/cacggghp/vk-turn-proxy/internal/client/browserprofile"
|
||||
"github.com/cacggghp/vk-turn-proxy/internal/client/captcha"
|
||||
"github.com/cacggghp/vk-turn-proxy/internal/client/namegen"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
@@ -41,7 +41,7 @@ var Pool = sync.Pool{
|
||||
type GetCredsFunc func(ctx context.Context, link string, streamID int) (string, string, string, error)
|
||||
|
||||
// AuthHandler is the subset of vkauth.Client this package needs. Keeping it as
|
||||
// an interface lets internal/proxy/udp stay outside client/internal/.
|
||||
// an interface lets internal/proxy/udp avoid importing internal/client/vkauth.
|
||||
type AuthHandler interface {
|
||||
IsAuthError(err error) bool
|
||||
HandleAuthError(streamID int) bool
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Глоссарий vk-turn-proxy
|
||||
|
||||
Один абзац на термин. Если читаешь код и не понял слово — сюда.
|
||||
|
||||
---
|
||||
|
||||
## Слои протокола (снизу вверх)
|
||||
|
||||
```
|
||||
backend (WireGuard UDP | TCP service)
|
||||
↑
|
||||
[app proxy mode] ← UDP-relay | TCP-forward (vless) | TCP-forward+bond
|
||||
↑
|
||||
[wrap / SRTP-mimicry] (optional, AEAD obfuscation поверх payload)
|
||||
↑
|
||||
[DTLS] ← обфускация (не security): VK content-filter ожидает DTLS handshake
|
||||
↑
|
||||
[TURN ChannelData] ← пакеты идут через VK TURN-relay
|
||||
↑
|
||||
[TURN transport] ← UDP (-udp) | TCP/TLS (default), туннель до VK TURN
|
||||
↑
|
||||
client ←→ VK TURN-relay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Термины
|
||||
|
||||
### TURN
|
||||
Standard relay protocol (RFC 5766/8656). VK выдаёт креды на свои TURN-реле. Мы используем эти реле как транзит к собственному серверу. Транспорт до TURN выбирается флагом `-udp` (UDP/TCP).
|
||||
|
||||
### DTLS — это **обфускация, не security**
|
||||
VK content-filter дропает payload, который не похож на legit DTLS-handshake. Мы используем `pion/dtls` чтобы пройти фильтр. Шифрование DTLS — побочный эффект, не цель. Флаг `-no-dtls` был помечен DO NOT USE и удалён в V2-0 (в проде не работал).
|
||||
|
||||
### wrap / SRTP-mimicry
|
||||
Внутреннее имя пакета — `wrap` (планируется переименование в `srtpmimicry` на V2-4). CLI флаг `-wrap`. Это **ChaCha20-Poly1305 AEAD** с RTP-like wire-header (mimicry). Включается опционально поверх DTLS — даёт дополнительный слой обфускации с RTP-видимостью на проводе. CLI help говорит «ChaCha20-XOR» — **врёт**, реально AEAD. Не путать с Go-термином wrap (`errors.Wrap`).
|
||||
|
||||
### app proxy mode (3 значения)
|
||||
Что копируется поверх DTLS:
|
||||
- **UDP-relay** (default): UDP-пакеты 1-в-1. Для WireGuard.
|
||||
- **TCP-forward** (`-vless`): TCP-байты в smux-стрим, на сервере `net.Dial("tcp", backend)`. Generic, имя историческое (первый потребитель — Xray VLESS). Внутренний кандидат имени: `tcpfwd`.
|
||||
- **TCP-forward + bond** (`-vless-bond` на клиенте): один TCP-коннект страйпится по N smux-сессиям. Сервер автоопределяет по magic первых байтов (`bond-autodetect=true`). Серверный флаг `-vless-bond` удалён в V2-0 как избыточный.
|
||||
|
||||
### bond
|
||||
Опция TCP-forward режима. Не отдельная подсистема — feature `vless`. Wire-формат: magic + frame с seq/type/data. Кодек в `internal/bond`, клиент-handler в `internal/bond/client`, сервер-registry в `internal/bond/server` (планируется переезд в `internal/proxy/bond{client,server}` на V2-4).
|
||||
|
||||
### TURN stream
|
||||
Одна из N TURN-connection'ов клиента. Флаг `-n` (default 10). Каждый stream = отдельный TURN-allocate + DTLS-session. Не путать с smux stream.
|
||||
|
||||
### smux stream
|
||||
Внутри **одной** DTLS-сессии запускается KCP+smux, и поверх него — N smux-стримов. Используется только в TCP-forward режиме. Тип `*smux.Stream` из `xtaci/smux`.
|
||||
|
||||
### VK credential cache / stream-per-cred
|
||||
Один набор VK creds (полученный через VK API) обслуживает группу TURN streams. Флаг `-streams-per-cred` (default 10). В коде иногда называется `streamsPerCache`. То есть «cred» (одна credential) = «cache» (одна запись в `StreamCredentialsCache`).
|
||||
|
||||
### Client (4 разных)
|
||||
1. `cmd/vk-turn-client/` — наш клиентский бинарь.
|
||||
2. `vkauth.Client` — HTTP-клиент к VK API.
|
||||
3. `bondclient.Handler` — наша клиент-сторона bond.
|
||||
4. `turn.NewClient` — TURN-protocol-клиент (`pion/turn`).
|
||||
|
||||
При чтении кода держи контекст.
|
||||
|
||||
### VLESS
|
||||
Имя проксирующего протокола Xray. **Внутри этого репо** означает только «TCP-forwarder режим». Никакой VLESS-логики в коде нет — мы просто гоним TCP-байты. Имя историческое; внутреннее переименование в `tcpfwd` запланировано на V2-4 (CLI флаг `-vless` остаётся).
|
||||
|
||||
### iSH
|
||||
Linux user-mode environment для iOS (через usermode x86 emulation). Пакет `internal/ish` — listener shim вокруг iOS sandbox-ограничений. Только клиент-only; переедет в `internal/client/ish` на V2-4.
|
||||
|
||||
### tcputil
|
||||
Top-level пакет с **KCP/smux**-профилями. Имя врёт (никакого TCP внутри). Запланировано переименование в `internal/transport/kcptun` на V2-4.
|
||||
|
||||
### turnpipe
|
||||
Пакет с TURN dial + allocate. Имя `pipe` создаёт коллизию с `pipeConn` (bidi data-copy loop в server). Переедет в `internal/transport/turndial` на V2-4.
|
||||
|
||||
### netadapt
|
||||
Conn wrappers (DirectNet, ConnectedUDPConn, SplitFirstWriteConn). Имя не отражает суть — переедет в `internal/netconn` на V2-4.
|
||||
|
||||
### Mode (5 разных в CLI)
|
||||
Все ортогональны:
|
||||
- `-udp` — TURN transport (UDP|TCP к TURN).
|
||||
- `-vless` — app proxy mode (UDP-relay|TCP-forward).
|
||||
- `-vless-bond` — внутри TCP-forward (только client).
|
||||
- `-wrap` — wrap-обфускация (off|SRTP-mimicry).
|
||||
- `-dns` — DNS resolver (udp|doh|auto).
|
||||
|
||||
### captcha (auto / manual)
|
||||
Один домен, два пути:
|
||||
- **auto** (`internal/client/captcha`): VK slider-puzzle решается программно через `client/internal/captcha`.
|
||||
- **manual** (пока в `cmd/vk-turn-client/manual_captcha.go`, package main): HTTP-сервер на 127.0.0.1:8765, запуск браузера, gzip-rewrite VK-страниц. Запланирован вынос в `internal/client/captcha/manual` на V2-2.
|
||||
|
||||
Переключение: `vkauth.Client` пробует auto, при провале → manual fallback. Флаг `-manual-captcha` сразу идёт в manual.
|
||||
|
||||
### libvkturn.so
|
||||
Мобильная (Android) сборка клиента в виде shared library. Собирается **вручную** разработчиком и переносится в Android-приложение. CI не строит. Внешних `//export`-функций нет — `package main` ← buildmode определяется на стороне разработчика.
|
||||
|
||||
### globalBondRegistry
|
||||
Server-side глобал, держит активные bond-сессии. Инициализируется на package init — известная фрагильность (`isDebug` ещё false), работает только потому что bondserver `Debug`-поле не читается. Переедет в локальную переменную `cmd/vk-turn-server/main.go` на V2-3.
|
||||
|
||||
### `-no-dtls` (УДАЛЕНО)
|
||||
Был помечен DO NOT USE — в проде не работал, VK content-filter дропает payload без DTLS-handshake. Удалён в V2-0.
|
||||
|
||||
### `-vless-bond` на сервере (УДАЛЕНО)
|
||||
Сервер автоопределяет bond по magic первых байтов. Флаг был избыточен, удалён в V2-0. На клиенте — остаётся.
|
||||
Reference in New Issue
Block a user