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:
1 parent
ff69ea2ce8
commit
cfe1ccd154
2 files changed
+115
-54
No files matched your search
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -139,46 +140,87 @@ 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,顺带确认目标桶是否在列表中
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
||||||
var listResp struct {
|
|
||||||
Buckets struct {
|
|
||||||
Bucket []struct {
|
|
||||||
Name string `xml:"Name"`
|
|
||||||
} `xml:"Bucket"`
|
|
||||||
} `xml:"Buckets"`
|
|
||||||
}
|
|
||||||
_ = xml.Unmarshal(body, &listResp)
|
|
||||||
found := false
|
found := false
|
||||||
for _, b := range listResp.Buckets.Bucket {
|
if vhost {
|
||||||
if b.Name == p.Bucket {
|
// ListObjects:<ListBucketResult><Name>桶名</Name>… 能返回即说明桶存在且可读
|
||||||
found = true
|
if strings.Contains(string(body), "<ListBucketResult") {
|
||||||
break
|
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 {
|
||||||
|
Buckets struct {
|
||||||
|
Bucket []struct {
|
||||||
|
Name string `xml:"Name"`
|
||||||
|
} `xml:"Bucket"`
|
||||||
|
} `xml:"Buckets"`
|
||||||
|
}
|
||||||
|
_ = xml.Unmarshal(body, &listResp)
|
||||||
|
for _, b := range listResp.Buckets.Bucket {
|
||||||
|
if b.Name == p.Bucket {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
||||||
Buckets struct {
|
verified = strings.Contains(string(vbody), "<ListBucketResult")
|
||||||
Bucket []struct {
|
} else {
|
||||||
Name string `xml:"Name"`
|
var vl struct {
|
||||||
} `xml:"Bucket"`
|
Buckets struct {
|
||||||
} `xml:"Buckets"`
|
Bucket []struct {
|
||||||
}
|
Name string `xml:"Name"`
|
||||||
_ = xml.Unmarshal(vbody, &vlist)
|
} `xml:"Bucket"`
|
||||||
for _, b := range vlist.Buckets.Bucket {
|
} `xml:"Buckets"`
|
||||||
if b.Name == p.Bucket {
|
}
|
||||||
verified = true
|
_ = xml.Unmarshal(vbody, &vl)
|
||||||
break
|
for _, b := range vl.Buckets.Bucket {
|
||||||
|
if b.Name == p.Bucket {
|
||||||
|
verified = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !verified {
|
if verified {
|
||||||
result.BucketExists = false
|
result.BucketExists = true
|
||||||
result.BucketCreated = false
|
result.BucketCreated = true
|
||||||
result.Message = fmt.Sprintf(
|
result.Message = fmt.Sprintf("连接成功,桶「%s」不存在,已自动创建并校验通过 ✓", p.Bucket)
|
||||||
"凭证有效,但桶「%s」创建后校验失败:桶列表里找不到它。"+
|
|
||||||
"请确认 Endpoint / Region / 桶名是否正确"+
|
|
||||||
"(腾讯云 COS 桶名需带 APPID,例如 mybucket-1250000000),"+
|
|
||||||
"或先到云控制台手动创建该桶", p.Bucket)
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
result.BucketExists = true
|
result.Message = fmt.Sprintf(
|
||||||
result.BucketCreated = true
|
"凭证有效,但桶「%s」创建后校验失败:重新校验仍未找到该桶。"+
|
||||||
result.Message = fmt.Sprintf("连接成功,桶「%s」不存在,已自动创建并校验通过 ✓", p.Bucket)
|
"请确认 Endpoint / Region / 桶名是否正确"+
|
||||||
|
"(腾讯云 COS 桶名需带 APPID,例如 mybucket-1250000000)", p.Bucket)
|
||||||
return result
|
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
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user