Skip to content

Commit 62b2d4d

Browse files
authored
Merge pull request #141 from ucloud/feat/channel-key-header
feat(platform): support channel-key header for dedicated cloud channels
2 parents 37b3d3a + dde94dc commit 62b2d4d

7 files changed

Lines changed: 271 additions & 6 deletions

File tree

‎cmd/configure.go‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ func NewCmdConfig() *cobra.Command {
223223
PublicKey: cfg.PublicKey,
224224
Profile: cfg.Profile,
225225
BaseURL: cfg.BaseURL,
226+
ChannelKey: cfg.ChannelKey,
226227
Timeout: cfg.Timeout,
227228
Active: cfg.Active,
228229
Region: cfg.Region,
@@ -247,6 +248,14 @@ func NewCmdConfig() *cobra.Command {
247248
cacheConfig.BaseURL = cfg.BaseURL
248249
}
249250

251+
//channel-key 属连接类参数,与 base-url 同批应用:必须早于下方 region/project
252+
//远程校验,否则校验请求不带 key,专属云 profile 在配置时即报错而配不上。
253+
//用 Changed() 而非空值判断:空是合法值(专属云切回主站需清除它),
254+
//--channel-key "" 应能清空,这与 base-url「空=不改」的既有局限不同。
255+
if c.Flags().Changed("channel-key") {
256+
cacheConfig.ChannelKey = cfg.ChannelKey
257+
}
258+
250259
if cfg.Timeout == 0 {
251260
if cacheConfig.Timeout == 0 {
252261
cacheConfig.Timeout = platform.DefaultTimeoutSec
@@ -342,6 +351,7 @@ func NewCmdConfig() *cobra.Command {
342351
flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'")
343352
flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list")
344353
flags.StringVar(&cfg.BaseURL, "base-url", "", "Optional. Set default base url. For instance 'https://api.ucloud.cn/'")
354+
flags.StringVar(&cfg.ChannelKey, "channel-key", "", "Optional. Set channel-key for a dedicated cloud channel that reuses the main-site domain. For instance 'ch_xxx'. Leave empty for the main site or a channel with its own domain")
345355
flags.IntVar(&cfg.Timeout, "timeout-sec", 0, "Optional. Set default timeout for requesting API. Unit: seconds")
346356
cfg.MaxRetryTimes = flags.Int("max-retry-times", 0, "Optional. Set default max-retry-times for idempotent APIs which can be called many times without side effect, for example 'ReleaseEIP'")
347357
flags.StringVar(&active, "active", "", "Optional. Mark the profile to be effective or not. Accept valeus: true or false")
@@ -423,6 +433,7 @@ func NewCmdConfigAdd() *cobra.Command {
423433
flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'")
424434
flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list")
425435
flags.StringVar(&cfg.BaseURL, "base-url", platform.DefaultBaseURL, "Optional. Set default base url. For instance 'https://api.ucloud.cn/'")
436+
flags.StringVar(&cfg.ChannelKey, "channel-key", "", "Optional. Set channel-key for a dedicated cloud channel that reuses the main-site domain. For instance 'ch_xxx'. Leave empty for the main site or a channel with its own domain")
426437
flags.IntVar(&cfg.Timeout, "timeout-sec", platform.DefaultTimeoutSec, "Optional. Set default timeout for requesting API. Unit: seconds")
427438
cfg.MaxRetryTimes = flags.Int("max-retry-times", platform.DefaultMaxRetryTimes, "Optional. Set default max-retry-times for idempotent APIs which can be called many times without side effect, for example 'ReleaseEIP'")
428439
flags.StringVar(&active, "active", "false", "Optional. Mark the profile to be effective or not. Accept valeus: true or false")
@@ -472,12 +483,19 @@ func NewCmdConfigUpdate() *cobra.Command {
472483
platform.AggConfigListIns.UpdateAggConfig(cacheConfig)
473484
}
474485

475-
//先应用连接类参数(base-url/timeout-sec/max-retry-times),确保接下来的远程校验
486+
//先应用连接类参数(base-url/channel-key/timeout-sec/max-retry-times),确保接下来的远程校验
476487
//打到新网关而不是旧的(可能已不可用的)网关,避免旧base-url坏掉后无法改回的死锁
477488
if cfg.BaseURL != "" {
478489
cacheConfig.BaseURL = cfg.BaseURL
479490
}
480491

492+
//channel-key 同属连接类参数:专属云 profile 的远程校验请求必须带上它,
493+
//否则网关报 174 而校验失败,profile 永远配不上。
494+
//Changed() 使 --channel-key "" 可清除(专属云切回主站的场景)。
495+
if c.Flags().Changed("channel-key") {
496+
cacheConfig.ChannelKey = cfg.ChannelKey
497+
}
498+
481499
if timeout != "" {
482500
seconds, err := strconv.Atoi(timeout)
483501
if err != nil {
@@ -561,6 +579,7 @@ func NewCmdConfigUpdate() *cobra.Command {
561579
flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'")
562580
flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list")
563581
flags.StringVar(&cfg.BaseURL, "base-url", "", "Optional. Set default base url. For instance 'https://api.ucloud.cn/'")
582+
flags.StringVar(&cfg.ChannelKey, "channel-key", "", "Optional. Set channel-key for a dedicated cloud channel that reuses the main-site domain. For instance 'ch_xxx'. Pass an empty value to clear it")
564583
flags.StringVar(&timeout, "timeout-sec", "", "Optional. Set default timeout for requesting API. Unit: seconds")
565584
flags.StringVar(&maxRetries, "max-retry-times", "", "Optional. Set default max retry times for idempotent APIs which can be called many times without side effect, for example 'ReleaseEIP'")
566585
flags.StringVar(&active, "active", "", "Optional. Mark the profile to be effective")

‎cmd/internal/platform/client.go‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,45 @@ func newCredHeaderInjector(credConfig *CredentialConfig) sdk.HttpRequestHandler
7171
}
7272
}
7373

74+
// newChannelHeaderInjector 返回专属云渠道头注入 handler。
75+
//
76+
// 网关据 channel-key 识别「复用主站域名」的专属云渠道(如 api.ucloud-global.com);
77+
// 独立域名渠道与主站用户没有这个 key,故空值必须完全不注入该头 —— 与本文件中
78+
// Cookie/Csrf-Token「空值也照旧 set」的历史行为刻意相反:那是存量契约,而这是新增头,
79+
// 给全部存量用户平白加一个空头即构成回归。
80+
//
81+
// 取值源是 ac 而非 credConfig:channel-key 是接入点配置(与 base_url 同类、成对配置),
82+
// 不是凭据机制,与 auth_mode 正交(spec auth-guidelines「一个请求只携带一种凭据机制」
83+
// 说的是凭据,不含本头)。且 newServiceClientForConfig 传入的 ac 正是用户此刻正在配置
84+
// 的 profile,config 子命令自身的 region/project 远程校验请求因此天然带上正确的 key,
85+
// 不会出现「配置时校验失败 → 配不上」的死锁。
86+
//
87+
// 实测(2026-07-16,真实 combo 账号 + 真实网关):同一 token 同一域名,唯一变量为本头,
88+
// RetCode 174 → 0;且 Go 的 Header.Set 规范化为 Channel-Key 后网关照常接受。
89+
func newChannelHeaderInjector(ac *AggConfig) sdk.HttpRequestHandler {
90+
return func(c *sdk.Client, req *http.HttpRequest) (*http.HttpRequest, error) {
91+
if ac == nil || ac.ChannelKey == "" {
92+
return req, nil
93+
}
94+
return req, req.SetHeader("channel-key", ac.ChannelKey)
95+
}
96+
}
97+
7498
// authRetCodeWhitelist 鉴权类 RetCode 白名单(D6)。实测网关(2026-06-11 实探):
7599
// 鉴权失败以 HTTP 200 + RetCode 返回,401 仅作防御性分支保留。
76100
// 174 "Token Not Exists":伪造与已过期的 Bearer 同为 174(已实测确认);属网关
77101
// 前置鉴权拒绝,业务必未执行,重放一次安全。网关团队书面确认仍待补档(spec §7)。
78102
// 170(缺签名,oauth 请求恒带 Bearer 不会触发)、171/172(AK/SK 路径)不入列。
103+
//
104+
// 174 的第三种成因(2026-07-16 实测发现,已知且刻意保留现状):channel-key 与账号
105+
// 所属渠道不匹配时网关同样返回 174(同一有效 token 换成别的渠道 key 即报 174)。
106+
// 于是本白名单会把「配错 channel-key」误判为 token 过期 → 刷新(refresh_token 轮转、
107+
// 旧的立即作废)→ 重放 → 仍 174。后果是每次请求白耗一轮刷新,且错误文案把用户导向
108+
// 重新登录 —— 而重新登录永远治不好。本次不改控制流(改了会波及正常的过期刷新路径)。
109+
// 排障:OAuth profile 报 174 且重新登录无效时,优先检查 channel_key 是否与账号所属
110+
// 渠道匹配。
79111
var authRetCodeWhitelist = map[int]bool{
80-
174: true, // Token Not Exists:无效或过期 Bearer
112+
174: true, // Token Not Exists:无效或过期 Bearer(亦可能是 channel-key 不匹配,见上)
81113
}
82114

83115
// isAuthFailure 判定是否鉴权类失败:HTTP 401 或 body RetCode 在白名单(网关前置鉴权,业务必未执行)。
@@ -216,8 +248,8 @@ func normalizeProjectID(req request.Common) (request.Common, error) {
216248
return req, nil
217249
}
218250

219-
// attachHandlers 把三个平台 handler 挂到 service client 上:
220-
// project-id 归一化、凭据头注入、oauth 反应式重试。
251+
// attachHandlers 把平台 handler 挂到 service client 上:
252+
// project-id 归一化、请求日志、凭据头注入、专属云渠道头注入、oauth 反应式重试。
221253
// credConfig 与 ac 显式传入:NewClient 借此传它自己的构造来源 profile(ac),
222254
// 重试目标必须是构造本 client 的 profile,而非包级 ConfigIns
223255
// (详见 newOAuthRetryHandler 的注释:os.Args 扫描识别不了 -p X/--profile=X,
@@ -239,6 +271,7 @@ func attachHandlersWithManager(sc sdk.ServiceClient, credConfig *CredentialConfi
239271
return req, nil
240272
})
241273
sc.AddHttpRequestHandler(newCredHeaderInjector(credConfig))
274+
sc.AddHttpRequestHandler(newChannelHeaderInjector(ac))
242275
sc.AddHttpResponseHandler(newOAuthRetryHandler(credConfig, ac, manager))
243276
}
244277

‎cmd/internal/platform/client_test.go‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,132 @@ func TestOAuthProfileWithRetainedKeysDoesNotSign(t *testing.T) {
276276
}
277277
}
278278

279+
// channelInjectorHeaders 单独驱动渠道头注入 handler,取其产出的 header map。
280+
// 注意查的是 SetHeader 原样存入的 map(未经 Go 规范化),故 key 必须与注入端字面一致。
281+
func channelInjectorHeaders(t *testing.T, ac *AggConfig) map[string]string {
282+
t.Helper()
283+
h := newChannelHeaderInjector(ac)
284+
req, err := h(nil, uhttp.NewHttpRequest())
285+
if err != nil {
286+
t.Fatal(err)
287+
}
288+
return req.GetHeaderMap()
289+
}
290+
291+
// 配了 channel_key 的 profile:注入 channel-key 头(AC1)
292+
func TestChannelInjectorSetsHeader(t *testing.T) {
293+
headers := channelInjectorHeaders(t, &AggConfig{ChannelKey: "ch_combo_test"})
294+
if headers["channel-key"] != "ch_combo_test" {
295+
t.Errorf("channel-key = %q, want ch_combo_test", headers["channel-key"])
296+
}
297+
}
298+
299+
// CRITICAL 零回归(AC2):未配 channel_key 时该头必须完全不存在——不是空值,是键不存在。
300+
// 主站用户与独立域名专属云渠道恒走此路径,线路字节必须与引入本特性前逐字节一致。
301+
// 与同文件 Cookie/Csrf-Token「空值也照旧 set」的存量契约刻意相反:那是历史行为,
302+
// 本头是新增的,注入空头会给全部存量用户构成回归。
303+
func TestChannelInjectorAbsentWhenEmpty(t *testing.T) {
304+
for _, tc := range []struct {
305+
name string
306+
ac *AggConfig
307+
}{
308+
{"empty channel key", &AggConfig{}},
309+
{"nil agg config", nil}, // AttachHandlersWith(sc, nil, nil, nil) 降级路径确实存在
310+
} {
311+
t.Run(tc.name, func(t *testing.T) {
312+
if v, ok := channelInjectorHeaders(t, tc.ac)["channel-key"]; ok {
313+
t.Errorf("channel-key header must be absent, got present with value %q", v)
314+
}
315+
})
316+
}
317+
}
318+
319+
// channel-key 与凭据机制正交(AC3):auth_mode 不影响其注入。
320+
// spec auth-guidelines 的「一个请求只携带一种凭据机制」约束的是凭据,channel-key 是
321+
// 渠道路由标识,不在其列。2026-07-16 真实网关实测:Bearer 与 channel-key 同时上行被接受。
322+
func TestChannelInjectorOrthogonalToAuthMode(t *testing.T) {
323+
for _, tc := range []struct {
324+
name string
325+
ac *AggConfig
326+
}{
327+
{"aksk", &AggConfig{ChannelKey: "ch_x", PublicKey: "pub", PrivateKey: "pri"}},
328+
{"oauth", &AggConfig{ChannelKey: "ch_x", AuthMode: AuthModeOAuth, AccessToken: "tok"}},
329+
} {
330+
t.Run(tc.name, func(t *testing.T) {
331+
if got := channelInjectorHeaders(t, tc.ac)["channel-key"]; got != "ch_x" {
332+
t.Errorf("channel-key = %q, want ch_x", got)
333+
}
334+
})
335+
}
336+
}
337+
338+
// 端到端:经完整 handler 链发真实请求,断言服务端侧确实收到该头。
339+
// 这是唯一能验证「Go 的 Header.Set 规范化为 Channel-Key 后网关仍可取到」的用例
340+
// (单测查的 map 未规范化,验证不到线路形式),也顺带证明 handler 确实被挂上了。
341+
func TestChannelKeyHeaderEndToEnd(t *testing.T) {
342+
rec := &recordedRequest{}
343+
s := bizRecorderServer(t, rec)
344+
defer s.Close()
345+
346+
ac := &AggConfig{
347+
Profile: "combo", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0),
348+
Region: "hk", ChannelKey: "ch_combo_e2e",
349+
PublicKey: "pub", PrivateKey: "pri",
350+
}
351+
callGetRegion(t, ac, rec)
352+
353+
// http.Header.Get 大小写不敏感:线路上是 Channel-Key,此处照样取得到
354+
if got := rec.header.Get("channel-key"); got != "ch_combo_e2e" {
355+
t.Errorf("server received channel-key = %q, want ch_combo_e2e", got)
356+
}
357+
}
358+
359+
// AttachHandlers(读包级 ConfigIns 的那条路径,GetUserInfo 等在用)同样注入 channel-key。
360+
// 该路径实测难以触发(仅 agree_upload_log=true 的 DAS 日志上传会走 GetUserInfo),故以单测钉死。
361+
func TestAttachHandlersInjectsChannelKeyFromConfigIns(t *testing.T) {
362+
rec := &recordedRequest{}
363+
s := bizRecorderServer(t, rec)
364+
defer s.Close()
365+
366+
oldConfig, oldClientConfig, oldCred := ConfigIns, ClientConfig, AuthCredential
367+
t.Cleanup(func() { ConfigIns, ClientConfig, AuthCredential = oldConfig, oldClientConfig, oldCred })
368+
369+
ac := &AggConfig{
370+
Profile: "combo", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0),
371+
Region: "hk", ChannelKey: "ch_from_configins",
372+
PublicKey: "pub", PrivateKey: "pri",
373+
}
374+
ConfigIns = ac
375+
if err := InitClientRuntime(ac); err != nil {
376+
t.Fatal(err)
377+
}
378+
client := uaccount.NewClient(ClientConfig, BuildCredential())
379+
AttachHandlers(client) // 包级路径:等价于 AttachHandlersWith(sc, AuthCredential, ConfigIns, ...)
380+
if _, err := client.GetRegion(client.NewGetRegionRequest()); err != nil {
381+
t.Fatalf("GetRegion failed: %v", err)
382+
}
383+
if got := rec.header.Get("channel-key"); got != "ch_from_configins" {
384+
t.Errorf("AttachHandlers must inject channel-key from ConfigIns, got %q", got)
385+
}
386+
}
387+
388+
// 端到端零回归:未配 channel_key 时服务端不得看到该头
389+
func TestChannelKeyHeaderAbsentEndToEnd(t *testing.T) {
390+
rec := &recordedRequest{}
391+
s := bizRecorderServer(t, rec)
392+
defer s.Close()
393+
394+
ac := &AggConfig{
395+
Profile: "mainsite", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0),
396+
Region: "cn-bj2", PublicKey: "pub", PrivateKey: "pri",
397+
}
398+
callGetRegion(t, ac, rec)
399+
400+
if _, ok := rec.header["Channel-Key"]; ok {
401+
t.Errorf("main-site profile must not send channel-key header, got %q", rec.header.Get("channel-key"))
402+
}
403+
}
404+
279405
// 401 自动重放矩阵(D6 反应式兜底):401→刷新→重放成功;aksk 模式不重放。
280406
// 注意 SDK 行为:HttpClient.Send 对 status>=400 返回 (nil, StatusError),
281407
// 401 的 body 在 handler 层不可见,鉴权失败只能从 err 判定。

‎cmd/internal/platform/config.go‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ type GlobalFlag struct {
102102
PublicKey string
103103
PrivateKey string
104104
BaseURL string
105+
ChannelKey string
105106
Timeout int
106107
WaitTimeout int // 同步轮询总超时(秒),0 表示用内置默认(600s)
107108
MaxRetryTimes int
@@ -119,6 +120,10 @@ type CLIConfig struct {
119120
MaxRetryTimes *int `json:"max_retry_times"`
120121
AgreeUploadLog bool `json:"agree_upload_log"`
121122
OAuthBaseURL string `json:"oauth_base_url,omitempty"`
123+
// ChannelKey 专属云渠道标识,与 base_url 成对配置(复用主站域名的专属云渠道必填,
124+
// 独立域名渠道与主站用户为空)。属接入点配置而非用户凭据,故落在 config.json,
125+
// 不进 credential.json。
126+
ChannelKey string `json:"channel_key,omitempty"`
122127
}
123128

124129
// CredentialConfig credential element
@@ -155,6 +160,7 @@ type AggConfig struct {
155160
RefreshToken string `json:"refresh_token,omitempty"`
156161
ExpiresAt int64 `json:"expires_at,omitempty"`
157162
OAuthBaseURL string `json:"oauth_base_url,omitempty"`
163+
ChannelKey string `json:"channel_key,omitempty"`
158164
}
159165

160166
// ConfigPublicKey 输入公钥
@@ -248,6 +254,7 @@ func (p *AggConfig) copyToCLIConfig(target *CLIConfig) {
248254
target.MaxRetryTimes = p.MaxRetryTimes
249255
target.AgreeUploadLog = p.AgreeUploadLog
250256
target.OAuthBaseURL = p.OAuthBaseURL
257+
target.ChannelKey = p.ChannelKey
251258
}
252259

253260
func (p *AggConfig) copyToCredentialConfig(target *CredentialConfig) {
@@ -389,6 +396,7 @@ func (p *AggConfigManager) Load() error {
389396
RefreshToken: cred.RefreshToken,
390397
ExpiresAt: cred.ExpiresAt,
391398
OAuthBaseURL: config.OAuthBaseURL,
399+
ChannelKey: config.ChannelKey,
392400
}
393401
}
394402

@@ -668,7 +676,7 @@ func ListAggConfig(json bool) {
668676
HandleError(err)
669677
}
670678
} else {
671-
PrintTable(aggConfigs, []string{"Profile", "Active", "AuthMode", "ProjectID", "Region", "Zone", "BaseURL", "Timeout", "PublicKey", "PrivateKey", "MaxRetryTimes", "AgreeUploadLog"})
679+
PrintTable(aggConfigs, []string{"Profile", "Active", "AuthMode", "ProjectID", "Region", "Zone", "BaseURL", "ChannelKey", "Timeout", "PublicKey", "PrivateKey", "MaxRetryTimes", "AgreeUploadLog"})
672680
}
673681
}
674682

@@ -897,6 +905,9 @@ func mergeConfigIns(ins *AggConfig) {
897905
if Global.BaseURL != "" {
898906
ins.BaseURL = Global.BaseURL
899907
}
908+
if Global.ChannelKey != "" {
909+
ins.ChannelKey = Global.ChannelKey
910+
}
900911
if Global.Timeout != 0 {
901912
ins.Timeout = Global.Timeout
902913
}

0 commit comments

Comments
 (0)