mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-08 08:55:22 +00:00
将 codex_cli_only 客户端识别从「单一 strict 开关 + 固定 OR 头集合」重构为 可逐项管理的引擎指纹信号列表,加固整条判定链,并补齐账号级 app-server 控制、 对齐前端设置文案。 判定链(每步可短路): - 账号未开 codex_cli_only → 不限制;gateway.force_codex_cli → 旁路放行 - 全局黑名单命中(OR 宽 deny)→ 立即拒 - 身份候选:官方 UA(strict,仅前缀)/ 官方 originator(OR)/ 全局白名单(双因子 AND) / 全局 app-server 开关 OR 账号 app-server 开关;均不命中 → 拒 - 版本门(仅官方候选):UA 须可解析引擎版本,再校验 [min,max] 区间 - 引擎指纹 AND 硬门:按信号列表逐条勾选 AND、每条行内变体 OR;无 Required 信号 → 放行 引擎指纹信号列表(唯一真源) - 新增 openai.EngineFingerprintSignal 类型 + EvaluateEngineFingerprint 求值器 (勾选 AND / 行内变体 OR / 无勾选 → 放行) - CodexRestrictionPolicy 增 EngineFingerprintSignals;信号列表单一决定是否启用指纹门, 不再保留独立「要求引擎指纹」总开关(与「信号全不选」语义重复) - 新设置键 codex_cli_only_engine_fingerprint_signals(默认只勾 x-codex- 前缀); 旧 body 指纹开关幂等迁移并入信号列表;wire 接线 - 黑/白名单自由条目、命名预设、版本区间 全局设置管线 - gateway 缺 settingService(仅测试/误配可达)时指纹门回退默认种子信号、失败关闭, 不再因零值 policy(nil 信号)失败开放 账号级 Codex app-server(替换已失效的 ClaudeCode 放行机制) - account.IsCodexCLIOnlyAppServerAllowed() 读 extra.codex_cli_only_allow_app_server, 仅在 codex_cli_only 开启时生效;候选身份门「全局 OR 账号」,与旧系统双层控制对齐 - 移除已无入口的 claude_code 预设机制(allowedClientRegistry / MatchAllowedClients / 账号 GetCodexCLIOnlyAllowedClients / reason);白名单 AllowedClientEntry / IsAllowedClientMatch 保留 门加固(反伪 + 写入校验) - 官方 UA 访问门改 strict:IsCodexOfficialClientRequestStrict 仅前缀匹配,收窄「浏览器前缀 + 中段 codex token」伪造面(strict 仍保留 Codex 家族前缀与 UA 尾部兜底,故对「任意前缀 + 官方尾部 (name;ver)」仍放行——与 UA 可伪造、真正反伪靠引擎指纹门的设计一致) - 官方客户端识别扩展:新增 codex-tui/、codex_vscode_copilot/ 前缀 + UA 尾部 (name;ver) 兜底 (恢复 CODEX_INTERNAL_ORIGINATOR_OVERRIDE 的真实 client,如 cccc→codex-tui),originator 改 精确集。该识别经 IsCodexOfficialClientByHeaders 被 passthrough 复用,故透传的官方判定一并 修正(codex-tui 等不再被误改写 UA)——非「行为不变」,属有意修正 - 白名单写入校验 ValidateCodexWhitelistEntriesJSON + AllowedClientEntry.IsWhitelistable: 双因子 AND 条目须可命中(非空 originator + 非空 ua_contains),拒绝写入会静默失效的死规则; 黑名单(OR 宽 deny,允许 originator-only)不受约束 管理端 / 前端 - handler / DTO / settings_view / 契约测试;gateway 接入判定链 - 信号列表编辑器(替换 body 开关)、api 类型、SettingsView;无勾选给常驻警告 - Create/Edit/Bulk 三弹窗「Codex Only」下新增 app-server 开关(OR 合并全局) - 文案:UA/Originator → User-Agent/Originator;黑/白名单重命名为 User-Agent/Originator 黑/白名单; 「允许 App Server 第三方客户端」→「Codex app-server」+ 简介示例;i18n zh/en 同步 - 移除死代码 HasCodex*Fingerprint helper 测试:引擎指纹求值器 / 账号 app-server(OR 语义)/ detector(含 N1 strict、失败关闭)/ 白名单写入校验 / BulkEdit spec 等;后端 build + service/openai/admin 单测全绿,前端 vue-tsc + vitest 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
131 lines
4.1 KiB
Go
131 lines
4.1 KiB
Go
package openai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
// EngineFingerprintSignal 描述引擎指纹统一列表的一条信号。
|
|
// Required=true(勾选)= 该信号必须命中;多条 Required 之间 AND。
|
|
// Match 为同一信号的等价写法/变体,行内 OR(命中任一即算该条满足)。
|
|
type EngineFingerprintSignal struct {
|
|
Type string `json:"type"` // header_exact | header_prefix | body_path
|
|
Match []string `json:"match"` // 行内 OR 变体
|
|
Required bool `json:"required"` // 勾选=true
|
|
}
|
|
|
|
const (
|
|
FingerprintSignalHeaderExact = "header_exact"
|
|
FingerprintSignalHeaderPrefix = "header_prefix"
|
|
FingerprintSignalBodyPath = "body_path"
|
|
)
|
|
|
|
// DefaultEngineFingerprintSignals 默认种子:只勾 x-codex- 前缀,其余预填不勾。
|
|
// 依据:实测真 codex(含旧版)约 98.8% 必带 x-codex-window-id 头。
|
|
var DefaultEngineFingerprintSignals = []EngineFingerprintSignal{
|
|
{Type: FingerprintSignalHeaderPrefix, Match: []string{"x-codex-"}, Required: true},
|
|
{Type: FingerprintSignalHeaderExact, Match: []string{"session-id", "session_id"}, Required: false},
|
|
{Type: FingerprintSignalHeaderExact, Match: []string{"thread-id", "thread_id"}, Required: false},
|
|
{Type: FingerprintSignalBodyPath, Match: []string{"client_metadata.x-codex-window-id", "client_metadata.x-codex-installation-id"}, Required: false},
|
|
}
|
|
|
|
// EvaluateEngineFingerprint 应用「勾选 AND / 行内变体 OR」规则。
|
|
// 只有 Required=true 的条目参与;全部命中→true;任一缺失→false;无任何 Required→true。
|
|
func EvaluateEngineFingerprint(h http.Header, body []byte, signals []EngineFingerprintSignal) bool {
|
|
for _, s := range signals {
|
|
if !s.Required {
|
|
continue
|
|
}
|
|
if !engineSignalMatches(h, body, s) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func engineSignalMatches(h http.Header, body []byte, s EngineFingerprintSignal) bool {
|
|
switch s.Type {
|
|
case FingerprintSignalHeaderExact:
|
|
for _, name := range s.Match {
|
|
if n := strings.TrimSpace(name); n != "" && h != nil && strings.TrimSpace(h.Get(n)) != "" {
|
|
return true
|
|
}
|
|
}
|
|
case FingerprintSignalHeaderPrefix:
|
|
if h == nil {
|
|
return false
|
|
}
|
|
for k := range h {
|
|
lk := strings.ToLower(k)
|
|
for _, p := range s.Match {
|
|
if np := strings.ToLower(strings.TrimSpace(p)); np != "" && strings.HasPrefix(lk, np) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
case FingerprintSignalBodyPath:
|
|
if len(body) == 0 {
|
|
return false
|
|
}
|
|
for _, path := range s.Match {
|
|
if p := strings.TrimSpace(path); p != "" && gjson.GetBytes(body, p).Exists() {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ParseEngineFingerprintSignals 解析 JSON;空串→(nil,true);非法→(nil,false)。
|
|
func ParseEngineFingerprintSignals(raw string) ([]EngineFingerprintSignal, bool) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil, true
|
|
}
|
|
var sigs []EngineFingerprintSignal
|
|
if json.Unmarshal([]byte(raw), &sigs) != nil {
|
|
return nil, false
|
|
}
|
|
return sigs, true
|
|
}
|
|
|
|
// ValidateEngineFingerprintSignalsJSON 校验:空=合法;非空须为合法数组,
|
|
// 每条 type 合法且 match 至少一个非空项。供管理端写入校验复用。
|
|
func ValidateEngineFingerprintSignalsJSON(raw string) error {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
var sigs []EngineFingerprintSignal
|
|
if err := json.Unmarshal([]byte(trimmed), &sigs); err != nil {
|
|
return fmt.Errorf("must be empty or a valid JSON array of {type, match[], required}")
|
|
}
|
|
for i, s := range sigs {
|
|
switch s.Type {
|
|
case FingerprintSignalHeaderExact, FingerprintSignalHeaderPrefix, FingerprintSignalBodyPath:
|
|
default:
|
|
return fmt.Errorf("entry %d: type must be one of header_exact/header_prefix/body_path", i)
|
|
}
|
|
hasMatch := false
|
|
for _, m := range s.Match {
|
|
if strings.TrimSpace(m) != "" {
|
|
hasMatch = true
|
|
break
|
|
}
|
|
}
|
|
if !hasMatch {
|
|
return fmt.Errorf("entry %d: match must contain at least one non-empty value", i)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DefaultEngineFingerprintSignalsJSON 默认种子的 JSON 字符串。
|
|
func DefaultEngineFingerprintSignalsJSON() string {
|
|
b, _ := json.Marshal(DefaultEngineFingerprintSignals)
|
|
return string(b)
|
|
}
|