mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
fix(captcha): привести запросы к поведению живого виджета 1.1.1388
Сверка протокола с бандлом вскрыла три расхождения. Телеметрия: указатель виджет пишет только через externalCoords, а их заполняет хост-страница iframe или нативный бридж мобильного SDK. Мы ходим на captcha прямой навигацией, где живой браузер оставляет cursor и taps пустыми, - вместе с ними ушла синтетическая траектория руки, недостижимая для этого режима. A/B на живой captcha: 10/10 сессий против 9/10 со старым поведением. Заголовки: навигация документа не несла Upgrade-Insecure-Requests и Sec-Fetch-User, приоритет всем запросам ставился как у fetch, Origin уходил на GET-ы, где Chrome его не шлёт, Referer навигации указывал на саму цель вместо страницы-источника, а бандл тянулся профилем fetch вместо тега script. Cookie отсутствовал в списке порядка и уезжал последним заголовком. Куки: tls-client отбрасывает удалённые куки только по Max-Age - проверка Expires в его jar закомментирована. Погашенная сервером remixir=DELETED из-за этого уходила в каждом запросе. Обёртка в personanet транслирует протухший Expires в Max-Age, который фильтр библиотеки понимает. Трассировка запросов под FREETURN_CAPTCHA_TRACE - ей расхождения и найдены.
This commit is contained in:
@@ -18,3 +18,4 @@ client_config.json
|
||||
node_modules
|
||||
dist
|
||||
/mobile-dist/
|
||||
captcha_trace.md
|
||||
|
||||
@@ -94,8 +94,9 @@ type device struct {
|
||||
NotificationsPermission string `json:"notificationsPermission"`
|
||||
}
|
||||
|
||||
// Порядок заголовков h2 для fetch/XHR. Отсутствующие в запросе имена fhttp
|
||||
// пропускает, поэтому один список покрывает GET и POST.
|
||||
// Порядок заголовков h2. Отсутствующие в запросе имена fhttp пропускает, поэтому
|
||||
// один список покрывает и навигацию (upgrade-insecure-requests, sec-fetch-user),
|
||||
// и fetch/XHR (origin, content-type).
|
||||
var chromeHeaderOrder = []string{
|
||||
"content-length",
|
||||
"sec-ch-ua-platform",
|
||||
@@ -103,14 +104,17 @@ var chromeHeaderOrder = []string{
|
||||
"sec-ch-ua",
|
||||
"content-type",
|
||||
"sec-ch-ua-mobile",
|
||||
"upgrade-insecure-requests",
|
||||
"accept",
|
||||
"origin",
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-user",
|
||||
"sec-fetch-dest",
|
||||
"referer",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"cookie",
|
||||
"priority",
|
||||
}
|
||||
|
||||
@@ -228,7 +232,12 @@ func ApplyFhttp(req *fhttp.Request, profile Profile) {
|
||||
req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile)
|
||||
req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform)
|
||||
req.Header.Set("Accept-Language", profile.AcceptLanguage)
|
||||
req.Header.Set("Priority", "u=1, i")
|
||||
// Навигация документа идёт с нулевым приоритетом, u=1 - профиль fetch/XHR.
|
||||
if req.Header.Get("Sec-Fetch-Dest") == "document" {
|
||||
req.Header.Set("Priority", "u=0, i")
|
||||
} else {
|
||||
req.Header.Set("Priority", "u=1, i")
|
||||
}
|
||||
req.Header[fhttp.HeaderOrderKey] = chromeHeaderOrder
|
||||
}
|
||||
|
||||
|
||||
@@ -336,10 +336,16 @@ func captchaDomainFromRedirectURI(redirectURI string) string {
|
||||
|
||||
func (s *captchaSession) 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",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
// Переход по редиректу считается пользовательской активацией.
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
// Referer кросс-сайтового перехода режется политикой до origin страницы,
|
||||
// с которой ушли, а ей была страница звонка.
|
||||
"Referer": "https://" + s.domain + "/",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -367,9 +373,13 @@ func (s *captchaSession) fetchDebugInfoJS(scriptURL string) (string, error) {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
// Бандл тянет тег <script> страницы captcha, а не fetch: другой Dest и no-cors.
|
||||
body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{
|
||||
"Accept": "text/javascript,*/*",
|
||||
"Referer": captchaAPIOrigin + "/",
|
||||
"Accept": "*/*",
|
||||
"Sec-Fetch-Dest": "script",
|
||||
"Sec-Fetch-Mode": "no-cors",
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"Referer": captchaAPIOrigin + "/",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -445,9 +455,8 @@ func (s *captchaSession) captchaRequest(method string, form [][2]string) (map[st
|
||||
func (s *captchaSession) performCaptchaCheck(
|
||||
sessionToken string,
|
||||
answerJSON string,
|
||||
g gesture,
|
||||
) (*captchaCheck, error) {
|
||||
data := buildAnalytics(s.profile, s.sensors, g, time.Since(s.sensorsStart))
|
||||
data := buildAnalytics(s.profile, s.sensors, time.Since(s.sensorsStart))
|
||||
values := make([][2]string, 0, 15)
|
||||
values = append(values,
|
||||
[2]string{"session_token", sessionToken},
|
||||
@@ -466,8 +475,8 @@ func (s *captchaSession) performCaptchaCheck(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("captcha check failed: %w", err)
|
||||
}
|
||||
s.logger().Debugf("[Captcha] check payload answer_bytes=%d pointer_samples=%d conn_samples=%d",
|
||||
len(answerJSON), len(data.cursor)+len(data.taps), len(data.connRtt))
|
||||
s.logger().Debugf("[Captcha] check payload answer_bytes=%d conn_samples=%d accel_samples=%d",
|
||||
len(answerJSON), len(data.connRtt), len(data.accelerometer))
|
||||
check, err := parseCaptchaCheck(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -542,7 +551,7 @@ func (s *captchaSession) solveCheckboxCaptcha(sessionToken string) (string, erro
|
||||
return "", err
|
||||
}
|
||||
|
||||
check, err := s.performCaptchaCheck(sessionToken, "{}", g)
|
||||
check, err := s.performCaptchaCheck(sessionToken, "{}")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -607,9 +616,11 @@ func (s *captchaSession) doRaw(
|
||||
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", captchaAPIOrigin)
|
||||
req.Header.Set("Referer", captchaAPIOrigin+"/")
|
||||
if form != nil {
|
||||
// Origin ставится только на CORS-запросы виджета; GET навигации и тега
|
||||
// <script> его не несут.
|
||||
req.Header.Set("Origin", captchaAPIOrigin)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
for k, v := range extraHeaders {
|
||||
@@ -632,6 +643,7 @@ func (s *captchaSession) doRaw(
|
||||
}()
|
||||
data, readErr := io.ReadAll(resp.Body)
|
||||
s.logger().Debugf("[Captcha] http %s %s status=%d bytes=%d after=%s form=%s", method, captchaSafeURL(endpoint), resp.StatusCode, len(data), time.Since(start).Truncate(time.Millisecond), captchaFormSummary(form))
|
||||
traceExchange(req, form, resp, data, time.Since(start))
|
||||
return data, readErr
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ func (s *captchaSession) solveSliderCaptcha(
|
||||
}
|
||||
handle = target
|
||||
|
||||
check, err := s.performCaptchaCheck(sessionToken, string(answerData), g)
|
||||
check, err := s.performCaptchaCheck(sessionToken, string(answerData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package captcha
|
||||
// Телеметрия captcha.
|
||||
//
|
||||
// Виджет заводит setInterval(sensors_delay) и на каждом тике кладёт в аккумулятор
|
||||
// последнюю известную позицию указателя (cursor - мышь, taps - палец) плюс
|
||||
// navigator.connection.rtt/downlink. Акселерометр пишется отдельным потоком
|
||||
// событий Generic Sensor API с частотой 1000/sensors_delay. Позиция обновляется
|
||||
// только по событию ввода, поэтому неподвижный указатель даёт цепочку одинаковых
|
||||
// сэмплов, а до первого события массив пуст.
|
||||
// событий Generic Sensor API с частотой 1000/sensors_delay.
|
||||
//
|
||||
// Указатель (cursor - мышь, taps - палец) пишется только через externalCoords, а
|
||||
// их заполняет либо нативный бридж мобильного SDK, либо хост-страница, если
|
||||
// виджет открыт в iframe. Мы ходим на captcha прямой навигацией, без хоста, -
|
||||
// в этом режиме виджет живого браузера оставляет оба массива пустыми.
|
||||
//
|
||||
// В captchaNotRobot.check уходят все семь массивов, даже пустые: значения,
|
||||
// которых браузер не дал, виджет просто не кладёт в массив, но сам ключ
|
||||
@@ -133,32 +135,13 @@ type gesture struct {
|
||||
// buildAnalytics собирает аккумулятор так, как его собрал бы живой браузер за
|
||||
// elapsed с момента ответа settings: тиков ровно столько, сколько успел сделать
|
||||
// таймер, а непокрытые персоной сенсоры остаются пустыми.
|
||||
func buildAnalytics(p browserprofile.Profile, cfg sensorConfig, g gesture, elapsed time.Duration) analytics {
|
||||
func buildAnalytics(p browserprofile.Profile, cfg sensorConfig, elapsed time.Duration) analytics {
|
||||
ticks := ticksIn(elapsed, cfg.delay)
|
||||
if ticks == 0 {
|
||||
// Таймер не успел тикнуть ни разу - живой виджет отправил бы пустые массивы.
|
||||
return analytics{}
|
||||
}
|
||||
moveTicks := min(max(ticksIn(g.move, cfg.delay), 1), ticks)
|
||||
settleTicks := min(ticksIn(g.settle, cfg.delay), ticks-moveTicks)
|
||||
idleTicks := ticks - moveTicks - settleTicks
|
||||
|
||||
track := make([]point, 0, ticks)
|
||||
if !p.Touch() {
|
||||
// Мышь видна виджету с первого тика: до движения сыплются одинаковые
|
||||
// сэмплы стартовой позиции.
|
||||
track = appendRepeat(track, g.from, idleTicks)
|
||||
}
|
||||
track = append(track, samplePath(g.from, g.to, moveTicks)...)
|
||||
track = appendRepeat(track, track[len(track)-1], settleTicks)
|
||||
|
||||
out := analytics{}
|
||||
switch {
|
||||
case p.Touch() && cfg.taps:
|
||||
out.taps = track
|
||||
case !p.Touch() && cfg.cursor:
|
||||
out.cursor = track
|
||||
}
|
||||
out.connRtt, out.connDownlink = sampleConnection(p.IsMobile(), ticks)
|
||||
if p.Accelerometer() && cfg.accelerometer {
|
||||
out.accelerometer = sampleAccelerometer(ticks)
|
||||
@@ -173,52 +156,6 @@ func ticksIn(d time.Duration, delay time.Duration) int {
|
||||
return min(int(d/delay), sensorTicksMax)
|
||||
}
|
||||
|
||||
func appendRepeat(dst []point, value point, count int) []point {
|
||||
for range count {
|
||||
dst = append(dst, value)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// samplePath снимает count позиций с дуги from->to: живая рука идёт не по прямой
|
||||
// и тормозит у цели, а таймер снимает позицию через равные интервалы.
|
||||
func samplePath(from, to point, count int) []point {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
ctrl := point{
|
||||
X: (from.X+to.X)/2 + randx.Intn(61) - 30,
|
||||
Y: (from.Y+to.Y)/2 - randx.Intn(30) - 10,
|
||||
}
|
||||
points := make([]point, 0, count)
|
||||
for i := 1; i <= count; i++ {
|
||||
t := easeInOut(float64(i) / float64(count))
|
||||
x := quadBezier(float64(from.X), float64(ctrl.X), float64(to.X), t)
|
||||
y := quadBezier(float64(from.Y), float64(ctrl.Y), float64(to.Y), t)
|
||||
// Дрожание руки гаснет к концу движения.
|
||||
jitter := int(math.Round((1-t)*4)) + 1
|
||||
points = append(points, point{
|
||||
X: int(math.Round(x)) + randx.Intn(jitter*2+1) - jitter,
|
||||
Y: int(math.Round(y)) + randx.Intn(jitter*2+1) - jitter,
|
||||
})
|
||||
}
|
||||
points[len(points)-1] = to
|
||||
return points
|
||||
}
|
||||
|
||||
func quadBezier(p0, p1, p2, t float64) float64 {
|
||||
inv := 1 - t
|
||||
return inv*inv*p0 + 2*t*inv*p1 + t*t*p2
|
||||
}
|
||||
|
||||
func easeInOut(t float64) float64 {
|
||||
if t < 0.5 {
|
||||
return 2 * t * t
|
||||
}
|
||||
p := -2*t + 2
|
||||
return 1 - p*p/2
|
||||
}
|
||||
|
||||
// sampleConnection повторяет NetworkInformation: Chrome огрубляет rtt до кратных
|
||||
// 25 мс и downlink до шага 0.05 с потолком 10 Мбит/с, а между тиками оценка
|
||||
// меняется редко.
|
||||
|
||||
@@ -12,24 +12,13 @@ func testPersona(plat browserprofile.Platform) browserprofile.Profile {
|
||||
return browserprofile.For(plat, browserprofile.Identity{Seed: "telemetry-test"})
|
||||
}
|
||||
|
||||
func testGesture(p browserprofile.Profile) gesture {
|
||||
l := layoutFor(p)
|
||||
target := l.sliderTarget(3, 8)
|
||||
return gesture{
|
||||
from: approachFrom(p, l, target),
|
||||
to: target,
|
||||
move: 1500 * time.Millisecond,
|
||||
settle: 400 * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
// Виджет всегда сериализует все семь ключей аккумулятора: пустой массив - валидный
|
||||
// ответ браузера, отсутствующий ключ - невозможное состояние.
|
||||
func TestAnalyticsAlwaysSendsAllKeys(t *testing.T) {
|
||||
want := []string{"accelerometer", "gyroscope", "motion", "cursor", "taps", "connectionRtt", "connectionDownlink"}
|
||||
for _, plat := range []browserprofile.Platform{browserprofile.Desktop, browserprofile.Mobile} {
|
||||
p := testPersona(plat)
|
||||
fields := buildAnalytics(p, defaultSensorConfig(), testGesture(p), 4*time.Second).fields()
|
||||
fields := buildAnalytics(p, defaultSensorConfig(), 4*time.Second).fields()
|
||||
if len(fields) != len(want) {
|
||||
t.Fatalf("%s: %d fields, want %d", plat, len(fields), len(want))
|
||||
}
|
||||
@@ -47,16 +36,15 @@ func TestAnalyticsAlwaysSendsAllKeys(t *testing.T) {
|
||||
// Телеметрия не может содержать данных, которых персона физически не соберёт.
|
||||
func TestAnalyticsRespectsPersonaCapabilities(t *testing.T) {
|
||||
cases := []struct {
|
||||
plat browserprofile.Platform
|
||||
wantAccel bool
|
||||
wantPointer string
|
||||
plat browserprofile.Platform
|
||||
wantAccel bool
|
||||
}{
|
||||
{browserprofile.Desktop, false, "cursor"},
|
||||
{browserprofile.Mobile, true, "taps"},
|
||||
{browserprofile.Desktop, false},
|
||||
{browserprofile.Mobile, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
p := testPersona(tc.plat)
|
||||
data := buildAnalytics(p, defaultSensorConfig(), testGesture(p), 4*time.Second)
|
||||
data := buildAnalytics(p, defaultSensorConfig(), 4*time.Second)
|
||||
|
||||
if len(data.connRtt) == 0 {
|
||||
t.Fatalf("%s: NetworkInformation отсутствует", tc.plat)
|
||||
@@ -70,37 +58,34 @@ func TestAnalyticsRespectsPersonaCapabilities(t *testing.T) {
|
||||
if len(data.gyroscope) != 0 || len(data.motion) != 0 {
|
||||
t.Fatalf("%s: web-персона не может дать gyroscope/motion", tc.plat)
|
||||
}
|
||||
if tc.wantPointer == "cursor" && (len(data.cursor) == 0 || len(data.taps) != 0) {
|
||||
t.Fatalf("%s: мышь обязана писать cursor и не писать taps", tc.plat)
|
||||
}
|
||||
if tc.wantPointer == "taps" && (len(data.taps) == 0 || len(data.cursor) != 0) {
|
||||
t.Fatalf("%s: тач обязан писать taps и не писать cursor", tc.plat)
|
||||
}
|
||||
}
|
||||
|
||||
// Указатель пишется только через externalCoords, а их заполняет хост-страница или
|
||||
// нативный бридж. При прямой навигации виджет живого браузера оставляет cursor и
|
||||
// taps пустыми - и мы обязаны делать то же.
|
||||
func TestAnalyticsLeavesPointerEmpty(t *testing.T) {
|
||||
for _, plat := range []browserprofile.Platform{browserprofile.Desktop, browserprofile.Mobile} {
|
||||
p := testPersona(plat)
|
||||
data := buildAnalytics(p, defaultSensorConfig(), 10*time.Second)
|
||||
if len(data.cursor) != 0 || len(data.taps) != 0 {
|
||||
t.Fatalf("%s: cursor %d, taps %d, want 0", plat, len(data.cursor), len(data.taps))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Число сэмплов задаёт таймер виджета: сколько тиков уместилось в реальное время
|
||||
// сессии, столько и точек. Мышь видна с первого тика, палец - только с касания.
|
||||
// сессии, столько и точек.
|
||||
func TestAnalyticsSampleCountFollowsElapsed(t *testing.T) {
|
||||
cfg := sensorConfig{delay: 100 * time.Millisecond, cursor: true, taps: true, accelerometer: true}
|
||||
elapsed := 5 * time.Second
|
||||
ticks := int(elapsed / cfg.delay)
|
||||
|
||||
desktop := testPersona(browserprofile.Desktop)
|
||||
data := buildAnalytics(desktop, cfg, testGesture(desktop), elapsed)
|
||||
if len(data.cursor) != ticks || len(data.connRtt) != ticks {
|
||||
t.Fatalf("desktop: cursor %d, conn %d, want %d", len(data.cursor), len(data.connRtt), ticks)
|
||||
}
|
||||
|
||||
mobile := testPersona(browserprofile.Mobile)
|
||||
g := testGesture(mobile)
|
||||
data = buildAnalytics(mobile, cfg, g, elapsed)
|
||||
gestureTicks := int((g.move + g.settle) / cfg.delay)
|
||||
if len(data.taps) > gestureTicks+1 || len(data.taps) == 0 {
|
||||
t.Fatalf("mobile: taps %d, want <= %d (только время касания)", len(data.taps), gestureTicks+1)
|
||||
}
|
||||
if len(data.connRtt) != ticks {
|
||||
t.Fatalf("mobile: conn %d, want %d", len(data.connRtt), ticks)
|
||||
for _, plat := range []browserprofile.Platform{browserprofile.Desktop, browserprofile.Mobile} {
|
||||
data := buildAnalytics(testPersona(plat), cfg, elapsed)
|
||||
if len(data.connRtt) != ticks {
|
||||
t.Fatalf("%s: conn %d, want %d", plat, len(data.connRtt), ticks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +101,7 @@ func TestAnalyticsHonoursSensorList(t *testing.T) {
|
||||
}
|
||||
|
||||
p := testPersona(browserprofile.Mobile)
|
||||
data := buildAnalytics(p, cfg, testGesture(p), 3*time.Second)
|
||||
data := buildAnalytics(p, cfg, 3*time.Second)
|
||||
if len(data.taps) != 0 || len(data.accelerometer) != 0 {
|
||||
t.Fatalf("taps %d, accelerometer %d: сервер их не слушает", len(data.taps), len(data.accelerometer))
|
||||
}
|
||||
@@ -126,7 +111,7 @@ func TestAnalyticsHonoursSensorList(t *testing.T) {
|
||||
func TestAnalyticsWithoutTicksIsEmpty(t *testing.T) {
|
||||
p := testPersona(browserprofile.Mobile)
|
||||
cfg := sensorConfig{delay: 2 * time.Second, cursor: true, taps: true, accelerometer: true}
|
||||
data := buildAnalytics(p, cfg, testGesture(p), 500*time.Millisecond)
|
||||
data := buildAnalytics(p, cfg, 500*time.Millisecond)
|
||||
for _, kv := range data.fields() {
|
||||
if kv[1] != "[]" {
|
||||
t.Fatalf("field %q = %s, want []", kv[0], kv[1])
|
||||
@@ -145,19 +130,6 @@ func TestParseSensorConfigFallsBackToDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Координаты указателя обязаны лежать внутри окна, заявленного в componentDone.
|
||||
func TestPointerStaysInsideViewport(t *testing.T) {
|
||||
for _, plat := range []browserprofile.Platform{browserprofile.Desktop, browserprofile.Mobile} {
|
||||
p := testPersona(plat)
|
||||
data := buildAnalytics(p, defaultSensorConfig(), testGesture(p), 4*time.Second)
|
||||
for _, pt := range append(append([]point{}, data.cursor...), data.taps...) {
|
||||
if pt.X < 0 || pt.Y < 0 || pt.X > p.Viewport.W || pt.Y > p.Viewport.H {
|
||||
t.Fatalf("%s: точка %+v вне окна %+v", plat, pt, p.Viewport)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chrome огрубляет NetworkInformation: rtt кратен 25 мс, downlink - шагу 0.05 и
|
||||
// не превышает 10 Мбит/с.
|
||||
func TestConnectionSamplesQuantized(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package captcha
|
||||
|
||||
// Временная трассировка: полный дамп запросов и ответов captcha в один файл для
|
||||
// ручного разбора. Включается FREETURN_CAPTCHA_TRACE=<путь>.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
traceMu sync.Mutex
|
||||
traceSeq int
|
||||
)
|
||||
|
||||
func traceValue(v string, limit int) string {
|
||||
if len(v) <= limit {
|
||||
return v
|
||||
}
|
||||
return fmt.Sprintf("%s…%s [len=%d]", v[:limit/2], v[len(v)-limit/4:], len(v))
|
||||
}
|
||||
|
||||
// traceHeaders отдаёт заголовки в том порядке, в каком их отправит fhttp.
|
||||
func traceHeaders(h fhttp.Header) []string {
|
||||
order, _ := h[fhttp.HeaderOrderKey]
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(h))
|
||||
for _, name := range order {
|
||||
for key, values := range h {
|
||||
if strings.EqualFold(key, name) && !seen[key] {
|
||||
seen[key] = true
|
||||
out = append(out, fmt.Sprintf("%-27s %s", key+":", strings.Join(values, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
rest := make([]string, 0, len(h))
|
||||
for key, values := range h {
|
||||
if seen[key] || key == fhttp.HeaderOrderKey || key == fhttp.PHeaderOrderKey {
|
||||
continue
|
||||
}
|
||||
rest = append(rest, fmt.Sprintf("%-27s %s <- вне списка порядка", key+":", strings.Join(values, ", ")))
|
||||
}
|
||||
sort.Strings(rest)
|
||||
return append(out, rest...)
|
||||
}
|
||||
|
||||
func traceBody(body []byte) string {
|
||||
var pretty bytes.Buffer
|
||||
if json.Indent(&pretty, body, "", " ") == nil {
|
||||
return traceValue(pretty.String(), 4000)
|
||||
}
|
||||
return traceValue(string(body), 2000)
|
||||
}
|
||||
|
||||
func traceExchange(
|
||||
req *fhttp.Request,
|
||||
form [][2]string,
|
||||
resp *fhttp.Response,
|
||||
body []byte,
|
||||
elapsed time.Duration,
|
||||
) {
|
||||
path := os.Getenv("FREETURN_CAPTCHA_TRACE")
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
traceMu.Lock()
|
||||
defer traceMu.Unlock()
|
||||
traceSeq++
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "\n## %d. %s %s\n\n", traceSeq, req.Method, req.URL.String())
|
||||
fmt.Fprintf(&b, "### Заголовки запроса (в порядке отправки)\n\n```\n:method %s\n:authority %s\n:scheme %s\n:path %s\n%s\n```\n\n",
|
||||
req.Method, req.URL.Host, req.URL.Scheme, req.URL.RequestURI(), strings.Join(traceHeaders(req.Header), "\n"))
|
||||
|
||||
if len(form) > 0 {
|
||||
fmt.Fprintf(&b, "### Тело формы\n\n| поле | длина | значение |\n| --- | --- | --- |\n")
|
||||
for _, kv := range form {
|
||||
fmt.Fprintf(&b, "| `%s` | %d | `%s` |\n", kv[0], len(kv[1]), traceValue(kv[1], 200))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if resp != nil {
|
||||
fmt.Fprintf(&b, "### Ответ %s за %s\n\n```\n", resp.Status, elapsed.Truncate(time.Millisecond))
|
||||
names := make([]string, 0, len(resp.Header))
|
||||
for key := range resp.Header {
|
||||
names = append(names, key)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, key := range names {
|
||||
fmt.Fprintf(&b, "%-27s %s\n", key+":", traceValue(strings.Join(resp.Header[key], ", "), 300))
|
||||
}
|
||||
fmt.Fprintf(&b, "```\n\n```\n%s\n```\n", traceBody(body))
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
Log.Warnf("[Captcha][TRACE] open: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if _, err := f.WriteString(b.String()); err != nil {
|
||||
Log.Warnf("[Captcha][TRACE] write: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package personanet
|
||||
|
||||
import (
|
||||
neturl "net/url"
|
||||
"time"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// cookieJar чинит удаление кук в tls-client: его фильтр смотрит только на
|
||||
// Max-Age (jar.go, проверка Expires закомментирована), а VK гасит куки прошедшим
|
||||
// Expires. Без этого remixir=DELETED уезжает в каждом запросе - живой браузер
|
||||
// такую куку удаляет и не шлёт никогда.
|
||||
type cookieJar struct {
|
||||
inner tlsclient.CookieJar
|
||||
}
|
||||
|
||||
func NewCookieJar() tlsclient.CookieJar {
|
||||
return &cookieJar{inner: tlsclient.NewCookieJar()}
|
||||
}
|
||||
|
||||
func (j *cookieJar) SetCookies(u *neturl.URL, cookies []*fhttp.Cookie) {
|
||||
now := time.Now()
|
||||
for _, c := range cookies {
|
||||
if c.MaxAge == 0 && !c.Expires.IsZero() && !c.Expires.After(now) {
|
||||
c.MaxAge = -1
|
||||
}
|
||||
}
|
||||
j.inner.SetCookies(u, cookies)
|
||||
}
|
||||
|
||||
func (j *cookieJar) Cookies(u *neturl.URL) []*fhttp.Cookie { return j.inner.Cookies(u) }
|
||||
|
||||
func (j *cookieJar) GetAllCookies() map[string][]*fhttp.Cookie { return j.inner.GetAllCookies() }
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/logx"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/personanet"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/randx"
|
||||
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
@@ -268,7 +269,7 @@ func (c *Client) fetch(ctx context.Context, link string, streamID int) (string,
|
||||
|
||||
var lastErr error
|
||||
burns := 0
|
||||
jar := tlsclient.NewCookieJar()
|
||||
jar := personanet.NewCookieJar()
|
||||
for i := 0; i < len(c.credentials); {
|
||||
creds := c.credentials[i]
|
||||
c.log.Infof("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID)
|
||||
@@ -285,7 +286,7 @@ func (c *Client) fetch(ctx context.Context, link string, streamID int) (string,
|
||||
// куками, пока не кончатся режимы решения captcha.
|
||||
if errors.Is(err, ErrPersonaBurned) && burns < maxPersonaBurns {
|
||||
burns++
|
||||
jar = tlsclient.NewCookieJar()
|
||||
jar = personanet.NewCookieJar()
|
||||
continue
|
||||
}
|
||||
i++
|
||||
|
||||
Reference in New Issue
Block a user