Files
sub2api/backend/internal/service/openai_client_restriction_detector.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

192 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"fmt"
"net/http"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/gin-gonic/gin"
)
// CodexOfficialClientsOnlyMessage 是 codex_cli_only 拒绝时面向客户端的通用兜底文案。
// 仅当拒绝原因不是「可解析版本但越界」(VersionTooLow/VersionTooHigh)时使用:
// 未命中官方/黑名单/缺指纹/版本无法识别都沿用这句(避免向伪装客户端泄露门控细节)。
const CodexOfficialClientsOnlyMessage = "This account only allows Codex official clients"
const (
// CodexClientRestrictionReasonDisabled 表示账号未开启 codex_cli_only。
CodexClientRestrictionReasonDisabled = "codex_cli_only_disabled"
// CodexClientRestrictionReasonMatchedUA 表示请求命中官方客户端 UA 白名单。
CodexClientRestrictionReasonMatchedUA = "official_client_user_agent_matched"
// CodexClientRestrictionReasonMatchedOriginator 表示请求命中官方客户端 originator 白名单。
CodexClientRestrictionReasonMatchedOriginator = "official_client_originator_matched"
// CodexClientRestrictionReasonNotMatchedUA 表示请求未命中任何允许的客户端身份。
CodexClientRestrictionReasonNotMatchedUA = "official_client_user_agent_not_matched"
// CodexClientRestrictionReasonForceCodexCLI 表示通过 ForceCodexCLI 配置兜底放行。
CodexClientRestrictionReasonForceCodexCLI = "force_codex_cli_enabled"
// CodexClientRestrictionReasonBlacklisted 表示请求命中全局黑名单(门内 deny 最先,OR 语义)。
CodexClientRestrictionReasonBlacklisted = "blacklist_matched"
// CodexClientRestrictionReasonMatchedWhitelistClient 表示请求命中全局自由白名单条目(双因子 AND)。
CodexClientRestrictionReasonMatchedWhitelistClient = "whitelist_client_matched"
// CodexClientRestrictionReasonVersionTooLow 表示 UA 解析出的 Codex 引擎版本低于最低要求。
CodexClientRestrictionReasonVersionTooLow = "codex_version_too_low"
// CodexClientRestrictionReasonMissingEngineFingerprint 表示 strict 指纹门下缺少 codex 引擎指纹头。
CodexClientRestrictionReasonMissingEngineFingerprint = "missing_engine_fingerprint"
// CodexClientRestrictionReasonVersionUndetectable 表示 codex_cli_only 下无法从 UA 解析出 Codex 引擎版本。
CodexClientRestrictionReasonVersionUndetectable = "codex_version_undetectable"
// CodexClientRestrictionReasonVersionTooHigh 表示 UA 解析出的 Codex 引擎版本高于最高允许版本。
CodexClientRestrictionReasonVersionTooHigh = "codex_version_too_high"
// CodexClientRestrictionReasonMatchedAppServerClient 表示 App Server 开关开启时对未列名客户端开闸放行(仍过引擎门)。
CodexClientRestrictionReasonMatchedAppServerClient = "app_server_client_matched"
)
// CodexRestrictionPolicy 是 codex_cli_only 判定所需的全局策略快照,由调用方从全局设置解析注入(global-only)。
// 账号侧只有 codex_cli_only 开关本身;黑/白名单、最低版本、指纹门均为全局设置。
type CodexRestrictionPolicy struct {
Whitelist []openai.AllowedClientEntry // 全局自由白名单(双因子 AND,放行官方集未覆盖的 app-server client
Blacklist []openai.AllowedClientEntry // 全局自由黑名单(OR,宽 deny)
MinCodexVersion string // 最低 Codex 引擎版本 semver""=不校验
MaxCodexVersion string // 最高 Codex 引擎版本 semver""=不校验
AllowAppServerClients bool // App Server 开关:对未列名客户端开闸(仍受引擎门约束)
EngineFingerprintSignals []openai.EngineFingerprintSignal // 引擎指纹门信号列表(勾选 AND / 行内变体 OR);缺省=默认种子(只勾 x-codex-)
}
// CodexClientRestrictionDetectionResult 是 codex_cli_only 统一检测入口结果。
type CodexClientRestrictionDetectionResult struct {
Enabled bool
Matched bool
Reason string
// DetectedVersion 是从官方 UA 解析出的 Codex 引擎版本;仅在版本门拒绝
// (VersionTooLow / VersionTooHigh) 时填充,供面向客户端的差异化文案使用。
DetectedVersion string
// MinCodexVersion 是触发 VersionTooLow 时的最低要求版本(来自策略快照)。
MinCodexVersion string
// MaxCodexVersion 是触发 VersionTooHigh 时的最高允许版本(来自策略快照)。
MaxCodexVersion string
}
// CodexClientRestrictionDetector 定义 codex_cli_only 统一检测入口。
type CodexClientRestrictionDetector interface {
Detect(c *gin.Context, account *Account, policy CodexRestrictionPolicy, body []byte) CodexClientRestrictionDetectionResult
}
// OpenAICodexClientRestrictionDetector 为 OpenAI OAuth codex_cli_only 的默认实现。
type OpenAICodexClientRestrictionDetector struct {
cfg *config.Config
}
func NewOpenAICodexClientRestrictionDetector(cfg *config.Config) *OpenAICodexClientRestrictionDetector {
return &OpenAICodexClientRestrictionDetector{cfg: cfg}
}
// Detect 门控顺序(每步可短路):
// 1. 账号未开 codex_cli_only → 不限制(Disabled)。
// 2. gateway.force_codex_cli → 全局旁路放行(ForceCodexCLI)。
// 3. 黑名单命中 → 立即拒(门内 deny 最先,OR 语义)。
// 4. 身份候选:官方 UA / 官方 originator / 全局白名单 / App Server 开闸(全局开关 OR 账号开关);都不命中 → 拒(NotMatchedUA)。
// 5. Codex 版本(仅官方候选):版本必须可解析(否则 VersionUndetectable);< Min → 拒(TooLow);> Max → 拒(TooHigh)。
// 6. 引擎指纹 AND 硬门:按 EngineFingerprintSignals 列表勾选 AND 判定(无任何 Required 信号→放行,即「关闭指纹门」=取消所有勾选);白名单条目可显式 skip。
func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *Account, policy CodexRestrictionPolicy, body []byte) CodexClientRestrictionDetectionResult {
if account == nil || !account.IsCodexCLIOnlyEnabled() {
return CodexClientRestrictionDetectionResult{Enabled: false, Matched: false, Reason: CodexClientRestrictionReasonDisabled}
}
if d != nil && d.cfg != nil && d.cfg.Gateway.ForceCodexCLI {
return CodexClientRestrictionDetectionResult{Enabled: true, Matched: true, Reason: CodexClientRestrictionReasonForceCodexCLI}
}
userAgent := ""
originator := ""
var header http.Header
if c != nil {
userAgent = c.GetHeader("User-Agent")
originator = c.GetHeader("originator")
if c.Request != nil {
header = c.Request.Header
}
}
// 3. 黑名单优先(门内 deny 最先,OR:任一已声明字段命中即拒)。
if openai.MatchDenyEntries(userAgent, originator, policy.Blacklist) {
return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonBlacklisted}
}
// 4. 身份候选(优先级:官方 > 全局白名单 > App Server 开闸:全局开关 OR 账号开关)。
reason := ""
skipFingerprint := false
switch {
case openai.IsCodexOfficialClientRequestStrict(userAgent):
reason = CodexClientRestrictionReasonMatchedUA
case openai.IsCodexOfficialClientOriginator(originator):
reason = CodexClientRestrictionReasonMatchedOriginator
default:
if entry, ok := openai.MatchClientEntry(userAgent, originator, policy.Whitelist); ok {
reason = CodexClientRestrictionReasonMatchedWhitelistClient
skipFingerprint = entry.SkipEngineFingerprint
} else if policy.AllowAppServerClients || account.IsCodexCLIOnlyAppServerAllowed() {
reason = CodexClientRestrictionReasonMatchedAppServerClient
}
}
if reason == "" {
return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonNotMatchedUA}
}
// 5. Codex 版本:仅对官方候选(官方 UA / 官方 originator)。版本必须可识别(需求②),再校验 [min,max]。
// 白名单/账号预设/App Server 候选可能不带可解析引擎版本,整块跳过。
if reason == CodexClientRestrictionReasonMatchedUA || reason == CodexClientRestrictionReasonMatchedOriginator {
ver, ok := openai.ParseCodexEngineVersion(userAgent)
if !ok {
return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonVersionUndetectable}
}
if policy.MinCodexVersion != "" && CompareVersions(ver, policy.MinCodexVersion) < 0 {
return CodexClientRestrictionDetectionResult{
Enabled: true,
Matched: false,
Reason: CodexClientRestrictionReasonVersionTooLow,
DetectedVersion: ver,
MinCodexVersion: policy.MinCodexVersion,
}
}
if policy.MaxCodexVersion != "" && CompareVersions(ver, policy.MaxCodexVersion) > 0 {
return CodexClientRestrictionDetectionResult{
Enabled: true,
Matched: false,
Reason: CodexClientRestrictionReasonVersionTooHigh,
DetectedVersion: ver,
MaxCodexVersion: policy.MaxCodexVersion,
}
}
}
// 6. 引擎指纹 AND 硬门。对所有候选生效;唯一例外:命中的白名单条目显式 SkipEngineFingerprint。
// 按全局信号列表判定:所有勾选(Required)信号都命中即放行,每条命中任一变体即满足(行内 OR);
// 无任何勾选信号 → 视为无要求放行(即「关闭指纹门」=取消所有勾选)。ForceCodexCLI 与黑名单不经此门。
if !skipFingerprint {
if !openai.EvaluateEngineFingerprint(header, body, policy.EngineFingerprintSignals) {
return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonMissingEngineFingerprint}
}
}
return CodexClientRestrictionDetectionResult{Enabled: true, Matched: true, Reason: reason}
}
// CodexClientRestrictionMessage 把检测结果映射为面向客户端的 403 文案。
// 仅版本越界(VersionTooLow/VersionTooHigh)给出带实际版本号与边界的差异化提示——
// 这类请求其实已被识别为官方 Codex(命中官方 UA/originator),再回「只允许官方客户端」会误导;
// 其余拒绝原因统一沿用通用兜底句,不暴露门控细节。
func CodexClientRestrictionMessage(r CodexClientRestrictionDetectionResult) string {
switch r.Reason {
case CodexClientRestrictionReasonVersionTooLow:
return fmt.Sprintf(
"Your Codex version (%s) is below the minimum required version (%s). Please update Codex.",
r.DetectedVersion, r.MinCodexVersion)
case CodexClientRestrictionReasonVersionTooHigh:
return fmt.Sprintf(
"Your Codex version (%s) exceeds the maximum allowed version (%s). Please downgrade Codex to %s or lower.",
r.DetectedVersion, r.MaxCodexVersion, r.MaxCodexVersion)
default:
return CodexOfficialClientsOnlyMessage
}
}