mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(gateway): prevent Cloudflare 1010 from disabling OpenCode and Command Code accounts
This commit is contained in:
@@ -2103,6 +2103,9 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection(
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
|
||||
// 官方 OpenCode / Command Code 上游收敛为规范客户端 UA,与真实转发路径一致。
|
||||
applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header)
|
||||
|
||||
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
|
||||
|
||||
@@ -84,6 +84,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co
|
||||
// Ollama Cloud Anthropic 兼容端点按 adaptive 实际选用的 Anthropic
|
||||
// base_url 强制 Bearer,其余保持 extra/default 行为。
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetCNProtocolBaseURL(APIProtocolAnthropic))
|
||||
applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
|
||||
|
||||
@@ -178,6 +179,7 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
applyOpenAICodexProbeHeaders(req.Header)
|
||||
applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
|
||||
|
||||
@@ -267,6 +269,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a
|
||||
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持
|
||||
// extra/default 行为。
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetAnthropicProtocolBaseURL())
|
||||
applyOpenCodeUpstreamUserAgent(account, apiURL, req.Header)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
|
||||
|
||||
|
||||
@@ -225,6 +225,10 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
|
||||
}
|
||||
applyGrokCacheHeaders(upstreamReq.Header, grokCacheIdentity)
|
||||
}
|
||||
// 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库
|
||||
// UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。
|
||||
applyOpenCodeUpstreamUserAgent(account, targetURL, upstreamReq.Header)
|
||||
|
||||
// 账号级请求头覆写:放在所有内置默认头(含 Grok CLI 身份头)之后应用,
|
||||
// 使配置值获得除共享传输层强制头之外的最高优先级。
|
||||
account.ApplyHeaderOverrides(upstreamReq.Header)
|
||||
|
||||
@@ -1510,6 +1510,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
req.Header.Set("content-type", "application/json")
|
||||
}
|
||||
|
||||
// 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库
|
||||
// UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。
|
||||
applyOpenCodeUpstreamUserAgent(account, targetURL, req.Header)
|
||||
|
||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body, openCodeSessionHintBody(promptCacheKey))
|
||||
|
||||
@@ -208,6 +208,10 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest(
|
||||
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
|
||||
}
|
||||
|
||||
// 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库
|
||||
// UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。
|
||||
applyOpenCodeUpstreamUserAgent(account, targetURL, req.Header)
|
||||
|
||||
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
payloads := append([][]byte{body}, sessionBodies...)
|
||||
|
||||
@@ -719,6 +719,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
req.Header.Set("content-type", "application/json")
|
||||
}
|
||||
|
||||
// 官方 OpenCode / Command Code 上游收敛为规范客户端 UA:客户端透传的编程库
|
||||
// UA 会命中其前置 Cloudflare bot 拦截(CF 1010/403),并被计入账号 403 strike。
|
||||
applyOpenCodeUpstreamUserAgent(account, targetURL, req.Header)
|
||||
|
||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body)
|
||||
|
||||
@@ -14,6 +14,14 @@ import (
|
||||
const (
|
||||
openCodeSessionHeader = "X-OpenCode-Session"
|
||||
openCodeInboundBodyContextKey = "opencode_inbound_body"
|
||||
|
||||
// openCodeUpstreamUserAgent 是发往官方 OpenCode 上游的规范 User-Agent。
|
||||
// opencode.ai 前置 Cloudflare 按客户端 UA 签名做 bot 拦截:Python-urllib 等
|
||||
// 编程库 UA 会被 CF error code 1010 拒绝并返回 403。网关透传白名单放行
|
||||
// user-agent,客户端自报身份原样到达上游会命中 WAF;该 403 再被计入账号
|
||||
// 凭证类 strike,健康账号因此被自动禁用。取值沿用真实 opencode 客户端的
|
||||
// UA 格式(opencode/<version>)。
|
||||
openCodeUpstreamUserAgent = "opencode/1.0.0"
|
||||
)
|
||||
|
||||
// rememberOpenCodeInboundBody keeps the client request body so protocol
|
||||
@@ -97,6 +105,46 @@ func isOfficialOpenCodeHost(targetURL string) bool {
|
||||
return strings.EqualFold(parsed.Scheme, "https") && strings.EqualFold(parsed.Hostname(), "opencode.ai")
|
||||
}
|
||||
|
||||
func isOfficialCommandCodeHost(targetURL string) bool {
|
||||
parsed, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(parsed.Scheme, "https") && strings.EqualFold(parsed.Hostname(), "api.commandcode.ai")
|
||||
}
|
||||
|
||||
// applyOpenCodeUpstreamUserAgent 将发往官方 OpenCode / Command Code 上游的出站
|
||||
// User-Agent 收敛为规范客户端身份,覆盖客户端透传值与平台默认 UA。判定规则与
|
||||
// x-opencode-session 同源:opencode 平台账号,或(任意平台账号)目标为官方主机。
|
||||
// 必须先于 Account.ApplyHeaderOverrides 调用——账号 header_overrides 中显式
|
||||
// 配置的 user-agent 仍拥有最终决定权。
|
||||
func applyOpenCodeUpstreamUserAgent(account *Account, targetURL string, headers http.Header) {
|
||||
if headers == nil {
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := ""
|
||||
switch {
|
||||
case isOfficialCommandCodeHost(targetURL):
|
||||
userAgent = CodexCanonicalUserAgent()
|
||||
case account != nil && account.IsOpenCodeGo(), isOfficialOpenCodeHost(targetURL):
|
||||
userAgent = openCodeUpstreamUserAgent
|
||||
default:
|
||||
return
|
||||
}
|
||||
if userAgent == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 先删任意大小写变体再写入:透传链路与覆写直写 map 可能残留非 canonical 键。
|
||||
for key := range headers {
|
||||
if strings.EqualFold(key, "User-Agent") {
|
||||
delete(headers, key)
|
||||
}
|
||||
}
|
||||
headers.Set("User-Agent", userAgent)
|
||||
}
|
||||
|
||||
func resolveOpenCodeSessionID(c *gin.Context, headers http.Header, generate bool, bodies ...[]byte) string {
|
||||
if c != nil && c.Request != nil {
|
||||
if sessionID := sanitizeSessionID(c.GetHeader(openCodeSessionHeader)); sessionID != "" {
|
||||
|
||||
@@ -307,6 +307,188 @@ func (u *openCodeSessionHTTPUpstream) DoWithTLS(req *http.Request, proxyURL stri
|
||||
return u.Do(req, proxyURL, accountID, concurrency)
|
||||
}
|
||||
|
||||
func TestApplyOpenCodeUpstreamUserAgent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
targetURL string
|
||||
seed string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "opencode go account canonicalizes passthrough UA",
|
||||
account: &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey},
|
||||
targetURL: "https://opencode.ai/zen/go/v1/chat/completions",
|
||||
seed: "Python-urllib/3.13",
|
||||
want: openCodeUpstreamUserAgent,
|
||||
},
|
||||
{
|
||||
name: "opencode go account canonicalizes on custom relay too",
|
||||
account: &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey},
|
||||
targetURL: "https://relay.example.com/v1/chat/completions",
|
||||
seed: "Python-urllib/3.13",
|
||||
want: openCodeUpstreamUserAgent,
|
||||
},
|
||||
{
|
||||
name: "non-opencode account targeting official host",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
targetURL: "https://opencode.ai/zen/go/v1/responses",
|
||||
seed: "Python-urllib/3.13",
|
||||
want: openCodeUpstreamUserAgent,
|
||||
},
|
||||
{
|
||||
name: "official command code host uses canonical Codex UA",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
targetURL: "https://api.commandcode.ai/provider/v1/responses",
|
||||
seed: "Python-urllib/3.13",
|
||||
want: CodexCanonicalUserAgent(),
|
||||
},
|
||||
{
|
||||
name: "command code lookalike host keeps client UA",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
targetURL: "https://api.commandcode.ai.evil.example/provider/v1/responses",
|
||||
seed: "Python-urllib/3.13",
|
||||
want: "Python-urllib/3.13",
|
||||
},
|
||||
{
|
||||
name: "command code subdomain keeps client UA",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
targetURL: "https://proxy.api.commandcode.ai/provider/v1/responses",
|
||||
seed: "Python-urllib/3.13",
|
||||
want: "Python-urllib/3.13",
|
||||
},
|
||||
{
|
||||
name: "insecure command code host keeps client UA",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
targetURL: "http://api.commandcode.ai/provider/v1/responses",
|
||||
seed: "Python-urllib/3.13",
|
||||
want: "Python-urllib/3.13",
|
||||
},
|
||||
{
|
||||
name: "non-official account and host keeps client UA",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
targetURL: "https://api.openai.com/v1/chat/completions",
|
||||
seed: "Python-urllib/3.13",
|
||||
want: "Python-urllib/3.13",
|
||||
},
|
||||
{
|
||||
name: "lookalike host does not count as official",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
targetURL: "https://opencode.ai.evil.example/v1/chat/completions",
|
||||
seed: "Python-urllib/3.13",
|
||||
want: "Python-urllib/3.13",
|
||||
},
|
||||
{
|
||||
name: "missing UA is filled for opencode account",
|
||||
account: &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey},
|
||||
targetURL: "https://opencode.ai/zen/go/v1/messages",
|
||||
want: openCodeUpstreamUserAgent,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
headers := make(http.Header)
|
||||
if tt.seed != "" {
|
||||
headers.Set("User-Agent", tt.seed)
|
||||
}
|
||||
applyOpenCodeUpstreamUserAgent(tt.account, tt.targetURL, headers)
|
||||
require.Equal(t, tt.want, headers.Get("User-Agent"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOpenCodeUpstreamUserAgentDropsNonCanonicalKeyVariants(t *testing.T) {
|
||||
headers := http.Header{"user-agent": []string{"Python-urllib/3.13"}}
|
||||
applyOpenCodeUpstreamUserAgent(&Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey},
|
||||
"https://opencode.ai/zen/go/v1/chat/completions", headers)
|
||||
|
||||
count := 0
|
||||
for key, values := range headers {
|
||||
if strings.EqualFold(key, "User-Agent") {
|
||||
count += len(values)
|
||||
require.Equal(t, []string{openCodeUpstreamUserAgent}, values)
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestApplyOpenCodeUpstreamUserAgentNilSafe(t *testing.T) {
|
||||
applyOpenCodeUpstreamUserAgent(&Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey},
|
||||
"https://opencode.ai/zen/go/v1/chat/completions", nil)
|
||||
}
|
||||
|
||||
func TestOpenCodeUpstreamUserAgentCanonicalizedAfterPassthrough(t *testing.T) {
|
||||
// 回归:客户端透传的编程库 UA(Python-urllib)会被 opencode.ai 前置
|
||||
// Cloudflare 以 error code 1010 拦截并计入账号 403 strike,导致健康账号
|
||||
// 被自动禁用。出站 UA 必须收敛为规范 opencode 客户端身份。
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := openCodeSessionTestService()
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformOpenCodeGo,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://opencode.ai/zen/go/v1",
|
||||
},
|
||||
}
|
||||
c := newOpenCodeSessionTestContext(t, "")
|
||||
c.Request.Header.Set("User-Agent", "Python-urllib/3.13")
|
||||
|
||||
req, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, account,
|
||||
[]byte(`{"model":"glm-5.3","input":"hello"}`), "token", false, "", false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, openCodeUpstreamUserAgent, req.Header.Get("User-Agent"))
|
||||
}
|
||||
|
||||
func TestCommandCodeUpstreamUserAgentCanonicalizedAfterPassthrough(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := openCodeSessionTestService()
|
||||
account := &Account{
|
||||
ID: 2,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://api.commandcode.ai/provider/v1",
|
||||
},
|
||||
}
|
||||
c := newOpenCodeSessionTestContext(t, "")
|
||||
c.Request.Header.Set("User-Agent", "Python-urllib/3.13")
|
||||
|
||||
req, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, account,
|
||||
[]byte(`{"model":"gpt-5","input":"hello"}`), "token", false, "", false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, CodexCanonicalUserAgent(), req.Header.Get("User-Agent"))
|
||||
}
|
||||
|
||||
func TestOpenCodeUpstreamUserAgentYieldsToExplicitAccountOverride(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := openCodeSessionTestService()
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformOpenCodeGo,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://opencode.ai/zen/go/v1",
|
||||
credKeyHeaderOverrideEnabled: true,
|
||||
credKeyHeaderOverrides: map[string]any{"user-agent": "custom-relay-agent/2.0"},
|
||||
},
|
||||
}
|
||||
c := newOpenCodeSessionTestContext(t, "")
|
||||
c.Request.Header.Set("User-Agent", "Python-urllib/3.13")
|
||||
|
||||
req, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, account,
|
||||
[]byte(`{"model":"glm-5.3","input":"hello"}`), "token", false, "", false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "custom-relay-agent/2.0", req.Header.Get("User-Agent"))
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionForwardedByRawChatCompletionsAfterAccountOverride(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := &openCodeSessionHTTPUpstream{}
|
||||
|
||||
@@ -1039,6 +1039,15 @@ func (s *RateLimitService) handleOpenAI403(ctx context.Context, account *Account
|
||||
)
|
||||
return false
|
||||
}
|
||||
if isCloudflareBotBlockResponse(responseBody) {
|
||||
slog.Warn(
|
||||
"openai_403_cloudflare_bot_block_skips_account_penalty",
|
||||
"account_id", account.ID,
|
||||
"platform", account.Platform,
|
||||
"upstream_message", upstreamMsg,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
msg := buildForbiddenErrorMessage(
|
||||
"Access forbidden (403):",
|
||||
@@ -1084,6 +1093,14 @@ func (s *RateLimitService) handleOpenAI403(ctx context.Context, account *Account
|
||||
return true
|
||||
}
|
||||
|
||||
// isCloudflareBotBlockResponse reports Cloudflare's WAF bot-signature response
|
||||
// (error code 1010). The upstream never reached the account API, so this is a
|
||||
// request/edge-level failure and must not consume the account 403 strike budget.
|
||||
func isCloudflareBotBlockResponse(body []byte) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(string(body)))
|
||||
return strings.Contains(normalized, "error code: 1010")
|
||||
}
|
||||
|
||||
// handleAntigravity403 处理 Antigravity 平台的 403 错误
|
||||
// validation(需要验证)→ 永久 SetError(需人工去 Google 验证后恢复)
|
||||
// violation(违规封号)→ 永久 SetError(需人工处理)
|
||||
|
||||
@@ -68,6 +68,8 @@ func (h *openAI403TestHarness) requireNoAccountPenalty(t *testing.T) {
|
||||
const openAI403HTMLBody = "<!DOCTYPE html>\n<html><head><title>403 Forbidden</title></head>" +
|
||||
"<body><h1>403 Forbidden</h1></body></html>"
|
||||
|
||||
const openAI403Cloudflare1010Body = "error code: 1010\n"
|
||||
|
||||
func TestHandleUpstreamError_OpenAIHTML403DoesNotPenalizeAccount(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -102,6 +104,32 @@ func TestHandleUpstreamError_OpenAIHTML403RepeatedNeverEscalates(t *testing.T) {
|
||||
h.requireNoAccountPenalty(t)
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_OpenAICloudflare1010DoesNotPenalizeAccount(t *testing.T) {
|
||||
for _, platform := range []string{PlatformOpenAI, PlatformOpenCodeGo} {
|
||||
t.Run(platform, func(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
counter := &countingOpenAI403CounterCache{
|
||||
openAI403CounterCacheStub: openAI403CounterCacheStub{counts: []int64{openAI403DisableThreshold}},
|
||||
}
|
||||
blocker := &runtimeBlockRecorder{}
|
||||
svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
svc.SetOpenAI403CounterCache(counter)
|
||||
svc.SetAccountRuntimeBlocker(blocker)
|
||||
account := &Account{ID: 601, Platform: platform, Type: AccountTypeAPIKey}
|
||||
|
||||
shouldDisable := svc.HandleUpstreamError(
|
||||
context.Background(), account, http.StatusForbidden, http.Header{}, []byte(openAI403Cloudflare1010Body),
|
||||
)
|
||||
|
||||
require.False(t, shouldDisable, "Cloudflare 1010 不得判定账号应下线")
|
||||
require.Equal(t, 0, counter.increments, "Cloudflare 1010 不得递增账号 403 计数")
|
||||
require.Equal(t, 0, repo.setErrorCalls)
|
||||
require.Equal(t, 0, repo.tempCalls)
|
||||
require.Empty(t, blocker.accounts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 对照不变式:真正的结构化 JSON 403 是账号级证据,处罚链路必须原样保留。
|
||||
// 缺了这组断言,上面的跳过逻辑一旦写宽就会把真实的封号 403 也放过去。
|
||||
func TestHandleUpstreamError_OpenAIStructured403StillPenalizes(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user