Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion cmd/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ func NewCmdConfig() *cobra.Command {
PublicKey: cfg.PublicKey,
Profile: cfg.Profile,
BaseURL: cfg.BaseURL,
ChannelKey: cfg.ChannelKey,
Timeout: cfg.Timeout,
Active: cfg.Active,
Region: cfg.Region,
Expand All @@ -247,6 +248,14 @@ func NewCmdConfig() *cobra.Command {
cacheConfig.BaseURL = cfg.BaseURL
}

//channel-key 属连接类参数,与 base-url 同批应用:必须早于下方 region/project
//远程校验,否则校验请求不带 key,专属云 profile 在配置时即报错而配不上。
//用 Changed() 而非空值判断:空是合法值(专属云切回主站需清除它),
//--channel-key "" 应能清空,这与 base-url「空=不改」的既有局限不同。
if c.Flags().Changed("channel-key") {
cacheConfig.ChannelKey = cfg.ChannelKey
}

if cfg.Timeout == 0 {
if cacheConfig.Timeout == 0 {
cacheConfig.Timeout = platform.DefaultTimeoutSec
Expand Down Expand Up @@ -342,6 +351,7 @@ func NewCmdConfig() *cobra.Command {
flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'")
flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list")
flags.StringVar(&cfg.BaseURL, "base-url", "", "Optional. Set default base url. For instance 'https://api.ucloud.cn/'")
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")
flags.IntVar(&cfg.Timeout, "timeout-sec", 0, "Optional. Set default timeout for requesting API. Unit: seconds")
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'")
flags.StringVar(&active, "active", "", "Optional. Mark the profile to be effective or not. Accept valeus: true or false")
Expand Down Expand Up @@ -423,6 +433,7 @@ func NewCmdConfigAdd() *cobra.Command {
flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'")
flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list")
flags.StringVar(&cfg.BaseURL, "base-url", platform.DefaultBaseURL, "Optional. Set default base url. For instance 'https://api.ucloud.cn/'")
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")
flags.IntVar(&cfg.Timeout, "timeout-sec", platform.DefaultTimeoutSec, "Optional. Set default timeout for requesting API. Unit: seconds")
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'")
flags.StringVar(&active, "active", "false", "Optional. Mark the profile to be effective or not. Accept valeus: true or false")
Expand Down Expand Up @@ -472,12 +483,19 @@ func NewCmdConfigUpdate() *cobra.Command {
platform.AggConfigListIns.UpdateAggConfig(cacheConfig)
}

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

//channel-key 同属连接类参数:专属云 profile 的远程校验请求必须带上它,
//否则网关报 174 而校验失败,profile 永远配不上。
//Changed() 使 --channel-key "" 可清除(专属云切回主站的场景)。
if c.Flags().Changed("channel-key") {
cacheConfig.ChannelKey = cfg.ChannelKey
}

if timeout != "" {
seconds, err := strconv.Atoi(timeout)
if err != nil {
Expand Down Expand Up @@ -561,6 +579,7 @@ func NewCmdConfigUpdate() *cobra.Command {
flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'")
flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list")
flags.StringVar(&cfg.BaseURL, "base-url", "", "Optional. Set default base url. For instance 'https://api.ucloud.cn/'")
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")
flags.StringVar(&timeout, "timeout-sec", "", "Optional. Set default timeout for requesting API. Unit: seconds")
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'")
flags.StringVar(&active, "active", "", "Optional. Mark the profile to be effective")
Expand Down
39 changes: 36 additions & 3 deletions cmd/internal/platform/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,45 @@ func newCredHeaderInjector(credConfig *CredentialConfig) sdk.HttpRequestHandler
}
}

// newChannelHeaderInjector 返回专属云渠道头注入 handler。
//
// 网关据 channel-key 识别「复用主站域名」的专属云渠道(如 api.ucloud-global.com);
// 独立域名渠道与主站用户没有这个 key,故空值必须完全不注入该头 —— 与本文件中
// Cookie/Csrf-Token「空值也照旧 set」的历史行为刻意相反:那是存量契约,而这是新增头,
// 给全部存量用户平白加一个空头即构成回归。
//
// 取值源是 ac 而非 credConfig:channel-key 是接入点配置(与 base_url 同类、成对配置),
// 不是凭据机制,与 auth_mode 正交(spec auth-guidelines「一个请求只携带一种凭据机制」
// 说的是凭据,不含本头)。且 newServiceClientForConfig 传入的 ac 正是用户此刻正在配置
// 的 profile,config 子命令自身的 region/project 远程校验请求因此天然带上正确的 key,
// 不会出现「配置时校验失败 → 配不上」的死锁。
//
// 实测(2026-07-16,真实 combo 账号 + 真实网关):同一 token 同一域名,唯一变量为本头,
// RetCode 174 → 0;且 Go 的 Header.Set 规范化为 Channel-Key 后网关照常接受。
func newChannelHeaderInjector(ac *AggConfig) sdk.HttpRequestHandler {
return func(c *sdk.Client, req *http.HttpRequest) (*http.HttpRequest, error) {
if ac == nil || ac.ChannelKey == "" {
return req, nil
}
return req, req.SetHeader("channel-key", ac.ChannelKey)
}
}

// authRetCodeWhitelist 鉴权类 RetCode 白名单(D6)。实测网关(2026-06-11 实探):
// 鉴权失败以 HTTP 200 + RetCode 返回,401 仅作防御性分支保留。
// 174 "Token Not Exists":伪造与已过期的 Bearer 同为 174(已实测确认);属网关
// 前置鉴权拒绝,业务必未执行,重放一次安全。网关团队书面确认仍待补档(spec §7)。
// 170(缺签名,oauth 请求恒带 Bearer 不会触发)、171/172(AK/SK 路径)不入列。
//
// 174 的第三种成因(2026-07-16 实测发现,已知且刻意保留现状):channel-key 与账号
// 所属渠道不匹配时网关同样返回 174(同一有效 token 换成别的渠道 key 即报 174)。
// 于是本白名单会把「配错 channel-key」误判为 token 过期 → 刷新(refresh_token 轮转、
// 旧的立即作废)→ 重放 → 仍 174。后果是每次请求白耗一轮刷新,且错误文案把用户导向
// 重新登录 —— 而重新登录永远治不好。本次不改控制流(改了会波及正常的过期刷新路径)。
// 排障:OAuth profile 报 174 且重新登录无效时,优先检查 channel_key 是否与账号所属
// 渠道匹配。
var authRetCodeWhitelist = map[int]bool{
174: true, // Token Not Exists:无效或过期 Bearer
174: true, // Token Not Exists:无效或过期 Bearer(亦可能是 channel-key 不匹配,见上)
}

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

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

Expand Down
126 changes: 126 additions & 0 deletions cmd/internal/platform/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,132 @@ func TestOAuthProfileWithRetainedKeysDoesNotSign(t *testing.T) {
}
}

// channelInjectorHeaders 单独驱动渠道头注入 handler,取其产出的 header map。
// 注意查的是 SetHeader 原样存入的 map(未经 Go 规范化),故 key 必须与注入端字面一致。
func channelInjectorHeaders(t *testing.T, ac *AggConfig) map[string]string {
t.Helper()
h := newChannelHeaderInjector(ac)
req, err := h(nil, uhttp.NewHttpRequest())
if err != nil {
t.Fatal(err)
}
return req.GetHeaderMap()
}

// 配了 channel_key 的 profile:注入 channel-key 头(AC1)
func TestChannelInjectorSetsHeader(t *testing.T) {
headers := channelInjectorHeaders(t, &AggConfig{ChannelKey: "ch_combo_test"})
if headers["channel-key"] != "ch_combo_test" {
t.Errorf("channel-key = %q, want ch_combo_test", headers["channel-key"])
}
}

// CRITICAL 零回归(AC2):未配 channel_key 时该头必须完全不存在——不是空值,是键不存在。
// 主站用户与独立域名专属云渠道恒走此路径,线路字节必须与引入本特性前逐字节一致。
// 与同文件 Cookie/Csrf-Token「空值也照旧 set」的存量契约刻意相反:那是历史行为,
// 本头是新增的,注入空头会给全部存量用户构成回归。
func TestChannelInjectorAbsentWhenEmpty(t *testing.T) {
for _, tc := range []struct {
name string
ac *AggConfig
}{
{"empty channel key", &AggConfig{}},
{"nil agg config", nil}, // AttachHandlersWith(sc, nil, nil, nil) 降级路径确实存在
} {
t.Run(tc.name, func(t *testing.T) {
if v, ok := channelInjectorHeaders(t, tc.ac)["channel-key"]; ok {
t.Errorf("channel-key header must be absent, got present with value %q", v)
}
})
}
}

// channel-key 与凭据机制正交(AC3):auth_mode 不影响其注入。
// spec auth-guidelines 的「一个请求只携带一种凭据机制」约束的是凭据,channel-key 是
// 渠道路由标识,不在其列。2026-07-16 真实网关实测:Bearer 与 channel-key 同时上行被接受。
func TestChannelInjectorOrthogonalToAuthMode(t *testing.T) {
for _, tc := range []struct {
name string
ac *AggConfig
}{
{"aksk", &AggConfig{ChannelKey: "ch_x", PublicKey: "pub", PrivateKey: "pri"}},
{"oauth", &AggConfig{ChannelKey: "ch_x", AuthMode: AuthModeOAuth, AccessToken: "tok"}},
} {
t.Run(tc.name, func(t *testing.T) {
if got := channelInjectorHeaders(t, tc.ac)["channel-key"]; got != "ch_x" {
t.Errorf("channel-key = %q, want ch_x", got)
}
})
}
}

// 端到端:经完整 handler 链发真实请求,断言服务端侧确实收到该头。
// 这是唯一能验证「Go 的 Header.Set 规范化为 Channel-Key 后网关仍可取到」的用例
// (单测查的 map 未规范化,验证不到线路形式),也顺带证明 handler 确实被挂上了。
func TestChannelKeyHeaderEndToEnd(t *testing.T) {
rec := &recordedRequest{}
s := bizRecorderServer(t, rec)
defer s.Close()

ac := &AggConfig{
Profile: "combo", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0),
Region: "hk", ChannelKey: "ch_combo_e2e",
PublicKey: "pub", PrivateKey: "pri",
}
callGetRegion(t, ac, rec)

// http.Header.Get 大小写不敏感:线路上是 Channel-Key,此处照样取得到
if got := rec.header.Get("channel-key"); got != "ch_combo_e2e" {
t.Errorf("server received channel-key = %q, want ch_combo_e2e", got)
}
}

// AttachHandlers(读包级 ConfigIns 的那条路径,GetUserInfo 等在用)同样注入 channel-key。
// 该路径实测难以触发(仅 agree_upload_log=true 的 DAS 日志上传会走 GetUserInfo),故以单测钉死。
func TestAttachHandlersInjectsChannelKeyFromConfigIns(t *testing.T) {
rec := &recordedRequest{}
s := bizRecorderServer(t, rec)
defer s.Close()

oldConfig, oldClientConfig, oldCred := ConfigIns, ClientConfig, AuthCredential
t.Cleanup(func() { ConfigIns, ClientConfig, AuthCredential = oldConfig, oldClientConfig, oldCred })

ac := &AggConfig{
Profile: "combo", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0),
Region: "hk", ChannelKey: "ch_from_configins",
PublicKey: "pub", PrivateKey: "pri",
}
ConfigIns = ac
if err := InitClientRuntime(ac); err != nil {
t.Fatal(err)
}
client := uaccount.NewClient(ClientConfig, BuildCredential())
AttachHandlers(client) // 包级路径:等价于 AttachHandlersWith(sc, AuthCredential, ConfigIns, ...)
if _, err := client.GetRegion(client.NewGetRegionRequest()); err != nil {
t.Fatalf("GetRegion failed: %v", err)
}
if got := rec.header.Get("channel-key"); got != "ch_from_configins" {
t.Errorf("AttachHandlers must inject channel-key from ConfigIns, got %q", got)
}
}

// 端到端零回归:未配 channel_key 时服务端不得看到该头
func TestChannelKeyHeaderAbsentEndToEnd(t *testing.T) {
rec := &recordedRequest{}
s := bizRecorderServer(t, rec)
defer s.Close()

ac := &AggConfig{
Profile: "mainsite", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0),
Region: "cn-bj2", PublicKey: "pub", PrivateKey: "pri",
}
callGetRegion(t, ac, rec)

if _, ok := rec.header["Channel-Key"]; ok {
t.Errorf("main-site profile must not send channel-key header, got %q", rec.header.Get("channel-key"))
}
}

// 401 自动重放矩阵(D6 反应式兜底):401→刷新→重放成功;aksk 模式不重放。
// 注意 SDK 行为:HttpClient.Send 对 status>=400 返回 (nil, StatusError),
// 401 的 body 在 handler 层不可见,鉴权失败只能从 err 判定。
Expand Down
13 changes: 12 additions & 1 deletion cmd/internal/platform/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ type GlobalFlag struct {
PublicKey string
PrivateKey string
BaseURL string
ChannelKey string
Timeout int
WaitTimeout int // 同步轮询总超时(秒),0 表示用内置默认(600s)
MaxRetryTimes int
Expand All @@ -119,6 +120,10 @@ type CLIConfig struct {
MaxRetryTimes *int `json:"max_retry_times"`
AgreeUploadLog bool `json:"agree_upload_log"`
OAuthBaseURL string `json:"oauth_base_url,omitempty"`
// ChannelKey 专属云渠道标识,与 base_url 成对配置(复用主站域名的专属云渠道必填,
// 独立域名渠道与主站用户为空)。属接入点配置而非用户凭据,故落在 config.json,
// 不进 credential.json。
ChannelKey string `json:"channel_key,omitempty"`
}

// CredentialConfig credential element
Expand Down Expand Up @@ -155,6 +160,7 @@ type AggConfig struct {
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"`
OAuthBaseURL string `json:"oauth_base_url,omitempty"`
ChannelKey string `json:"channel_key,omitempty"`
}

// ConfigPublicKey 输入公钥
Expand Down Expand Up @@ -248,6 +254,7 @@ func (p *AggConfig) copyToCLIConfig(target *CLIConfig) {
target.MaxRetryTimes = p.MaxRetryTimes
target.AgreeUploadLog = p.AgreeUploadLog
target.OAuthBaseURL = p.OAuthBaseURL
target.ChannelKey = p.ChannelKey
}

func (p *AggConfig) copyToCredentialConfig(target *CredentialConfig) {
Expand Down Expand Up @@ -389,6 +396,7 @@ func (p *AggConfigManager) Load() error {
RefreshToken: cred.RefreshToken,
ExpiresAt: cred.ExpiresAt,
OAuthBaseURL: config.OAuthBaseURL,
ChannelKey: config.ChannelKey,
}
}

Expand Down Expand Up @@ -668,7 +676,7 @@ func ListAggConfig(json bool) {
HandleError(err)
}
} else {
PrintTable(aggConfigs, []string{"Profile", "Active", "AuthMode", "ProjectID", "Region", "Zone", "BaseURL", "Timeout", "PublicKey", "PrivateKey", "MaxRetryTimes", "AgreeUploadLog"})
PrintTable(aggConfigs, []string{"Profile", "Active", "AuthMode", "ProjectID", "Region", "Zone", "BaseURL", "ChannelKey", "Timeout", "PublicKey", "PrivateKey", "MaxRetryTimes", "AgreeUploadLog"})
}
}

Expand Down Expand Up @@ -897,6 +905,9 @@ func mergeConfigIns(ins *AggConfig) {
if Global.BaseURL != "" {
ins.BaseURL = Global.BaseURL
}
if Global.ChannelKey != "" {
ins.ChannelKey = Global.ChannelKey
}
if Global.Timeout != 0 {
ins.Timeout = Global.Timeout
}
Expand Down
Loading
Loading