openai_client_restriction_detector.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. package service
  2. import (
  3. "fmt"
  4. "net/http"
  5. "github.com/Wei-Shaw/sub2api/internal/config"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  7. "github.com/gin-gonic/gin"
  8. )
  9. // CodexOfficialClientsOnlyMessage 是 codex_cli_only 拒绝时面向客户端的通用兜底文案。
  10. // 仅当拒绝原因不是「可解析版本但越界」(VersionTooLow/VersionTooHigh)时使用:
  11. // 未命中官方/黑名单/缺指纹/版本无法识别都沿用这句(避免向伪装客户端泄露门控细节)。
  12. const CodexOfficialClientsOnlyMessage = "This account only allows Codex official clients"
  13. const (
  14. // CodexClientRestrictionReasonDisabled 表示账号未开启 codex_cli_only。
  15. CodexClientRestrictionReasonDisabled = "codex_cli_only_disabled"
  16. // CodexClientRestrictionReasonMatchedUA 表示请求命中官方客户端 UA 白名单。
  17. CodexClientRestrictionReasonMatchedUA = "official_client_user_agent_matched"
  18. // CodexClientRestrictionReasonMatchedOriginator 表示请求命中官方客户端 originator 白名单。
  19. CodexClientRestrictionReasonMatchedOriginator = "official_client_originator_matched"
  20. // CodexClientRestrictionReasonNotMatchedUA 表示请求未命中任何允许的客户端身份。
  21. CodexClientRestrictionReasonNotMatchedUA = "official_client_user_agent_not_matched"
  22. // CodexClientRestrictionReasonForceCodexCLI 表示通过 ForceCodexCLI 配置兜底放行。
  23. CodexClientRestrictionReasonForceCodexCLI = "force_codex_cli_enabled"
  24. // CodexClientRestrictionReasonBlacklisted 表示请求命中全局黑名单(门内 deny 最先,OR 语义)。
  25. CodexClientRestrictionReasonBlacklisted = "blacklist_matched"
  26. // CodexClientRestrictionReasonMatchedWhitelistClient 表示请求命中全局自由白名单条目(双因子 AND)。
  27. CodexClientRestrictionReasonMatchedWhitelistClient = "whitelist_client_matched"
  28. // CodexClientRestrictionReasonVersionTooLow 表示 UA 解析出的 Codex 引擎版本低于最低要求。
  29. CodexClientRestrictionReasonVersionTooLow = "codex_version_too_low"
  30. // CodexClientRestrictionReasonMissingEngineFingerprint 表示 strict 指纹门下缺少 codex 引擎指纹头。
  31. CodexClientRestrictionReasonMissingEngineFingerprint = "missing_engine_fingerprint"
  32. // CodexClientRestrictionReasonVersionUndetectable 表示 codex_cli_only 下无法从 UA 解析出 Codex 引擎版本。
  33. CodexClientRestrictionReasonVersionUndetectable = "codex_version_undetectable"
  34. // CodexClientRestrictionReasonVersionTooHigh 表示 UA 解析出的 Codex 引擎版本高于最高允许版本。
  35. CodexClientRestrictionReasonVersionTooHigh = "codex_version_too_high"
  36. // CodexClientRestrictionReasonMatchedAppServerClient 表示 App Server 开关开启时对未列名客户端开闸放行(仍过引擎门)。
  37. CodexClientRestrictionReasonMatchedAppServerClient = "app_server_client_matched"
  38. )
  39. // CodexRestrictionPolicy 是 codex_cli_only 判定所需的全局策略快照,由调用方从全局设置解析注入(global-only)。
  40. // 账号侧只有 codex_cli_only 开关本身;黑/白名单、最低版本、指纹门均为全局设置。
  41. type CodexRestrictionPolicy struct {
  42. Whitelist []openai.AllowedClientEntry // 全局自由白名单(双因子 AND,放行官方集未覆盖的 app-server client)
  43. Blacklist []openai.AllowedClientEntry // 全局自由黑名单(OR,宽 deny)
  44. MinCodexVersion string // 最低 Codex 引擎版本 semver;""=不校验
  45. MaxCodexVersion string // 最高 Codex 引擎版本 semver;""=不校验
  46. AllowAppServerClients bool // App Server 开关:对未列名客户端开闸(仍受引擎门约束)
  47. EngineFingerprintSignals []openai.EngineFingerprintSignal // 引擎指纹门信号列表(勾选 AND / 行内变体 OR);缺省=默认种子(只勾 x-codex-)
  48. }
  49. // CodexClientRestrictionDetectionResult 是 codex_cli_only 统一检测入口结果。
  50. type CodexClientRestrictionDetectionResult struct {
  51. Enabled bool
  52. Matched bool
  53. Reason string
  54. // DetectedVersion 是从官方 UA 解析出的 Codex 引擎版本;仅在版本门拒绝
  55. // (VersionTooLow / VersionTooHigh) 时填充,供面向客户端的差异化文案使用。
  56. DetectedVersion string
  57. // MinCodexVersion 是触发 VersionTooLow 时的最低要求版本(来自策略快照)。
  58. MinCodexVersion string
  59. // MaxCodexVersion 是触发 VersionTooHigh 时的最高允许版本(来自策略快照)。
  60. MaxCodexVersion string
  61. }
  62. // CodexClientRestrictionDetector 定义 codex_cli_only 统一检测入口。
  63. type CodexClientRestrictionDetector interface {
  64. Detect(c *gin.Context, account *Account, policy CodexRestrictionPolicy, body []byte) CodexClientRestrictionDetectionResult
  65. }
  66. // OpenAICodexClientRestrictionDetector 为 OpenAI OAuth codex_cli_only 的默认实现。
  67. type OpenAICodexClientRestrictionDetector struct {
  68. cfg *config.Config
  69. }
  70. func NewOpenAICodexClientRestrictionDetector(cfg *config.Config) *OpenAICodexClientRestrictionDetector {
  71. return &OpenAICodexClientRestrictionDetector{cfg: cfg}
  72. }
  73. // Detect 门控顺序(每步可短路):
  74. // 1. 账号未开 codex_cli_only → 不限制(Disabled)。
  75. // 2. gateway.force_codex_cli → 全局旁路放行(ForceCodexCLI)。
  76. // 3. 黑名单命中 → 立即拒(门内 deny 最先,OR 语义)。
  77. // 4. 身份候选:官方 UA / 官方 originator / 全局白名单 / App Server 开闸(全局开关 OR 账号开关);都不命中 → 拒(NotMatchedUA)。
  78. // 5. Codex 版本(仅官方候选):版本必须可解析(否则 VersionUndetectable);< Min → 拒(TooLow);> Max → 拒(TooHigh)。
  79. // 6. 引擎指纹 AND 硬门:按 EngineFingerprintSignals 列表勾选 AND 判定(无任何 Required 信号→放行,即「关闭指纹门」=取消所有勾选);白名单条目可显式 skip。
  80. func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *Account, policy CodexRestrictionPolicy, body []byte) CodexClientRestrictionDetectionResult {
  81. if account == nil || !account.IsCodexCLIOnlyEnabled() {
  82. return CodexClientRestrictionDetectionResult{Enabled: false, Matched: false, Reason: CodexClientRestrictionReasonDisabled}
  83. }
  84. if d != nil && d.cfg != nil && d.cfg.Gateway.ForceCodexCLI {
  85. return CodexClientRestrictionDetectionResult{Enabled: true, Matched: true, Reason: CodexClientRestrictionReasonForceCodexCLI}
  86. }
  87. userAgent := ""
  88. originator := ""
  89. var header http.Header
  90. if c != nil {
  91. userAgent = c.GetHeader("User-Agent")
  92. originator = c.GetHeader("originator")
  93. if c.Request != nil {
  94. header = c.Request.Header
  95. }
  96. }
  97. // 3. 黑名单优先(门内 deny 最先,OR:任一已声明字段命中即拒)。
  98. if openai.MatchDenyEntries(userAgent, originator, policy.Blacklist) {
  99. return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonBlacklisted}
  100. }
  101. // 4. 身份候选(优先级:官方 > 全局白名单 > App Server 开闸:全局开关 OR 账号开关)。
  102. reason := ""
  103. skipFingerprint := false
  104. switch {
  105. case openai.IsCodexOfficialClientRequestStrict(userAgent):
  106. reason = CodexClientRestrictionReasonMatchedUA
  107. case openai.IsCodexOfficialClientOriginator(originator):
  108. reason = CodexClientRestrictionReasonMatchedOriginator
  109. default:
  110. if entry, ok := openai.MatchClientEntry(userAgent, originator, policy.Whitelist); ok {
  111. reason = CodexClientRestrictionReasonMatchedWhitelistClient
  112. skipFingerprint = entry.SkipEngineFingerprint
  113. } else if policy.AllowAppServerClients || account.IsCodexCLIOnlyAppServerAllowed() {
  114. reason = CodexClientRestrictionReasonMatchedAppServerClient
  115. }
  116. }
  117. if reason == "" {
  118. return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonNotMatchedUA}
  119. }
  120. // 5. Codex 版本:仅对官方候选(官方 UA / 官方 originator)。版本必须可识别(需求②),再校验 [min,max]。
  121. // 白名单/账号预设/App Server 候选可能不带可解析引擎版本,整块跳过。
  122. if reason == CodexClientRestrictionReasonMatchedUA || reason == CodexClientRestrictionReasonMatchedOriginator {
  123. ver, ok := openai.ParseCodexEngineVersion(userAgent)
  124. if !ok {
  125. return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonVersionUndetectable}
  126. }
  127. if policy.MinCodexVersion != "" && CompareVersions(ver, policy.MinCodexVersion) < 0 {
  128. return CodexClientRestrictionDetectionResult{
  129. Enabled: true,
  130. Matched: false,
  131. Reason: CodexClientRestrictionReasonVersionTooLow,
  132. DetectedVersion: ver,
  133. MinCodexVersion: policy.MinCodexVersion,
  134. }
  135. }
  136. if policy.MaxCodexVersion != "" && CompareVersions(ver, policy.MaxCodexVersion) > 0 {
  137. return CodexClientRestrictionDetectionResult{
  138. Enabled: true,
  139. Matched: false,
  140. Reason: CodexClientRestrictionReasonVersionTooHigh,
  141. DetectedVersion: ver,
  142. MaxCodexVersion: policy.MaxCodexVersion,
  143. }
  144. }
  145. }
  146. // 6. 引擎指纹 AND 硬门。对所有候选生效;唯一例外:命中的白名单条目显式 SkipEngineFingerprint。
  147. // 按全局信号列表判定:所有勾选(Required)信号都命中即放行,每条命中任一变体即满足(行内 OR);
  148. // 无任何勾选信号 → 视为无要求放行(即「关闭指纹门」=取消所有勾选)。ForceCodexCLI 与黑名单不经此门。
  149. if !skipFingerprint {
  150. if !openai.EvaluateEngineFingerprint(header, body, policy.EngineFingerprintSignals) {
  151. return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonMissingEngineFingerprint}
  152. }
  153. }
  154. return CodexClientRestrictionDetectionResult{Enabled: true, Matched: true, Reason: reason}
  155. }
  156. // CodexClientRestrictionMessage 把检测结果映射为面向客户端的 403 文案。
  157. // 仅版本越界(VersionTooLow/VersionTooHigh)给出带实际版本号与边界的差异化提示——
  158. // 这类请求其实已被识别为官方 Codex(命中官方 UA/originator),再回「只允许官方客户端」会误导;
  159. // 其余拒绝原因统一沿用通用兜底句,不暴露门控细节。
  160. func CodexClientRestrictionMessage(r CodexClientRestrictionDetectionResult) string {
  161. switch r.Reason {
  162. case CodexClientRestrictionReasonVersionTooLow:
  163. return fmt.Sprintf(
  164. "Your Codex version (%s) is below the minimum required version (%s). Please update Codex.",
  165. r.DetectedVersion, r.MinCodexVersion)
  166. case CodexClientRestrictionReasonVersionTooHigh:
  167. return fmt.Sprintf(
  168. "Your Codex version (%s) exceeds the maximum allowed version (%s). Please downgrade Codex to %s or lower.",
  169. r.DetectedVersion, r.MaxCodexVersion, r.MaxCodexVersion)
  170. default:
  171. return CodexOfficialClientsOnlyMessage
  172. }
  173. }