refactor(captcha): вынести ручной flow в internal/client/captcha/manual

- cmd/vk-turn-client/manual_captcha.go (875 строк, был в package main) →
  internal/client/captcha/manual/manual.go как отдельный пакет. Экспортирует
  SolveViaProxy и SolveViaHTTP (переименованы из solveCaptchaViaProxy/
  solveCaptchaViaHTTP). Остальное package-private.

- Глобал isDebug, который ручной flow читал из main, заменён на manual.Debug
  (package var). cmd/vk-turn-client/main.go ставит manualcaptcha.Debug =
  cfg.Debug рядом с существующим isDebug = cfg.Debug.

- Тестовый файл перенесён с кодом; пакет переключён с main на manual.
  TestRewriteProxyRedirectLocation по-прежнему зовёт package-private rewriter
  изнутри пакета.

- План предлагал дробить manual.go дальше (server.go/browser.go/proxy.go/
  rewrite_html.go). Пропущено: лишний churn без выгоды, раз файл живёт в
  своём пакете. Можно сделать позже.
This commit is contained in:
samosvalishe
2026-05-19 17:21:07 +03:00
parent 2167ba990b
commit acbd90f52e
4 changed files with 1626 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
// 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"
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()
}
+892
View File
@@ -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 <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:
}
}
}
// SolveViaHTTP serves a tiny HTML page asking the user to solve the
// pictured CAPTCHA, opens it in the browser, and blocks until the key
// is posted back.
func SolveViaHTTP(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 Debug {
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)
}
// SolveViaProxy proxies the VK redirect_uri page through a local HTTP
// server, rewriting absolute URLs so the browser stays on
// 127.0.0.1:8765 throughout the puzzle, then returns the resulting
// auth token.
func SolveViaProxy(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 Debug {
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
}
@@ -0,0 +1,65 @@
package manual
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)
}
})
}
}
+399
View File
@@ -0,0 +1,399 @@
# Архитектурный аудит V2 — vk-turn-proxy
Дата: 2026-05-19.
Контекст: после закрытия этапов 1–6 первого `REFACTOR_PLAN.md` итог не устроил заказчика. Этот документ — независимый повторный аудит (структура + предметная область) и план финализации.
Предыдущий план: `notes/REFACTOR_PLAN.md`. Не отменяется — закрытые этапы остаются как есть, V2 строится поверх.
---
## 1. Снимок текущего состояния
```
client/
main.go (272) # package main: wiring + isDebug + debugf
manual_captcha.go (875) # package main: HTTP-сервер, browser launch, gzip proxy
manual_captcha_test.go
internal/
browserprofile/ captcha/ dnsdial/ namegen/ vkauth/
server/
main.go (402) # package main: wiring + globalBondRegistry + debugf
internal/
bond/{frame.go,client/,server/}
config/ dtlsdial/ ish/ netadapt/ proxy/{udp,vless}
stats/ turnpipe/ wrap/
tcputil/
tcputil.go # KCP/smux профили, top-level
go.mod
notes/ README.md Dockerfile routes-*.{sh,ps1}
```
CLI флаги (текущие):
- client: `-turn -port -listen -vk-link -peer -n -udp -no-dtls -vless -vless-bond -wrap -wrap-key -gen-wrap-key -streams-per-cred -debug -manual-captcha -dns -dns-servers`
- server: `-listen -connect -vless -vless-bond -wrap -wrap-key -gen-wrap-key -debug`
---
## 2. Структурные болячки
### 2.1. Нет `cmd/`
Бинари в `client/` и `server/` рядом с бизнес-кодом. Канон Go: `cmd/<bin>/main.go` = wiring only. Сейчас `client/` одновременно:
- путь к бинарю;
- родитель `client/internal/`;
- содержит 875-строчный `manual_captcha.go` в `package main`.
### 2.2. `manual_captcha.go` в `package main`
875 строк (больше самого `main.go`): HTTP-сервер на 8765, запуск браузера, gzip-rewrite VK-страниц, регекс-патчинг, capability detection. Тесты в `package main` — изолированно не тестируется. Должен быть отдельный пакет.
### 2.3. Двойная `internal/` иерархия
`client/internal/` vs `internal/`. Правило выбора **де-факто**: «если общее — наверх, если клиент-only — вниз». Сломалось на `internal/ish` — он клиент-only, но потребитель (vless) живёт в `internal/`, поэтому ish вытолкнут наверх. То есть правило перевёрнуто: «куда импортируется из общего, то наверх». Это симптом, не дизайн.
Раздробление:
- `client/internal/vkauth, captcha, browserprofile, dnsdial, namegen` — клиент-only.
- `internal/proxy/{udp,vless}` — по смыслу клиент-only, но в общем.
- `internal/ish` — клиент-only, но в общем (ради vless).
- `internal/bond/{frame,client,server}` — frame общий, client клиент-only, server сервер-only — три разных аудита смешаны.
### 2.4. `tcputil` top-level
Не `cmd/`, не `internal/`. Side-quest (переименовать в `kcptun`) не закрыт. Имя врёт: внутри KCP/smux профили, никакого TCP.
### 2.5. Side-quests не закрыты (из старого плана)
- `tcputil` → `kcptun` — не сделан.
- `selectedKCPProfile()` `os.Getenv` в hot path → `sync.Once` — не сделан.
- `isDebug` глобал → logger interface — не сделан.
- `globalAppCancel` убран (4.1), но `globalBondRegistry` остался (5.2).
### 2.6. Логирование — три способа
- `log.Printf` (stdlib).
- `debugf` (gated глобал в обоих main).
- `Deps{Debug bool, Debugf func}` callback в каждом пакете.
Один `internal/logx.Logger` решает.
### 2.7. `globalBondRegistry` инициализация на package init
`server/main.go:155`:
```go
var globalBondRegistry = bondserver.NewRegistry(bondserver.Deps{Debug: isDebug, Debugf: debugf})
```
Здесь `isDebug == false` (main ещё не выполнен). Работает только потому что bondserver **не читает** поле `Debug`, использует только `Debugf` callback. Bug-by-design, который сейчас не стреляет. Стрельнёт когда кто-то прочтёт `Debug`.
### 2.8. `proxy/udp` и `proxy/vless` — 70% общего кода
Оба:
- открывают N TURN-стримов через `turnpipe`;
- крутят DTLS через `dtlsdial`;
- ходят за VK creds (`vkauth.Client`);
- держат `activeLocalPeer` / `connectedStreams`.
Различие: что копировать поверх DTLS. Это **handler**, не отдельный mode. Нет общего `proxy.Engine` + handler-интерфейса.
---
## 3. Путаница в предметной области
### 3.1. Имена врут
| Имя | Что говорит | Что делает |
|-----|-------------|------------|
| `-wrap` (CLI help) | «ChaCha20-XOR obfuscate» | ChaCha20-Poly1305 **AEAD** |
| `wrap` (пакет doc) | «SRTP-mimicry AEAD» | то же |
| `wrap` (Go-термин) | `errors.Wrap`, `net.Wrap*` | здесь — VK-bypass обфускация |
| `tcputil` | TCP utils | KCP/smux профили |
| `turnpipe` | data pipe | TURN dial+allocate setup |
| `pipeConn` (server) | то же слово | **bidi copy loop** — другое! |
| `netadapt` | adapter | conn wrappers |
| `-no-dtls` ↔ `Direct` | один флаг | два имени между CLI и Go |
| `-turn` → `Host` | «TURN server ip» | принимает host (не только IP) |
| `-connect` (server) | error: «server address» | реально backend address |
### 3.2. Перегруженные термины
**«Client» — 4 сущности:**
- `cmd/client` бинарь
- `vkauth.Client` HTTP-клиент к VK
- `bondclient.Handler` наша bond-сторона
- `turn.NewClient` TURN-protocol-клиент
**«Bond» — feature или подсистема?** Layout `internal/bond/{frame,client,server}` делает его равным `wrap`. Реально bond — **только опция vless-режима**. Иерархия преувеличивает значимость.
**«Stream» — 3 значения:**
- TURN stream (одна из N TURN-connections)
- smux stream (внутри одной DTLS-сессии)
- VK credential stream / cache (одни creds на группу TURN-streams)
`-streams-per-cred` → `streamsPerCache` в коде. Грепаешь — путаешься.
**«Mode» — 5 ортогональных переключателей**, нигде не сгруппированы:
- `-udp`: TURN transport (UDP|TCP к TURN).
- `-vless`: app proxy (UDP-relay|TCP-forward).
- `-vless-bond`: внутри TCP-forward.
- `-wrap`: obfuscation (off|SRTP-mimicry).
- `-no-dtls`: DTLS off (DO NOT USE).
- `-dns`: udp|doh|auto.
Плоские bool в `config.Client` без группировки.
### 3.3. Концептуальные дыры
**a) DTLS = obfuscation, не security.** Доки, переменные, контекст говорят про DTLS как защиту. Реально DTLS нужен чтобы VK content-filter увидел legit-handshake — иначе drop. `-no-dtls` помечен «DO NOT USE» именно потому что не работает в проде. Это **нигде не зафиксировано**.
**b) Captcha разбит на 3 места:**
- `client/internal/captcha/` — auto slider-solver.
- `client/manual_captcha.go` (875 строк, `package main`) — manual flow (browser+http+gzip).
- `vkauth.AutoSolveFunc`/`ManualSolveFunc` — DI-точки.
Один домен «как пройти VK капчу», три места.
**c) `ish` package** — iOS sandbox shim. Имя расшифровывается только если знаешь iSH (Linux-под-iOS на iPhone). Без header-doc — мистика.
**d) Auto-detect bond на сервере** (`bond-autodetect=true` на старте) делает `server.-vless-bond` флаг **частично избыточным**. Документация не объясняет когда он нужен.
### 3.4. TCP/UDP терминология (специально вынесено)
**Два разных слоя:**
1. **TURN transport** — флаг `-udp`. Default `false` → TCP/TLS к TURN. True → UDP к TURN.
2. **App proxy mode** — флаг `-vless`. Default `false` → UDP packet relay. True → TCP forwarder (внутри VLESS-генерик).
«TCP-режим» в проекте **есть** — это `vless`. Имя историческое (первый потребитель — Xray VLESS). Внутри ничего VLESS-специфичного: tcp-байты в smux-стрим, на сервере `net.Dial("tcp", backend)`. Честное имя пакета: `tcp` или `tcpfwd`.
---
## 4. Корневая причина
Проект рос **по фичам, не по доменам**. Каждая новая способность (wrap, bond, vless, scan) приклеивалась соседней сущностью вместо встраивания в иерархию. Итог:
- термины перегружены (client/bond/mode/stream);
- CLI ≠ Go-имена;
- DTLS-as-obfuscation нигде не объяснён;
- captcha и proxy раздроблены без причины;
- два режима прокси не имеют общего родителя.
Первый рефакторинг (1–6) **вытаскивал** код из main, но не **перестраивал** домен. Сейчас main маленький, но домен такой же запутанный.
---
## 5. Целевая архитектура
```
cmd/
vk-turn-client/main.go # wiring only, ~150 строк
vk-turn-server/main.go # wiring only, ~100 строк
internal/
logx/ # slog-обёртка, заменяет isDebug+debugf
config/ # Parse*; группированные опции
ctxutil/ # closeWrite, pipe (data-copy)
wire/
srtpmimicry/ # бывш. internal/wrap
bondframe/ # бывш. internal/bond/frame.go
transport/
turndial/ # бывш. internal/turnpipe
dtlsdial/ # есть
kcptun/ # бывш. tcputil; sync.Once для профиля
proxy/
engine/ # общая часть (TURN×N + DTLS + creds + counters)
udprelay/ # бывш. proxy/udp; Handler
tcpfwd/ # бывш. proxy/vless; Handler
bondclient/ # бывш. internal/bond/client
bondserver/ # бывш. internal/bond/server
netconn/ # бывш. netadapt
client/ # КЛИЕНТ-ONLY (схлопывает client/internal)
vkauth/ # бывш. client/internal/vkauth
captcha/
auto/ # бывш. client/internal/captcha
manual/ # бывш. client/manual_captcha.go (НОВЫЙ ПАКЕТ)
browserprofile/ # бывш. client/internal/browserprofile
dnsdial/ # бывш. client/internal/dnsdial
namegen/ # бывш. client/internal/namegen
ish/ # бывш. internal/ish (явно клиент-only)
server/
bondregistry/ # бывш. internal/bond/server, если выносить globalBondRegistry чисто
stats/ # есть
```
### Принципы
1. **`cmd/<bin>/main.go` = wiring only.** Любая логика — в пакет.
2. **Одна `internal/` иерархия.** `internal/client/*` и `internal/server/*` — бинарь-специфика; `internal/{wire,transport,proxy}` — общее.
3. **Группировка по слою:** wire (байты на проводе) → transport (как открыть сокет) → proxy (что копировать) → client/server (роль).
4. **`logx.Logger` interface** заменяет `Deps{Debug bool, Debugf func}`. Один раз конфигурим в main.
5. **Глобалы умирают:** `globalBondRegistry` → локальная переменная в `cmd/vk-turn-server/main.go`. `isDebug` → logger level.
6. **Имена честные:**
- `tcputil` → `kcptun`
- `wrap` → `srtpmimicry`
- `netadapt` → `netconn`
- `turnpipe` → `turndial`
- `vless` → `tcpfwd` (внутреннее; CLI остаётся `-vless`)
- `bond` (codec) → `bondframe`
7. **`manual_captcha.go` → `internal/client/captcha/manual/`** как полноценный пакет с экспортируемым `Server`/`Solver`.
8. **`proxy.Engine` + `Handler` interface:** убирает дублирование udp/vless.
9. **Side-quests закрываются в этом этапе или не закрываются никогда.**
### Что НЕ делать
- `src/` — не Go-way, не вводить.
- `pkg/` — не вводить, наружу ничего публичного не отдаём.
- Wire-форматы / CLI флаги / behavior — заморожены.
---
## 6. План реализации
Каждый этап = один PR. Все PR — non-breaking по CLI/wire. Контрольная панель после каждого: `gofmt -l .` пуст / `go vet ./...` / `golangci-lint run ./...` / `go test ./...`.
### Этап V2-1 — `cmd/` + глоссарий (низкий риск)
- [x] **V2-1.1** Создан `cmd/vk-turn-client/main.go` (переезд `client/main.go`) и `cmd/vk-turn-server/main.go` (переезд `server/main.go`).
- [x] **V2-1.2** Обновлены `Dockerfile`, `README.md`, `.github/workflows/{ci,release}.yml` — пути `./cmd/vk-turn-client`/`./cmd/vk-turn-server`. README дополнен Android arm64 + Linux arm64/amd64 `.so`-сборками.
- [x] **V2-1.3** `client/` и `server/` директории удалены.
- [x] **V2-1.4** `notes/GLOSSARY.md` создан (~20 терминов).
Бонус: `client/internal/*` → `internal/client/*` сделан в этом же PR (половина V2-5) как зависимость переезда в `cmd/`.
### Этап V2-2 — manual captcha как пакет
- [x] **V2-2.1** Создан `internal/client/captcha/manual/manual.go` (875 строк, один файл). Экспортированы: `SolveViaProxy`, `SolveViaHTTP`. Package-var `Debug bool` заменяет глобал `isDebug` из main.
- [ ] **V2-2.2** Разбивка на server.go/browser.go/proxy.go/rewrite_html.go **намеренно пропущена** — добавляет риск без выигрыша; один файл в изолированном пакете уже читаем. Если нужно — отдельный PR.
- [x] **V2-2.3** Тест `manual_test.go` переехал, package переключен с `main` на `manual`, тест `TestRewriteProxyRedirectLocation` зелёный.
- [x] **V2-2.4** `cmd/vk-turn-client/main.go`: import `manualcaptcha "internal/client/captcha/manual"`, мост `manualCaptchaSolver` зовёт `manualcaptcha.SolveVia*`, в main устанавливается `manualcaptcha.Debug = cfg.Debug`.
### Этап V2-3 — `internal/logx` + удаление isDebug-глобалов
- [ ] **V2-3.1** Создать `internal/logx/logx.go`: интерфейс `Logger{Debug(format,...), Info, Warn, Error}` + конструктор из `slog.Level`.
- [ ] **V2-3.2** Заменить `Deps{Debug bool, Debugf func}` на `Deps{Log logx.Logger}` во всех пакетах: `bondclient`, `bondserver`, `proxy/udp`, `proxy/vless`, `stats`.
- [ ] **V2-3.3** Удалить `var isDebug bool` + `func debugf` из обоих main. В main: `log := logx.New(cfg.Debug)`.
- [ ] **V2-3.4** `globalBondRegistry` → локальная в `cmd/vk-turn-server/main.go`, инициализация **после** `cfg := config.ParseServer`.
### Этап V2-4 — переименования (механика, без логики)
- [ ] **V2-4.1** `internal/wrap` → `internal/wire/srtpmimicry`. Обновить импорты + комментарий пакета (зафиксировать «obfuscation, не security»).
- [ ] **V2-4.2** `internal/bond/frame.go` → `internal/wire/bondframe/`. `internal/bond/client` → `internal/proxy/bondclient`. `internal/bond/server` → `internal/proxy/bondserver`.
- [ ] **V2-4.3** `internal/netadapt` → `internal/netconn`.
- [ ] **V2-4.4** `internal/turnpipe` → `internal/transport/turndial`. `internal/dtlsdial` → `internal/transport/dtlsdial`.
- [ ] **V2-4.5** `tcputil` → `internal/transport/kcptun`. **Закрыть side-quest `sync.Once` для `selectedKCPProfile`** в этом же PR (нагрузка маленькая, контекст подходящий).
- [ ] **V2-4.6** `internal/proxy/udp` → `internal/proxy/udprelay`. `internal/proxy/vless` → `internal/proxy/tcpfwd` (CLI флаг `-vless` остаётся; внутреннее имя пакета честное).
- [ ] **V2-4.7** `internal/ish` → `internal/client/ish` + header-doc «iSH = Linux user-mode под iOS; этот пакет — listener shim для sandbox-ограничений iSH».
### Этап V2-5 — схлопывание `client/internal/` в `internal/client/`
- [ ] **V2-5.1** `client/internal/vkauth` → `internal/client/vkauth`.
- [ ] **V2-5.2** `client/internal/captcha` → `internal/client/captcha/auto`.
- [ ] **V2-5.3** `client/internal/browserprofile` → `internal/client/browserprofile`.
- [ ] **V2-5.4** `client/internal/dnsdial` → `internal/client/dnsdial`.
- [ ] **V2-5.5** `client/internal/namegen` → `internal/client/namegen`.
- [ ] **V2-5.6** Удалить пустой `client/` (после переезда `main.go` на V2-1 и `manual_captcha` на V2-2 — он пуст).
### Этап V2-6 — `proxy.Engine` + Handler
- [ ] **V2-6.1** Создать `internal/proxy/engine/engine.go`: интерфейс `Handler` (`Serve(ctx, dtlsConn)`), общий `Engine{Open N TURN-stream, DTLS handshake, creds rotation, counters}`.
- [ ] **V2-6.2** `udprelay` и `tcpfwd` рефакторятся в реализации `Handler`. Дублирование выходит. `Deps` сокращается.
- [ ] **V2-6.3** Bond становится опцией `tcpfwd.Handler` (флаг конструктора), не отдельным пакетом-сосседом. `bondclient` / `bondserver` остаются как helper'ы, но вызываются изнутри `tcpfwd`.
### Этап V2-7 — config структуризация
- [ ] **V2-7.1** В `internal/config`: разбить `Client` struct на подгруппы:
```go
type Client struct {
TURN TURNOpts // Host, Port, UDP, N
Obf ObfOpts // WrapMode, WrapKey, Direct
Proxy ProxyOpts // Mode (udp|tcpfwd|tcpfwd-bond), Listen
VK VKOpts // Link, StreamsPerCred, ManualCaptcha
DNS DNSOpts // Mode, Servers
Log LogOpts // Debug
}
```
`Mode` enum (string `"udp"|"tcpfwd"|"tcpfwd-bond"`) вместо двух bool-флагов в коде. **CLI флаги остаются те же** — мапим в Parse.
- [ ] **V2-7.2** Те же группы для `Server`.
### Этап V2-8 — документирование и решения
- [ ] **V2-8.1** Header-doc в `internal/transport/dtlsdial/doc.go`: «DTLS используется как obfuscation (mimicry для VK content-filter), не как security layer. `-no-dtls` помечен DO NOT USE потому что без DTLS VK дропает не-handshake пакеты.»
- [ ] **V2-8.2** Решить судьбу `server -vless-bond` флага: либо удалить (autodetect покрывает), либо задокументировать когда нужен. Внести решение в `GLOSSARY.md`.
- [ ] **V2-8.3** README обновить: путь сборки `go build ./cmd/...`, актуальная архитектура (короткое дерево), глоссарий.
---
## 7. Порядок и приоритеты
**Жёсткие зависимости:**
- V2-4 (переименования) после V2-3 (logx) — иначе придётся переписывать Deps дважды.
- V2-5 (схлопывание client/internal) после V2-1 (cmd/) и V2-2 (manual_captcha вынесен) — иначе client/ не пустой.
- V2-6 (Engine) после V2-4 — работает с новыми именами.
- V2-7 (config группировка) — независим, можно когда угодно.
- V2-8 (doc) — последним.
**Рекомендуемая последовательность:**
1. V2-1 (cmd + GLOSSARY)
2. V2-2 (manual captcha)
3. V2-3 (logx)
4. V2-4 (переименования)
5. V2-5 (client/internal схлопывается)
6. V2-6 (Engine + Handler)
7. V2-7 (config группировка)
8. V2-8 (doc)
**Грубая оценка размера (LOC diff на PR):** V2-1 ~150 / V2-2 ~900 / V2-3 ~400 / V2-4 ~600 (mostly move) / V2-5 ~300 (mostly move) / V2-6 ~500 / V2-7 ~200 / V2-8 ~100.
---
## 8. Гарантии non-breaking
После каждого PR:
- `go build ./...` зелёный.
- `go test ./...` зелёный.
- `golangci-lint run` без новых ворнингов.
- CLI флаги, дефолты, behavior — bit-exact.
- Wire-форматы (wrap/SRTP-mimicry, bond frame, TURN, DTLS, KCP/smux) — bit-exact.
- Поведенческие инварианты сохранены: handshake-сем 3 (UDP-mode), captcha auto→manual fallback, creds cache lifetime 10m-60s safety, throttle 3s+rand.
---
## 9. Что НЕ трогать (даже в этом плане)
- Wire-форматы любые.
- CLI флаги: имена, дефолты, поведение.
- `tcputil` KCP/smux параметры (только переезд).
- `client/internal/captcha/*` slider-логику (только переезд).
- `internal/wrap` AEAD-байты (только переезд + rename).
- Алгоритм auto-detect bond на сервере.
---
## 10. Решения заказчика (зафиксировано 2026-05-19)
1. **libvkturn.so** — мобильная сборка, собирается вручную, переносится в приложение руками. CI не строит. Внешних `//export` в коде нет. **Решение:** `pkg/` не вводим, всё `internal/`. Пользователь после V2-1 пересоберёт с новым путём `./cmd/vk-turn-client` (или с buildmode-target, который он использует).
2. **Server-флаг `-vless-bond`** — **удаляем**. Autodetect по magic покрывает. Это **breaking CLI change на сервере** — фиксируем в release notes.
3. **`-no-dtls` (client `Direct`)** — **удаляем**. Помечен DO NOT USE, в проде не работает. Это **breaking CLI change на клиенте**.
4. **Server multi-tenant** (`notes/AUTH_PLAN.md`) — **план отменён**, вычёркиваем из контекста.
5. **TURN-scanner** (`client/scan*.go`) — открытый вопрос; кандидат `internal/client/turnscan/`. Решить при V2-5.
### Влияние на non-breaking гарантии
Пункт 8 («CLI флаги bit-exact») **корректируется**:
- Удаляются: `-no-dtls` (client), `-vless-bond` (server). Остальные CLI флаги — без изменений.
- Дефолты и поведение оставшихся флагов — bit-exact.
- Wire-форматы — bit-exact (без изменений).
### Новый этап V2-0 (вынесен в начало)
- [x] **V2-0.1** Удалён `-no-dtls` из client CLI. `config.Client.Direct` поле + dispatcher-guard в main + тест `TestParseClient_WrapDirectConflict` — удалены. Веток в proxy/* не было (только main).
- [x] **V2-0.2** Удалён `-vless-bond` из server CLI и `config.Server.VLESSBond`. `useBond` параметр `handleVLESSConnection` убран, warning-ветка «non-bond stream while bond enabled» удалена, поле снято со startup-лога. Сервер автоопределяет bond по magic.
- [ ] **V2-0.3** README Breaking changes — **пока не сделано**. Можно отложить до большого release-notes под весь V2.
V2-0 идёт **до V2-1** — упрощает дерево перед переездом в `cmd/`.
### Обновлённый порядок
1. V2-0 (breaking CLI cleanup)
2. V2-1 (cmd/ + GLOSSARY)
3. V2-2 (manual captcha пакет)
4. V2-3 (logx)
5. V2-4 (переименования)
6. V2-5 (client/internal схлопывается + решение по turnscan)
7. V2-6 (Engine + Handler)
8. V2-7 (config группировка)
9. V2-8 (doc)