committed by
GitHub
29 changed files with 7257 additions and 2957 deletions
@ -0,0 +1 @@ |
|||||
|
.gocache/ |
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
@ -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) |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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 } |
||||
@ -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 |
||||
|
} |
||||
File diff suppressed because it is too large
@ -1,4 +1,4 @@ |
|||||
package main |
package clientcore |
||||
|
|
||||
import "testing" |
import "testing" |
||||
|
|
||||
@ -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) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
@ -1,4 +1,4 @@ |
|||||
package main |
package clientcore |
||||
|
|
||||
import ( |
import ( |
||||
"fmt" |
"fmt" |
||||
@ -1,4 +1,4 @@ |
|||||
package main |
package clientcore |
||||
|
|
||||
import ( |
import ( |
||||
"bytes" |
"bytes" |
||||
@ -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 |
||||
|
} |
||||
@ -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") |
||||
|
} |
||||
|
} |
||||
@ -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...) |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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) } |
||||
Loading…
Reference in new issue