Moroka8 1 month ago
committed by GitHub
parent
commit
f3c8c45104
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .gitignore
  2. 8
      .golangci.yml
  3. 3
      Dockerfile
  4. 712
      README.md
  5. 2325
      client/main.go
  6. 65
      client/manual_captcha_test.go
  7. 16
      docker-entrypoint.sh
  8. 5
      go.mod
  9. 575
      pkg/clientcore/captcha_v2.go
  10. 606
      pkg/clientcore/captcha_v2_slider.go
  11. 66
      pkg/clientcore/cli.go
  12. 135
      pkg/clientcore/ish_listener_linux_386.go
  13. 10
      pkg/clientcore/ish_listener_other.go
  14. 3194
      pkg/clientcore/main.go
  15. 2
      pkg/clientcore/main_test.go
  16. 352
      pkg/clientcore/manual_captcha.go
  17. 109
      pkg/clientcore/manual_captcha_test.go
  18. 2
      pkg/clientcore/namegen.go
  19. 37
      pkg/clientcore/profiles.go
  20. 273
      pkg/clientcore/slider_captcha.go
  21. 2
      pkg/clientcore/slider_captcha_test.go
  22. 166
      pkg/clientcore/wrap.go
  23. 186
      pkg/clientcore/wrap_test.go
  24. 685
      server/main.go
  25. 123
      server/pktinfo_linux.go
  26. 9
      server/pktinfo_other.go
  27. 240
      server/udp_listener.go
  28. 196
      server/wrap.go
  29. 111
      tcputil/tcputil.go

1
.gitignore

@ -0,0 +1 @@
.gocache/

8
.github/workflows/.golangci.yml → .golangci.yml

@ -49,13 +49,13 @@ linters:
- third_party$
- builtin$
- examples$
rules:
- linters:
- errcheck
source: "doRequest|packetPool\\.Get"
issues:
max-issues-per-linter: 0
max-same-issues: 0
exclude-rules:
- linters:
- errcheck
source: "doRequest|packetPool\\.Get"
formatters:
exclusions:
generated: lax

3
Dockerfile

@ -13,8 +13,9 @@ WORKDIR /app
COPY docker-entrypoint.sh .
COPY --from=builder /build/vk-turn-proxy .
RUN chmod +x docker-entrypoint.sh
RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh
EXPOSE 56000/tcp
EXPOSE 56000/udp
ENTRYPOINT ["./docker-entrypoint.sh"]

712
README.md

File diff suppressed because one or more lines are too long

2325
client/main.go

File diff suppressed because it is too large

65
client/manual_captcha_test.go

@ -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)
}
})
}
}

16
docker-entrypoint.sh

@ -2,10 +2,24 @@
set -e
CONNECT="${CONNECT_ADDR:?CONNECT_ADDR is required}"
LISTEN="${LISTEN_ADDR:-0.0.0.0:56000}"
VLESS_FLAG=""
if [ "${VLESS_MODE}" = "true" ]; then
VLESS_FLAG="-vless"
fi
exec ./vk-turn-proxy -listen 0.0.0.0:56000 -connect "$CONNECT" $VLESS_FLAG
BOND_FLAG=""
if [ "${VLESS_BOND}" = "true" ]; then
BOND_FLAG="-vless-bond"
fi
WRAP_FLAG=""
WRAP_KEY_FLAG=""
if [ "${WRAP_MODE}" = "true" ]; then
WRAP="${WRAP_KEY:?WRAP_KEY is required when WRAP_MODE=true}"
WRAP_FLAG="-wrap"
WRAP_KEY_FLAG="-wrap-key $WRAP"
fi
exec ./vk-turn-proxy -listen "$LISTEN" -connect "$CONNECT" $VLESS_FLAG $BOND_FLAG $WRAP_FLAG $WRAP_KEY_FLAG

5
go.mod

@ -49,3 +49,8 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc v1.80.0 // indirect
)
exclude (
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55
)

575
pkg/clientcore/captcha_v2.go

@ -0,0 +1,575 @@
package clientcore
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
mathrand "math/rand"
"regexp"
"strconv"
"strings"
"sync"
"time"
fhttp "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
)
const (
captchaV2APIVersion = "5.131"
captchaV2ScriptVersion = "1.1.1324"
captchaV2DeviceInfo = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1080,"innerWidth":1920,"innerHeight":951,"devicePixelRatio":1,"language":"en-US","languages":["en-US","en"],"webdriver":false,"hardwareConcurrency":8,"notificationsPermission":"denied"}`
)
var (
reCaptchaV2PowInput = regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`)
reCaptchaV2Difficulty = regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`)
reCaptchaV2WindowInit = regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;`)
reCaptchaV2ScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`)
reCaptchaV2DebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`)
reCaptchaV2Version = regexp.MustCompile(`vkid/([0-9.]*)/not_robot_captcha\.js`)
errCaptchaV2RateLimit = errors.New("captcha session rate limit reached")
errCaptchaV2Bot = errors.New("captcha bot challenge")
captchaV2MaxAttempts = 2
captchaV2DebugCache sync.Map // scriptURL -> string
captchaV2HeaderOrder = []string{
"host",
"content-length",
"sec-ch-ua-platform",
"accept-language",
"sec-ch-ua",
"content-type",
"sec-ch-ua-mobile",
"user-agent",
"accept",
"origin",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"referer",
"accept-encoding",
"priority",
}
captchaV2PHeaderOrder = []string{":method", ":path", ":authority", ":scheme"}
)
type captchaV2Init struct {
Data captchaV2InitData `json:"data"`
}
type captchaV2InitData struct {
ShowCaptchaType string `json:"show_captcha_type"`
CaptchaSettings []captchaV2InitSetting `json:"captcha_settings"`
}
type captchaV2InitSetting struct {
Type string `json:"type"`
Settings string `json:"settings"`
}
type captchaV2Page struct {
PowInput string
PowDifficulty int
ScriptURL string
Init *captchaV2Init
}
type captchaV2Check struct {
Status string
SuccessToken string
ShowType string
}
type captchaV2ShowTypeError struct {
ShowType string
}
func (e *captchaV2ShowTypeError) Error() string {
return "captcha show type mismatch: " + e.ShowType
}
type captchaV2Session struct {
ctx context.Context
client tlsclient.HttpClient
profile Profile
savedProfile *SavedProfile
}
func solveVkCaptchaV2(
ctx context.Context,
captchaErr *VkCaptchaError,
streamID int,
client tlsclient.HttpClient,
profile Profile,
savedProfile *SavedProfile,
) (string, error) {
if captchaErr == nil || captchaErr.SessionToken == "" {
return "", fmt.Errorf("no session_token in redirect_uri")
}
log.Printf("[STREAM %d] [Captcha] Solving VK Smart Captcha automatically (v2)...", streamID)
s := &captchaV2Session{ctx: ctx, client: client, profile: profile, savedProfile: savedProfile}
for attempt := 1; attempt <= captchaV2MaxAttempts; attempt++ {
token, solveErr := s.solveOnce(captchaErr)
if solveErr == nil {
return token, nil
}
log.Printf("[STREAM %d] [Captcha] v2 captcha solve attempt %d failed: %v", streamID, attempt, solveErr)
if errors.Is(solveErr, errCaptchaV2RateLimit) {
return "", solveErr
}
backoffSteps := attempt
if backoffSteps > 10 {
backoffSteps = 10
}
timer := time.NewTimer(time.Duration(backoffSteps) * 500 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return "", ctx.Err()
case <-timer.C:
}
}
return "", fmt.Errorf("v2 captcha attempts exhausted")
}
func (s *captchaV2Session) solveOnce(captchaErr *VkCaptchaError) (string, error) {
html, err := s.fetchCaptchaHTML(captchaErr.RedirectURI)
if err != nil {
return "", err
}
page, err := parseCaptchaV2Page(html)
if err != nil {
return "", err
}
if page.PowInput == "" {
return "", errors.New("failed to find PoW settings")
}
sliderSettings := ""
if page.Init != nil {
for _, setting := range page.Init.Data.CaptchaSettings {
if setting.Type == "slider" {
sliderSettings = setting.Settings
}
}
}
if page.Init != nil && page.Init.Data.ShowCaptchaType == "slider" && sliderSettings == "" {
return "", errors.New("failed to find slider captcha settings")
}
log.Printf("v2 captcha solving pow difficulty=%d", page.PowDifficulty)
hash := solveCaptchaPoWV2(s.ctx, page.PowInput, page.PowDifficulty)
if hash == "" {
return "", errors.New("captcha pow failed")
}
log.Printf("v2 captcha pow solved")
base := captchaV2BaseValues(captchaErr.SessionToken)
if _, settingsErr := s.captchaRequest("captchaNotRobot.settings", base); settingsErr != nil {
return "", fmt.Errorf("captcha settings failed: %w", settingsErr)
}
browserFP, err := captchaV2BrowserFP()
if err != nil {
return "", err
}
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.BrowserFp) != "" {
browserFP = s.savedProfile.BrowserFp
}
if m := reCaptchaV2Version.FindStringSubmatch(page.ScriptURL); len(m) > 1 {
if m[1] != captchaV2ScriptVersion {
log.Printf("v2 captcha script version drift: known=%s latest=%s", captchaV2ScriptVersion, m[1])
}
}
debugInfo, err := s.fetchDebugInfo(page.ScriptURL)
if err != nil {
return "", fmt.Errorf("failed to fetch debug info: %w (script_version=%s)", err, captchaV2ScriptVersion)
}
showType := ""
if page.Init != nil {
showType = page.Init.Data.ShowCaptchaType
}
var token string
for {
log.Printf("v2 captcha solving show_type=%s", showType)
switch showType {
case "slider":
token, err = s.solveSliderCaptcha(captchaErr.SessionToken, browserFP, hash, sliderSettings, debugInfo)
case "checkbox", "":
token, err = s.solveCheckboxCaptcha(captchaErr.SessionToken, browserFP, hash, debugInfo)
default:
return "", fmt.Errorf("unsupported captcha type: %s", showType)
}
if err == nil {
break
}
if errors.Is(err, errCaptchaV2Bot) && !strings.EqualFold(showType, "slider") && sliderSettings != "" {
log.Printf("v2 captcha checkbox returned BOT, trying slider challenge from page settings")
showType = "slider"
continue
}
var stErr *captchaV2ShowTypeError
if !errors.As(err, &stErr) || stErr.ShowType == "" {
return "", err
}
showType = stErr.ShowType
}
if _, endErr := s.captchaRequest("captchaNotRobot.endSession", base); endErr != nil {
log.Printf("v2 captcha endSession failed: %v", endErr)
}
return token, nil
}
func captchaV2BaseValues(sessionToken string) [][2]string {
return [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
{"adFp", ""},
{"access_token", ""},
}
}
func captchaV2BrowserFP() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("browser fp generate: %w", err)
}
return hex.EncodeToString(b), nil
}
func (s *captchaV2Session) fetchCaptchaHTML(redirectURI string) (string, error) {
body, err := s.doRaw(fhttp.MethodGet, redirectURI, nil, map[string]string{
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "cross-site",
})
if err != nil {
return "", err
}
return string(body), nil
}
func (s *captchaV2Session) fetchDebugInfo(scriptURL string) (string, error) {
if cached, ok := captchaV2DebugCache.Load(scriptURL); ok {
if cachedDebugInfo, ok := cached.(string); ok {
return cachedDebugInfo, nil
}
captchaV2DebugCache.Delete(scriptURL)
}
body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{
"Accept": "text/javascript,*/*",
"Referer": "https://id.vk.com/",
})
if err != nil {
return "", err
}
m := reCaptchaV2DebugInfo.FindSubmatch(body)
if len(m) < 2 {
return "", errors.New("debug_info match not found")
}
v := string(m[1])
captchaV2DebugCache.Store(scriptURL, v)
log.Printf("v2 captcha debug_info fetched url=%s", scriptURL)
return v, nil
}
func parseCaptchaV2Page(html string) (*captchaV2Page, error) {
page := &captchaV2Page{}
match := reCaptchaV2WindowInit.FindStringSubmatch(html)
if len(match) < 2 {
return nil, errors.New("captcha init json not found")
}
var init captchaV2Init
if err := json.Unmarshal([]byte(match[1]), &init); err != nil {
return nil, fmt.Errorf("captcha init json parse: %w", err)
}
page.Init = &init
match = reCaptchaV2ScriptSrc.FindStringSubmatch(html)
if len(match) < 2 {
return nil, errors.New("captcha script url not found")
}
page.ScriptURL = match[1]
if m := reCaptchaV2PowInput.FindStringSubmatch(html); len(m) >= 2 {
page.PowInput = m[1]
}
if page.PowInput == "" {
return page, nil
}
match = reCaptchaV2Difficulty.FindStringSubmatch(html)
if len(match) < 2 {
return nil, errors.New("captcha difficulty const not found")
}
difficulty, err := strconv.Atoi(match[1])
if err != nil || difficulty <= 0 {
return nil, fmt.Errorf("invalid captcha difficulty %q", match[1])
}
page.PowDifficulty = difficulty
return page, nil
}
func (s *captchaV2Session) captchaRequest(method string, form [][2]string) (map[string]any, error) {
endpoint := "https://api.vk.ru/method/" + method + "?v=" + captchaV2APIVersion
body, err := s.doRaw(fhttp.MethodPost, endpoint, form, map[string]string{
"Origin": "https://id.vk.com",
"Referer": "https://id.vk.com/",
"Priority": "u=1, i",
})
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("captcha api decode: %w", err)
}
return out, nil
}
func (s *captchaV2Session) performCaptchaCheck(
sessionToken string,
browserFP string,
hash string,
answerJSON string,
cursor string,
debugInfo string,
) (*captchaV2Check, error) {
values := [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
{"adFp", ""},
{"accelerometer", "[]"},
{"gyroscope", "[]"},
{"motion", "[]"},
{"cursor", cursor},
{"taps", "[]"},
{"connectionRtt", "[]"},
{"connectionDownlink", "[]"},
{"browser_fp", browserFP},
{"hash", hash},
{"answer", base64.StdEncoding.EncodeToString([]byte(answerJSON))},
{"debug_info", debugInfo},
{"access_token", ""},
}
resp, err := s.captchaRequest("captchaNotRobot.check", values)
if err != nil {
return nil, fmt.Errorf("captcha check failed: %w", err)
}
check, err := parseCaptchaV2Check(resp)
if err != nil {
return nil, err
}
if check.ShowType != "" {
log.Printf("v2 captcha check status=%s show_type=%s", check.Status, check.ShowType)
} else {
log.Printf("v2 captcha check status=%s", check.Status)
}
return check, nil
}
func parseCaptchaV2Check(raw map[string]any) (*captchaV2Check, error) {
resp, ok := raw["response"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid captcha check response: %v", raw)
}
out := &captchaV2Check{
Status: captchaV2StringifyAny(resp["status"]),
SuccessToken: captchaV2StringifyAny(resp["success_token"]),
ShowType: captchaV2StringifyAny(resp["show_captcha_type"]),
}
if out.Status == "" {
return nil, fmt.Errorf("captcha check status missing: %v", raw)
}
return out, nil
}
func (s *captchaV2Session) solveCheckboxCaptcha(
sessionToken string,
browserFP string,
hash string,
debugInfo string,
) (string, error) {
deviceJSON := captchaV2DeviceInfo
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
deviceJSON = s.savedProfile.DeviceJSON
}
if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
{"adFp", ""},
{"browser_fp", browserFP},
{"device", deviceJSON},
{"access_token", ""},
}); err != nil {
return "", fmt.Errorf("captcha componentDone failed: %w", err)
}
select {
case <-s.ctx.Done():
return "", s.ctx.Err()
case <-time.After(time.Duration(400+mathrand.Intn(250)) * time.Millisecond):
}
check, err := s.performCaptchaCheck(sessionToken, browserFP, hash, "{}", "[]", debugInfo)
if err != nil {
return "", err
}
if check.ShowType != "" && !strings.EqualFold(check.ShowType, "checkbox") {
return "", &captchaV2ShowTypeError{ShowType: check.ShowType}
}
if strings.EqualFold(check.Status, "error_limit") {
return "", errCaptchaV2RateLimit
}
if strings.EqualFold(check.Status, "bot") {
return "", fmt.Errorf("%w: checkbox captcha rejected: status=%s", errCaptchaV2Bot, check.Status)
}
if !strings.EqualFold(check.Status, "ok") {
return "", fmt.Errorf("checkbox captcha rejected: status=%s", check.Status)
}
if check.SuccessToken == "" {
return "", errors.New("captcha success token not found")
}
return check.SuccessToken, nil
}
func solveCaptchaPoWV2(ctx context.Context, input string, difficulty int) string {
if input == "" || difficulty <= 0 {
return ""
}
target := strings.Repeat("0", difficulty)
for nonce := 1; nonce <= 10_000_000; nonce++ {
if nonce%4096 == 0 {
select {
case <-ctx.Done():
return ""
default:
}
}
sum := sha256.Sum256([]byte(input + strconv.Itoa(nonce)))
hashHex := hex.EncodeToString(sum[:])
if strings.HasPrefix(hashHex, target) {
return hashHex
}
}
return ""
}
func (s *captchaV2Session) doRaw(
method string,
endpoint string,
form [][2]string,
extraHeaders map[string]string,
) ([]byte, error) {
var body []byte
if form != nil {
body = []byte(captchaV2EncodeForm(form))
}
req, err := fhttp.NewRequestWithContext(s.ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
applyBrowserProfileFhttp(req, s.profile)
req.Header.Set("Accept", "*/*")
req.Header.Set("Sec-Fetch-Site", "same-site")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Origin", "https://vk.com")
req.Header.Set("Referer", "https://vk.com/")
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
req.Header[fhttp.HeaderOrderKey] = captchaV2HeaderOrder
req.Header[fhttp.PHeaderOrderKey] = captchaV2PHeaderOrder
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
log.Printf("v2 captcha close body: %s", closeErr)
}
}()
return io.ReadAll(resp.Body)
}
func captchaV2EncodeForm(values [][2]string) string {
if len(values) == 0 {
return ""
}
var sb strings.Builder
for i, kv := range values {
if i > 0 {
sb.WriteByte('&')
}
sb.WriteString(captchaV2QueryEscape(kv[0]))
sb.WriteByte('=')
sb.WriteString(captchaV2QueryEscape(kv[1]))
}
return sb.String()
}
func captchaV2QueryEscape(s string) string {
const upper = "0123456789ABCDEF"
hexDigits := func(b byte) [3]byte {
return [3]byte{'%', upper[b>>4], upper[b&0xF]}
}
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == ' ':
out = append(out, '+')
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('0' <= c && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~':
out = append(out, c)
default:
h := hexDigits(c)
out = append(out, h[:]...)
}
}
return string(out)
}
func captchaV2StringifyAny(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case bool:
return strconv.FormatBool(v)
default:
data, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(data)
}
}

606
pkg/clientcore/captcha_v2_slider.go

@ -0,0 +1,606 @@
package clientcore
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"image"
"image/color"
_ "image/jpeg" // register JPEG decoder
"log"
"math"
mathrand "math/rand"
"runtime"
"sort"
"strconv"
"strings"
"sync"
)
type sliderPuzzleV2 struct {
Image image.Image
Size int
Swaps []int
Attempts int
}
type sliderGuessV2 struct {
Index int
Swaps []int
Score int64
ScoreRGB int64
ScoreLuma int64
ScoreText float64
ConsensusRank int
}
func (s *captchaV2Session) solveSliderCaptcha(
sessionToken string,
browserFP string,
hash string,
settings string,
debugInfo string,
) (string, error) {
values := [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
{"adFp", ""},
{"access_token", ""},
{"captcha_settings", settings},
}
resp, err := s.captchaRequest("captchaNotRobot.getContent", values)
if err != nil {
return "", fmt.Errorf("slider getContent failed: %w", err)
}
puzzle, err := parseSliderPuzzleV2(resp)
if err != nil {
return "", err
}
log.Printf("v2 slider puzzle decoded: grid=%d attempts=%d swaps=%d", puzzle.Size, puzzle.Attempts, len(puzzle.Swaps))
guesses, err := rankSliderGuessesV2(puzzle.Image, puzzle.Size, puzzle.Swaps)
if err != nil {
return "", err
}
limit := puzzle.Attempts
if limit > len(guesses) {
limit = len(guesses)
}
if limit <= 0 {
return "", errors.New("slider has no attempts available")
}
log.Printf("v2 slider guesses ranked: total=%d limit=%d", len(guesses), limit)
deviceJSON := captchaV2DeviceInfo
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" {
deviceJSON = s.savedProfile.DeviceJSON
}
if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{
{"session_token", sessionToken},
{"domain", "vk.com"},
{"adFp", ""},
{"access_token", ""},
{"browser_fp", browserFP},
{"device", deviceJSON},
}); err != nil {
return "", fmt.Errorf("captcha componentDone failed: %w", err)
}
for i := 0; i < limit; i++ {
log.Printf("v2 slider attempt %d/%d (guess #%d)", i+1, limit, guesses[i].Index)
answerData, err := json.Marshal(struct {
Value []int `json:"value"`
}{Value: guesses[i].Swaps})
if err != nil {
return "", err
}
check, err := s.performCaptchaCheck(
sessionToken,
browserFP,
hash,
string(answerData),
buildSliderCursorV2(guesses[i].Index, len(guesses)),
debugInfo,
)
if err != nil {
return "", err
}
if strings.EqualFold(check.Status, "ok") {
if check.SuccessToken == "" {
return "", errors.New("captcha success token not found")
}
log.Printf("v2 slider accepted on attempt %d", i+1)
return check.SuccessToken, nil
}
if strings.EqualFold(check.Status, "error_limit") {
return "", errCaptchaV2RateLimit
}
}
return "", errors.New("slider guesses exhausted")
}
func parseSliderPuzzleV2(raw map[string]any) (*sliderPuzzleV2, error) {
resp, ok := raw["response"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid slider content response: %v", raw)
}
status := captchaV2StringifyAny(resp["status"])
if !strings.EqualFold(status, "ok") {
return nil, fmt.Errorf("slider getContent status: %s", status)
}
rawImage := captchaV2StringifyAny(resp["image"])
if rawImage == "" {
return nil, errors.New("slider image missing")
}
rawSteps, ok := resp["steps"].([]any)
if !ok {
return nil, errors.New("slider steps missing")
}
steps := make([]int, 0, len(rawSteps))
for _, item := range rawSteps {
switch v := item.(type) {
case float64:
steps = append(steps, int(v))
case int:
steps = append(steps, v)
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return nil, fmt.Errorf("invalid numeric value: %v", item)
}
steps = append(steps, n)
default:
return nil, fmt.Errorf("invalid numeric value: %v", item)
}
}
size, swaps, attempts, err := splitSliderStepsV2(steps)
if err != nil {
return nil, err
}
data, err := base64.StdEncoding.DecodeString(rawImage)
if err != nil {
return nil, fmt.Errorf("decode slider image: %w", err)
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode slider image: %w", err)
}
return &sliderPuzzleV2{Image: img, Size: size, Swaps: swaps, Attempts: attempts}, nil
}
func splitSliderStepsV2(steps []int) (int, []int, int, error) {
if len(steps) < 3 {
return 0, nil, 0, errors.New("slider steps payload too short")
}
size := steps[0]
if size <= 0 {
return 0, nil, 0, fmt.Errorf("invalid slider size: %d", size)
}
tail := append([]int(nil), steps[1:]...)
attempts := 4
if len(tail)%2 != 0 {
attempts = tail[len(tail)-1]
tail = tail[:len(tail)-1]
log.Printf("v2 slider payload had odd-length tail; fallback attempts=%d", attempts)
}
if attempts <= 0 {
attempts = 4
}
if len(tail) == 0 || len(tail)%2 != 0 {
return 0, nil, 0, errors.New("invalid slider swap payload")
}
return size, tail, attempts, nil
}
func rankSliderGuessesV2(img image.Image, gridSize int, swaps []int) ([]sliderGuessV2, error) {
candidateCount := len(swaps) / 2
if candidateCount == 0 {
return nil, errors.New("slider has no candidates")
}
guesses := make([]sliderGuessV2, candidateCount)
for idx := 1; idx <= candidateCount; idx++ {
active := activeSwapsForIndexV2(swaps, idx)
mapping, err := applySliderSwapsV2(gridSize, active)
if err != nil {
return nil, err
}
guesses[idx-1] = sliderGuessV2{Index: idx, Swaps: active}
guesses[idx-1].ScoreLuma = seamScoreLumaV2(img, gridSize, mapping)
}
lumaOrder := append([]sliderGuessV2(nil), guesses...)
sort.SliceStable(lumaOrder, func(i, j int) bool {
if lumaOrder[i].ScoreLuma == lumaOrder[j].ScoreLuma {
return lumaOrder[i].Index < lumaOrder[j].Index
}
return lumaOrder[i].ScoreLuma < lumaOrder[j].ScoreLuma
})
lumaRank := make(map[int]int, candidateCount)
for rank, g := range lumaOrder {
lumaRank[g.Index] = rank
}
stage2Count := candidateCount
if stage2Count > 12 {
stage2Count = 12
}
stage2Set := make(map[int]struct{}, stage2Count)
for i := 0; i < stage2Count; i++ {
stage2Set[lumaOrder[i].Index] = struct{}{}
}
type stage2Result struct {
index int
rgb int64
text float64
err error
}
jobs := make([]int, 0, stage2Count)
for idx := range stage2Set {
jobs = append(jobs, idx)
}
jobCh := make(chan int, len(jobs))
resCh := make(chan stage2Result, len(jobs))
workers := runtime.NumCPU()
if workers < 1 {
workers = 1
}
if workers > len(jobs) {
workers = len(jobs)
}
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for index := range jobCh {
mapping, err := applySliderSwapsV2(gridSize, guesses[index-1].Swaps)
if err != nil {
resCh <- stage2Result{index: index, err: err}
continue
}
rgb, text := seamScoreRGBTextV2(img, gridSize, mapping)
resCh <- stage2Result{index: index, rgb: rgb, text: text}
}
}()
}
for _, idx := range jobs {
jobCh <- idx
}
close(jobCh)
wg.Wait()
close(resCh)
for r := range resCh {
if r.err != nil {
return nil, r.err
}
g := &guesses[r.index-1]
g.ScoreRGB = r.rgb
g.ScoreText = r.text
}
stage2 := make([]sliderGuessV2, 0, stage2Count)
for _, g := range guesses {
if _, ok := stage2Set[g.Index]; ok {
stage2 = append(stage2, g)
}
}
rgbOrder := append([]sliderGuessV2(nil), stage2...)
sort.SliceStable(rgbOrder, func(i, j int) bool {
if rgbOrder[i].ScoreRGB == rgbOrder[j].ScoreRGB {
return rgbOrder[i].Index < rgbOrder[j].Index
}
return rgbOrder[i].ScoreRGB < rgbOrder[j].ScoreRGB
})
rgbRank := make(map[int]int, len(rgbOrder))
for rank, g := range rgbOrder {
rgbRank[g.Index] = rank
}
textOrder := append([]sliderGuessV2(nil), stage2...)
sort.SliceStable(textOrder, func(i, j int) bool {
if textOrder[i].ScoreText == textOrder[j].ScoreText {
return textOrder[i].Index < textOrder[j].Index
}
return textOrder[i].ScoreText < textOrder[j].ScoreText
})
textRank := make(map[int]int, len(textOrder))
for rank, g := range textOrder {
textRank[g.Index] = rank
}
for i := range guesses {
g := &guesses[i]
g.ConsensusRank = lumaRank[g.Index]
if _, ok := stage2Set[g.Index]; ok {
g.ConsensusRank += rgbRank[g.Index] + textRank[g.Index]
} else {
g.ConsensusRank += candidateCount
}
g.Score = int64(g.ConsensusRank)
}
sort.SliceStable(guesses, func(i, j int) bool {
if guesses[i].ConsensusRank == guesses[j].ConsensusRank {
if guesses[i].ScoreLuma == guesses[j].ScoreLuma {
return guesses[i].Index < guesses[j].Index
}
return guesses[i].ScoreLuma < guesses[j].ScoreLuma
}
return guesses[i].ConsensusRank < guesses[j].ConsensusRank
})
return guesses, nil
}
func activeSwapsForIndexV2(swaps []int, index int) []int {
if index <= 0 {
return []int{}
}
end := index * 2
if end > len(swaps) {
end = len(swaps)
}
return append([]int(nil), swaps[:end]...)
}
func applySliderSwapsV2(gridSize int, swaps []int) ([]int, error) {
tileCount := gridSize * gridSize
if tileCount <= 0 {
return nil, fmt.Errorf("invalid slider tile count: %d", tileCount)
}
if len(swaps)%2 != 0 {
return nil, fmt.Errorf("invalid slider swaps length: %d", len(swaps))
}
mapping := make([]int, tileCount)
for i := range mapping {
mapping[i] = i
}
for i := 0; i < len(swaps); i += 2 {
left := swaps[i]
right := swaps[i+1]
if left < 0 || right < 0 || left >= tileCount || right >= tileCount {
return nil, fmt.Errorf("slider step out of range: %d,%d", left, right)
}
mapping[left], mapping[right] = mapping[right], mapping[left]
}
return mapping, nil
}
func seamScoreLumaV2(img image.Image, gridSize int, mapping []int) int64 {
bounds := img.Bounds()
var score int64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
leftIdx := row*gridSize + col
rightIdx := leftIdx + 1
leftDst := sliderTileRect(bounds, gridSize, leftIdx)
rightDst := sliderTileRect(bounds, gridSize, rightIdx)
leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
h := leftDst.Dy()
if rightDst.Dy() < h {
h = rightDst.Dy()
}
for y := 0; y < h; y++ {
yy := leftDst.Min.Y + y
a := sampleLumaMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
b := sampleLumaMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy)
score += int64(absIntV2(int(a) - int(b)))
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
topIdx := row*gridSize + col
bottomIdx := (row+1)*gridSize + col
topDst := sliderTileRect(bounds, gridSize, topIdx)
bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
w := topDst.Dx()
if bottomDst.Dx() < w {
w = bottomDst.Dx()
}
for x := 0; x < w; x++ {
xx := topDst.Min.X + x
a := sampleLumaMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1)
b := sampleLumaMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
score += int64(absIntV2(int(a) - int(b)))
}
}
}
return score
}
func seamScoreRGBTextV2(img image.Image, gridSize int, mapping []int) (int64, float64) {
bounds := img.Bounds()
height := float64(bounds.Dy())
textCenters := []float64{
float64(bounds.Min.Y) + 0.2*height,
float64(bounds.Min.Y) + 0.5*height,
float64(bounds.Min.Y) + 0.8*height,
}
sigma := height * 0.14
if sigma < 1.0 {
sigma = 1.0
}
weight := func(y int) float64 {
yf := float64(y)
best := absFloatV2(yf - textCenters[0])
for i := 1; i < len(textCenters); i++ {
d := absFloatV2(yf - textCenters[i])
if d < best {
best = d
}
}
return 1 + 3*math.Exp(-(best*best)/(2*sigma*sigma))
}
var rgbScore int64
var textScore float64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
leftIdx := row*gridSize + col
rightIdx := leftIdx + 1
leftDst := sliderTileRect(bounds, gridSize, leftIdx)
rightDst := sliderTileRect(bounds, gridSize, rightIdx)
leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
h := leftDst.Dy()
if rightDst.Dy() < h {
h = rightDst.Dy()
}
for y := 0; y < h; y++ {
yy := leftDst.Min.Y + y
l := sampleColorMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
r := sampleColorMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy)
rgbScore += pixelDiff(l, r)
_, _, lb, _ := l.RGBA()
_, _, rb, _ := r.RGBA()
textScore += weight(yy) * float64(absIntV2(int(lb>>8)-int(rb>>8)))
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
topIdx := row*gridSize + col
bottomIdx := (row+1)*gridSize + col
topDst := sliderTileRect(bounds, gridSize, topIdx)
bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
w := topDst.Dx()
if bottomDst.Dx() < w {
w = bottomDst.Dx()
}
for x := 0; x < w; x++ {
xx := topDst.Min.X + x
t := sampleColorMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1)
b := sampleColorMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
rgbScore += pixelDiff(t, b)
_, _, tb, _ := t.RGBA()
_, _, bb, _ := b.RGBA()
textScore += 0.65 * float64(absIntV2(int(tb>>8)-int(bb>>8)))
}
}
}
return rgbScore, textScore
}
func sampleColorMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) color.Color {
dx := dstRect.Dx()
if dx < 1 {
dx = 1
}
dy := dstRect.Dy()
if dy < 1 {
dy = 1
}
sx := srcRect.Min.X + (dstX-dstRect.Min.X)*srcRect.Dx()/dx
sy := srcRect.Min.Y + (dstY-dstRect.Min.Y)*srcRect.Dy()/dy
return img.At(sx, sy)
}
func sampleLumaMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) uint8 {
c := sampleColorMappedV2(img, dstRect, srcRect, dstX, dstY)
r, g, b, _ := c.RGBA()
y := (299*(r>>8) + 587*(g>>8) + 114*(b>>8)) / 1000
return uint8(y)
}
func absFloatV2(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func absIntV2(v int) int {
if v < 0 {
return -v
}
return v
}
func buildSliderCursorV2(candidateIndex int, candidateCount int) string {
if candidateCount <= 0 {
return "[]"
}
if candidateIndex < 1 {
candidateIndex = 1
}
if candidateIndex > candidateCount {
candidateIndex = candidateCount
}
type cursorPoint struct {
X int `json:"x"`
Y int `json:"y"`
}
startX := 570 + mathrand.Intn(40)
startY := 875 + mathrand.Intn(30)
denom := candidateCount - 1
if denom < 1 {
denom = 1
}
baseTargetX := 734 + (937-734)*(candidateIndex-1)/denom
targetX := baseTargetX + mathrand.Intn(10) - 5
targetY := 655 + mathrand.Intn(14)
points := make([]cursorPoint, 0, 28)
for i := 0; i < 1+mathrand.Intn(3); i++ {
points = append(points, cursorPoint{
X: startX + mathrand.Intn(5) - 2,
Y: startY + mathrand.Intn(5) - 2,
})
}
transitSteps := 2 + mathrand.Intn(3)
arcOffX := mathrand.Intn(60) - 30
arcOffY := -(mathrand.Intn(30) + 10)
for i := 1; i <= transitSteps; i++ {
t := float64(i) / float64(transitSteps+1)
cx := float64(startX+targetX)/2 + float64(arcOffX)
cy := float64(startY+targetY)/2 + float64(arcOffY)
bx := (1-t)*(1-t)*float64(startX) + 2*t*(1-t)*cx + t*t*float64(targetX)
by := (1-t)*(1-t)*float64(startY) + 2*t*(1-t)*cy + t*t*float64(targetY)
jitter := int((1-t)*8) + 2
points = append(points, cursorPoint{
X: int(math.Round(bx)) + mathrand.Intn(jitter*2+1) - jitter,
Y: int(math.Round(by)) + mathrand.Intn(jitter*2+1) - jitter,
})
}
approachSteps := 4 + mathrand.Intn(4)
prev := points[len(points)-1]
for i := 1; i <= approachSteps; i++ {
t := float64(i) / float64(approachSteps)
ax := prev.X + int(math.Round(t*float64(targetX-prev.X))) + mathrand.Intn(5) - 2
ay := prev.Y + int(math.Round(t*float64(targetY-prev.Y))) + mathrand.Intn(5) - 2
points = append(points, cursorPoint{X: ax, Y: ay})
}
settleCount := 3 + mathrand.Intn(5)
for i := 0; i < settleCount; i++ {
points = append(points, cursorPoint{
X: targetX + mathrand.Intn(7) - 3,
Y: targetY + mathrand.Intn(7) - 3,
})
}
data, err := json.Marshal(points)
if err != nil {
return "[]"
}
return string(data)
}

66
pkg/clientcore/cli.go

@ -0,0 +1,66 @@
//go:build !ios
package clientcore
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
)
func RunCLI() {
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 := Config{}
genWrapKey := flag.Bool("gen-wrap-key", false, "print a fresh 64-character hex key for -wrap-key and exit")
flag.StringVar(&cfg.TURNHost, "turn", "", "override TURN server ip")
flag.StringVar(&cfg.TURNPort, "port", "", "override TURN port")
flag.StringVar(&cfg.Listen, "listen", "127.0.0.1:9000", "listen on ip:port")
flag.StringVar(&cfg.VKLink, "vk-link", "", "VK calls invite link \"https://vk.com/call/join/...\"")
flag.StringVar(&cfg.YandexLink, "yandex-link", "", "Yandex telemost invite link \"https://telemost.yandex.ru/j/...\"")
flag.StringVar(&cfg.PeerAddr, "peer", "", "peer server address (host:port)")
flag.IntVar(&cfg.NumStreams, "n", 0, "connections to TURN (default 10 for VK, 1 for Yandex)")
flag.BoolVar(&cfg.UseUDP, "udp", false, "connect to TURN with UDP")
flag.BoolVar(&cfg.NoDTLS, "no-dtls", false, "connect without obfuscation. DO NOT USE")
flag.BoolVar(&cfg.VLESSMode, "vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets")
flag.BoolVar(&cfg.VLESSBond, "vless-bond", false, "bond one VLESS TCP connection across all active smux sessions")
flag.BoolVar(&cfg.WrapMode, "wrap", false, "WRAP mode: SRTP-like AEAD obfuscation for DTLS packets before they reach TURN ChannelData")
flag.StringVar(&cfg.WrapKeyHex, "wrap-key", "", "32-byte hex-encoded shared key for -wrap (64 hex chars)")
flag.IntVar(&cfg.StreamsPerCred, "streams-per-cred", streamsPerCache, "number of TURN streams sharing one VK credential cache")
flag.BoolVar(&cfg.Debug, "debug", false, "enable debug logging")
flag.BoolVar(&cfg.ManualCaptcha, "manual-captcha", false, "skip auto captcha solving, use manual mode immediately")
flag.StringVar(&cfg.CaptchaSolver, "captcha-solver", "v2", "auto captcha solver implementation: v1|v2")
flag.StringVar(&cfg.CaptchaHost, "captcha-host", "", "manual captcha host:port to expose in addition to localhost:8765")
flag.Parse()
if *genWrapKey {
key, err := genWrapKeyHex()
if err != nil {
log.Panicf("%v", err)
}
fmt.Println(key)
return
}
if err := Run(ctx, cfg); err != nil {
log.Panicf("%v", err)
}
}

135
pkg/clientcore/ish_listener_linux_386.go

@ -0,0 +1,135 @@
//go:build linux && 386
package clientcore
import (
"io"
"net"
"os"
"syscall"
"time"
"unsafe"
)
type ishListener struct {
net.Listener
f *os.File
fd int
}
// wrapISHListener overrides the standard net.Listener with a legacy syscall listener
// designed specifically for the iSH simulator on iOS, which lacks modern `accept4`.
func wrapISHListener(ln net.Listener) (net.Listener, error) {
tl, ok := ln.(*net.TCPListener)
if !ok {
return ln, nil
}
f, err := tl.File()
if err != nil {
return nil, err
}
// Keep a reference to *os.File so the garbage collector doesn't close the FD.
return &ishListener{Listener: ln, f: f, fd: int(f.Fd())}, nil
}
func (l *ishListener) Accept() (net.Conn, error) {
// Set the listener socket to blocking mode. Go makes it non-blocking by default.
// This avoids using time.Sleep in a spin-loop, which triggers futex_time64 SIGSYS in modern Go on iSH.
if err := syscall.SetNonblock(l.fd, false); err != nil {
return nil, err
}
for {
addr := make([]byte, 128)
addrlen := uintptr(128)
// i386 network syscalls are multiplexed via socketcall (102).
// SYS_ACCEPT is subcall 5.
args := [3]uintptr{uintptr(l.fd), uintptr(unsafe.Pointer(&addr[0])), uintptr(unsafe.Pointer(&addrlen))}
// Use Syscall6 to ensure we have enough arguments registers for the platform.
r1, _, errno := syscall.Syscall6(102, 5, uintptr(unsafe.Pointer(&args)), 0, 0, 0, 0)
if errno != 0 {
if errno == syscall.EINTR {
continue
}
return nil, errno
}
nfd := int(r1)
_ = syscall.SetsockoptInt(nfd, syscall.IPPROTO_TCP, syscall.TCP_NODELAY, 1)
_ = syscall.SetsockoptInt(nfd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, 256*1024)
_ = syscall.SetsockoptInt(nfd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, 256*1024)
// We avoid Go's net.FileConn because it tries to register the fd with Go's epoll poller,
// which in iSH emulator consistency fails with EEXIST (file exists).
// Instead, we return a custom blocking net.Conn wrapper.
conn := &ishConn{fd: nfd}
return conn, nil
}
}
func (l *ishListener) Close() error {
// Close both the duplicated FD and the original listener.
err1 := l.f.Close()
err2 := l.Listener.Close()
if err1 != nil {
return err1
}
return err2
}
// ishConn bypasses Go's network poller to prevent EEXIST bugs in iSH
type ishConn struct {
fd int
}
func (c *ishConn) Read(b []byte) (n int, err error) {
for {
n, err = syscall.Read(c.fd, b)
if err == syscall.EINTR {
continue
}
if err != nil {
return n, err
}
if n == 0 {
return 0, os.ErrClosed
}
return n, nil
}
}
func (c *ishConn) Write(b []byte) (n int, err error) {
for n < len(b) {
written, writeErr := syscall.Write(c.fd, b[n:])
if writeErr == syscall.EINTR {
continue
}
if writeErr != nil {
return n, writeErr
}
if written == 0 {
return n, io.ErrShortWrite
}
n += written
}
return n, nil
}
func (c *ishConn) Close() error {
return syscall.Close(c.fd)
}
func (c *ishConn) LocalAddr() net.Addr {
return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 9000}
}
func (c *ishConn) RemoteAddr() net.Addr {
return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}
}
func (c *ishConn) SetDeadline(t time.Time) error { return nil }
func (c *ishConn) SetReadDeadline(t time.Time) error { return nil }
func (c *ishConn) SetWriteDeadline(t time.Time) error { return nil }

10
pkg/clientcore/ish_listener_other.go

@ -0,0 +1,10 @@
//go:build !(linux && 386)
package clientcore
import "net"
// wrapISHListener is a no-op for architectures that don't need the legacy socketcall accept bypass.
func wrapISHListener(ln net.Listener) (net.Listener, error) {
return ln, nil
}

3194
pkg/clientcore/main.go

File diff suppressed because it is too large

2
client/main_test.go → pkg/clientcore/main_test.go

@ -1,4 +1,4 @@
package main
package clientcore
import "testing"

352
client/manual_captcha.go → pkg/clientcore/manual_captcha.go

@ -1,4 +1,4 @@
package main
package clientcore
import (
"bytes"
@ -14,6 +14,7 @@ import (
"net/http/httputil"
neturl "net/url"
"os/exec"
"regexp"
"runtime"
"strings"
"time"
@ -23,28 +24,79 @@ import (
const captchaListenPort = "8765"
var customCaptchaHost string
type browserCommand struct {
name string
args []string
}
func setLocalCaptchaHost(host string) error {
host = strings.TrimSpace(host)
if host == "" {
customCaptchaHost = ""
return nil
}
if strings.Contains(host, "://") {
return fmt.Errorf("-captcha-host must be host:port without scheme")
}
hostname, port, err := net.SplitHostPort(host)
if err != nil {
return fmt.Errorf("-captcha-host must be host:port: %w", err)
}
if hostname == "" || port == "" {
return fmt.Errorf("-captcha-host must include both host and port")
}
u := &neturl.URL{Scheme: "http", Host: host}
if u.String() == "" {
return fmt.Errorf("-captcha-host is invalid")
}
customCaptchaHost = host
return nil
}
func localCaptchaHost() string {
if customCaptchaHost != "" {
return customCaptchaHost
}
return "localhost:" + captchaListenPort
}
func localCaptchaOrigin() string {
return "http://localhost:" + captchaListenPort
return (&neturl.URL{Scheme: "http", Host: localCaptchaHost()}).String()
}
func localCaptchaListenAddrs() []string {
return []string{
addrs := []string{
"127.0.0.1:" + captchaListenPort,
"[::1]:" + captchaListenPort,
}
if customCaptchaHost != "" {
addrs = appendUniqueFold(addrs, customCaptchaHost)
}
return addrs
}
func localCaptchaHosts() []string {
return []string{
hosts := []string{
"localhost:" + captchaListenPort,
"127.0.0.1:" + captchaListenPort,
"[::1]:" + captchaListenPort,
}
if customCaptchaHost != "" {
hosts = appendUniqueFold(hosts, customCaptchaHost)
}
return hosts
}
func appendUniqueFold(values []string, value string) []string {
for _, existing := range values {
if strings.EqualFold(existing, value) {
return values
}
}
return append(values, value)
}
func isLocalCaptchaHost(host string) bool {
@ -59,7 +111,7 @@ func isLocalCaptchaHost(host string) bool {
func localCaptchaURLForTarget(targetURL *neturl.URL) string {
localURL := &neturl.URL{
Scheme: "http",
Host: "localhost:" + captchaListenPort,
Host: localCaptchaHost(),
Path: targetURL.Path,
RawPath: targetURL.RawPath,
RawQuery: targetURL.RawQuery,
@ -164,11 +216,100 @@ func rewriteProxyCookies(header http.Header) {
}
}
// 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() {
@ -207,14 +348,52 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
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: 'token=' + encodeURIComponent(token)
}).then(function() {
document.body.innerHTML = '<h2 style="text-align:center;margin-top:20vh">Done! You can close the page.</h2>';
setTimeout(function() { window.close(); }, 300);
}).catch(function() {});
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;
@ -325,7 +504,11 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
</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>"):
@ -352,20 +535,33 @@ func newCaptchaProxyTransport(dialer *dnsdialer.Dialer) *http.Transport {
func startCaptchaServer(srv *http.Server, logPrefix string) error {
var listenErrs []string
var customListenErr 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))
if customCaptchaHost != "" && strings.EqualFold(addr, customCaptchaHost) {
customListenErr = fmt.Sprintf("%s (%v)", addr, err)
}
continue
}
listening = true
wrappedListener, err := wrapISHListener(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)
}
}(listener)
}(wrappedListener)
}
if customListenErr != "" {
return fmt.Errorf("captcha listener failed: %s", customListenErr)
}
if listening {
@ -385,18 +581,24 @@ func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-ch
fmt.Println("\n==============================================")
fmt.Println("ACTION REQUIRED: MANUAL CAPTCHA SOLVING NEEDED")
fmt.Println("Open this URL in your browser: " + localCaptchaOrigin())
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
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
return "", err
// 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
@ -443,6 +645,59 @@ button{font-size:24px;padding:12px 32px;margin-top:12px;cursor:pointer}</style>
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 := SavedProfile{
Profile: 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 := SaveProfileToDisk(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 *dnsdialer.Dialer) (string, error) {
keyCh := make(chan string, 1)
@ -451,7 +706,7 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
return "", fmt.Errorf("invalid redirect URI: %v", err)
}
transport := newCaptchaProxyTransport(dialer)
transport := &loggingTransport{rt: newCaptchaProxyTransport(dialer)}
proxy := &httputil.ReverseProxy{
Transport: transport,
@ -469,7 +724,7 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
if res.StatusCode >= 300 && res.StatusCode < 400 {
if loc := res.Header.Get("Location"); loc != "" {
log.Printf("[Captcha Proxy] Redirecting to: %s", 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 {
@ -480,7 +735,9 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
contentType := res.Header.Get("Content-Type")
contentEncoding := res.Header.Get("Content-Encoding")
log.Printf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding)
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") ||
@ -545,8 +802,15 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
mux := http.NewServeMux()
mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) {
notifyKey(keyCh, r.FormValue("token")) // r.FormValue automatically parses the form
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")
})
@ -564,14 +828,50 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
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.String())
log.Printf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.Path)
if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" {
log.Printf("[Captcha Proxy] Redirecting ROOT to: %s", localCaptchaURLForTarget(targetURL))
// Don't log the full redirect URL to keep console clean
http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect)
return
}
@ -592,7 +892,13 @@ func openBrowser(url string) {
func browserOpenCommands(goos string, url string) []browserCommand {
switch goos {
case "windows":
return []browserCommand{{name: "cmd", args: []string{"/c", "start", url}}}
// '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":

109
pkg/clientcore/manual_captcha_test.go

@ -0,0 +1,109 @@
package clientcore
import (
"net/url"
"testing"
)
func TestRewriteProxyRedirectLocation(t *testing.T) {
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) {
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)
}
})
}
}
func TestCustomCaptchaHost(t *testing.T) {
if err := setLocalCaptchaHost("192.168.99.1:8765"); err != nil {
t.Fatalf("setLocalCaptchaHost() failed: %v", err)
}
defer func() {
if err := setLocalCaptchaHost(""); err != nil {
t.Fatalf("reset local captcha host: %v", err)
}
}()
targetURL, err := url.Parse("https://id.vk.ru/captcha?step=2")
if err != nil {
t.Fatalf("failed to parse target URL: %v", err)
}
if got, want := localCaptchaOrigin(), "http://192.168.99.1:8765"; got != want {
t.Fatalf("localCaptchaOrigin() = %q, want %q", got, want)
}
if got, want := localCaptchaURLForTarget(targetURL), "http://192.168.99.1:8765/captcha?step=2"; got != want {
t.Fatalf("localCaptchaURLForTarget() = %q, want %q", got, want)
}
if !isLocalCaptchaHost("192.168.99.1:8765") {
t.Fatal("custom captcha host should be accepted as local")
}
addrs := localCaptchaListenAddrs()
if len(addrs) != 3 || addrs[2] != "192.168.99.1:8765" {
t.Fatalf("localCaptchaListenAddrs() = %v, want custom host appended", addrs)
}
}
func TestSetLocalCaptchaHostRejectsInvalidValues(t *testing.T) {
testCases := []string{
"http://192.168.99.1:8765",
"192.168.99.1",
":8765",
}
for _, tc := range testCases {
tc := tc
t.Run(tc, func(t *testing.T) {
if err := setLocalCaptchaHost(tc); err == nil {
t.Fatalf("setLocalCaptchaHost(%q) succeeded, want error", tc)
}
})
}
}

2
client/namegen.go → pkg/clientcore/namegen.go

@ -1,4 +1,4 @@
package main
package clientcore
import (
"fmt"

37
client/profiles.go → pkg/clientcore/profiles.go

@ -1,7 +1,9 @@
package main
package clientcore
import (
"encoding/json"
"math/rand"
"os"
)
type Profile struct {
@ -11,8 +13,37 @@ type Profile struct {
SecChUaPlatform string
}
type SavedProfile struct {
Profile
DeviceJSON string
BrowserFp string
}
const profileFile = "vk_profile.json"
func LoadProfileFromDisk() (*SavedProfile, error) {
data, err := os.ReadFile(profileFile)
if err != nil {
return nil, err
}
var sp SavedProfile
if err := json.Unmarshal(data, &sp); err != nil {
return nil, err
}
return &sp, nil
}
func SaveProfileToDisk(sp SavedProfile) error {
data, err := json.MarshalIndent(sp, "", " ")
if err != nil {
return err
}
return os.WriteFile(profileFile, data, 0644)
}
// profiles contain paired User-Agent and Client Hints strings to harden bot detection.
var profile = []Profile{
// Used only as a fallback if no saved profile exists (which we shouldn't really use for check anymore).
var profileList = []Profile{
// Windows Chrome
{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
@ -78,5 +109,5 @@ var profile = []Profile{
// getRandomProfile returns a paired User-Agent and Client Hints profile.
func getRandomProfile() Profile {
return profile[rand.Intn(len(profile))]
return profileList[rand.Intn(len(profileList))]
}

273
client/slider_captcha.go → pkg/clientcore/slider_captcha.go

@ -1,4 +1,4 @@
package main
package clientcore
import (
"bytes"
@ -8,9 +8,10 @@ import (
"fmt"
"image"
"image/color"
_ "image/jpeg"
_ "image/jpeg" // register JPEG decoder
"io"
"log"
"math/rand"
neturl "net/url"
"regexp"
"sort"
@ -23,7 +24,6 @@ import (
)
const (
captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c"
sliderCaptchaType = "slider"
defaultSliderAttempts = 4
)
@ -36,6 +36,17 @@ type captchaNotRobotSession struct {
client tlsclient.HttpClient
profile Profile
browserFp string
adFp string
savedProfile *SavedProfile
}
func generateAdFp() string {
b := make([]byte, 16)
// simple random bytes (or any pseudo-random logic that matches the 21-char base64 footprint)
for i := range b {
b[i] = byte(rand.Intn(256))
}
return base64.RawURLEncoding.EncodeToString(b)[:21]
}
type captchaSettingsResponse struct {
@ -68,14 +79,12 @@ type captchaBootstrap struct {
Settings *captchaSettingsResponse
}
func newCaptchaNotRobotSession(
ctx context.Context,
sessionToken string,
hash string,
streamID int,
client tlsclient.HttpClient,
profile Profile,
) *captchaNotRobotSession {
func newCaptchaNotRobotSession(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile, savedProfile *SavedProfile) *captchaNotRobotSession {
browserFp := generateBrowserFp(profile)
if savedProfile != nil {
browserFp = savedProfile.BrowserFp
}
return &captchaNotRobotSession{
ctx: ctx,
sessionToken: sessionToken,
@ -83,7 +92,9 @@ func newCaptchaNotRobotSession(
streamID: streamID,
client: client,
profile: profile,
browserFp: generateBrowserFp(profile),
browserFp: browserFp,
adFp: generateAdFp(),
savedProfile: savedProfile,
}
}
@ -91,7 +102,7 @@ func (s *captchaNotRobotSession) baseValues() neturl.Values {
values := neturl.Values{}
values.Set("session_token", s.sessionToken)
values.Set("domain", "vk.com")
values.Set("adFp", "")
values.Set("adFp", s.adFp)
values.Set("access_token", "")
return values
}
@ -104,6 +115,16 @@ func (s *captchaNotRobotSession) request(method string, values neturl.Values) (m
return nil, err
}
// Match the headers that the real VK captcha JS sends, same as callCaptchaNotRobot.
applyBrowserProfileFhttp(req, s.profile)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "*/*")
req.Header.Set("Origin", "https://api.vk.ru")
req.Header.Set("Referer", fmt.Sprintf("https://api.vk.ru/not_robot_captcha?domain=vk.com&session_token=%s&variant=popup&blank=1", s.sessionToken))
req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Dest", "empty")
httpResp, err := s.client.Do(req)
if err != nil {
return nil, err
@ -135,7 +156,12 @@ func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, er
func (s *captchaNotRobotSession) requestComponentDone() error {
values := s.baseValues()
values.Set("browser_fp", s.browserFp)
values.Set("device", buildCaptchaDeviceJSON(s.profile))
deviceJSON := buildCaptchaDeviceJSON(s.profile)
if s.savedProfile != nil {
deviceJSON = s.savedProfile.DeviceJSON
}
values.Set("device", deviceJSON)
resp, err := s.request("captchaNotRobot.componentDone", values)
if err != nil {
@ -144,8 +170,8 @@ func (s *captchaNotRobotSession) requestComponentDone() error {
respObj, ok := resp["response"].(map[string]interface{})
if ok {
if status, _ := respObj["status"].(string); status != "" && status != "OK" {
return fmt.Errorf("componentDone status: %s", status)
if statusVal, ok := respObj["status"].(string); ok && statusVal != "" && statusVal != "OK" {
return fmt.Errorf("componentDone status: %s", statusVal)
}
}
@ -153,7 +179,7 @@ func (s *captchaNotRobotSession) requestComponentDone() error {
}
func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, error) {
return s.requestCheck(generateSliderCursor(0, 1), base64.StdEncoding.EncodeToString([]byte("{}")))
return s.requestCheck("[]", base64.StdEncoding.EncodeToString([]byte("{}")))
}
func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*sliderCaptchaContent, error) {
@ -169,28 +195,68 @@ func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*s
return parseSliderCaptchaContentResponse(resp)
}
// requestSliderContentWithFallback tries to get slider content using multiple strategies:
// first with the provided captcha_settings, then without it (and vice versa).
// VK sometimes reports show_type=checkbox in settings but actually serves slider content,
// so we need to probe both variants.
func (s *captchaNotRobotSession) requestSliderContentWithFallback(sliderSettings string, streamID int) (*sliderCaptchaContent, error) {
type attempt struct {
settings string
desc string
}
var attempts []attempt
if sliderSettings != "" {
attempts = []attempt{
{settings: sliderSettings, desc: "with captcha_settings"},
{settings: "", desc: "without captcha_settings"},
}
} else {
// We have no slider settings; just one attempt without captcha_settings
attempts = []attempt{
{settings: "", desc: "without captcha_settings"},
}
}
var lastErr error
for _, a := range attempts {
log.Printf("[STREAM %d] [Captcha] Requesting slider content (%s)...", streamID, a.desc)
content, err := s.requestSliderContent(a.settings)
if err == nil {
return content, nil
}
log.Printf("[STREAM %d] [Captcha] getContent failed (%s): %v", streamID, a.desc, err)
lastErr = err
}
return nil, lastErr
}
func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) {
answer, err := encodeSliderAnswer(activeSteps)
if err != nil {
return nil, err
}
return s.requestCheck(generateSliderCursor(candidateIndex, candidateCount), answer)
return s.requestCheck("[]", answer)
}
func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*captchaCheckResult, error) {
values := s.baseValues()
// The real browser sends a static SHA-256 hash for debug_info.
// We use the exact one captured from the real browser's session.
debugInfo := "f3ef768dab7a20f574c6461f34e4257894d2a3c30a53d8727a3edaf7ab70847d"
values.Set("accelerometer", "[]")
values.Set("gyroscope", "[]")
values.Set("motion", "[]")
values.Set("cursor", cursor)
values.Set("taps", "[]")
values.Set("connectionRtt", "[]")
values.Set("connectionDownlink", "[]")
values.Set("connectionRtt", "[250,250,250,250,250]")
values.Set("connectionDownlink", "[1.45,1.45,1.45,1.45,1.45]")
values.Set("browser_fp", s.browserFp)
values.Set("hash", s.hash)
values.Set("answer", answer)
values.Set("debug_info", captchaDebugInfo)
values.Set("debug_info", debugInfo)
resp, err := s.request("captchaNotRobot.check", values)
if err != nil {
@ -214,8 +280,9 @@ func callCaptchaNotRobotWithSliderPOC(
client tlsclient.HttpClient,
profile Profile,
initialSettings *captchaSettingsResponse,
savedProfile *SavedProfile,
) (string, error) {
session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile)
session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile, savedProfile)
log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID)
settingsResp, err := session.requestSettings()
@ -227,7 +294,8 @@ func callCaptchaNotRobotWithSliderPOC(
time.Sleep(200 * time.Millisecond)
log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID)
if err := session.requestComponentDone(); err != nil {
err = session.requestComponentDone()
if err != nil {
return "", err
}
@ -265,24 +333,25 @@ func callCaptchaNotRobotWithSliderPOC(
log.Printf("[STREAM %d] [Captcha] Trying experimental slider solver...", streamID)
}
sliderContent, err := session.requestSliderContent(sliderSettings)
// After check returns BOT, a real browser renders the slider widget and calls
// componentDone again to signal "slider component is now loaded". Without this,
// VK refuses getContent with ERROR because it expects the widget lifecycle.
log.Printf("[STREAM %d] [Captcha] Re-registering slider component before getContent...", streamID)
time.Sleep(300 * time.Millisecond)
err = session.requestComponentDone()
if err != nil {
// Non-fatal: log and continue — getContent may still succeed.
log.Printf("[STREAM %d] [Captcha] Warning: slider componentDone failed: %v", streamID, err)
}
time.Sleep(200 * time.Millisecond)
sliderContent, err := session.requestSliderContentWithFallback(sliderSettings, streamID)
if err != nil {
log.Printf(
"[STREAM %d] [Captcha] Slider getContent failed (status: %v). Trying to solve as a checkbox instead...",
"[STREAM %d] [Captcha] All slider getContent attempts failed: %v",
streamID,
err,
)
// Fallback: maybe it's just a checkbox that needs a human-like check
time.Sleep(300 * time.Millisecond)
finalCheck, err2 := session.requestCheckboxCheck()
if err2 == nil && finalCheck.Status == "OK" {
if finalCheck.SuccessToken == "" {
return "", fmt.Errorf("success_token not found in fallback check")
}
log.Printf("[STREAM %d] [Captcha] Fallback checkbox check succeeded!", streamID)
session.requestEndSession()
return finalCheck.SuccessToken, nil
}
return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err)
}
@ -317,8 +386,10 @@ func callCaptchaNotRobotWithSliderPOC(
}
func buildCaptchaDeviceJSON(profile Profile) string {
// Fallback device JSON if no saved profile is available.
// We include the User-Agent from the current profile to maintain some consistency.
return fmt.Sprintf(
`{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default","userAgent":"%s","platform":"Win32"}`,
`{"screenWidth":1536,"screenHeight":864,"screenAvailWidth":1536,"screenAvailHeight":816,"innerWidth":1536,"innerHeight":730,"devicePixelRatio":1.25,"language":"ru-RU","languages":["ru-RU","ru","en-US","en"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"prompt","userAgent":"%s"}`,
profile.UserAgent,
)
}
@ -332,7 +403,7 @@ func parseCaptchaSettingsResponse(resp map[string]interface{}) (*captchaSettings
settings := &captchaSettingsResponse{
SettingsByType: make(map[string]string),
}
settings.ShowCaptchaType, _ = respObj["show_captcha_type"].(string)
settings.ShowCaptchaType, _ = respObj["show_captcha_type"].(string) //nolint:errcheck
rawSettings, ok := expandCaptchaSettings(respObj["captcha_settings"])
if !ok {
@ -345,7 +416,7 @@ func parseCaptchaSettingsResponse(resp map[string]interface{}) (*captchaSettings
continue
}
captchaType, _ := item["type"].(string)
captchaType, _ := item["type"].(string) //nolint:errcheck
if captchaType == "" {
continue
}
@ -511,9 +582,9 @@ func parseCaptchaCheckResult(resp map[string]interface{}) (*captchaCheckResult,
}
result := &captchaCheckResult{}
result.Status, _ = respObj["status"].(string)
result.SuccessToken, _ = respObj["success_token"].(string)
result.ShowCaptchaType, _ = respObj["show_captcha_type"].(string)
result.Status, _ = respObj["status"].(string) //nolint:errcheck
result.SuccessToken, _ = respObj["success_token"].(string) //nolint:errcheck
result.ShowCaptchaType, _ = respObj["show_captcha_type"].(string) //nolint:errcheck
if result.Status == "" {
return nil, fmt.Errorf("check status missing: %v", resp)
}
@ -527,18 +598,27 @@ func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCapt
return nil, fmt.Errorf("invalid slider content response: %v", resp)
}
status, _ := respObj["status"].(string)
status, _ := respObj["status"].(string) //nolint:errcheck
if status != "OK" {
// Log all fields from the response to help diagnose why VK rejected getContent.
var debugFields []string
for k, v := range respObj {
if k != "image" { // skip base64 image blob
debugFields = append(debugFields, fmt.Sprintf("%s=%v", k, v))
}
}
sort.Strings(debugFields)
log.Printf("[Captcha] getContent ERROR response fields: %s", strings.Join(debugFields, " "))
return nil, fmt.Errorf("slider getContent status: %s", status)
}
extension, _ := respObj["extension"].(string)
extension, _ := respObj["extension"].(string) //nolint:errcheck
extension = strings.ToLower(extension)
if extension != "jpeg" && extension != "jpg" {
return nil, fmt.Errorf("unsupported slider image format: %s", extension)
}
rawImage, _ := respObj["image"].(string)
rawImage, _ := respObj["image"].(string) //nolint:errcheck
if rawImage == "" {
return nil, fmt.Errorf("slider image missing")
}
@ -731,12 +811,70 @@ func rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderC
}
func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, error) {
rendered, err := renderSliderCandidate(img, gridSize, mapping)
if err != nil {
return 0, err
bounds := img.Bounds()
var score int64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
dstLeftIndex := row*gridSize + col
dstRightIndex := row*gridSize + col + 1
srcLeftIndex := mapping[dstLeftIndex]
srcRightIndex := mapping[dstRightIndex]
dstLeftRect := sliderTileRect(bounds, gridSize, dstLeftIndex)
dstRightRect := sliderTileRect(bounds, gridSize, dstRightIndex)
srcLeftRect := sliderTileRect(bounds, gridSize, srcLeftIndex)
srcRightRect := sliderTileRect(bounds, gridSize, srcRightIndex)
height := minInt(dstLeftRect.Dy(), dstRightRect.Dy())
leftSrcXRel := (dstLeftRect.Dx() - 1) * srcLeftRect.Dx() / dstLeftRect.Dx()
sxLeft := srcLeftRect.Min.X + leftSrcXRel
sxRight := srcRightRect.Min.X
for offset := 0; offset < height; offset++ {
syLeft := srcLeftRect.Min.Y + offset*srcLeftRect.Dy()/dstLeftRect.Dy()
syRight := srcRightRect.Min.Y + offset*srcRightRect.Dy()/dstRightRect.Dy()
score += pixelDiff(
img.At(sxLeft, syLeft),
img.At(sxRight, syRight),
)
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
dstTopIndex := row*gridSize + col
dstBottomIndex := (row+1)*gridSize + col
srcTopIndex := mapping[dstTopIndex]
srcBottomIndex := mapping[dstBottomIndex]
dstTopRect := sliderTileRect(bounds, gridSize, dstTopIndex)
dstBottomRect := sliderTileRect(bounds, gridSize, dstBottomIndex)
srcTopRect := sliderTileRect(bounds, gridSize, srcTopIndex)
srcBottomRect := sliderTileRect(bounds, gridSize, srcBottomIndex)
width := minInt(dstTopRect.Dx(), dstBottomRect.Dx())
topSrcYRel := (dstTopRect.Dy() - 1) * srcTopRect.Dy() / dstTopRect.Dy()
syTop := srcTopRect.Min.Y + topSrcYRel
syBottom := srcBottomRect.Min.Y
for offset := 0; offset < width; offset++ {
sxTop := srcTopRect.Min.X + offset*srcTopRect.Dx()/dstTopRect.Dx()
sxBottom := srcBottomRect.Min.X + offset*srcBottomRect.Dx()/dstBottomRect.Dx()
score += pixelDiff(
img.At(sxTop, syTop),
img.At(sxBottom, syBottom),
)
}
}
}
return scoreRenderedSliderImage(rendered, gridSize), nil
return score, nil
}
func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image.RGBA, error) {
@ -760,41 +898,6 @@ func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image
return rendered, nil
}
func scoreRenderedSliderImage(img image.Image, gridSize int) int64 {
bounds := img.Bounds()
var score int64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
leftRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
rightRect := sliderTileRect(bounds, gridSize, row*gridSize+col+1)
height := minInt(leftRect.Dy(), rightRect.Dy())
for offset := 0; offset < height; offset++ {
score += pixelDiff(
img.At(leftRect.Max.X-1, leftRect.Min.Y+offset),
img.At(rightRect.Min.X, rightRect.Min.Y+offset),
)
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
topRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
bottomRect := sliderTileRect(bounds, gridSize, (row+1)*gridSize+col)
width := minInt(topRect.Dx(), bottomRect.Dx())
for offset := 0; offset < width; offset++ {
score += pixelDiff(
img.At(topRect.Min.X+offset, topRect.Max.Y-1),
img.At(bottomRect.Min.X+offset, bottomRect.Min.Y),
)
}
}
}
return score
}
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
row := index / gridSize
col := index % gridSize
@ -840,6 +943,7 @@ func absDiff(left uint32, right uint32) int64 {
return int64(right - left)
}
/*
func generateSliderCursor(candidateIndex int, candidateCount int) string {
return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli())
}
@ -876,6 +980,7 @@ func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64)
}
return string(data)
}
*/
func trySliderCaptchaCandidates(
candidates []sliderCandidate,

2
client/slider_captcha_test.go → pkg/clientcore/slider_captcha_test.go

@ -1,4 +1,4 @@
package main
package clientcore
import (
"bytes"

166
pkg/clientcore/wrap.go

@ -0,0 +1,166 @@
// SPDX-License-Identifier: MIT
package clientcore
import (
"crypto/cipher"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"sync/atomic"
"golang.org/x/crypto/chacha20poly1305"
)
// Wire format - SRTP-like mimicry:
//
// [12B RTP header | 12B explicit nonce | AEAD ciphertext | 16B tag]
//
// RTP header (RFC 3550):
//
// byte 0: 0x80 V=2, P=0, X=0, CC=0
// byte 1: 0x6F M=0, PT=111 (opus, typical voice PT)
// byte 2-3: seq16 BE monotonic, init random
// byte 4-7: ts32 BE monotonic, init random, increments by 960 (20ms @ 48kHz)
// byte 8-11: SSRC random per conn, MSB encodes direction
//
// 12B explicit nonce = 4B sessionID || 8B counter (BE). sessionID MSB
// matches SSRC MSB (direction bit). counter starts at a random uint64.
// AAD = first 24 bytes (RTP header || nonce).
//
// VK TURN appears to forward SRTP-shaped ChannelData on a fast path and
// drop anomalous payloads. AEAD ciphertext + 16B tag is plausible as
// AES-GCM SRTP per RFC 7714.
const (
wrapKeyLen = 32
wrapRTPHdrLen = 12
wrapNonceLen = 12
wrapTagLen = 16
wrapHeaderLen = wrapRTPHdrLen + wrapNonceLen
wrapOverhead = wrapHeaderLen + wrapTagLen
wrapRTPVersion = 0x80
wrapRTPPT = 0x6F
wrapTSStep = 960
)
type wrapConn struct {
aead cipher.AEAD
sessionID [4]byte
ssrc [4]byte
counter atomic.Uint64
seq atomic.Uint32
timestamp atomic.Uint32
}
func newWrapConn(key []byte, isServer bool) (*wrapConn, error) {
if len(key) != wrapKeyLen {
return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key))
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, fmt.Errorf("wrap: aead init: %w", err)
}
w := &wrapConn{aead: aead}
var rnd [16]byte
if _, err := rand.Read(rnd[:]); err != nil {
return nil, fmt.Errorf("wrap: rand init: %w", err)
}
copy(w.sessionID[:], rnd[0:4])
copy(w.ssrc[:], rnd[4:8])
if isServer {
w.sessionID[0] |= 0x80
w.ssrc[0] |= 0x80
} else {
w.sessionID[0] &^= 0x80
w.ssrc[0] &^= 0x80
}
w.seq.Store(uint32(binary.BigEndian.Uint16(rnd[8:10])))
w.timestamp.Store(binary.BigEndian.Uint32(rnd[10:14]))
var cb [8]byte
if _, err := rand.Read(cb[:]); err != nil {
return nil, fmt.Errorf("wrap: counter rand: %w", err)
}
w.counter.Store(binary.BigEndian.Uint64(cb[:]))
return w, nil
}
func wrapMaxWire(payloadLen int) int {
return wrapOverhead + payloadLen
}
func (w *wrapConn) wrapInto(dst, payload []byte) (int, error) {
wireLen := wrapOverhead + len(payload)
if len(dst) < wireLen {
return 0, errors.New("wrap: dst buffer too small")
}
dst[0] = wrapRTPVersion
dst[1] = wrapRTPPT
seq := uint16(w.seq.Add(1) - 1)
binary.BigEndian.PutUint16(dst[2:4], seq)
ts := w.timestamp.Add(wrapTSStep) - wrapTSStep
binary.BigEndian.PutUint32(dst[4:8], ts)
copy(dst[8:12], w.ssrc[:])
noncePos := wrapRTPHdrLen
copy(dst[noncePos:noncePos+4], w.sessionID[:])
ctr := w.counter.Add(1) - 1
binary.BigEndian.PutUint64(dst[noncePos+4:noncePos+wrapNonceLen], ctr)
nonce := dst[noncePos : noncePos+wrapNonceLen]
aad := dst[:wrapHeaderLen]
ctPos := wrapHeaderLen
copy(dst[ctPos:], payload)
w.aead.Seal(dst[ctPos:ctPos], nonce, dst[ctPos:ctPos+len(payload)], aad)
return wireLen, nil
}
func (w *wrapConn) unwrapPacket(wire, dst []byte) (int, error) {
if len(wire) < wrapOverhead {
return 0, errors.New("wrap: packet too short")
}
nonce := wire[wrapRTPHdrLen : wrapRTPHdrLen+wrapNonceLen]
aad := wire[:wrapHeaderLen]
ct := wire[wrapHeaderLen:]
plain, err := w.aead.Open(ct[:0], nonce, ct, aad)
if err != nil {
return 0, fmt.Errorf("wrap: AEAD open: %w", err)
}
if len(plain) > len(dst) {
return 0, errors.New("wrap: dst buffer too small")
}
copy(dst[:len(plain)], plain)
return len(plain), nil
}
func genWrapKeyHex() (string, error) {
key := make([]byte, wrapKeyLen)
if _, err := rand.Read(key); err != nil {
return "", fmt.Errorf("wrap: key gen: %w", err)
}
return hex.EncodeToString(key), nil
}
func decodeWrapKey(enabled bool, raw string) ([]byte, error) {
if !enabled {
return nil, nil
}
if raw == "" {
return nil, errors.New("-wrap requires -wrap-key")
}
key, err := hex.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("-wrap-key invalid hex: %w", err)
}
if len(key) != wrapKeyLen {
return nil, fmt.Errorf("-wrap-key must decode to %d bytes (got %d)", wrapKeyLen, len(key))
}
return key, nil
}

186
pkg/clientcore/wrap_test.go

@ -0,0 +1,186 @@
package clientcore
import (
"bytes"
"encoding/binary"
"strings"
"testing"
)
func TestWrapConnRoundTrip(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
payload := []byte("dtls record bytes")
client, err := newWrapConn(key, false)
if err != nil {
t.Fatalf("newWrapConn(client): %v", err)
}
server, err := newWrapConn(key, true)
if err != nil {
t.Fatalf("newWrapConn(server): %v", err)
}
wire := make([]byte, wrapMaxWire(len(payload)))
n, err := client.wrapInto(wire, payload)
if err != nil {
t.Fatalf("wrapInto returned error: %v", err)
}
wire = wire[:n]
if wire[0] != wrapRTPVersion {
t.Fatalf("RTP byte0 = 0x%02X, want 0x%02X", wire[0], wrapRTPVersion)
}
if wire[1] != wrapRTPPT {
t.Fatalf("RTP byte1 (PT) = 0x%02X, want 0x%02X", wire[1], wrapRTPPT)
}
if bytes.Contains(wire, payload) {
t.Fatalf("wrapped packet contains plaintext payload")
}
dst := make([]byte, len(payload))
n, err = server.unwrapPacket(wire, dst)
if err != nil {
t.Fatalf("unwrapPacket returned error: %v", err)
}
if n != len(payload) {
t.Fatalf("unwrapped len = %d, want %d", n, len(payload))
}
if !bytes.Equal(dst[:n], payload) {
t.Fatalf("round trip mismatch: got %q want %q", dst[:n], payload)
}
}
func TestWrapRTPHeaderProgression(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
wc, err := newWrapConn(key, false)
if err != nil {
t.Fatalf("newWrapConn: %v", err)
}
payload := []byte("x")
wire1 := make([]byte, wrapMaxWire(len(payload)))
n1, err := wc.wrapInto(wire1, payload)
if err != nil {
t.Fatalf("wrapInto 1: %v", err)
}
wire2 := make([]byte, wrapMaxWire(len(payload)))
n2, err := wc.wrapInto(wire2, payload)
if err != nil {
t.Fatalf("wrapInto 2: %v", err)
}
if n1 != n2 {
t.Fatalf("wire size variance: %d vs %d", n1, n2)
}
seq1 := binary.BigEndian.Uint16(wire1[2:4])
seq2 := binary.BigEndian.Uint16(wire2[2:4])
if seq2 != seq1+1 {
t.Fatalf("seq did not increment: %d -> %d", seq1, seq2)
}
ts1 := binary.BigEndian.Uint32(wire1[4:8])
ts2 := binary.BigEndian.Uint32(wire2[4:8])
if ts2-ts1 != wrapTSStep {
t.Fatalf("timestamp step = %d, want %d", ts2-ts1, wrapTSStep)
}
if !bytes.Equal(wire1[8:12], wire2[8:12]) {
t.Fatalf("SSRC changed between packets")
}
}
func TestWrapDirectionBit(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
client, err := newWrapConn(key, false)
if err != nil {
t.Fatalf("newWrapConn(client): %v", err)
}
server, err := newWrapConn(key, true)
if err != nil {
t.Fatalf("newWrapConn(server): %v", err)
}
if client.sessionID[0]&0x80 != 0 {
t.Fatalf("client sessionID MSB should be 0, got 0x%02X", client.sessionID[0])
}
if server.sessionID[0]&0x80 == 0 {
t.Fatalf("server sessionID MSB should be 1, got 0x%02X", server.sessionID[0])
}
if client.ssrc[0]&0x80 != 0 {
t.Fatalf("client SSRC MSB should be 0, got 0x%02X", client.ssrc[0])
}
if server.ssrc[0]&0x80 == 0 {
t.Fatalf("server SSRC MSB should be 1, got 0x%02X", server.ssrc[0])
}
}
func TestDecodeWrapKeyRequiresValidKeyWhenEnabled(t *testing.T) {
if key, err := decodeWrapKey(false, ""); err != nil || key != nil {
t.Fatalf("disabled decodeWrapKey = (%v, %v), want (nil, nil)", key, err)
}
if _, err := decodeWrapKey(true, ""); err == nil {
t.Fatalf("decodeWrapKey accepted empty key")
}
shortHex := strings.Repeat("ab", wrapKeyLen-1)
if _, err := decodeWrapKey(true, shortHex); err == nil {
t.Fatalf("decodeWrapKey accepted short key")
}
fullHex := strings.Repeat("ab", wrapKeyLen)
key, err := decodeWrapKey(true, fullHex)
if err != nil {
t.Fatalf("decodeWrapKey returned error: %v", err)
}
if len(key) != wrapKeyLen {
t.Fatalf("decoded key len = %d, want %d", len(key), wrapKeyLen)
}
}
func TestUnwrapRejectsShortPacket(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
wc, err := newWrapConn(key, false)
if err != nil {
t.Fatalf("newWrapConn: %v", err)
}
if _, err := wc.unwrapPacket([]byte("short"), make([]byte, 16)); err == nil {
t.Fatalf("unwrapPacket accepted short packet")
}
}
func TestUnwrapRejectsTamperedPacket(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, wrapKeyLen)
client, err := newWrapConn(key, false)
if err != nil {
t.Fatalf("newWrapConn(client): %v", err)
}
server, err := newWrapConn(key, true)
if err != nil {
t.Fatalf("newWrapConn(server): %v", err)
}
payload := []byte("integrity test")
wire := make([]byte, wrapMaxWire(len(payload)))
n, err := client.wrapInto(wire, payload)
if err != nil {
t.Fatalf("wrapInto: %v", err)
}
wire = wire[:n]
wire[wrapHeaderLen+1] ^= 0xFF
dst := make([]byte, 1600)
if _, unwrapErr := server.unwrapPacket(wire, dst); unwrapErr == nil {
t.Fatalf("unwrapPacket accepted tampered ciphertext")
}
n, err = client.wrapInto(wire, payload)
if err != nil {
t.Fatalf("wrapInto: %v", err)
}
wire = wire[:n]
wire[8] ^= 0x01
if _, unwrapErr := server.unwrapPacket(wire, dst); unwrapErr == nil {
t.Fatalf("unwrapPacket accepted tampered AAD")
}
}

685
server/main.go

@ -2,6 +2,9 @@ package main
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"flag"
"fmt"
"io"
@ -10,6 +13,7 @@ import (
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
@ -19,11 +23,34 @@ import (
"github.com/xtaci/smux"
)
var isDebug bool
func debugf(format string, v ...any) {
if isDebug {
log.Printf(format, v...)
}
}
func main() {
listen := flag.String("listen", "0.0.0.0:56000", "listen on ip:port")
connect := flag.String("connect", "", "connect to ip:port")
vlessMode := flag.Bool("vless", false, "VLESS mode: forward TCP connections (for VLESS) instead of UDP packets")
vlessBond := flag.Bool("vless-bond", false, "bond one VLESS TCP connection across all active smux sessions")
wrapMode := flag.Bool("wrap", false, "WRAP mode: SRTP-like AEAD obfuscation for DTLS packets before they reach TURN ChannelData")
wrapKeyHex := flag.String("wrap-key", "", "32-byte hex-encoded shared key for -wrap (64 hex chars)")
genWrapKey := flag.Bool("gen-wrap-key", false, "print a fresh 64-character hex key for -wrap-key and exit")
debugFlag := flag.Bool("debug", false, "enable debug logging")
flag.Parse()
isDebug = *debugFlag
if *genWrapKey {
key := make([]byte, wrapKeyLen)
if _, err := rand.Read(key); err != nil {
log.Panicf("gen-wrap-key: rand.Read: %v", err)
}
fmt.Println(hex.EncodeToString(key))
return
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@ -44,6 +71,20 @@ func main() {
if len(*connect) == 0 {
log.Panicf("server address is required")
}
var wrapKey []byte
if *wrapMode {
if *wrapKeyHex == "" {
log.Panicf("-wrap requires -wrap-key")
}
wrapKey, err = hex.DecodeString(*wrapKeyHex)
if err != nil {
log.Panicf("-wrap-key invalid hex: %v", err)
}
if len(wrapKey) != wrapKeyLen {
log.Panicf("-wrap-key must decode to %d bytes (got %d)", wrapKeyLen, len(wrapKey))
}
}
log.Printf("Starting server listen=%s connect=%s vless=%t vless-bond=%t wrap=%t bond-autodetect=true", *listen, *connect, *vlessMode, *vlessBond, *wrapMode)
// Generate a certificate and private key to secure the connection
certificate, genErr := selfsign.GenerateSelfSigned()
if genErr != nil {
@ -54,15 +95,27 @@ func main() {
// Everything below is the pion-DTLS API! Thanks for using it ❤️.
//
// Connect to a DTLS server
listener, err := dtls.ListenWithOptions(
"udp",
addr,
dtlsOpts := []dtls.ServerOption{
dtls.WithCertificates(certificate),
dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret),
dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256),
dtls.WithConnectionIDGenerator(dtls.RandomCIDGenerator(8)),
)
}
var listener net.Listener
if *wrapMode {
log.Printf("WRAP mode enabled: listener only accepts clients with matching -wrap-key")
wrapListener, werr := listenWrapped(addr, wrapKey)
if werr != nil {
panic(werr)
}
listener, err = dtls.NewListenerWithOptions(wrapListener, dtlsOpts...)
} else {
udpListener, lerr := listenUDPForDTLS(addr)
if lerr != nil {
panic(lerr)
}
listener, err = dtls.NewListenerWithOptions(udpListener, dtlsOpts...)
}
if err != nil {
panic(err)
}
@ -96,7 +149,7 @@ func main() {
log.Printf("failed to close incoming connection: %s", closeErr)
}
}()
log.Printf("Connection from %s\n", conn.RemoteAddr())
debugf("Connection from %s\n", conn.RemoteAddr())
// Perform the handshake with a 30-second timeout
ctx1, cancel1 := context.WithTimeout(ctx, 30*time.Second)
@ -107,24 +160,563 @@ func main() {
log.Println("Type error: expected *dtls.Conn")
return
}
log.Println("Start handshake")
debugf("Start handshake")
if err := dtlsConn.HandshakeContext(ctx1); err != nil {
log.Printf("Handshake failed: %v", err)
return
}
log.Println("Handshake done")
debugf("Handshake done")
if *vlessMode {
handleVLESSConnection(ctx, dtlsConn, *connect)
handleVLESSConnection(ctx, dtlsConn, *connect, *vlessBond)
} else {
handleUDPConnection(ctx, conn, *connect)
}
log.Printf("Connection closed: %s\n", conn.RemoteAddr())
debugf("Connection closed: %s\n", conn.RemoteAddr())
}(conn)
}
}
type throughputStats struct {
tx atomic.Uint64
rx atomic.Uint64
}
func (s *throughputStats) addTx(n int) {
if n > 0 {
s.tx.Add(uint64(n))
}
}
func (s *throughputStats) addRx(n int) {
if n > 0 {
s.rx.Add(uint64(n))
}
}
func (s *throughputStats) logEvery(ctx context.Context, label, txName, rxName string) {
if !isDebug {
return
}
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
var prevTx, prevRx uint64
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
tx := s.tx.Load()
rx := s.rx.Load()
deltaTx := tx - prevTx
deltaRx := rx - prevRx
prevTx = tx
prevRx = rx
if deltaTx == 0 && deltaRx == 0 {
continue
}
debugf(
"%s throughput: %s=%s %s=%s total_%s=%s total_%s=%s",
label,
txName,
formatBitsPerSecond(deltaTx, 5*time.Second),
rxName,
formatBitsPerSecond(deltaRx, 5*time.Second),
txName,
formatByteCount(tx),
rxName,
formatByteCount(rx),
)
}
}
}
func formatBitsPerSecond(bytes uint64, interval time.Duration) string {
if interval <= 0 {
interval = time.Second
}
bps := float64(bytes*8) / interval.Seconds()
if bps >= 1_000_000 {
return fmt.Sprintf("%.2f Mbit/s", bps/1_000_000)
}
if bps >= 1_000 {
return fmt.Sprintf("%.1f kbit/s", bps/1_000)
}
return fmt.Sprintf("%.0f bit/s", bps)
}
func formatByteCount(bytes uint64) string {
if bytes >= 1024*1024 {
return fmt.Sprintf("%.2f MiB", float64(bytes)/(1024*1024))
}
if bytes >= 1024 {
return fmt.Sprintf("%.1f KiB", float64(bytes)/1024)
}
return fmt.Sprintf("%d B", bytes)
}
type countingConn struct {
net.Conn
stats *throughputStats
}
func (c *countingConn) Read(p []byte) (int, error) {
n, err := c.Conn.Read(p)
c.stats.addRx(n)
return n, err
}
func (c *countingConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
c.stats.addTx(n)
return n, err
}
const (
bondVersion = 1
bondMagic = "VLB1"
bondFrameData byte = 1
bondFrameFIN byte = 2
bondMaxChunk = 16 * 1024
bondLaneAttachTimeout = 300 * time.Millisecond
)
type bondHello struct {
connID uint64
laneIndex uint16
laneCount uint16
}
type bondFrame struct {
typ byte
seq uint64
data []byte
}
func readBondHelloAfterMagic(r io.Reader, magic [4]byte) (bondHello, error) {
var hdr [17]byte
copy(hdr[0:4], magic[:])
if _, err := io.ReadFull(r, hdr[4:]); err != nil {
return bondHello{}, err
}
return parseBondHelloHeader(hdr[:])
}
func parseBondHelloHeader(hdr []byte) (bondHello, error) {
if len(hdr) != 17 {
return bondHello{}, fmt.Errorf("bad bond hello size: %d", len(hdr))
}
if string(hdr[0:4]) != bondMagic {
return bondHello{}, fmt.Errorf("bad bond magic")
}
if hdr[4] != bondVersion {
return bondHello{}, fmt.Errorf("unsupported bond version: %d", hdr[4])
}
return bondHello{
connID: binary.BigEndian.Uint64(hdr[5:13]),
laneIndex: binary.BigEndian.Uint16(hdr[13:15]),
laneCount: binary.BigEndian.Uint16(hdr[15:17]),
}, nil
}
func writeBondFrame(w io.Writer, typ byte, seq uint64, data []byte) error {
var hdr [13]byte
hdr[0] = typ
binary.BigEndian.PutUint64(hdr[1:9], seq)
binary.BigEndian.PutUint32(hdr[9:13], uint32(len(data)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
if len(data) == 0 {
return nil
}
_, err := w.Write(data)
return err
}
func readBondFrame(r io.Reader) (bondFrame, error) {
var hdr [13]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return bondFrame{}, err
}
size := binary.BigEndian.Uint32(hdr[9:13])
if size > 4*1024*1024 {
return bondFrame{}, fmt.Errorf("bond frame too large: %d", size)
}
f := bondFrame{
typ: hdr[0],
seq: binary.BigEndian.Uint64(hdr[1:9]),
}
if size > 0 {
f.data = make([]byte, size)
if _, err := io.ReadFull(r, f.data); err != nil {
return bondFrame{}, err
}
}
return f, nil
}
func closeWrite(conn net.Conn) {
type closeWriter interface {
CloseWrite() error
}
if cw, ok := conn.(closeWriter); ok {
if err := cw.CloseWrite(); err != nil {
debugf("CloseWrite failed: %v", err)
}
}
}
type bondServerLane struct {
index uint16
stream *smux.Stream
mu sync.Mutex
}
type bondServerConn struct {
id uint64
connectAddr string
ctx context.Context
cancel context.CancelFunc
done chan struct{}
lanesMu sync.RWMutex
lanes []*bondServerLane
want uint16
ready chan struct{}
recvCh chan bondFrame
once sync.Once
}
type bondRegistry struct {
mu sync.Mutex
conns map[uint64]*bondServerConn
}
var globalBondRegistry = &bondRegistry{conns: make(map[uint64]*bondServerConn)}
func (r *bondRegistry) get(ctx context.Context, id uint64, connectAddr string) *bondServerConn {
r.mu.Lock()
defer r.mu.Unlock()
if c := r.conns[id]; c != nil {
return c
}
connCtx, cancel := context.WithCancel(ctx)
c := &bondServerConn{
id: id,
connectAddr: connectAddr,
ctx: connCtx,
cancel: cancel,
done: make(chan struct{}),
ready: make(chan struct{}, 1),
recvCh: make(chan bondFrame, 1024),
}
r.conns[id] = c
go func() {
<-c.done
r.mu.Lock()
if r.conns[id] == c {
delete(r.conns, id)
}
r.mu.Unlock()
}()
return c
}
func (c *bondServerConn) addLane(l *bondServerLane, laneCount uint16) {
c.lanesMu.Lock()
if laneCount > c.want {
c.want = laneCount
}
c.lanes = append(c.lanes, l)
count := len(c.lanes)
c.lanesMu.Unlock()
debugf("[bond %d] lane %d attached (lanes=%d)", c.id, l.index, count)
select {
case c.ready <- struct{}{}:
default:
}
go c.readLane(l)
c.once.Do(func() {
go c.run()
})
}
func (c *bondServerConn) snapshotLanes() []*bondServerLane {
c.lanesMu.RLock()
defer c.lanesMu.RUnlock()
out := make([]*bondServerLane, len(c.lanes))
copy(out, c.lanes)
return out
}
func (c *bondServerConn) removeLane(l *bondServerLane) int {
c.lanesMu.Lock()
defer c.lanesMu.Unlock()
for i, lane := range c.lanes {
if lane == l {
c.lanes = append(c.lanes[:i], c.lanes[i+1:]...)
break
}
}
return len(c.lanes)
}
func (c *bondServerConn) waitForInitialLanes() {
timer := time.NewTimer(bondLaneAttachTimeout)
defer timer.Stop()
for {
c.lanesMu.RLock()
count := len(c.lanes)
want := int(c.want)
c.lanesMu.RUnlock()
if want <= 0 || count >= want {
return
}
select {
case <-c.ctx.Done():
return
case <-c.ready:
case <-timer.C:
debugf("[bond %d] starting with %d/%d lanes after attach timeout", c.id, count, want)
return
}
}
}
func (c *bondServerConn) readLane(l *bondServerLane) {
for {
f, err := readBondFrame(l.stream)
if err != nil {
left := c.removeLane(l)
select {
case <-c.ctx.Done():
default:
if err != io.EOF {
debugf("[bond %d] lane %d read error: %v (lanes=%d)", c.id, l.index, err, left)
}
if left == 0 {
c.cancel()
}
}
return
}
select {
case c.recvCh <- f:
case <-c.ctx.Done():
return
}
}
}
func (c *bondServerConn) run() {
defer close(c.done)
defer c.cancel()
c.waitForInitialLanes()
backendConn, err := net.DialTimeout("tcp", c.connectAddr, 10*time.Second)
if err != nil {
log.Printf("[bond %d] backend dial error: %s", c.id, err)
return
}
defer func() {
if err := backendConn.Close(); err != nil {
log.Printf("[bond %d] failed to close backend connection: %v", c.id, err)
}
}()
context.AfterFunc(c.ctx, func() {
now := time.Now()
if err := backendConn.SetDeadline(now); err != nil {
log.Printf("[bond %d] backend deadline error: %v", c.id, err)
}
for _, lane := range c.snapshotLanes() {
if err := lane.stream.SetDeadline(now); err != nil {
log.Printf("[bond %d] lane %d deadline error: %v", c.id, lane.index, err)
}
}
})
debugf("[bond %d] backend connected", c.id)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
c.copyBondToBackend(backendConn)
}()
go func() {
defer wg.Done()
defer c.cancel()
c.copyBackendToBond(backendConn)
}()
wg.Wait()
}
func (c *bondServerConn) copyBondToBackend(backendConn net.Conn) {
pending := make(map[uint64][]byte)
var expect uint64
var finSeq *uint64
for {
if finSeq != nil && expect == *finSeq {
closeWrite(backendConn)
debugf("[bond %d] upload to backend finished chunks=%d", c.id, expect)
return
}
select {
case <-c.ctx.Done():
return
case f := <-c.recvCh:
switch f.typ {
case bondFrameData:
pending[f.seq] = f.data
case bondFrameFIN:
v := f.seq
if finSeq == nil || v < *finSeq {
finSeq = &v
}
default:
log.Printf("[bond %d] unknown frame type %d", c.id, f.typ)
return
}
for {
data, ok := pending[expect]
if !ok {
break
}
delete(pending, expect)
if len(data) > 0 {
if _, err := backendConn.Write(data); err != nil {
log.Printf("[bond %d] backend write error: %v", c.id, err)
return
}
}
expect++
}
}
}
}
func (c *bondServerConn) copyBackendToBond(backendConn net.Conn) {
buf := make([]byte, bondMaxChunk)
var seq uint64
var laneIdx uint64
for {
n, err := backendConn.Read(buf)
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
if writeErr := c.writeToNextLane(bondFrameData, seq, data, &laneIdx); writeErr != nil {
log.Printf("[bond %d] lane write data error: %v", c.id, writeErr)
return
}
seq++
}
if err != nil {
lanes := c.snapshotLanes()
for _, lane := range lanes {
lane.mu.Lock()
writeErr := writeBondFrame(lane.stream, bondFrameFIN, seq, nil)
lane.mu.Unlock()
if writeErr != nil && c.ctx.Err() == nil {
log.Printf("[bond %d] lane %d write FIN error: %v", c.id, lane.index, writeErr)
}
}
debugf("[bond %d] download from backend finished chunks=%d", c.id, seq)
return
}
select {
case <-c.ctx.Done():
return
default:
}
}
}
func (c *bondServerConn) writeToNextLane(typ byte, seq uint64, data []byte, laneIdx *uint64) error {
for {
lanes := c.snapshotLanes()
for attempts := 0; attempts < len(lanes); attempts++ {
lane := lanes[*laneIdx%uint64(len(lanes))]
(*laneIdx)++
lane.mu.Lock()
err := writeBondFrame(lane.stream, typ, seq, data)
lane.mu.Unlock()
if err == nil {
return nil
}
left := c.removeLane(lane)
log.Printf("[bond %d] lane %d write error: %v (lanes=%d)", c.id, lane.index, err, left)
if left == 0 {
return err
}
}
select {
case <-c.ctx.Done():
return c.ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
}
func handleBondServerStreamAfterMagic(ctx context.Context, stream *smux.Stream, connectAddr string, magic [4]byte) {
handleBondServerStreamWithHello(ctx, stream, connectAddr, func(r io.Reader) (bondHello, error) {
return readBondHelloAfterMagic(r, magic)
})
}
func handleBondServerStreamWithHello(ctx context.Context, stream *smux.Stream, connectAddr string, readHello func(io.Reader) (bondHello, error)) {
defer func() {
if err := stream.Close(); err != nil && err != smux.ErrGoAway {
log.Printf("failed to close bond smux stream: %v", err)
}
}()
hello, err := readHello(stream)
if err != nil {
log.Printf("bond hello error: %v", err)
return
}
conn := globalBondRegistry.get(ctx, hello.connID, connectAddr)
conn.addLane(&bondServerLane{
index: hello.laneIndex,
stream: stream,
}, hello.laneCount)
select {
case <-ctx.Done():
case <-conn.done:
}
}
type prefixedConn struct {
net.Conn
prefix []byte
}
func (c *prefixedConn) Read(p []byte) (int, error) {
if len(c.prefix) > 0 {
n := copy(p, c.prefix)
c.prefix = c.prefix[n:]
return n, nil
}
return c.Conn.Read(p)
}
// handleUDPConnection forwards DTLS packets to a UDP backend (WireGuard).
func handleUDPConnection(ctx context.Context, conn net.Conn, connectAddr string) {
serverConn, err := net.Dial("udp", connectAddr)
@ -141,6 +733,14 @@ func handleUDPConnection(ctx context.Context, conn net.Conn, connectAddr string)
var wg sync.WaitGroup
wg.Add(2)
ctx2, cancel2 := context.WithCancel(ctx)
stats := &throughputStats{}
go stats.logEvery(
ctx2,
fmt.Sprintf("[DTLS %s]", conn.RemoteAddr()),
"dtls-to-backend",
"backend-to-dtls",
)
context.AfterFunc(ctx2, func() {
if err := conn.SetDeadline(time.Now()); err != nil {
log.Printf("failed to set incoming deadline: %s", err)
@ -173,7 +773,8 @@ func handleUDPConnection(ctx context.Context, conn net.Conn, connectAddr string)
log.Printf("Failed: %s", err1)
return
}
_, err1 = serverConn.Write(buf[:n])
written, err1 := serverConn.Write(buf[:n])
stats.addTx(written)
if err1 != nil {
log.Printf("Failed: %s", err1)
return
@ -204,7 +805,8 @@ func handleUDPConnection(ctx context.Context, conn net.Conn, connectAddr string)
log.Printf("Failed: %s", err1)
return
}
_, err1 = conn.Write(buf[:n])
written, err1 := conn.Write(buf[:n])
stats.addRx(written)
if err1 != nil {
log.Printf("Failed: %s", err1)
return
@ -216,19 +818,29 @@ 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) {
func handleVLESSConnection(ctx context.Context, dtlsConn net.Conn, connectAddr string, bond bool) {
// 1. Create KCP session over DTLS
kcpSess, err := tcputil.NewKCPOverDTLS(dtlsConn, true)
statsCtx, statsCancel := context.WithCancel(ctx)
defer statsCancel()
stats := &throughputStats{}
go stats.logEvery(
statsCtx,
fmt.Sprintf("[VLESS %s]", dtlsConn.RemoteAddr()),
"to-client",
"from-client",
)
kcpSess, err := tcputil.NewKCPOverDTLS(&countingConn{Conn: dtlsConn, stats: stats}, true)
if err != nil {
log.Printf("KCP session error: %s", err)
return
}
defer func() {
if err := kcpSess.Close(); err != nil {
log.Printf("failed to close KCP session: %v", err)
if closeErr := kcpSess.Close(); closeErr != nil {
log.Printf("failed to close KCP session: %v", closeErr)
}
}()
log.Printf("KCP session established (server)")
debugf("KCP session established (server)")
// 2. Create smux server session over KCP
smuxSess, err := smux.Server(kcpSess, tcputil.DefaultSmuxConfig())
@ -241,7 +853,7 @@ func handleVLESSConnection(ctx context.Context, dtlsConn net.Conn, connectAddr s
log.Printf("failed to close smux session: %v", err)
}
}()
log.Printf("smux session established (server)")
debugf("smux session established (server)")
// 3. Accept smux streams and forward to backend via TCP
var wg sync.WaitGroup
@ -260,6 +872,23 @@ func handleVLESSConnection(ctx context.Context, dtlsConn net.Conn, connectAddr s
go func(s *smux.Stream) {
defer wg.Done()
var prefix [4]byte
if _, err := io.ReadFull(s, prefix[:]); err != nil {
if err != io.EOF && err != io.ErrUnexpectedEOF {
log.Printf("smux stream prefix read error: %v", err)
}
_ = s.Close()
return
}
if string(prefix[:]) == bondMagic {
debugf("auto-detected bond smux stream")
handleBondServerStreamAfterMagic(ctx, s, connectAddr, prefix)
return
}
if bond {
log.Printf("non-bond smux stream accepted while -vless-bond is enabled")
}
defer func() {
if err := s.Close(); err != nil && err != smux.ErrGoAway {
log.Printf("failed to close smux stream: %v", err)
@ -279,7 +908,7 @@ func handleVLESSConnection(ctx context.Context, dtlsConn net.Conn, connectAddr s
}()
// Bidirectional copy
pipeConn(ctx, s, backendConn)
pipeConn(ctx, &prefixedConn{Conn: s, prefix: prefix[:]}, backendConn)
}(stream)
}
wg.Wait()
@ -292,10 +921,10 @@ func pipeConn(ctx context.Context, c1, c2 net.Conn) {
context.AfterFunc(ctx2, func() {
if err := c1.SetDeadline(time.Now()); err != nil {
log.Printf("pipeConn: failed to set deadline c1: %v", err)
debugf("pipeConn: failed to set deadline c1: %v", err)
}
if err := c2.SetDeadline(time.Now()); err != nil {
log.Printf("pipeConn: failed to set deadline c2: %v", err)
debugf("pipeConn: failed to set deadline c2: %v", err)
}
})
@ -305,20 +934,24 @@ func pipeConn(ctx context.Context, c1, c2 net.Conn) {
go func() {
defer wg.Done()
if _, err := io.Copy(c1, c2); err != nil {
log.Printf("pipeConn: c1<-c2 copy error: %v", err)
debugf("pipeConn: c1<-c2 copy error: %v", err)
}
}()
go func() {
defer wg.Done()
if _, err := io.Copy(c2, c1); err != nil {
log.Printf("pipeConn: c2<-c1 copy error: %v", err)
debugf("pipeConn: c2<-c1 copy error: %v", err)
}
}()
wg.Wait()
// Reset deadlines
_ = c1.SetDeadline(time.Time{})
_ = c2.SetDeadline(time.Time{})
// Reset deadlines (best-effort; connection may already be closed)
if err := c1.SetDeadline(time.Time{}); err != nil {
debugf("pipeConn: failed to reset deadline c1: %v", err)
}
if err := c2.SetDeadline(time.Time{}); err != nil {
debugf("pipeConn: failed to reset deadline c2: %v", err)
}
}

123
server/pktinfo_linux.go

@ -0,0 +1,123 @@
//go:build linux
package main
import (
"fmt"
"net"
"sync"
"time"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
type packetInfoUDPConn struct {
conn *net.UDPConn
ipv4 *ipv4.PacketConn
ipv6 *ipv6.PacketConn
v6 bool
mu sync.RWMutex
localIPs map[string]net.IP
}
func listenPacketInfoUDP(network string, laddr *net.UDPAddr) (net.PacketConn, error) {
conn, err := net.ListenUDP(network, laddr)
if err != nil {
return nil, err
}
pc := &packetInfoUDPConn{
conn: conn,
ipv4: ipv4.NewPacketConn(conn),
ipv6: ipv6.NewPacketConn(conn),
v6: laddr != nil && laddr.IP != nil && laddr.IP.To4() == nil,
localIPs: make(map[string]net.IP),
}
if pc.v6 {
if err = pc.ipv6.SetControlMessage(ipv6.FlagDst, true); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("enable IPv6 packet info: %w", err)
}
} else if err = pc.ipv4.SetControlMessage(ipv4.FlagDst, true); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("enable IPv4 packet info: %w", err)
}
return pc, nil
}
func (c *packetInfoUDPConn) ReadFrom(p []byte) (int, net.Addr, error) {
if c.v6 {
n, cm, addr, err := c.ipv6.ReadFrom(p)
if err != nil {
return n, addr, err
}
if udpAddr, ok := addr.(*net.UDPAddr); ok && cm != nil && cm.Dst != nil {
c.rememberLocalIP(udpAddr.String(), cm.Dst)
}
return n, addr, nil
}
n, cm, addr, err := c.ipv4.ReadFrom(p)
if err != nil {
return n, addr, err
}
if udpAddr, ok := addr.(*net.UDPAddr); ok && cm != nil && cm.Dst != nil {
c.rememberLocalIP(udpAddr.String(), cm.Dst)
}
return n, addr, nil
}
func (c *packetInfoUDPConn) WriteTo(p []byte, addr net.Addr) (int, error) {
udpAddr, ok := addr.(*net.UDPAddr)
if !ok {
return 0, fmt.Errorf("packet info write: expected *net.UDPAddr, got %T", addr)
}
localIP := c.localIPFor(udpAddr.String())
if localIP == nil {
return c.conn.WriteTo(p, addr)
}
if localIP.To4() != nil {
return c.ipv4.WriteTo(p, &ipv4.ControlMessage{Src: localIP}, addr)
}
return c.ipv6.WriteTo(p, &ipv6.ControlMessage{Src: localIP}, addr)
}
func (c *packetInfoUDPConn) Close() error {
return c.conn.Close()
}
func (c *packetInfoUDPConn) LocalAddr() net.Addr {
return c.conn.LocalAddr()
}
func (c *packetInfoUDPConn) SetDeadline(t time.Time) error {
return c.conn.SetDeadline(t)
}
func (c *packetInfoUDPConn) SetReadDeadline(t time.Time) error {
return c.conn.SetReadDeadline(t)
}
func (c *packetInfoUDPConn) SetWriteDeadline(t time.Time) error {
return c.conn.SetWriteDeadline(t)
}
func (c *packetInfoUDPConn) rememberLocalIP(remote string, ip net.IP) {
c.mu.Lock()
defer c.mu.Unlock()
c.localIPs[remote] = append(net.IP(nil), ip...)
}
func (c *packetInfoUDPConn) localIPFor(remote string) net.IP {
c.mu.RLock()
defer c.mu.RUnlock()
ip := c.localIPs[remote]
if ip == nil {
return nil
}
return append(net.IP(nil), ip...)
}

9
server/pktinfo_other.go

@ -0,0 +1,9 @@
//go:build !linux
package main
import "net"
func listenPacketInfoUDP(network string, laddr *net.UDPAddr) (net.PacketConn, error) {
return net.ListenUDP(network, laddr)
}

240
server/udp_listener.go

@ -0,0 +1,240 @@
package main
import (
"context"
"errors"
"net"
"sync"
"sync/atomic"
"time"
dtlsnet "github.com/pion/dtls/v3/pkg/net"
"github.com/pion/dtls/v3/pkg/protocol"
"github.com/pion/dtls/v3/pkg/protocol/recordlayer"
"github.com/pion/transport/v4/deadline"
"github.com/pion/transport/v4/packetio"
)
const udpReceiveMTU = 8192
var errUDPPacketListenerClosed = errors.New("udp packet listener closed")
type udpAcceptFilter func([]byte) bool
type udpPacketListener struct {
pConn net.PacketConn
acceptFilter udpAcceptFilter
accepting atomic.Bool
acceptCh chan *udpPacketConn
doneCh chan struct{}
doneOnce sync.Once
connLock sync.Mutex
conns map[string]*udpPacketConn
connWG sync.WaitGroup
readDoneCh chan struct{}
readWG sync.WaitGroup
errRead atomic.Value
errClose atomic.Value
}
func listenUDPForDTLS(addr *net.UDPAddr) (dtlsnet.PacketListener, error) {
pConn, err := listenPacketInfoUDP("udp", addr)
if err != nil {
return nil, err
}
return newUDPPacketListener(pConn, isDTLSHandshakePacket), nil
}
func newUDPPacketListener(pConn net.PacketConn, acceptFilter udpAcceptFilter) dtlsnet.PacketListener {
l := &udpPacketListener{
pConn: pConn,
acceptFilter: acceptFilter,
acceptCh: make(chan *udpPacketConn, 128),
doneCh: make(chan struct{}),
conns: make(map[string]*udpPacketConn),
readDoneCh: make(chan struct{}),
}
l.accepting.Store(true)
l.connWG.Add(1)
l.readWG.Add(2)
go l.readLoop()
go func() {
l.connWG.Wait()
if err := l.pConn.Close(); err != nil {
l.errClose.Store(err)
}
l.readWG.Done()
}()
return l
}
func (l *udpPacketListener) Accept() (net.PacketConn, net.Addr, error) {
select {
case c := <-l.acceptCh:
l.connWG.Add(1)
return c, c.rAddr, nil
case <-l.readDoneCh:
if err, ok := l.errRead.Load().(error); ok {
return nil, nil, err
}
return nil, nil, errUDPPacketListenerClosed
case <-l.doneCh:
return nil, nil, errUDPPacketListenerClosed
}
}
func (l *udpPacketListener) Close() error {
var err error
l.doneOnce.Do(func() {
l.accepting.Store(false)
close(l.doneCh)
l.connLock.Lock()
for {
select {
case c := <-l.acceptCh:
close(c.doneCh)
delete(l.conns, c.rAddr.String())
default:
l.connLock.Unlock()
l.connWG.Done()
l.readWG.Wait()
if errClose, ok := l.errClose.Load().(error); ok {
err = errClose
}
return
}
}
})
return err
}
func (l *udpPacketListener) Addr() net.Addr {
return l.pConn.LocalAddr()
}
func (l *udpPacketListener) readLoop() {
defer l.readWG.Done()
defer close(l.readDoneCh)
buf := make([]byte, udpReceiveMTU)
for {
n, raddr, err := l.pConn.ReadFrom(buf)
if err != nil {
l.errRead.Store(err)
return
}
l.dispatchMsg(raddr, buf[:n])
}
}
func (l *udpPacketListener) dispatchMsg(raddr net.Addr, buf []byte) {
conn, ok := l.getConn(raddr, buf)
if ok {
if _, err := conn.buffer.Write(buf); err != nil {
debugf("udp listener buffer write failed: %v", err)
}
}
}
func (l *udpPacketListener) getConn(raddr net.Addr, buf []byte) (*udpPacketConn, bool) {
l.connLock.Lock()
defer l.connLock.Unlock()
conn, ok := l.conns[raddr.String()]
if !ok {
if !l.accepting.Load() {
return nil, false
}
if l.acceptFilter != nil && !l.acceptFilter(buf) {
return nil, false
}
conn = &udpPacketConn{
listener: l,
rAddr: raddr,
buffer: packetio.NewBuffer(),
doneCh: make(chan struct{}),
writeDeadline: deadline.New(),
}
select {
case l.acceptCh <- conn:
l.conns[raddr.String()] = conn
default:
return nil, false
}
}
return conn, true
}
type udpPacketConn struct {
listener *udpPacketListener
rAddr net.Addr
buffer *packetio.Buffer
doneCh chan struct{}
doneOnce sync.Once
writeDeadline *deadline.Deadline
}
func (c *udpPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
n, err := c.buffer.Read(p)
return n, c.rAddr, err
}
func (c *udpPacketConn) WriteTo(p []byte, _ net.Addr) (int, error) {
select {
case <-c.writeDeadline.Done():
return 0, context.DeadlineExceeded
default:
}
return c.listener.pConn.WriteTo(p, c.rAddr)
}
func (c *udpPacketConn) Close() error {
var err error
c.doneOnce.Do(func() {
c.listener.connWG.Done()
close(c.doneCh)
c.listener.connLock.Lock()
delete(c.listener.conns, c.rAddr.String())
c.listener.connLock.Unlock()
if errBuf := c.buffer.Close(); errBuf != nil {
err = errBuf
}
})
return err
}
func (c *udpPacketConn) LocalAddr() net.Addr {
return c.listener.pConn.LocalAddr()
}
func (c *udpPacketConn) SetDeadline(t time.Time) error {
c.writeDeadline.Set(t)
return c.SetReadDeadline(t)
}
func (c *udpPacketConn) SetReadDeadline(t time.Time) error {
return c.buffer.SetReadDeadline(t)
}
func (c *udpPacketConn) SetWriteDeadline(t time.Time) error {
c.writeDeadline.Set(t)
return nil
}
func isDTLSHandshakePacket(packet []byte) bool {
pkts, err := recordlayer.UnpackDatagram(packet)
if err != nil || len(pkts) == 0 {
return false
}
h := &recordlayer.Header{}
if err := h.Unmarshal(pkts[0]); err != nil {
return false
}
return h.ContentType == protocol.ContentTypeHandshake
}

196
server/wrap.go

@ -0,0 +1,196 @@
// SPDX-License-Identifier: MIT
package main
import (
"crypto/cipher"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"
"time"
dtlsnet "github.com/pion/dtls/v3/pkg/net"
"golang.org/x/crypto/chacha20poly1305"
)
// Wire format is identical to client. Server sets the MSB of sessionID/SSRC;
// client clears it. RTP header fields are per-conn.
const (
wrapKeyLen = 32
wrapRTPHdrLen = 12
wrapNonceLen = 12
wrapTagLen = 16
wrapHeaderLen = wrapRTPHdrLen + wrapNonceLen
wrapOverhead = wrapHeaderLen + wrapTagLen
wrapRTPVersion = 0x80
wrapRTPPT = 0x6F
wrapTSStep = 960
)
var bufPool = sync.Pool{
New: func() any {
b := make([]byte, 1600+wrapOverhead)
return &b
},
}
type wrapState struct {
aead cipher.AEAD
}
func newWrapState(key []byte) (*wrapState, error) {
if len(key) != wrapKeyLen {
return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", wrapKeyLen, len(key))
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, fmt.Errorf("wrap: aead init: %w", err)
}
return &wrapState{aead: aead}, nil
}
func listenWrapped(addr *net.UDPAddr, key []byte) (dtlsnet.PacketListener, error) {
ws, err := newWrapState(key)
if err != nil {
return nil, err
}
innerConn, err := listenPacketInfoUDP("udp", addr)
if err != nil {
return nil, fmt.Errorf("wrap: udp listen: %w", err)
}
return &wrapPacketListener{
inner: newUDPPacketListener(innerConn, nil),
ws: ws,
}, nil
}
type wrapPacketListener struct {
inner dtlsnet.PacketListener
ws *wrapState
}
func (l *wrapPacketListener) Accept() (net.PacketConn, net.Addr, error) {
pc, addr, err := l.inner.Accept()
if err != nil {
return pc, addr, err
}
c := &wrapPacketConn{inner: pc, ws: l.ws}
var rnd [16]byte
if _, err := rand.Read(rnd[:]); err != nil {
return nil, addr, fmt.Errorf("wrap: rand init: %w", err)
}
copy(c.sessionID[:], rnd[0:4])
copy(c.ssrc[:], rnd[4:8])
c.sessionID[0] |= 0x80
c.ssrc[0] |= 0x80
c.seq.Store(uint32(binary.BigEndian.Uint16(rnd[8:10])))
c.timestamp.Store(binary.BigEndian.Uint32(rnd[10:14]))
var cb [8]byte
if _, err := rand.Read(cb[:]); err != nil {
return nil, addr, fmt.Errorf("wrap: counter rand: %w", err)
}
c.counter.Store(binary.BigEndian.Uint64(cb[:]))
return c, addr, nil
}
func (l *wrapPacketListener) Close() error { return l.inner.Close() }
func (l *wrapPacketListener) Addr() net.Addr { return l.inner.Addr() }
type wrapPacketConn struct {
inner net.PacketConn
ws *wrapState
sessionID [4]byte
ssrc [4]byte
counter atomic.Uint64
seq atomic.Uint32
timestamp atomic.Uint32
}
func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
bp, ok := bufPool.Get().(*[]byte)
if !ok {
return 0, nil, errors.New("wrap: buffer pool returned invalid type")
}
buf := *bp
need := len(p) + wrapOverhead
if cap(buf) < need {
buf = make([]byte, need)
*bp = buf
}
defer bufPool.Put(bp)
n, addr, err := c.inner.ReadFrom(buf[:cap(buf)])
if err != nil {
return 0, addr, err
}
wire := buf[:n]
if len(wire) < wrapOverhead {
return 0, addr, errors.New("wrap: packet too short")
}
nonce := wire[wrapRTPHdrLen : wrapRTPHdrLen+wrapNonceLen]
aad := wire[:wrapHeaderLen]
ct := wire[wrapHeaderLen:]
plain, err := c.ws.aead.Open(ct[:0], nonce, ct, aad)
if err != nil {
return 0, addr, fmt.Errorf("wrap: AEAD open: %w", err)
}
if len(plain) > len(p) {
return 0, addr, errors.New("wrap: dst buffer too small")
}
copy(p[:len(plain)], plain)
return len(plain), addr, nil
}
func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
wireLen := wrapOverhead + len(p)
bp, ok := bufPool.Get().(*[]byte)
if !ok {
return 0, errors.New("wrap: buffer pool returned invalid type")
}
out := *bp
if cap(out) < wireLen {
out = make([]byte, wireLen)
*bp = out
}
out = out[:wireLen]
defer bufPool.Put(bp)
out[0] = wrapRTPVersion
out[1] = wrapRTPPT
seq := uint16(c.seq.Add(1) - 1)
binary.BigEndian.PutUint16(out[2:4], seq)
ts := c.timestamp.Add(wrapTSStep) - wrapTSStep
binary.BigEndian.PutUint32(out[4:8], ts)
copy(out[8:12], c.ssrc[:])
noncePos := wrapRTPHdrLen
copy(out[noncePos:noncePos+4], c.sessionID[:])
ctr := c.counter.Add(1) - 1
binary.BigEndian.PutUint64(out[noncePos+4:noncePos+wrapNonceLen], ctr)
nonce := out[noncePos : noncePos+wrapNonceLen]
aad := out[:wrapHeaderLen]
ctPos := wrapHeaderLen
copy(out[ctPos:], p)
c.ws.aead.Seal(out[ctPos:ctPos], nonce, out[ctPos:ctPos+len(p)], aad)
if _, err := c.inner.WriteTo(out, addr); err != nil {
return 0, err
}
return len(p), nil
}
func (c *wrapPacketConn) Close() error { return c.inner.Close() }
func (c *wrapPacketConn) LocalAddr() net.Addr { return c.inner.LocalAddr() }
func (c *wrapPacketConn) SetDeadline(t time.Time) error { return c.inner.SetDeadline(t) }
func (c *wrapPacketConn) SetReadDeadline(t time.Time) error { return c.inner.SetReadDeadline(t) }
func (c *wrapPacketConn) SetWriteDeadline(t time.Time) error { return c.inner.SetWriteDeadline(t) }

111
tcputil/tcputil.go

@ -2,12 +2,111 @@ package tcputil
import (
"net"
"os"
"strconv"
"strings"
"time"
"github.com/xtaci/kcp-go/v5"
"github.com/xtaci/smux"
)
type kcpProfile struct {
nodelay int
interval int
resend int
nc int
sndWnd int
rcvWnd int
mtu int
ackNoDelay bool
}
func selectedKCPProfile() kcpProfile {
profile := strings.ToLower(strings.TrimSpace(os.Getenv("VK_TURN_KCP_PROFILE")))
var cfg kcpProfile
switch profile {
case "legacy", "fast":
cfg = kcpProfile{
nodelay: 1,
interval: 10,
resend: 2,
nc: 1,
sndWnd: 4096,
rcvWnd: 4096,
mtu: 1280,
ackNoDelay: true,
}
case "cc", "balanced":
cfg = kcpProfile{
nodelay: 1,
interval: 20,
resend: 2,
nc: 0,
sndWnd: 512,
rcvWnd: 512,
mtu: 1200,
ackNoDelay: true,
}
case "slow", "conservative":
cfg = kcpProfile{
nodelay: 0,
interval: 40,
resend: 2,
nc: 0,
sndWnd: 256,
rcvWnd: 256,
mtu: 1150,
ackNoDelay: false,
}
default:
cfg = kcpProfile{
nodelay: 1,
interval: 20,
resend: 2,
nc: 1,
sndWnd: 512,
rcvWnd: 512,
mtu: 1200,
ackNoDelay: true,
}
}
cfg.nodelay = envInt("VK_TURN_KCP_NODELAY", cfg.nodelay)
cfg.interval = envInt("VK_TURN_KCP_INTERVAL", cfg.interval)
cfg.resend = envInt("VK_TURN_KCP_RESEND", cfg.resend)
cfg.nc = envInt("VK_TURN_KCP_NC", cfg.nc)
cfg.sndWnd = envInt("VK_TURN_KCP_SNDWND", cfg.sndWnd)
cfg.rcvWnd = envInt("VK_TURN_KCP_RCVWND", cfg.rcvWnd)
cfg.mtu = envInt("VK_TURN_KCP_MTU", cfg.mtu)
cfg.ackNoDelay = envBool("VK_TURN_KCP_ACK_NODELAY", cfg.ackNoDelay)
return cfg
}
func envInt(name string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return value
}
func envBool(name string, fallback bool) bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv(name)))
switch raw {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
// DtlsPacketConn wraps a net.Conn (DTLS) as a net.PacketConn for KCP.
// Each DTLS Read/Write preserves message boundaries (datagram semantics).
type DtlsPacketConn struct {
@ -81,13 +180,11 @@ func NewKCPOverDTLS(dtlsConn net.Conn, isServer bool) (*kcp.UDPSession, error) {
}
}
// Tune KCP for TURN tunnel:
// - NoDelay mode for lower latency
// - Window sizes suitable for ~5Mbit/s
sess.SetNoDelay(1, 20, 2, 1) // nodelay, interval(ms), resend, nc
sess.SetWindowSize(256, 256)
sess.SetMtu(1200) // conservative MTU to fit inside DTLS+TURN
sess.SetACKNoDelay(true)
profile := selectedKCPProfile()
sess.SetNoDelay(profile.nodelay, profile.interval, profile.resend, profile.nc)
sess.SetWindowSize(profile.sndWnd, profile.rcvWnd)
sess.SetMtu(profile.mtu)
sess.SetACKNoDelay(profile.ackNoDelay)
return sess, nil
}

Loading…
Cancel
Save