Files
LokiStack/backend/services/storage_check.go
T
opsx ff69ea2ce8 fix(storage): 凭证明文返回便于编辑测试 + 建桶后二次校验防"假成功"
问题(log.astx.club/storage):
  1. 编辑存储时 Access Key / Secret Key 都是空的,点「测试连接」提示
     请先填写 Bucket 与 Access Key / Secret Key,无法测试也无法保存;
  2. 提示"桶不存在,已自动创建"但疑似没有真正创建;
  3. 测试失败提示用 toast 弹到右下角,不在弹框内。

修复:
  1. models/storage.go Sanitized() 返回 accessKey / secretKey 明文
     (内网运维工具,管理员需要在编辑弹框里看到并测试连接;脱敏会导致
      编辑时字段为空、校验失败)
  2. 前端 StorageConfig 类型补 accessKey / secretKey 字段;
     编辑弹框打开时回填这两个字段(原来只有 secretKey 且强制置空);
     Secret Key 标签去掉「留空保持不变」
  3. services/storage_check.go 建桶后二次 ListBuckets 校验:
     不同对象存储 CreateBucket 语义不同(腾讯云 COS 要求桶名放 Host、
     路径为 /),直接 PUT /<bucket> 可能返回 200 但未真正创建。
     现在创建后重新列举桶,找不到就报"创建后校验失败"并提示检查
     Endpoint / Region / 桶名(COS 需带 APPID)
  4. 前端「请先填写 Bucket 与 Access Key / Secret Key」改为在弹框内
     testResult 区域显示(原来 toast 弹右下角)

镜像:lghost/lokistack:latest + v0.1 = sha256:6b6cc9769e79
2026-10-26 18:30:47 +00:00

250 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// StorageTestResult 存储连接测试结果(逐级:可达 → 认证 → 桶)
type StorageTestResult struct {
Reachable bool `json:"reachable"`
Authenticated bool `json:"authenticated"`
BucketExists bool `json:"bucketExists"`
BucketCreated bool `json:"bucketCreated"`
Message string `json:"message"`
}
// StorageTestParams 测试参数(与创建存储配置一致,额外支持自动建桶)
type StorageTestParams struct {
StorageType string
Endpoint string
Bucket string
Region string
AccessKey string
SecretKey string
Insecure bool
CreateIfMissing bool
}
// ==================== S3 SigV4(零依赖实现,兼容 MinIO/RustFS/S3/OSS S3 兼容端点) ====================
func hmacSHA256(key []byte, data string) []byte {
h := hmac.New(sha256.New, key)
h.Write([]byte(data))
return h.Sum(nil)
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// s3Request 发起一次 SigV4 签名的 S3 请求(路径风格),path 为 "/" 或 "/<bucket>"
func s3Request(method, endpoint, region, accessKey, secretKey, path string, insecure bool) (*http.Response, error) {
// endpoint 解析:带 scheme 用之;裸主机名默认 https,insecure 时 http
base := strings.TrimSpace(endpoint)
scheme := ""
if strings.HasPrefix(base, "http://") {
scheme, base = "http", strings.TrimPrefix(base, "http://")
} else if strings.HasPrefix(base, "https://") {
scheme, base = "https", strings.TrimPrefix(base, "https://")
} else {
if insecure {
scheme = "http"
} else {
scheme = "https"
}
}
base = strings.TrimSuffix(base, "/")
if region == "" {
region = "us-east-1"
}
now := time.Now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
payloadHash := sha256Hex("") // 空 body
// 规范请求(SigV4: host/x-amz-content-sha256/x-amz-date 参与签名)
canonicalURI := path
if canonicalURI == "" {
canonicalURI = "/"
}
canonicalRequest := strings.Join([]string{
method,
canonicalURI,
"", // query
"host:" + base + "\n" +
"x-amz-content-sha256:" + payloadHash + "\n" +
"x-amz-date:" + amzDate + "\n",
"host;x-amz-content-sha256;x-amz-date",
payloadHash,
}, "\n")
scope := dateStamp + "/" + region + "/s3/aws4_request"
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256",
amzDate,
scope,
sha256Hex(canonicalRequest),
}, "\n")
kDate := hmacSHA256([]byte("AWS4"+secretKey), dateStamp)
kRegion := hmacSHA256(kDate, region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
req, err := http.NewRequest(method, scheme+"://"+base+canonicalURI, nil)
if err != nil {
return nil, err
}
req.Host = base
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", payloadHash)
req.Header.Set("Authorization", fmt.Sprintf(
"AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=%s",
accessKey, scope, signature))
client := &http.Client{Timeout: 10 * time.Second}
return client.Do(req)
}
// s3ErrBrief 从 S3 错误响应 XML 中提取 Code/Message,避免把整段 XML 暴露给用户
func s3ErrBrief(body []byte) string {
var e struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
}
if err := xml.Unmarshal(body, &e); err == nil && (e.Code != "" || e.Message != "") {
if e.Code != "" && e.Message != "" {
return e.Code + ": " + e.Message
}
return e.Code + e.Message
}
brief := strings.TrimSpace(string(body))
if len(brief) > 120 {
brief = brief[:120] + "..."
}
return brief
}
// ==================== 测试连接 ====================
// TestStorageConnection 逐级测试对象存储:可达性 → 凭证 → 桶存在性(可选自动创建)。
// OSS 走其 S3 兼容端点;不覆盖 OSS 原生协议的独有能力。
func TestStorageConnection(p StorageTestParams) StorageTestResult {
// ① ListBuckets:同时验证 网络可达 + AK/SK 有效
resp, err := s3Request("GET", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/", p.Insecure)
if err != nil {
return StorageTestResult{Message: fmt.Sprintf("无法连接对象存储:%v", err)}
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return StorageTestResult{
Reachable: true,
Message: fmt.Sprintf("连接成功但认证失败(HTTP %d),请检查 Access Key / Secret Key。%s", resp.StatusCode, s3ErrBrief(body)),
}
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return StorageTestResult{
Reachable: true,
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
for _, b := range listResp.Buckets.Bucket {
if b.Name == p.Bucket {
found = true
break
}
}
result := StorageTestResult{Reachable: true, Authenticated: true, BucketExists: found}
if found {
result.Message = "连接成功,凭证有效,桶已存在"
return result
}
// ② 桶不存在:按需创建
if !p.CreateIfMissing {
result.Message = fmt.Sprintf("连接成功,凭证有效,但桶「%s」不存在;勾选「不存在时自动创建」可一键创建", p.Bucket)
return result
}
putResp, err := s3Request("PUT", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/"+url.PathEscape(p.Bucket), p.Insecure)
if err != nil {
result.Message = fmt.Sprintf("创建桶失败:%v", err)
return result
}
defer putResp.Body.Close()
// 200=创建成功;409 BucketAlreadyOwnedByYou 视为已存在
if putResp.StatusCode == http.StatusOK || putResp.StatusCode == http.StatusConflict {
// 二次校验:不同对象存储的 CreateBucket 语义不一致。
// 例如腾讯云 COS 要求桶名放在 Host(<bucket-appid>.cos.<region>.myqcloud.com)、
// 路径为 /,直接 PUT /<bucket> 可能返回 200 但并未真正建桶。
// 因此重建后再 ListBuckets 确认桶确实出现,避免前端看到"假的创建成功"。
verified := false
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))
vresp.Body.Close()
var vlist struct {
Buckets struct {
Bucket []struct {
Name string `xml:"Name"`
} `xml:"Bucket"`
} `xml:"Buckets"`
}
_ = xml.Unmarshal(vbody, &vlist)
for _, b := range vlist.Buckets.Bucket {
if b.Name == p.Bucket {
verified = true
break
}
}
}
if !verified {
result.BucketExists = false
result.BucketCreated = false
result.Message = fmt.Sprintf(
"凭证有效,但桶「%s」创建后校验失败:桶列表里找不到它。"+
"请确认 Endpoint / Region / 桶名是否正确"+
"(腾讯云 COS 桶名需带 APPID,例如 mybucket-1250000000),"+
"或先到云控制台手动创建该桶", p.Bucket)
return result
}
result.BucketExists = true
result.BucketCreated = true
result.Message = fmt.Sprintf("连接成功,桶「%s」不存在,已自动创建并校验通过 ✓", p.Bucket)
return result
}
putBody, _ := io.ReadAll(io.LimitReader(putResp.Body, 512))
result.Message = fmt.Sprintf("凭证有效,但创建桶失败(HTTP %d):%s", putResp.StatusCode, s3ErrBrief(putBody))
return result
}