feat: sync billing header cc_version with User-Agent and add opt-in CCH signing
- Sync cc_version in x-anthropic-billing-header with the fingerprint User-Agent version, preserving the message-derived suffix - Implement xxHash64-based CCH signing to replace the cch=00000 placeholder with a computed hash - Add admin toggle (enable_cch_signing) under gateway forwarding settings, disabled by default
This commit is contained in:
@ -128,6 +128,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
BackendModeEnabled: settings.BackendModeEnabled,
|
BackendModeEnabled: settings.BackendModeEnabled,
|
||||||
EnableFingerprintUnification: settings.EnableFingerprintUnification,
|
EnableFingerprintUnification: settings.EnableFingerprintUnification,
|
||||||
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
|
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
|
||||||
|
EnableCCHSigning: settings.EnableCCHSigning,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,6 +212,7 @@ type UpdateSettingsRequest struct {
|
|||||||
// Gateway forwarding behavior
|
// Gateway forwarding behavior
|
||||||
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
|
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
|
||||||
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
|
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
|
||||||
|
EnableCCHSigning *bool `json:"enable_cch_signing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettings 更新系统设置
|
// UpdateSettings 更新系统设置
|
||||||
@ -614,6 +616,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
return previousSettings.EnableMetadataPassthrough
|
return previousSettings.EnableMetadataPassthrough
|
||||||
}(),
|
}(),
|
||||||
|
EnableCCHSigning: func() bool {
|
||||||
|
if req.EnableCCHSigning != nil {
|
||||||
|
return *req.EnableCCHSigning
|
||||||
|
}
|
||||||
|
return previousSettings.EnableCCHSigning
|
||||||
|
}(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil {
|
if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil {
|
||||||
@ -693,6 +701,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
BackendModeEnabled: updatedSettings.BackendModeEnabled,
|
BackendModeEnabled: updatedSettings.BackendModeEnabled,
|
||||||
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
|
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
|
||||||
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
|
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
|
||||||
|
EnableCCHSigning: updatedSettings.EnableCCHSigning,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -871,6 +880,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough {
|
if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough {
|
||||||
changed = append(changed, "enable_metadata_passthrough")
|
changed = append(changed, "enable_metadata_passthrough")
|
||||||
}
|
}
|
||||||
|
if before.EnableCCHSigning != after.EnableCCHSigning {
|
||||||
|
changed = append(changed, "enable_cch_signing")
|
||||||
|
}
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -97,6 +97,7 @@ type SystemSettings struct {
|
|||||||
// Gateway forwarding behavior
|
// Gateway forwarding behavior
|
||||||
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
|
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
|
||||||
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
|
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
|
||||||
|
EnableCCHSigning bool `json:"enable_cch_signing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DefaultSubscriptionSetting struct {
|
type DefaultSubscriptionSetting struct {
|
||||||
|
|||||||
@ -218,6 +218,8 @@ const (
|
|||||||
SettingKeyEnableFingerprintUnification = "enable_fingerprint_unification"
|
SettingKeyEnableFingerprintUnification = "enable_fingerprint_unification"
|
||||||
// SettingKeyEnableMetadataPassthrough 是否透传客户端原始 metadata.user_id(默认 false)
|
// SettingKeyEnableMetadataPassthrough 是否透传客户端原始 metadata.user_id(默认 false)
|
||||||
SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough"
|
SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough"
|
||||||
|
// SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false)
|
||||||
|
SettingKeyEnableCCHSigning = "enable_cch_signing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
|
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
|
||||||
|
|||||||
73
backend/internal/service/gateway_billing_header.go
Normal file
73
backend/internal/service/gateway_billing_header.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cespare/xxhash/v2"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ccVersionInBillingRe matches the semver part of cc_version (X.Y.Z), preserving
|
||||||
|
// the trailing message-derived suffix (e.g. ".c02") if present.
|
||||||
|
var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`)
|
||||||
|
|
||||||
|
// cchPlaceholderRe matches the cch=00000 placeholder in billing header text,
|
||||||
|
// scoped to x-anthropic-billing-header to avoid touching user content.
|
||||||
|
var cchPlaceholderRe = regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)(00000)(;)`)
|
||||||
|
|
||||||
|
const cchSeed uint64 = 0x6E52736AC806831E
|
||||||
|
|
||||||
|
// syncBillingHeaderVersion rewrites cc_version in x-anthropic-billing-header
|
||||||
|
// system text blocks to match the version extracted from userAgent.
|
||||||
|
// Only touches system array blocks whose text starts with "x-anthropic-billing-header".
|
||||||
|
func syncBillingHeaderVersion(body []byte, userAgent string) []byte {
|
||||||
|
version := ExtractCLIVersion(userAgent)
|
||||||
|
if version == "" {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
systemResult := gjson.GetBytes(body, "system")
|
||||||
|
if !systemResult.Exists() || !systemResult.IsArray() {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
replacement := "cc_version=" + version
|
||||||
|
idx := 0
|
||||||
|
systemResult.ForEach(func(_, item gjson.Result) bool {
|
||||||
|
text := item.Get("text")
|
||||||
|
if text.Exists() && text.Type == gjson.String &&
|
||||||
|
strings.HasPrefix(text.String(), "x-anthropic-billing-header") {
|
||||||
|
newText := ccVersionInBillingRe.ReplaceAllString(text.String(), replacement)
|
||||||
|
if newText != text.String() {
|
||||||
|
if updated, err := sjson.SetBytes(body, fmt.Sprintf("system.%d.text", idx), newText); err == nil {
|
||||||
|
body = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// signBillingHeaderCCH computes the xxHash64-based CCH signature for the request
|
||||||
|
// body and replaces the cch=00000 placeholder with the computed 5-hex-char hash.
|
||||||
|
// The body must contain the placeholder when this function is called.
|
||||||
|
func signBillingHeaderCCH(body []byte) []byte {
|
||||||
|
if !cchPlaceholderRe.Match(body) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
cch := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF)
|
||||||
|
return cchPlaceholderRe.ReplaceAll(body, []byte("${1}"+cch+"${3}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// xxHash64Seeded computes xxHash64 of data with a custom seed.
|
||||||
|
func xxHash64Seeded(data []byte, seed uint64) uint64 {
|
||||||
|
d := xxhash.NewWithSeed(seed)
|
||||||
|
d.Write(data)
|
||||||
|
return d.Sum64()
|
||||||
|
}
|
||||||
165
backend/internal/service/gateway_billing_header_test.go
Normal file
165
backend/internal/service/gateway_billing_header_test.go
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cespare/xxhash/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncBillingHeaderVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
userAgent string
|
||||||
|
wantSub string // substring expected in result
|
||||||
|
unchanged bool // expect body to remain the same
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "replaces cc_version preserving message-derived suffix",
|
||||||
|
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}}],"messages":[]}`,
|
||||||
|
userAgent: "claude-cli/2.1.22 (external, cli)",
|
||||||
|
wantSub: "cc_version=2.1.22.df2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no billing header in system",
|
||||||
|
body: `{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`,
|
||||||
|
userAgent: "claude-cli/2.1.22",
|
||||||
|
unchanged: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no system field",
|
||||||
|
body: `{"messages":[]}`,
|
||||||
|
userAgent: "claude-cli/2.1.22",
|
||||||
|
unchanged: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user-agent without version",
|
||||||
|
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
unchanged: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty user-agent",
|
||||||
|
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
|
||||||
|
userAgent: "",
|
||||||
|
unchanged: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version already matches",
|
||||||
|
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.22; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
|
||||||
|
userAgent: "claude-cli/2.1.22",
|
||||||
|
unchanged: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := syncBillingHeaderVersion([]byte(tt.body), tt.userAgent)
|
||||||
|
if tt.unchanged {
|
||||||
|
assert.Equal(t, tt.body, string(result), "body should remain unchanged")
|
||||||
|
} else {
|
||||||
|
assert.Contains(t, string(result), tt.wantSub)
|
||||||
|
// Ensure old semver is gone
|
||||||
|
assert.NotContains(t, string(result), "cc_version=2.1.81")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignBillingHeaderCCH(t *testing.T) {
|
||||||
|
t.Run("replaces placeholder with hash", func(t *testing.T) {
|
||||||
|
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||||
|
result := signBillingHeaderCCH(body)
|
||||||
|
|
||||||
|
// Should not have the placeholder anymore
|
||||||
|
assert.NotContains(t, string(result), "cch=00000")
|
||||||
|
|
||||||
|
// Should have a 5 hex-char cch value
|
||||||
|
billingText := gjson.GetBytes(result, "system.0.text").String()
|
||||||
|
require.Contains(t, billingText, "cch=")
|
||||||
|
assert.Regexp(t, `cch=[0-9a-f]{5};`, billingText)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no placeholder - body unchanged", func(t *testing.T) {
|
||||||
|
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=abcde;"}],"messages":[]}`)
|
||||||
|
result := signBillingHeaderCCH(body)
|
||||||
|
assert.Equal(t, string(body), string(result))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no billing header - body unchanged", func(t *testing.T) {
|
||||||
|
body := []byte(`{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`)
|
||||||
|
result := signBillingHeaderCCH(body)
|
||||||
|
assert.Equal(t, string(body), string(result))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cch=00000 in user content is not touched", func(t *testing.T) {
|
||||||
|
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"keep literal cch=00000 in this message"}]}]}`)
|
||||||
|
result := signBillingHeaderCCH(body)
|
||||||
|
|
||||||
|
// Billing header should be signed
|
||||||
|
billingText := gjson.GetBytes(result, "system.0.text").String()
|
||||||
|
assert.NotContains(t, billingText, "cch=00000")
|
||||||
|
|
||||||
|
// User message should keep its literal cch=00000
|
||||||
|
userText := gjson.GetBytes(result, "messages.0.content.0.text").String()
|
||||||
|
assert.Contains(t, userText, "cch=00000")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("signing is deterministic", func(t *testing.T) {
|
||||||
|
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`)
|
||||||
|
r1 := signBillingHeaderCCH(body)
|
||||||
|
body2 := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`)
|
||||||
|
r2 := signBillingHeaderCCH(body2)
|
||||||
|
assert.Equal(t, string(r1), string(r2))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("matches reference algorithm", func(t *testing.T) {
|
||||||
|
// Verify: signBillingHeaderCCH(body) produces cch = xxHash64(body_with_placeholder, seed) & 0xFFFFF
|
||||||
|
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||||
|
expectedCCH := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF)
|
||||||
|
|
||||||
|
result := signBillingHeaderCCH(body)
|
||||||
|
billingText := gjson.GetBytes(result, "system.0.text").String()
|
||||||
|
assert.Contains(t, billingText, "cch="+expectedCCH+";")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXXHash64Seeded(t *testing.T) {
|
||||||
|
t.Run("matches cespare/xxhash for seed 0", func(t *testing.T) {
|
||||||
|
inputs := []string{"", "a", "hello world", "The quick brown fox jumps over the lazy dog"}
|
||||||
|
for _, s := range inputs {
|
||||||
|
data := []byte(s)
|
||||||
|
expected := xxhash.Sum64(data)
|
||||||
|
got := xxHash64Seeded(data, 0)
|
||||||
|
assert.Equal(t, expected, got, "mismatch for input %q", s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("large input matches cespare", func(t *testing.T) {
|
||||||
|
data := make([]byte, 256)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte(i)
|
||||||
|
}
|
||||||
|
expected := xxhash.Sum64(data)
|
||||||
|
got := xxHash64Seeded(data, 0)
|
||||||
|
assert.Equal(t, expected, got)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deterministic with custom seed", func(t *testing.T) {
|
||||||
|
data := []byte("hello world")
|
||||||
|
h1 := xxHash64Seeded(data, cchSeed)
|
||||||
|
h2 := xxHash64Seeded(data, cchSeed)
|
||||||
|
assert.Equal(t, h1, h2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("different seeds produce different results", func(t *testing.T) {
|
||||||
|
data := []byte("test data for hashing")
|
||||||
|
h1 := xxHash64Seeded(data, 0)
|
||||||
|
h2 := xxHash64Seeded(data, cchSeed)
|
||||||
|
assert.NotEqual(t, h1, h2)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -4002,7 +4002,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
|
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
|
||||||
if err == nil && fp != nil {
|
if err == nil && fp != nil {
|
||||||
// metadata 透传开启时跳过 metadata 注入
|
// metadata 透传开启时跳过 metadata 注入
|
||||||
_, mimicMPT := s.settingService.GetGatewayForwardingSettings(ctx)
|
_, mimicMPT, _ := s.settingService.GetGatewayForwardingSettings(ctx)
|
||||||
if !mimicMPT {
|
if !mimicMPT {
|
||||||
if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
|
if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
|
||||||
normalizeOpts.injectMetadata = true
|
normalizeOpts.injectMetadata = true
|
||||||
@ -5548,9 +5548,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
|||||||
|
|
||||||
// OAuth账号:应用统一指纹和metadata重写(受设置开关控制)
|
// OAuth账号:应用统一指纹和metadata重写(受设置开关控制)
|
||||||
var fingerprint *Fingerprint
|
var fingerprint *Fingerprint
|
||||||
enableFP, enableMPT := true, false
|
enableFP, enableMPT, enableCCH := true, false, false
|
||||||
if s.settingService != nil {
|
if s.settingService != nil {
|
||||||
enableFP, enableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
|
enableFP, enableMPT, enableCCH = s.settingService.GetGatewayForwardingSettings(ctx)
|
||||||
}
|
}
|
||||||
if account.IsOAuth() && s.identityService != nil {
|
if account.IsOAuth() && s.identityService != nil {
|
||||||
// 1. 获取或创建指纹(包含随机生成的ClientID)
|
// 1. 获取或创建指纹(包含随机生成的ClientID)
|
||||||
@ -5577,6 +5577,15 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步 billing header cc_version 与实际发送的 User-Agent 版本
|
||||||
|
if fingerprint != nil {
|
||||||
|
body = syncBillingHeaderVersion(body, fingerprint.UserAgent)
|
||||||
|
}
|
||||||
|
// CCH 签名:将 cch=00000 占位符替换为 xxHash64 签名(需在所有 body 修改之后)
|
||||||
|
if enableCCH {
|
||||||
|
body = signBillingHeaderCCH(body)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -8461,9 +8470,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
|||||||
|
|
||||||
// OAuth 账号:应用统一指纹和重写 userID(受设置开关控制)
|
// OAuth 账号:应用统一指纹和重写 userID(受设置开关控制)
|
||||||
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
|
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
|
||||||
ctEnableFP, ctEnableMPT := true, false
|
ctEnableFP, ctEnableMPT, ctEnableCCH := true, false, false
|
||||||
if s.settingService != nil {
|
if s.settingService != nil {
|
||||||
ctEnableFP, ctEnableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
|
ctEnableFP, ctEnableMPT, ctEnableCCH = s.settingService.GetGatewayForwardingSettings(ctx)
|
||||||
}
|
}
|
||||||
var ctFingerprint *Fingerprint
|
var ctFingerprint *Fingerprint
|
||||||
if account.IsOAuth() && s.identityService != nil {
|
if account.IsOAuth() && s.identityService != nil {
|
||||||
@ -8481,6 +8490,14 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步 billing header cc_version 与实际发送的 User-Agent 版本
|
||||||
|
if ctFingerprint != nil && ctEnableFP {
|
||||||
|
body = syncBillingHeaderVersion(body, ctFingerprint.UserAgent)
|
||||||
|
}
|
||||||
|
if ctEnableCCH {
|
||||||
|
body = signBillingHeaderCCH(body)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@ -81,6 +81,7 @@ const backendModeDBTimeout = 5 * time.Second
|
|||||||
type cachedGatewayForwardingSettings struct {
|
type cachedGatewayForwardingSettings struct {
|
||||||
fingerprintUnification bool
|
fingerprintUnification bool
|
||||||
metadataPassthrough bool
|
metadataPassthrough bool
|
||||||
|
cchSigning bool
|
||||||
expiresAt int64 // unix nano
|
expiresAt int64 // unix nano
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -514,6 +515,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
|||||||
// Gateway forwarding behavior
|
// Gateway forwarding behavior
|
||||||
updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
|
updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
|
||||||
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
|
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
|
||||||
|
updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning)
|
||||||
|
|
||||||
err = s.settingRepo.SetMultiple(ctx, updates)
|
err = s.settingRepo.SetMultiple(ctx, updates)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@ -533,6 +535,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
|||||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||||
fingerprintUnification: settings.EnableFingerprintUnification,
|
fingerprintUnification: settings.EnableFingerprintUnification,
|
||||||
metadataPassthrough: settings.EnableMetadataPassthrough,
|
metadataPassthrough: settings.EnableMetadataPassthrough,
|
||||||
|
cchSigning: settings.EnableCCHSigning,
|
||||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||||
})
|
})
|
||||||
if s.onUpdate != nil {
|
if s.onUpdate != nil {
|
||||||
@ -639,20 +642,20 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
|
|||||||
|
|
||||||
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
|
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
|
||||||
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
|
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
|
||||||
// Returns (fingerprintUnification, metadataPassthrough).
|
// Returns (fingerprintUnification, metadataPassthrough, cchSigning).
|
||||||
func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough bool) {
|
func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) {
|
||||||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||||||
if time.Now().UnixNano() < cached.expiresAt {
|
if time.Now().UnixNano() < cached.expiresAt {
|
||||||
return cached.fingerprintUnification, cached.metadataPassthrough
|
return cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
type gwfResult struct {
|
type gwfResult struct {
|
||||||
fp, mp bool
|
fp, mp, cch bool
|
||||||
}
|
}
|
||||||
val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
|
val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
|
||||||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||||||
if time.Now().UnixNano() < cached.expiresAt {
|
if time.Now().UnixNano() < cached.expiresAt {
|
||||||
return gwfResult{cached.fingerprintUnification, cached.metadataPassthrough}, nil
|
return gwfResult{cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
|
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
|
||||||
@ -660,32 +663,36 @@ func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fing
|
|||||||
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
||||||
SettingKeyEnableFingerprintUnification,
|
SettingKeyEnableFingerprintUnification,
|
||||||
SettingKeyEnableMetadataPassthrough,
|
SettingKeyEnableMetadataPassthrough,
|
||||||
|
SettingKeyEnableCCHSigning,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to get gateway forwarding settings", "error", err)
|
slog.Warn("failed to get gateway forwarding settings", "error", err)
|
||||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||||
fingerprintUnification: true,
|
fingerprintUnification: true,
|
||||||
metadataPassthrough: false,
|
metadataPassthrough: false,
|
||||||
|
cchSigning: false,
|
||||||
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
|
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
|
||||||
})
|
})
|
||||||
return gwfResult{true, false}, nil
|
return gwfResult{true, false, false}, nil
|
||||||
}
|
}
|
||||||
fp := true
|
fp := true
|
||||||
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
||||||
fp = v == "true"
|
fp = v == "true"
|
||||||
}
|
}
|
||||||
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
|
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
|
||||||
|
cch := values[SettingKeyEnableCCHSigning] == "true"
|
||||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||||
fingerprintUnification: fp,
|
fingerprintUnification: fp,
|
||||||
metadataPassthrough: mp,
|
metadataPassthrough: mp,
|
||||||
|
cchSigning: cch,
|
||||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||||
})
|
})
|
||||||
return gwfResult{fp, mp}, nil
|
return gwfResult{fp, mp, cch}, nil
|
||||||
})
|
})
|
||||||
if r, ok := val.(gwfResult); ok {
|
if r, ok := val.(gwfResult); ok {
|
||||||
return r.fp, r.mp
|
return r.fp, r.mp, r.cch
|
||||||
}
|
}
|
||||||
return true, false // fail-open defaults
|
return true, false, false // fail-open defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
||||||
@ -983,13 +990,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
// 分组隔离
|
// 分组隔离
|
||||||
result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true"
|
result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true"
|
||||||
|
|
||||||
// Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false)
|
// Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false, cch_signing=false)
|
||||||
if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
||||||
result.EnableFingerprintUnification = v == "true"
|
result.EnableFingerprintUnification = v == "true"
|
||||||
} else {
|
} else {
|
||||||
result.EnableFingerprintUnification = true // default: enabled (current behavior)
|
result.EnableFingerprintUnification = true // default: enabled (current behavior)
|
||||||
}
|
}
|
||||||
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
|
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
|
||||||
|
result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true"
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,6 +78,7 @@ type SystemSettings struct {
|
|||||||
// Gateway forwarding behavior
|
// Gateway forwarding behavior
|
||||||
EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
|
EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
|
||||||
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
|
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
|
||||||
|
EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false)
|
||||||
}
|
}
|
||||||
|
|
||||||
type DefaultSubscriptionSetting struct {
|
type DefaultSubscriptionSetting struct {
|
||||||
|
|||||||
@ -89,6 +89,7 @@ export interface SystemSettings {
|
|||||||
// Gateway forwarding behavior
|
// Gateway forwarding behavior
|
||||||
enable_fingerprint_unification: boolean
|
enable_fingerprint_unification: boolean
|
||||||
enable_metadata_passthrough: boolean
|
enable_metadata_passthrough: boolean
|
||||||
|
enable_cch_signing: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSettingsRequest {
|
export interface UpdateSettingsRequest {
|
||||||
@ -146,6 +147,7 @@ export interface UpdateSettingsRequest {
|
|||||||
allow_ungrouped_key_scheduling?: boolean
|
allow_ungrouped_key_scheduling?: boolean
|
||||||
enable_fingerprint_unification?: boolean
|
enable_fingerprint_unification?: boolean
|
||||||
enable_metadata_passthrough?: boolean
|
enable_metadata_passthrough?: boolean
|
||||||
|
enable_cch_signing?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4268,6 +4268,8 @@ export default {
|
|||||||
fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.',
|
fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.',
|
||||||
metadataPassthrough: 'Metadata Passthrough',
|
metadataPassthrough: 'Metadata Passthrough',
|
||||||
metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.',
|
metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.',
|
||||||
|
cchSigning: 'CCH Signing',
|
||||||
|
cchSigningHint: 'Sign the billing header in forwarded requests with CCH hash. When disabled, the placeholder is preserved.',
|
||||||
},
|
},
|
||||||
site: {
|
site: {
|
||||||
title: 'Site Settings',
|
title: 'Site Settings',
|
||||||
|
|||||||
@ -4431,6 +4431,8 @@ export default {
|
|||||||
fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。',
|
fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。',
|
||||||
metadataPassthrough: 'Metadata 透传',
|
metadataPassthrough: 'Metadata 透传',
|
||||||
metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。',
|
metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。',
|
||||||
|
cchSigning: 'CCH 签名',
|
||||||
|
cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。',
|
||||||
},
|
},
|
||||||
site: {
|
site: {
|
||||||
title: '站点设置',
|
title: '站点设置',
|
||||||
|
|||||||
@ -1376,6 +1376,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<Toggle v-model="form.enable_metadata_passthrough" />
|
<Toggle v-model="form.enable_metadata_passthrough" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CCH Signing -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.gatewayForwarding.cchSigning') }}
|
||||||
|
</label>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.gatewayForwarding.cchSigningHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="form.enable_cch_signing" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /Tab: Gateway — Claude Code, Scheduling -->
|
</div><!-- /Tab: Gateway — Claude Code, Scheduling -->
|
||||||
@ -2248,7 +2261,8 @@ const form = reactive<SettingsForm>({
|
|||||||
allow_ungrouped_key_scheduling: false,
|
allow_ungrouped_key_scheduling: false,
|
||||||
// Gateway forwarding behavior
|
// Gateway forwarding behavior
|
||||||
enable_fingerprint_unification: true,
|
enable_fingerprint_unification: true,
|
||||||
enable_metadata_passthrough: false
|
enable_metadata_passthrough: false,
|
||||||
|
enable_cch_signing: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const defaultSubscriptionGroupOptions = computed<DefaultSubscriptionGroupOption[]>(() =>
|
const defaultSubscriptionGroupOptions = computed<DefaultSubscriptionGroupOption[]>(() =>
|
||||||
@ -2556,7 +2570,8 @@ async function saveSettings() {
|
|||||||
max_claude_code_version: form.max_claude_code_version,
|
max_claude_code_version: form.max_claude_code_version,
|
||||||
allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling,
|
allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling,
|
||||||
enable_fingerprint_unification: form.enable_fingerprint_unification,
|
enable_fingerprint_unification: form.enable_fingerprint_unification,
|
||||||
enable_metadata_passthrough: form.enable_metadata_passthrough
|
enable_metadata_passthrough: form.enable_metadata_passthrough,
|
||||||
|
enable_cch_signing: form.enable_cch_signing
|
||||||
}
|
}
|
||||||
const updated = await adminAPI.settings.updateSettings(payload)
|
const updated = await adminAPI.settings.updateSettings(payload)
|
||||||
Object.assign(form, updated)
|
Object.assign(form, updated)
|
||||||
|
|||||||
Reference in New Issue
Block a user