fix(leonardo): get-session 走代理并重试 10 次,绕过 Vercel 人机校验的偶发放行
This commit is contained in:
@@ -33,6 +33,11 @@ const (
|
|||||||
// sec-ch-ua must agree with userAgent's major version — a mismatch is itself a
|
// sec-ch-ua must agree with userAgent's major version — a mismatch is itself a
|
||||||
// bot signal.
|
// bot signal.
|
||||||
secChUA = `"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"`
|
secChUA = `"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"`
|
||||||
|
// get-session is fronted by Vercel's checkpoint, which 429s most machine
|
||||||
|
// requests; a proxied retry (new exit IP per attempt) succeeds roughly one time
|
||||||
|
// in five, so several spaced attempts are needed for one refresh to land.
|
||||||
|
getSessionAttempts = 10
|
||||||
|
getSessionRetryDelay = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -189,66 +194,49 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
|||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
client, err := c.newDirectTLSClient()
|
// Vercel's checkpoint 429s the vast majority of get-session calls, whatever the
|
||||||
if err != nil {
|
// TLS fingerprint — but a proxied retry does get through every few attempts
|
||||||
return nil, err
|
// (each new client takes a new exit IP), so retry instead of giving up on the
|
||||||
|
// first 429.
|
||||||
|
var body []byte
|
||||||
|
var status int
|
||||||
|
var setCookies []string
|
||||||
|
var err error
|
||||||
|
for attempt := 0; attempt < getSessionAttempts; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(getSessionRetryDelay):
|
||||||
}
|
}
|
||||||
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/get-session", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
req = req.WithContext(ctx)
|
status, body, setCookies, err = c.fetchSession(ctx, send, c.proxy != "")
|
||||||
// Header set/order copied from a real browser's get-session call (HAR): a
|
if err == nil && status != 429 && status != 403 {
|
||||||
// same-origin GET carries NO origin header and DOES carry the ua client hints
|
break
|
||||||
// + priority — sending origin while omitting the hints is exactly the shape
|
}
|
||||||
// Vercel's checkpoint 429s.
|
|
||||||
req.Header = http.Header{
|
|
||||||
"accept": {"*/*"},
|
|
||||||
"accept-language": {"en-US,en;q=0.9"},
|
|
||||||
"cache-control": {"no-cache"},
|
|
||||||
"cookie": {send},
|
|
||||||
"pragma": {"no-cache"},
|
|
||||||
"priority": {"u=1, i"},
|
|
||||||
"referer": {appBase + "/"},
|
|
||||||
"sec-ch-ua": {secChUA},
|
|
||||||
"sec-ch-ua-mobile": {"?0"},
|
|
||||||
"sec-ch-ua-platform": {`"Windows"`},
|
|
||||||
"sec-fetch-dest": {"empty"},
|
|
||||||
"sec-fetch-mode": {"cors"},
|
|
||||||
"sec-fetch-site": {"same-origin"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
http.HeaderOrderKey: {
|
|
||||||
"accept", "accept-language", "cache-control", "cookie", "pragma",
|
|
||||||
"priority", "referer", "sec-ch-ua", "sec-ch-ua-mobile",
|
|
||||||
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode",
|
|
||||||
"sec-fetch-site", "user-agent",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error())
|
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error())
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
// Only a real app answer may rotate the stored cookie. The 403/429 人机校验 页
|
// Only a real app answer may rotate the stored cookie. The 403/429 人机校验 页
|
||||||
// also sends Set-Cookie (often CLEARING better-auth cookies), and persisting
|
// also sends Set-Cookie (often CLEARING better-auth cookies), and persisting
|
||||||
// that would strip the session_data cache — after which get-session answers
|
// that would strip the session_data cache — after which get-session answers
|
||||||
// 200 null and a perfectly healthy account looks dead.
|
// 200 null and a perfectly healthy account looks dead.
|
||||||
if resp.StatusCode == 200 {
|
if status == 200 {
|
||||||
if merged := mergeCookies(send, resp.Header["Set-Cookie"]); merged != send && keepsSession(merged) {
|
if merged := mergeCookies(send, setCookies); merged != send && keepsSession(merged) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.rotated[cookie] = merged
|
c.rotated[cookie] = merged
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
send = merged
|
send = merged
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resp.StatusCode == 401 {
|
if status == 401 {
|
||||||
return nil, fmt.Errorf("%w: get-session http 401: %s", ErrAuth, clip(body, 160))
|
return nil, fmt.Errorf("%w: get-session http 401: %s", ErrAuth, clip(body, 160))
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 {
|
if status != 200 {
|
||||||
// 403 / 429 here is the Vercel / Cloudflare 人机校验 页,不是 cookie 失效 —
|
// 403 / 429 here is the Vercel / Cloudflare 人机校验 页,不是 cookie 失效 —
|
||||||
// 当成临时错误,否则健康的号会被误判死。
|
// 当成临时错误,否则健康的号会被误判死。
|
||||||
return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, resp.StatusCode, clip(body, 160))
|
return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, status, clip(body, 160))
|
||||||
}
|
}
|
||||||
var raw struct {
|
var raw struct {
|
||||||
Session struct {
|
Session struct {
|
||||||
@@ -297,6 +285,53 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
|||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchSession performs one get-session call and returns its status, body and
|
||||||
|
// Set-Cookie headers.
|
||||||
|
func (c *Client) fetchSession(ctx context.Context, cookie string, useProxy bool) (int, []byte, []string, error) {
|
||||||
|
client, err := c.newTLSClientP(useProxy)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/get-session", nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, nil, err
|
||||||
|
}
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
// Header set/order copied from a real browser's get-session call (HAR): a
|
||||||
|
// same-origin GET carries NO origin header and DOES carry the ua client hints
|
||||||
|
// + priority — sending origin while omitting the hints is exactly the shape
|
||||||
|
// Vercel's checkpoint 429s.
|
||||||
|
req.Header = http.Header{
|
||||||
|
"accept": {"*/*"},
|
||||||
|
"accept-language": {"en-US,en;q=0.9"},
|
||||||
|
"cache-control": {"no-cache"},
|
||||||
|
"cookie": {cookie},
|
||||||
|
"pragma": {"no-cache"},
|
||||||
|
"priority": {"u=1, i"},
|
||||||
|
"referer": {appBase + "/"},
|
||||||
|
"sec-ch-ua": {secChUA},
|
||||||
|
"sec-ch-ua-mobile": {"?0"},
|
||||||
|
"sec-ch-ua-platform": {`"Windows"`},
|
||||||
|
"sec-fetch-dest": {"empty"},
|
||||||
|
"sec-fetch-mode": {"cors"},
|
||||||
|
"sec-fetch-site": {"same-origin"},
|
||||||
|
"user-agent": {userAgent},
|
||||||
|
http.HeaderOrderKey: {
|
||||||
|
"accept", "accept-language", "cache-control", "cookie", "pragma",
|
||||||
|
"priority", "referer", "sec-ch-ua", "sec-ch-ua-mobile",
|
||||||
|
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode",
|
||||||
|
"sec-fetch-site", "user-agent",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, body, resp.Header["Set-Cookie"], nil
|
||||||
|
}
|
||||||
|
|
||||||
// session returns the cookie's access token, optionally forcing a fresh mint
|
// session returns the cookie's access token, optionally forcing a fresh mint
|
||||||
// (dropping the cache) — used when the upstream rejected the current bearer.
|
// (dropping the cache) — used when the upstream rejected the current bearer.
|
||||||
func (c *Client) session(ctx context.Context, cookie string, force bool) (*Session, error) {
|
func (c *Client) session(ctx context.Context, cookie string, force bool) (*Session, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user