Files
sub2api/backend/internal/service/openai_codex_identity.go
T
shaw c899c8cf37 fix(codex): 管理员配置的 UA 只贡献指纹,版本段一律用生效版本重建
面板「OpenAI Codex UA」此前只在客户端 UA 是浏览器型时才生效,本分支把它提升为
所有 OAuth 出站的规范身份来源,作用域扩大了一个数量级。而它的旧 placeholder 原文
就是 codex_cli_rs/0.144.1 (...):照抄填写过的存量部署会被永久钉在 0.144.1 上——
该值不低于上游门槛 0.144.0,version 头也随之变成 0.144.1,绕过版本自动同步,
稳定落在上游优先降载的那一侧,且面板、日志、审计都不提示。账号级自定义 UA
(credentials.user_agent)有完全相同的陷阱。

改为:管理员配置的 UA 只贡献客户端名与 OS / 架构 / 终端指纹(这是该输入框唯一
不可替代的价值),版本段一律用当前生效版本重建。存量的陈旧配置无需迁移即自愈,
UA 与 version 头从此由构造保证同源,原先「覆写 UA 陈旧时二者不一致」的次优解消失。

- 新增 openai.SetCodexUserAgentVersion:重建首段版本声明,OS / 架构 / 终端指纹
  原样保留;尾部官方客户端标识组 (name; version) 与首段同源,一并更新,避免拼出
  首段声明新版本、尾部仍是旧版本的自相矛盾身份;非官方括号组(如 OS 组)不动。
- resolveCodexOutboundIdentity 收敛为单一funnel:生效版本只从规范身份取,
  候选 UA 的版本段不再参与判定。
- GetOpenAICodexCanonicalUserAgent 重建面板 UA 的版本段;原「面板值等于兜底常量
  视同未填」的特例随之成为恒等变换,删除。
- 补齐 4 处静默丢弃账号级自定义 UA 的出站路径(WS 握手、账号测试 x2、用量探针):
  它们先写 customUA 再调不带 override 的收口,赋值随即被覆盖,既是行为不一致
  也是死代码;账号测试尤其要紧——注释写着「与真实转发一致」而实际不一致。
- 补测试:SetCodexUserAgentVersion / CodexUserAgentVersion 包内用例、陈旧面板 UA
  与账号 UA 的版本重建回归、WS 握手尊重账号级 UA。
2026-08-03 21:50:49 +08:00

216 lines
9.2 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 (
"net/http"
"regexp"
"strings"
"sync"
"sync/atomic"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/google/uuid"
)
// codexUpstreamMinVersion 上游 /backend-api/codex 接受的最低 version 头:
// 若请求携带 version 且低于该值,上游直接 404(issue #3901,2026-07 实测)。
const codexUpstreamMinVersion = "0.144.0"
// codexClientVersionMaxLen 官方版本号均为短 ASCII 串,远低于此上限。
const codexClientVersionMaxLen = 64
// codexClientVersionPattern 允许 0.146.0 与 0.147.0-alpha.4 两类官方形态。
var codexClientVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`)
// NormalizeCodexClientVersion 校验并归一化 Codex 客户端版本号,非法值返回空串。
// 该值会被拼进出站 User-Agent 与 version 头,必须拒绝任意字节,避免管理员误填或
// 自动同步拿到异常值时把不可控内容透给上游。
func NormalizeCodexClientVersion(version string) string {
version = strings.TrimSpace(version)
if version == "" || len(version) > codexClientVersionMaxLen || !codexClientVersionPattern.MatchString(version) {
return ""
}
return version
}
// buildCodexCLIUserAgent 按版本号拼出规范 Codex CLI User-Agent。
// UA 形态只在 codexCLIUserAgentSuffix 一处定义,避免多处拼装漂移。
func buildCodexCLIUserAgent(version string) string {
if version = NormalizeCodexClientVersion(version); version == "" {
return codexCLIUserAgent
}
return openai.CodexCLIOriginator + "/" + version + codexCLIUserAgentSuffix
}
// codexIdentityEnforcement 控制 enforceCodexIdentityHeaders 是否强制统一出站身份,
// 由 gateway.disable_codex_identity_enforcement 在服务构造时取反发布。
// 默认开启:上游在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到
// HTTP 200 + 流内 server_is_overloaded,本次请求即失败;强制统一出口可确保没有
// 请求带着第三方或陈旧身份出站。关闭后退回「仅按最终 UA 配对 originator」的收口语义。
var codexIdentityEnforcement = func() *atomic.Bool {
v := &atomic.Bool{}
v.Store(true)
return v
}()
// SetCodexIdentityEnforcementEnabled 发布 Codex 出站身份强制统一开关。
// enforceCodexIdentityHeaders 是所有出站路径共用的纯函数收口点,无法在热路径注入配置,
// 故由持有配置的服务在构造时发布进程级快照。
func SetCodexIdentityEnforcementEnabled(enabled bool) {
codexIdentityEnforcement.Store(enabled)
}
// codexCanonicalUserAgentResolver 返回当前生效的规范 Codex User-Agent(后台设置 / 自动同步版本号)。
// 由 SettingService 在装配时注入;解析器内部自带 TTL 缓存,热路径不触库。
type codexCanonicalUserAgentResolver func() string
var (
codexCanonicalUAMu sync.RWMutex
codexCanonicalUAResolver codexCanonicalUserAgentResolver
)
// SetCodexCanonicalUserAgentResolver 注入规范 User-Agent 解析器。
// 未注入或解析结果非法时回退到编译期常量 codexCLIUserAgent。
func SetCodexCanonicalUserAgentResolver(resolver func() string) {
codexCanonicalUAMu.Lock()
defer codexCanonicalUAMu.Unlock()
codexCanonicalUAResolver = resolver
}
// codexCanonicalUserAgent 返回出站规范 User-Agent。
func codexCanonicalUserAgent() string {
codexCanonicalUAMu.RLock()
resolver := codexCanonicalUAResolver
codexCanonicalUAMu.RUnlock()
if resolver != nil {
if ua := strings.TrimSpace(resolver()); ua != "" {
return ua
}
}
return codexCLIUserAgent
}
// codexOutboundIdentity 出站身份三元组,三者必须同源自洽:
// originator 与 User-Agent 首段配套(否则上游 404,issue #3901),
// version 等于 User-Agent 的版本段且不低于上游门槛。
type codexOutboundIdentity struct {
userAgent string
originator string
version string
}
// resolveCodexOutboundIdentity 由候选 User-Agent 推导自洽的出站身份。
// candidateUA 为空时使用规范 User-Agent;推导不出官方身份时整体回退为规范 CLI 身份。
//
// 候选 UA(面板 / 账号级的管理员显式配置)只贡献客户端名与 OS / 架构 / 终端指纹,
// 其自带的版本段一律用当前生效版本重建:一条填写于某个历史版本的 UA 否则会把出站身份
// 永久钉死在陈旧版本上,绕过版本自动同步,落回上游优先降载的那一侧。
// 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。
func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity {
canonical := codexCanonicalUserAgent()
ua := strings.TrimSpace(candidateUA)
if ua == "" {
ua = canonical
}
originator, pairedUA, ok := openai.PairCodexClientIdentity(ua)
if !ok {
if originator, pairedUA, ok = openai.PairCodexClientIdentity(canonical); !ok {
originator, pairedUA = openai.CodexCLIOriginator, codexCLIUserAgent
}
}
// 生效版本只有一个来源:规范身份(面板版本号 → 自动同步值 → 内置常量,见
// SettingService.GetOpenAICodexClientVersion)。UA 与 version 头由此同源派生。
version := codexClientVersionFromUA(canonical)
if rebuilt := openai.SetCodexUserAgentVersion(pairedUA, version); rebuilt != "" {
pairedUA = rebuilt
}
return codexOutboundIdentity{userAgent: pairedUA, originator: originator, version: version}
}
// codexClientVersionFromUA 取 UA 的版本段作为生效版本;
// 非法或低于上游门槛(低于则上游 404,issue #3901)时回退编译期常量。
func codexClientVersionFromUA(ua string) string {
version := NormalizeCodexClientVersion(openai.CodexUserAgentVersion(ua))
if version == "" || CompareVersions(version, codexUpstreamMinVersion) < 0 {
return codexCLIVersion
}
return version
}
// ensureCodexIdentityHeaders 补齐 OAuth(ChatGPT 内部接口)出站请求所需的 Codex 身份头。
// 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders 收口。
func ensureCodexIdentityHeaders(h http.Header) {
if h == nil {
return
}
identity := resolveCodexOutboundIdentity("")
if strings.TrimSpace(h.Get("user-agent")) == "" {
h.Set("user-agent", identity.userAgent)
}
if strings.TrimSpace(h.Get("originator")) == "" {
h.Set("originator", identity.originator)
}
if strings.TrimSpace(h.Get("version")) == "" {
h.Set("version", identity.version)
}
h.Set("OpenAI-Beta", "responses=experimental")
}
// applyOpenAICodexProbeHeaders 为合成探测请求补齐 Codex 身份和引擎指纹。
func applyOpenAICodexProbeHeaders(h http.Header) {
if h == nil {
return
}
ensureCodexIdentityHeaders(h)
h.Set("X-Codex-Window-ID", uuid.NewString())
}
// enforceCodexIdentityHeaders 收口 OAuth(ChatGPT 内部接口)出站请求的客户端身份头。
// 见 enforceCodexIdentityHeadersWithUA;无账号级自定义 User-Agent 时使用本函数。
func enforceCodexIdentityHeaders(h http.Header) {
enforceCodexIdentityHeadersWithUA(h, "")
}
// enforceCodexIdentityHeadersWithUA 强制统一 OAuth 出站身份:User-Agent / originator / version
// 一律改写为网关的规范身份,客户端自报身份不参与构造。上游在容量紧张时按客户端身份分优先级
// 降载,被降载的请求会拿到 HTTP 200 + 流内 server_is_overloaded;统一出口可确保没有请求带着
// 第三方或陈旧身份出站,也天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。
//
// overrideUA 是账号级自定义 User-Agent:管理员的显式配置仍然生效,但只贡献客户端名与
// OS / 架构 / 终端指纹——版本段与 originator 都由规范身份重建,不允许出现自相矛盾或陈旧的身份。
//
// 强制统一被 gateway.disable_codex_identity_enforcement 关闭时,退回「按最终 User-Agent 配对
// originator + version 门槛校正」的收口语义,供上游策略变动时回滚。
//
// 仅对携带 originator 的请求生效:compat 桥接等非 ChatGPT 内部接口路径会显式删除 originator,
// 不应被补回。需要从缺失身份头恢复的调用方应先调用 ensureCodexIdentityHeaders。
// 必须在所有 User-Agent 改写之后调用。
func enforceCodexIdentityHeadersWithUA(h http.Header, overrideUA string) {
if h == nil || h.Get("originator") == "" {
return
}
if !codexIdentityEnforcement.Load() {
pairCodexIdentityHeaders(h)
return
}
identity := resolveCodexOutboundIdentity(overrideUA)
h.Set("user-agent", identity.userAgent)
h.Set("originator", identity.originator)
h.Set("version", identity.version)
}
// pairCodexIdentityHeaders 是关闭强制统一后的兜底收口:保留客户端真实身份,
// 仅保证 originator 与最终 User-Agent 首段配套、version 不低于上游门槛(issue #3901)。
func pairCodexIdentityHeaders(h http.Header) {
originator, pairedUA, ok := openai.PairCodexClientIdentity(h.Get("user-agent"))
if !ok {
identity := resolveCodexOutboundIdentity("")
originator, pairedUA = identity.originator, identity.userAgent
h.Set("version", identity.version)
}
h.Set("user-agent", pairedUA)
h.Set("originator", originator)
if v := strings.TrimSpace(h.Get("version")); v != "" && CompareVersions(v, codexUpstreamMinVersion) < 0 {
h.Set("version", codexCLIVersion)
}
}