feat: Antigravity 100% 指纹还原 + BoringCrypto TLS
Antigravity:
- Client ID 保留双 ID 支持(二进制确认两个都存在)
- Daily URL 去掉 .sandbox 后缀(日志确认)
- Redirect URI /callback → /oauth-callback(extension.js 确认)
- User-Agent 动态 OS/arch: antigravity/{ver} {os}/{arch}
- 新增 x-goog-api-client: gl-go/{goVer} gax-go/v2 grpc-go/1.81.0-dev
- googleapis 不再走 Node.js proxy → Go 原生 TLS(匹配真实 BoringCrypto)
- 新增 Go 后端心跳服务(每5分钟 loadCodeAssist + fetchAvailableModels)
- Dockerfile 切换 BoringCrypto 编译(CGO_ENABLED=1 GOEXPERIMENT=boringcrypto)
GeminiCLI:
- User-Agent 动态化: GeminiCLI/0.1.5 ({OS}; {ARCH})
- AI Studio 请求补上 User-Agent
Claude:
- CLI 版本 2.1.84, 包版本 0.74.0, 运行时 v24.3.0
- Token 交换 axios/1.13.6, timeout 15s
- proxy.js 仅服务 api.anthropic.com(Claude 专属)
架构变更:
- Node.js proxy 仅用于 Claude (api.anthropic.com)
- Antigravity (googleapis) 走 Go 原生 HTTP + GOST proxy
- TLS 指纹: Go BoringCrypto ≈ 真实 Antigravity BoringCrypto
This commit is contained in:
parent
8c6e578a84
commit
ffe6a5e331
14
Dockerfile
14
Dockerfile
@ -7,7 +7,7 @@
|
||||
# =============================================================================
|
||||
|
||||
ARG NODE_IMAGE=node:24-alpine
|
||||
ARG GOLANG_IMAGE=golang:1.26.1-alpine
|
||||
ARG GOLANG_IMAGE=golang:1.26.1
|
||||
ARG ALPINE_IMAGE=alpine:3.21
|
||||
ARG POSTGRES_IMAGE=postgres:18-alpine
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
@ -46,8 +46,8 @@ ARG GOSUMDB
|
||||
ENV GOPROXY=${GOPROXY}
|
||||
ENV GOSUMDB=${GOSUMDB}
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
# Install build dependencies (non-alpine image uses apt)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
@ -61,14 +61,14 @@ COPY backend/ ./
|
||||
# Copy frontend dist from previous stage (must be after backend copy to avoid being overwritten)
|
||||
COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
|
||||
|
||||
# Build the binary (BuildType=release for CI builds, embed frontend)
|
||||
# Version precedence: build arg VERSION > cmd/server/VERSION
|
||||
# Build the binary with BoringCrypto (matches real Antigravity TLS fingerprint)
|
||||
# CGO_ENABLED=1 required for BoringCrypto; static linking via -extldflags for scratch-like deployment
|
||||
RUN VERSION_VALUE="${VERSION}" && \
|
||||
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(tr -d '\r\n' < ./cmd/server/VERSION)"; fi && \
|
||||
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
CGO_ENABLED=1 GOEXPERIMENT=boringcrypto GOOS=linux go build \
|
||||
-tags embed \
|
||||
-ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
|
||||
-ldflags="-s -w -linkmode external -extldflags '-static' -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
|
||||
-trimpath \
|
||||
-o /app/sub2api \
|
||||
./cmd/server
|
||||
|
||||
@ -16,10 +16,10 @@ const CONNECT_TIMEOUT = parseInt(process.env.CONNECT_TIMEOUT || '30000', 10);
|
||||
const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '600000', 10);
|
||||
const TELEMETRY_ENABLED = process.env.TELEMETRY_ENABLED !== 'false'; // 默认开启
|
||||
const DD_API_KEY = process.env.DD_API_KEY || 'pubbbf48e6d78dae54bceaa4acf463299bf';
|
||||
const CLI_VERSION = process.env.CLI_VERSION || '2.1.81';
|
||||
const BUILD_TIME = process.env.BUILD_TIME || '2026-03-20T21:26:18Z';
|
||||
// 伪装的 Node 版本(CLI 2.1.81 打包的 Node 版本)
|
||||
const FAKE_NODE_VERSION = process.env.FAKE_NODE_VERSION || 'v22.14.0';
|
||||
const CLI_VERSION = process.env.CLI_VERSION || '2.1.84';
|
||||
const BUILD_TIME = process.env.BUILD_TIME || '2026-03-25T23:49:18Z';
|
||||
// 伪装的 Node 版本(CLI 2.1.84 打包的 Bun 报告的 Node 兼容版本)
|
||||
const FAKE_NODE_VERSION = process.env.FAKE_NODE_VERSION || 'v24.3.0';
|
||||
|
||||
const log = (level, msg, extra = {}) => {
|
||||
const entry = { time: new Date().toISOString(), level, msg, ...extra };
|
||||
@ -166,9 +166,9 @@ function buildEnvBlock(hostId) {
|
||||
platform: platformStr,
|
||||
node_version: FAKE_NODE_VERSION,
|
||||
terminal: hostId.terminal,
|
||||
package_managers: 'npm',
|
||||
runtimes: 'node',
|
||||
is_running_with_bun: false,
|
||||
package_managers: 'npm,pnpm',
|
||||
runtimes: 'deno,node',
|
||||
is_running_with_bun: true,
|
||||
is_ci: false,
|
||||
is_claubbit: false,
|
||||
is_github_action: false,
|
||||
@ -199,8 +199,8 @@ function buildProcessMetrics(uptime) {
|
||||
heapTotal,
|
||||
heapUsed,
|
||||
external: 14_000_000 + Math.floor(Math.random() * 2_000_000),
|
||||
arrayBuffers: Math.floor(Math.random() * 10_000),
|
||||
constrainedMemory: 0,
|
||||
arrayBuffers: Math.floor(Math.random() * 200_000),
|
||||
constrainedMemory: 51539607552,
|
||||
cpuUsage: {
|
||||
user: Math.floor(uptime * 10_000 + Math.random() * 300_000),
|
||||
system: Math.floor(uptime * 2_000 + Math.random() * 80_000),
|
||||
@ -251,12 +251,8 @@ function sendTelemetryEvents(events, session) {
|
||||
'x-service-name': 'claude-code',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
// 如果有 session,注入 OTEL trace headers(匹配 CLI 的 W3C Trace Context)
|
||||
if (session) {
|
||||
const traceId = crypto.randomBytes(16).toString('hex');
|
||||
const spanId = crypto.randomBytes(8).toString('hex');
|
||||
headers['traceparent'] = `00-${traceId}-${spanId}-01`;
|
||||
}
|
||||
// 注意:真实 CLI 2.1.84 的 event_logging/batch 不发 traceparent
|
||||
// traceparent 仅在 OTLP exporter(单独通道)中使用,不在这个端点
|
||||
|
||||
const opts = {
|
||||
hostname: 'api.anthropic.com',
|
||||
@ -682,6 +678,8 @@ async function proxyRequest(req, res) {
|
||||
'api.anthropic.com',
|
||||
'cloudaicompanion.googleapis.com',
|
||||
'generativelanguage.googleapis.com',
|
||||
'cloudcode-pa.googleapis.com',
|
||||
'daily-cloudcode-pa.googleapis.com',
|
||||
]);
|
||||
if (H2_PREFER_HOSTS.has(targetHost) || h2Hosts.has(targetHost)) {
|
||||
await sendViaH2(targetHost, req.method, req.url, req.headers, body, res, savedHeaders);
|
||||
|
||||
@ -4,7 +4,7 @@ VERSION ?= $(shell tr -d '\r\n' < ./cmd/server/VERSION)
|
||||
LDFLAGS ?= -s -w -X main.Version=$(VERSION)
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -trimpath -o bin/server ./cmd/server
|
||||
CGO_ENABLED=1 GOEXPERIMENT=boringcrypto go build -ldflags="$(LDFLAGS)" -trimpath -o bin/server ./cmd/server
|
||||
|
||||
generate:
|
||||
go generate ./ent
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -29,6 +30,21 @@ func (e *ForbiddenError) Error() string {
|
||||
return fmt.Sprintf("fetchAvailableModels 失败 (HTTP %d): %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// GetGoogAPIClient 返回 x-goog-api-client 头的值(导出供心跳等外部使用)
|
||||
// 格式与真实 Antigravity 的 Go SDK 一致: gl-go/{goVersion} gax-go/v2 grpc-go/1.81.0-dev
|
||||
func GetGoogAPIClient() string {
|
||||
goVer := runtime.Version() // e.g. "go1.22.0"
|
||||
return fmt.Sprintf("gl-go/%s gax-go/v2 grpc-go/1.81.0-dev", goVer)
|
||||
}
|
||||
|
||||
// setAntigravityHeaders 设置与真实 Antigravity IDE 一致的 HTTP 请求头
|
||||
func setAntigravityHeaders(req *http.Request, accessToken string) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
|
||||
}
|
||||
|
||||
// NewAPIRequestWithURL 使用指定的 base URL 创建 Antigravity API 请求(v1internal 端点)
|
||||
func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken string, body []byte) (*http.Request, error) {
|
||||
// 构建 URL,流式请求添加 ?alt=sse 参数
|
||||
@ -43,10 +59,8 @@ func NewAPIRequestWithURL(ctx context.Context, baseURL, action, accessToken stri
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 基础 Headers(与 Antigravity-Manager 保持一致,只设置这 3 个)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
// 设置与真实 Antigravity IDE 一致的请求头
|
||||
setAntigravityHeaders(req, accessToken)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
@ -274,6 +288,11 @@ func NewClient(proxyURL string) (*Client, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DoRaw 执行原始 HTTP 请求(供心跳等内部使用)
|
||||
func (c *Client) DoRaw(req *http.Request) (*http.Response, error) {
|
||||
return c.httpClient.Do(req)
|
||||
}
|
||||
|
||||
// IsConnectionError 判断是否为连接错误(网络超时、DNS 失败、连接拒绝)
|
||||
func IsConnectionError(err error) bool {
|
||||
if err == nil {
|
||||
@ -451,6 +470,7 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@ -530,6 +550,7 @@ func (c *Client) OnboardUser(ctx context.Context, accessToken, tierID string) (s
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@ -664,6 +685,7 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", GetGoogAPIClient())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@ -23,13 +24,16 @@ const (
|
||||
UserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
|
||||
// Antigravity OAuth 客户端凭证
|
||||
// 注意:真实 Antigravity 主 Client ID 是 884354919052-...,但需要对应的 client_secret
|
||||
// 当前使用的 1071006060591-... 同样存在于真实二进制中(可能是备用登录模式)
|
||||
// 如需切换,必须同时更新 client_secret(通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET)
|
||||
ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||
|
||||
// AntigravityOAuthClientSecretEnv 是 Antigravity OAuth client_secret 的环境变量名。
|
||||
AntigravityOAuthClientSecretEnv = "ANTIGRAVITY_OAUTH_CLIENT_SECRET"
|
||||
|
||||
// 固定的 redirect_uri(用户需手动复制 code)
|
||||
RedirectURI = "http://localhost:8085/callback"
|
||||
// redirect_uri — 真实 Antigravity IDE 使用 localhost 动态端口 + /oauth-callback 路径
|
||||
RedirectURI = "http://localhost:8085/oauth-callback"
|
||||
|
||||
// OAuth scopes
|
||||
Scopes = "https://www.googleapis.com/auth/cloud-platform " +
|
||||
@ -44,13 +48,13 @@ const (
|
||||
// URL 可用性 TTL(不可用 URL 的恢复时间)
|
||||
URLAvailabilityTTL = 5 * time.Minute
|
||||
|
||||
// Antigravity API 端点
|
||||
// Antigravity API 端点(真实 Antigravity 日志确认使用 daily-cloudcode-pa 无 sandbox 后缀)
|
||||
antigravityProdBaseURL = "https://cloudcode-pa.googleapis.com"
|
||||
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||
antigravityDailyBaseURL = "https://daily-cloudcode-pa.googleapis.com"
|
||||
)
|
||||
|
||||
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.20.5
|
||||
var defaultUserAgentVersion = "1.20.5"
|
||||
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 0.2.0(匹配真实 extension 版本)
|
||||
var defaultUserAgentVersion = "0.2.0"
|
||||
|
||||
// defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置
|
||||
var defaultClientSecret = "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"
|
||||
@ -66,9 +70,11 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserAgent 返回当前配置的 User-Agent
|
||||
// GetUserAgent 返回当前配置的 User-Agent(匹配真实 Antigravity 格式: antigravity/{version} {os}/{arch})
|
||||
func GetUserAgent() string {
|
||||
return fmt.Sprintf("antigravity/%s windows/amd64", defaultUserAgentVersion)
|
||||
osName := runtime.GOOS // darwin, linux, windows
|
||||
arch := runtime.GOARCH // arm64, amd64
|
||||
return fmt.Sprintf("antigravity/%s %s/%s", defaultUserAgentVersion, osName, arch)
|
||||
}
|
||||
|
||||
func getClientSecret() (string, error) {
|
||||
|
||||
@ -52,13 +52,13 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking
|
||||
var DefaultHeaders = map[string]string{
|
||||
// Keep these in sync with recent Claude CLI traffic to reduce the chance
|
||||
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
|
||||
"User-Agent": "claude-cli/2.1.81 (external, cli)",
|
||||
"User-Agent": "claude-cli/2.1.84 (external, cli)",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Package-Version": "0.80.0",
|
||||
"X-Stainless-OS": "Linux",
|
||||
"X-Stainless-Package-Version": "0.74.0",
|
||||
"X-Stainless-OS": "MacOS",
|
||||
"X-Stainless-Arch": "arm64",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v24.13.0",
|
||||
"X-Stainless-Runtime-Version": "v24.3.0",
|
||||
"X-Stainless-Retry-Count": "0",
|
||||
"X-Stainless-Timeout": "600",
|
||||
"X-App": "cli",
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
// Package geminicli provides helpers for interacting with Gemini CLI tools.
|
||||
package geminicli
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
AIStudioBaseURL = "https://generativelanguage.googleapis.com"
|
||||
@ -47,5 +52,14 @@ const (
|
||||
SessionTTL = 30 * time.Minute
|
||||
|
||||
// GeminiCLIUserAgent mimics Gemini CLI to maximize compatibility with internal endpoints.
|
||||
GeminiCLIUserAgent = "GeminiCLI/0.1.5 (Windows; AMD64)"
|
||||
// Note: The real Gemini CLI uses OS-appropriate platform strings.
|
||||
// Use GetGeminiCLIUserAgent() for runtime-aware User-Agent.
|
||||
GeminiCLIUserAgent = "GeminiCLI/0.1.5"
|
||||
)
|
||||
|
||||
// GetGeminiCLIUserAgent 返回带有正确平台信息的 Gemini CLI User-Agent
|
||||
func GetGeminiCLIUserAgent() string {
|
||||
osName := strings.Title(runtime.GOOS) // Darwin, Linux, Windows
|
||||
arch := strings.ToUpper(runtime.GOARCH)
|
||||
return fmt.Sprintf("GeminiCLI/0.1.5 (%s; %s)", osName, arch)
|
||||
}
|
||||
|
||||
@ -212,7 +212,7 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
|
||||
SetContext(ctx).
|
||||
SetHeader("Accept", "application/json, text/plain, */*").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("User-Agent", "axios/1.8.4").
|
||||
SetHeader("User-Agent", "axios/1.13.6").
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(s.tokenURL)
|
||||
@ -242,6 +242,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshToken,
|
||||
"client_id": oauth.ClientID,
|
||||
"scope": oauth.ScopeAPI,
|
||||
}
|
||||
|
||||
var tokenResp oauth.TokenResponse
|
||||
@ -250,7 +251,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
|
||||
SetContext(ctx).
|
||||
SetHeader("Accept", "application/json, text/plain, */*").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("User-Agent", "axios/1.8.4").
|
||||
SetHeader("User-Agent", "axios/1.13.6").
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(s.tokenURL)
|
||||
@ -268,9 +269,9 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
|
||||
|
||||
func createReqClient(proxyURL string) (*req.Client, error) {
|
||||
// 禁用 CookieJar,确保每次授权都是干净的会话
|
||||
// 不使用 ImpersonateChrome() — 真实 Claude CLI 用 axios (Bun fetch),TLS 指纹应为 Node.js/Bun
|
||||
client := req.C().
|
||||
SetTimeout(60 * time.Second).
|
||||
ImpersonateChrome().
|
||||
SetTimeout(15 * time.Second).
|
||||
SetCookieJar(nil) // 禁用 CookieJar
|
||||
|
||||
trimmed, _, err := proxyurl.Parse(proxyURL)
|
||||
|
||||
@ -124,13 +124,12 @@ func NewHTTPUpstream(cfg *config.Config) service.HTTPUpstream {
|
||||
// - 调用方必须关闭 resp.Body,否则会导致 inFlight 计数泄漏
|
||||
// - inFlight > 0 的客户端不会被淘汰,确保活跃请求不被中断
|
||||
func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
// Node.js TLS 代理:Anthropic + Google APIs
|
||||
// 无论是否绑定 per-account 代理,都走 node-tls-proxy(指纹伪装)
|
||||
// Node.js TLS 代理:仅 Anthropic API
|
||||
// Antigravity (googleapis) 使用 Go 原生 TLS(更接近真实 BoringCrypto 指纹)
|
||||
// proxyURL 通过 X-Upstream-Proxy header 传递给 node-tls-proxy 动态选择出口
|
||||
if s.isNodeTLSProxyEnabled() && req != nil && req.URL != nil && req.URL.Scheme == "https" {
|
||||
host := req.URL.Hostname()
|
||||
if host == "api.anthropic.com" ||
|
||||
strings.HasSuffix(host, ".googleapis.com") {
|
||||
if host == "api.anthropic.com" {
|
||||
return s.doViaNodeTLSProxy(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
}
|
||||
@ -186,11 +185,11 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
// 优先使用 Node.js TLS 代理模式(Anthropic + Google APIs)
|
||||
// 无论是否绑定 per-account 代理,都走 node-tls-proxy(指纹伪装)
|
||||
// 优先使用 Node.js TLS 代理模式(仅 Anthropic API)
|
||||
// Antigravity (googleapis) 使用 Go 原生 TLS(更接近真实 BoringCrypto 指纹)
|
||||
if s.isNodeTLSProxyEnabled() && req != nil && req.URL != nil {
|
||||
host := req.URL.Hostname()
|
||||
if host == "api.anthropic.com" || strings.HasSuffix(host, ".googleapis.com") {
|
||||
if host == "api.anthropic.com" {
|
||||
return s.doViaNodeTLSProxy(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
204
backend/internal/service/antigravity_heartbeat.go
Normal file
204
backend/internal/service/antigravity_heartbeat.go
Normal file
@ -0,0 +1,204 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
// AntigravityHeartbeat 模拟真实 Antigravity IDE 的心跳行为
|
||||
// 真实 IDE 每 5 分钟发送 loadCodeAssist + fetchAvailableModels
|
||||
type AntigravityHeartbeat struct {
|
||||
mu sync.Mutex
|
||||
sessions map[int64]*heartbeatSession // accountID -> session
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
type heartbeatSession struct {
|
||||
accountID int64
|
||||
accessToken string
|
||||
projectID string
|
||||
proxyURL string
|
||||
lastBeat time.Time
|
||||
}
|
||||
|
||||
// NewAntigravityHeartbeat 创建心跳管理器
|
||||
func NewAntigravityHeartbeat() *AntigravityHeartbeat {
|
||||
hb := &AntigravityHeartbeat{
|
||||
sessions: make(map[int64]*heartbeatSession),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go hb.loop()
|
||||
return hb
|
||||
}
|
||||
|
||||
// Register 注册账号心跳(首次 API 调用时调用)
|
||||
func (h *AntigravityHeartbeat) Register(accountID int64, accessToken, projectID, proxyURL string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if _, exists := h.sessions[accountID]; exists {
|
||||
// 更新 token(可能已刷新)
|
||||
h.sessions[accountID].accessToken = accessToken
|
||||
return
|
||||
}
|
||||
|
||||
h.sessions[accountID] = &heartbeatSession{
|
||||
accountID: accountID,
|
||||
accessToken: accessToken,
|
||||
projectID: projectID,
|
||||
proxyURL: proxyURL,
|
||||
lastBeat: time.Now(),
|
||||
}
|
||||
log.Printf("[antigravity-heartbeat] registered account %d (project: %s)", accountID, projectID)
|
||||
}
|
||||
|
||||
// UpdateToken 更新账号的 access token(token 刷新后调用)
|
||||
func (h *AntigravityHeartbeat) UpdateToken(accountID int64, accessToken string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if s, ok := h.sessions[accountID]; ok {
|
||||
s.accessToken = accessToken
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister 移除账号心跳
|
||||
func (h *AntigravityHeartbeat) Unregister(accountID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.sessions, accountID)
|
||||
}
|
||||
|
||||
// Stop 停止心跳
|
||||
func (h *AntigravityHeartbeat) Stop() {
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
default:
|
||||
close(h.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AntigravityHeartbeat) loop() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
h.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AntigravityHeartbeat) tick() {
|
||||
h.mu.Lock()
|
||||
// 收集需要心跳的 session
|
||||
var toSend []*heartbeatSession
|
||||
now := time.Now()
|
||||
for _, s := range h.sessions {
|
||||
if now.Sub(s.lastBeat) >= 5*time.Minute {
|
||||
s.lastBeat = now
|
||||
// 复制一份避免持锁时发请求
|
||||
cp := *s
|
||||
toSend = append(toSend, &cp)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, s := range toSend {
|
||||
go h.sendHeartbeat(s)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AntigravityHeartbeat) sendHeartbeat(s *heartbeatSession) {
|
||||
client, err := antigravity.NewClient(s.proxyURL)
|
||||
if err != nil {
|
||||
log.Printf("[antigravity-heartbeat] account %d: client error: %v", s.accountID, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. loadCodeAssist
|
||||
h.doLoadCodeAssist(ctx, client, s)
|
||||
|
||||
// 模拟真实 IDE 的延迟(~500ms)
|
||||
time.Sleep(time.Duration(400+rand.Intn(200)) * time.Millisecond)
|
||||
|
||||
// 2. fetchAvailableModels
|
||||
h.doFetchAvailableModels(ctx, client, s)
|
||||
}
|
||||
|
||||
func (h *AntigravityHeartbeat) doLoadCodeAssist(ctx context.Context, client *antigravity.Client, s *heartbeatSession) {
|
||||
reqBody := map[string]any{
|
||||
"metadata": map[string]string{
|
||||
"ideType": "ANTIGRAVITY",
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
for _, baseURL := range antigravity.BaseURLs {
|
||||
apiURL := fmt.Sprintf("%s/v1internal:loadCodeAssist", baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.accessToken)
|
||||
req.Header.Set("User-Agent", antigravity.GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", antigravity.GetGoogAPIClient())
|
||||
|
||||
resp, err := client.DoRaw(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AntigravityHeartbeat) doFetchAvailableModels(ctx context.Context, client *antigravity.Client, s *heartbeatSession) {
|
||||
reqBody := map[string]string{
|
||||
"project": s.projectID,
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
for _, baseURL := range antigravity.BaseURLs {
|
||||
apiURL := fmt.Sprintf("%s/v1internal:fetchAvailableModels", baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.accessToken)
|
||||
req.Header.Set("User-Agent", antigravity.GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", antigravity.GetGoogAPIClient())
|
||||
|
||||
resp, err := client.DoRaw(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -670,7 +670,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
}
|
||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent())
|
||||
return upstreamReq, "x-request-id", nil
|
||||
} else {
|
||||
// Mode 2: AI Studio API with OAuth (like API key mode, but using Bearer token)
|
||||
@ -691,6 +691,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
}
|
||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent())
|
||||
return upstreamReq, "x-request-id", nil
|
||||
}
|
||||
}
|
||||
@ -722,7 +723,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
c.Set(OpsUpstreamRequestBodyKey, string(body))
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@ -1171,7 +1172,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
}
|
||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent())
|
||||
return upstreamReq, "x-request-id", nil
|
||||
} else {
|
||||
// Mode 2: AI Studio API with OAuth (like API key mode, but using Bearer token)
|
||||
@ -1192,6 +1193,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
}
|
||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
upstreamReq.Header.Set("User-Agent", geminicli.GetGeminiCLIUserAgent())
|
||||
return upstreamReq, "x-request-id", nil
|
||||
}
|
||||
}
|
||||
@ -1222,7 +1224,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
c.Set(OpsUpstreamRequestBodyKey, string(body))
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@ -2589,7 +2591,7 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac
|
||||
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -26,13 +26,13 @@ var (
|
||||
|
||||
// 默认指纹值(当客户端未提供时使用)
|
||||
var defaultFingerprint = Fingerprint{
|
||||
UserAgent: "claude-cli/2.1.81 (external, cli)",
|
||||
UserAgent: "claude-cli/2.1.84 (external, cli)",
|
||||
StainlessLang: "js",
|
||||
StainlessPackageVersion: "0.80.0",
|
||||
StainlessOS: "Linux",
|
||||
StainlessPackageVersion: "0.74.0",
|
||||
StainlessOS: "MacOS",
|
||||
StainlessArch: "arm64",
|
||||
StainlessRuntime: "node",
|
||||
StainlessRuntimeVersion: "v24.13.0",
|
||||
StainlessRuntimeVersion: "v24.3.0",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -409,10 +409,10 @@ gateway:
|
||||
# other sub2api deployments. Empty values use built-in defaults.
|
||||
# 每个实例可设置不同的版本号,与其他 sub2api 部署区分。空值使用内置默认值。
|
||||
fingerprint_defaults:
|
||||
# claude_cli_version: "2.1.81"
|
||||
# stainless_package_version: "0.80.0"
|
||||
# stainless_runtime_version: "v24.13.0"
|
||||
# stainless_os: "Linux" # Linux / Darwin
|
||||
# claude_cli_version: "2.1.84"
|
||||
# stainless_package_version: "0.74.0"
|
||||
# stainless_runtime_version: "v24.3.0"
|
||||
# stainless_os: "MacOS" # MacOS / Linux (注意大小写,真实CLI用MacOS)
|
||||
# stainless_arch: "arm64" # arm64 / x64
|
||||
|
||||
# =============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user