fix(storage): 按 endpoint 形态解析响应 + 凭据解密返回 + 无建桶权限友好提示

【真实根因】COS 桶明明存在却报"桶不存在/创建失败"

用户配置:endpoint=loki-log-1394668510.cos.ap-hongkong.myqcloud.com(虚拟主机式,已含桶名)
旧代码:始终 GET / 并按 ListBuckets 的 <Buckets><Bucket><Name> 解析。
但在虚拟主机式 endpoint 下,GET / 返回的是 ListObjects(<ListBucketResult>)——
桶内对象列表,不是桶列表。解析必然找不到桶名 → 误判"桶不存在"
→ 触发建桶 PUT → 用户的 AK 无 cos:PutBucket 权限 → 403
→ 最终报"创建后校验失败"。

【实测确认(用该 AK 直连 COS)】
  GET / (vhost)         → 200  <ListBucketResult>         桶存在,能列对象
  GET / (区域端点)       → 200  <ListAllMyBucketsResult>   AK 有 cos:GetService
  HEAD /                → 403  AK 无 cos:HeadBucket
  PUT /  (CreateBucket) → 403  AK 无 cos:PutBucket
即 QcloudCOSDataFullControl = 数据面全控(读写/列桶),不含管控面(建桶/HeadBucket)。

【修复】
1. 探测统一用 GET /,再按 endpoint 形态解释响应:
   - 虚拟主机式 → 按 ListObjects(<ListBucketResult>)判断,200 即桶存在
   - 区域端点   → 按 ListBuckets(<Buckets><Bucket><Name>)匹配桶名
   桶存在就直接返回"桶已存在",不再走建桶流程。
2. 建桶二次校验同样改用 GET /(不再用 HEAD,避免 HeadBucket 403 误判)。
3. 建桶返回 403 时给出明确友好提示:
   "当前 Access Key 无建桶权限。腾讯云 QcloudCOSDataFullControl 只授予数据读写/列出,
    不含 CreateBucket。请到 COS 控制台手动创建桶,或在 CAM 追加 cos:PutBucket"
4. models/storage.go Sanitized() 用 crypto.DecryptField 解密后再返回 AK/SK
   (原来直接返回 ENC:xxx 密文,前端编辑框显示密文)。
5. 前端访问地址输入框补充说明:推荐填区域端点(不带桶名)。

镜像:lghost/lokistack:latest + v0.1 = sha256:350478003870
This commit is contained in:
opsx committed 2026-10-26 18:38:00 +00:00
1 parent ff69ea2ce8
commit cfe1ccd154
2 files changed
+93 -32

No files matched your search

+15 -6
View File
@@ -1,6 +1,10 @@
package models package models
import "time" import (
"time"
"opsxloki/pkg/crypto"
)
type StorageConfig struct { type StorageConfig struct {
ID uint `json:"id"` ID uint `json:"id"`
@@ -19,6 +23,13 @@ type StorageConfig struct {
} }
func (s *StorageConfig) Sanitized() map[string]interface{} { func (s *StorageConfig) Sanitized() map[string]interface{} {
// AccessKey / SecretKey 在库里是 ENC:<ciphertext> 加密存储,
// 直接返回会让前端编辑框显示 ENC:xxx 密文。这里统一解密后返回明文,
// 前端 Secret Key 仍用密码框(默认掩码,点「显示」才展开),
// 便于管理员核对凭据并「测试连接」。
plainAK := crypto.DecryptField(s.AccessKey)
plainSK := crypto.DecryptField(s.SecretKey)
return map[string]interface{}{ return map[string]interface{}{
"id": s.ID, "id": s.ID,
"name": s.Name, "name": s.Name,
@@ -26,14 +37,12 @@ func (s *StorageConfig) Sanitized() map[string]interface{} {
"endpoint": s.Endpoint, "endpoint": s.Endpoint,
"bucket": s.Bucket, "bucket": s.Bucket,
"region": s.Region, "region": s.Region,
// accessKey / secretKey 明文返回:本平台为内网运维工具,管理员需要在 "accessKey": plainAK,
// 编辑弹框里看到并「测试连接」,脱敏会导致编辑时字段为空无法保存/测试。 "secretKey": plainSK,
"accessKey": s.AccessKey,
"secretKey": s.SecretKey,
"pathPrefix": s.PathPrefix, "pathPrefix": s.PathPrefix,
"insecure": s.Insecure, "insecure": s.Insecure,
"isDefault": s.IsDefault, "isDefault": s.IsDefault,
"hasSecretKey": s.SecretKey != "", "hasSecretKey": plainSK != "",
"createdAt": s.CreatedAt, "createdAt": s.CreatedAt,
"updatedAt": s.UpdatedAt, "updatedAt": s.UpdatedAt,
} }
+78 -26
View File
@@ -9,6 +9,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strings" "strings"
"time" "time"
) )
@@ -139,33 +140,74 @@ func s3ErrBrief(body []byte) string {
// ==================== 测试连接 ==================== // ==================== 测试连接 ====================
// endpointHasBucket 判断 endpoint 是否已包含桶名(虚拟主机式)
func endpointHasBucket(endpoint, bucket string) bool {
host := strings.TrimSpace(endpoint)
if i := strings.Index(host, "://"); i >= 0 {
host = host[i+3:]
}
if i := strings.IndexByte(host, '/'); i >= 0 {
host = host[:i]
}
return bucket != "" && strings.HasPrefix(strings.ToLower(host), strings.ToLower(bucket)+".")
}
// bucketPathFor 根据 endpoint 风格决定桶路径(用于建桶 PUT)。
//
// 1) 区域端点(推荐): cos.ap-hongkong.myqcloud.com → 路径式 /<bucket>
// 2) 虚拟主机式: mybucket-1250000000.cos.<region>… → 路径为 /
func bucketPathFor(endpoint, bucket string) string {
if endpointHasBucket(endpoint, bucket) {
return "/"
}
return "/" + url.PathEscape(bucket)
}
// TestStorageConnection 逐级测试对象存储:可达性 → 凭证 → 桶存在性(可选自动创建)。 // TestStorageConnection 逐级测试对象存储:可达性 → 凭证 → 桶存在性(可选自动创建)。
// OSS 走其 S3 兼容端点;不覆盖 OSS 原生协议的独有能力。 //
// 探测统一用 GET /,再按 endpoint 形态解释响应(不用 HEAD /<bucket>):
// - 区域端点 → GET / 是 ListBuckets,解析 <Buckets><Bucket><Name>
// - 虚拟主机式端点 → GET / 是 ListObjects,返回 <ListBucketResult><Name>桶名</Name>
//
// 之所以不用 HEAD:腾讯云仅有 QcloudCOSDataFullControl(数据读写)的 AK
// 并没有 cos:HeadBucket 权限,HEAD 会 403,会被误判成"桶不存在/无权限"。
func TestStorageConnection(p StorageTestParams) StorageTestResult { func TestStorageConnection(p StorageTestParams) StorageTestResult {
// ① ListBuckets:同时验证 网络可达 + AK/SK 有效 vhost := endpointHasBucket(p.Endpoint, p.Bucket)
resp, err := s3Request("GET", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/", p.Insecure) resp, err := s3Request("GET", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/", p.Insecure)
if err != nil { if err != nil {
return StorageTestResult{Message: fmt.Sprintf("无法连接对象存储:%v", err)} return StorageTestResult{Message: fmt.Sprintf("无法连接对象存储:%v", err)}
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized { if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return StorageTestResult{ return StorageTestResult{
Reachable: true, Reachable: true,
Message: fmt.Sprintf("连接成功但认证失败(HTTP %d),请检查 Access Key / Secret Key。%s", resp.StatusCode, s3ErrBrief(body)), Message: fmt.Sprintf("连接成功但认证失败(HTTP %d),请检查 Access Key / Secret Key。%s",
resp.StatusCode, s3ErrBrief(body)),
} }
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return StorageTestResult{ return StorageTestResult{
Reachable: true, Reachable: true,
Message: fmt.Sprintf("对象存储返回异常(HTTP %d):%s", resp.StatusCode, s3ErrBrief(body)), Message: fmt.Sprintf("对象存储返回异常(HTTP %d):%s",
resp.StatusCode, s3ErrBrief(body)),
} }
} }
// 解析 ListBuckets,顺带确认目标桶是否在列表中 found := false
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if vhost {
// ListObjects:<ListBucketResult><Name>桶名</Name>… 能返回即说明桶存在且可读
if strings.Contains(string(body), "<ListBucketResult") {
if m := regexp.MustCompile(`<Name>([^<]*)</Name>`).FindSubmatch(body); len(m) > 1 {
found = strings.EqualFold(string(m[1]), p.Bucket)
} else {
found = true
}
}
} else {
// ListBuckets:<ListAllMyBucketsResult><Buckets><Bucket><Name>桶名</Name>
var listResp struct { var listResp struct {
Buckets struct { Buckets struct {
Bucket []struct { Bucket []struct {
@@ -174,13 +216,13 @@ func TestStorageConnection(p StorageTestParams) StorageTestResult {
} `xml:"Buckets"` } `xml:"Buckets"`
} }
_ = xml.Unmarshal(body, &listResp) _ = xml.Unmarshal(body, &listResp)
found := false
for _, b := range listResp.Buckets.Bucket { for _, b := range listResp.Buckets.Bucket {
if b.Name == p.Bucket { if b.Name == p.Bucket {
found = true found = true
break break
} }
} }
}
result := StorageTestResult{Reachable: true, Authenticated: true, BucketExists: found} result := StorageTestResult{Reachable: true, Authenticated: true, BucketExists: found}
@@ -195,7 +237,7 @@ func TestStorageConnection(p StorageTestParams) StorageTestResult {
return result return result
} }
putResp, err := s3Request("PUT", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/"+url.PathEscape(p.Bucket), p.Insecure) putResp, err := s3Request("PUT", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, bucketPathFor(p.Endpoint, p.Bucket), p.Insecure)
if err != nil { if err != nil {
result.Message = fmt.Sprintf("创建桶失败:%v", err) result.Message = fmt.Sprintf("创建桶失败:%v", err)
return result return result
@@ -204,46 +246,56 @@ func TestStorageConnection(p StorageTestParams) StorageTestResult {
// 200=创建成功;409 BucketAlreadyOwnedByYou 视为已存在 // 200=创建成功;409 BucketAlreadyOwnedByYou 视为已存在
if putResp.StatusCode == http.StatusOK || putResp.StatusCode == http.StatusConflict { if putResp.StatusCode == http.StatusOK || putResp.StatusCode == http.StatusConflict {
// 二次校验:不同对象存储的 CreateBucket 语义不一致。 // 二次校验:再用 GET / 确认桶真的存在(不用 HEAD —— 很多 AK 没有 cos:HeadBucket)
// 例如腾讯云 COS 要求桶名放在 Host(<bucket-appid>.cos.<region>.myqcloud.com)、
// 路径为 /,直接 PUT /<bucket> 可能返回 200 但并未真正建桶。
// 因此重建后再 ListBuckets 确认桶确实出现,避免前端看到"假的创建成功"。
verified := false verified := false
if vresp, verr := s3Request("GET", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/", p.Insecure); verr == nil { if vresp, verr := s3Request("GET", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/", p.Insecure); verr == nil {
vbody, _ := io.ReadAll(io.LimitReader(vresp.Body, 1<<20)) vbody, _ := io.ReadAll(io.LimitReader(vresp.Body, 1<<20))
vresp.Body.Close() vresp.Body.Close()
var vlist struct { if vhost {
verified = strings.Contains(string(vbody), "<ListBucketResult")
} else {
var vl struct {
Buckets struct { Buckets struct {
Bucket []struct { Bucket []struct {
Name string `xml:"Name"` Name string `xml:"Name"`
} `xml:"Bucket"` } `xml:"Bucket"`
} `xml:"Buckets"` } `xml:"Buckets"`
} }
_ = xml.Unmarshal(vbody, &vlist) _ = xml.Unmarshal(vbody, &vl)
for _, b := range vlist.Buckets.Bucket { for _, b := range vl.Buckets.Bucket {
if b.Name == p.Bucket { if b.Name == p.Bucket {
verified = true verified = true
break break
} }
} }
} }
if !verified {
result.BucketExists = false
result.BucketCreated = false
result.Message = fmt.Sprintf(
"凭证有效,但桶「%s」创建后校验失败:桶列表里找不到它。"+
"请确认 Endpoint / Region / 桶名是否正确"+
"(腾讯云 COS 桶名需带 APPID,例如 mybucket-1250000000),"+
"或先到云控制台手动创建该桶", p.Bucket)
return result
} }
if verified {
result.BucketExists = true result.BucketExists = true
result.BucketCreated = true result.BucketCreated = true
result.Message = fmt.Sprintf("连接成功,桶「%s」不存在,已自动创建并校验通过 ✓", p.Bucket) result.Message = fmt.Sprintf("连接成功,桶「%s」不存在,已自动创建并校验通过 ✓", p.Bucket)
return result return result
} }
result.Message = fmt.Sprintf(
"凭证有效,但桶「%s」创建后校验失败:重新校验仍未找到该桶。"+
"请确认 Endpoint / Region / 桶名是否正确"+
"(腾讯云 COS 桶名需带 APPID,例如 mybucket-1250000000)", p.Bucket)
return result
}
putBody, _ := io.ReadAll(io.LimitReader(putResp.Body, 512)) putBody, _ := io.ReadAll(io.LimitReader(putResp.Body, 512))
// 403:AK 通常只有数据读写权限,没有 CreateBucket 权限
if putResp.StatusCode == http.StatusForbidden || putResp.StatusCode == http.StatusUnauthorized {
result.Message = fmt.Sprintf(
"凭证有效,但当前 Access Key 无建桶权限(HTTP %d)。"+
"腾讯云 QcloudCOSDataFullControl 只授予数据读写/列出的权限,不含 CreateBucket。"+
"请到 COS 控制台手动创建桶「%s」(区域需与 Endpoint 一致),创建后重新测试;"+
"或在 CAM 为该 AK 追加 cos:PutBucket 权限",
putResp.StatusCode, p.Bucket)
return result
}
result.Message = fmt.Sprintf("凭证有效,但创建桶失败(HTTP %d):%s", putResp.StatusCode, s3ErrBrief(putBody)) result.Message = fmt.Sprintf("凭证有效,但创建桶失败(HTTP %d):%s", putResp.StatusCode, s3ErrBrief(putBody))
return result return result
} }