refactor(provider): re-implement deployment provider of huaweicloud obs with custom sdk

This commit is contained in:
Fu Diwei
2026-06-22 16:32:18 +08:00
committed by RHQYZ
parent ec6a4aea8c
commit c3fc1866c7
6 changed files with 368 additions and 66 deletions
@@ -17,11 +17,12 @@ func init() {
}
provider, err := dplyimpl.NewDeployer(&dplyimpl.DeployerConfig{
AccessKeyId: credentials.AccessKeyId,
SecretAccessKey: credentials.SecretAccessKey,
Region: xmaps.GetString(options.ProviderExtendedConfig, "region"),
Bucket: xmaps.GetString(options.ProviderExtendedConfig, "bucket"),
Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"),
AccessKeyId: credentials.AccessKeyId,
SecretAccessKey: credentials.SecretAccessKey,
EnterpriseProjectId: credentials.EnterpriseProjectId,
Region: xmaps.GetString(options.ProviderExtendedConfig, "region"),
Bucket: xmaps.GetString(options.ProviderExtendedConfig, "bucket"),
Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"),
})
return provider, err
})
@@ -1,18 +1,13 @@
package huaweicloudobs
import (
"bytes"
"context"
"crypto/hmac"
"crypto/md5"
"crypto/sha1"
"encoding/base64"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/certimate-go/certimate/pkg/core"
cmgrimpl "github.com/certimate-go/certimate/pkg/core/certmgr/providers/huaweicloud-scm"
obssdk "github.com/certimate-go/certimate/pkg/sdk3rd/huaweicloud/obs"
)
type (
@@ -25,6 +20,8 @@ type DeployerConfig struct {
AccessKeyId string `json:"accessKeyId"`
// 华为云 SecretAccessKey。
SecretAccessKey string `json:"secretAccessKey"`
// 华为云企业项目 ID。
EnterpriseProjectId string `json:"enterpriseProjectId,omitempty"`
// 华为云区域。
Region string `json:"region"`
// 存储桶名。
@@ -34,8 +31,10 @@ type DeployerConfig struct {
}
type Deployer struct {
config *DeployerConfig
logger *slog.Logger
config *DeployerConfig
logger *slog.Logger
sdkClient *obssdk.Client
sdkCertmgr core.Certmgr
}
var _ Provider = (*Deployer)(nil)
@@ -45,9 +44,25 @@ func NewDeployer(config *DeployerConfig) (*Deployer, error) {
return nil, fmt.Errorf("the configuration of the deployer provider is nil")
}
client, err := createSDKClient(config.AccessKeyId, config.SecretAccessKey, config.Region, config.Bucket)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
pcertmgr, err := cmgrimpl.NewCertmgr(&cmgrimpl.CertmgrConfig{
AccessKeyId: config.AccessKeyId,
SecretAccessKey: config.SecretAccessKey,
EnterpriseProjectId: config.EnterpriseProjectId,
})
if err != nil {
return nil, fmt.Errorf("could not create certmgr: %w", err)
}
return &Deployer{
config: config,
logger: slog.Default(),
config: config,
logger: slog.Default(),
sdkClient: client,
sdkCertmgr: pcertmgr,
}, nil
}
@@ -57,6 +72,8 @@ func (d *Deployer) SetLogger(logger *slog.Logger) {
} else {
d.logger = logger
}
d.sdkCertmgr.SetLogger(logger)
}
func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*DeployResult, error) {
@@ -70,61 +87,42 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep
return nil, fmt.Errorf("config `domain` is required")
}
// 上传证书
upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to upload certificate file: %w", err)
} else {
d.logger.Info("ssl certificate uploaded", slog.Any("result", upres))
}
// REF: https://support.huaweicloud.com/usermanual-obs/obs_06_3200.html
// REF: https://support.huaweicloud.com/api-obs/obs_04_0059.html
url := fmt.Sprintf("https://%s.obs.%s.myhuaweicloud.com/?customdomain=%s", d.config.Bucket, d.config.Region, d.config.Domain)
bodyXML := fmt.Sprintf(`
<CustomDomainConfiguration>
<Name>%s</Name>
<Certificate>%s</Certificate>
<CertificateChain>%s</CertificateChain>
<PrivateKey>%s</PrivateKey>
</CustomDomainConfiguration>`,
d.config.Bucket+"_"+d.config.Domain, certPEM, certPEM, privkeyPEM,
)
// 计算 Content-MD5(Base64 编码)
md5sum := md5.Sum([]byte(bodyXML))
md5sumEncoded := base64.StdEncoding.EncodeToString(md5sum[:])
// 构造签名字符串
date := time.Now().UTC().Format(http.TimeFormat)
method := "PUT"
contentType := "application/xml"
canonicalizedResource := fmt.Sprintf("/%s/?customdomain=%s", d.config.Bucket, d.config.Domain)
stringToSign := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", method, md5sumEncoded, contentType, date, canonicalizedResource)
// HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(d.config.SecretAccessKey))
h.Write([]byte(stringToSign))
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
// Authorization
authHeader := fmt.Sprintf("OBS %s:%s", d.config.AccessKeyId, signature)
// 创建请求
req, err := http.NewRequest(method, url, bytes.NewBuffer([]byte(bodyXML)))
if err != nil {
return nil, fmt.Errorf("huaweicloud obs api error: %w", err)
putBucketCustomDomainReq := &obssdk.PutBucketCustomDomainRequest{
CustomDomain: d.config.Domain,
Name: upres.CertName,
CertificateId: upres.CertId,
Certificate: certPEM,
CertificateChain: certPEM,
PrivateKey: privkeyPEM,
}
req.Header.Set("Date", date)
req.Header.Set("Authorization", authHeader)
req.Header.Set("Content-MD5", md5sumEncoded)
req.Header.Set("Content-Type", contentType)
// 请求
resp, err := http.DefaultClient.Do(req)
putBucketCustomDomainResp, err := d.sdkClient.PutBucketCustomDomainWithContext(ctx, d.config.Bucket, putBucketCustomDomainReq)
d.logger.Debug("sdk request 'obs.PutBucketCustomDomain'", slog.String("params.bucket", d.config.Bucket), slog.String("params.customdomain", d.config.Domain), slog.Any("request", putBucketCustomDomainReq), slog.Any("response", putBucketCustomDomainResp))
if err != nil {
return nil, fmt.Errorf("huaweicloud obs api error: %w", err)
}
defer resp.Body.Close()
// 响应
if resp.StatusCode != http.StatusOK {
body := &bytes.Buffer{}
body.ReadFrom(resp.Body)
return nil, fmt.Errorf("huaweicloud obs api error: unexpected status code: %d (resp: %s)", resp.StatusCode, body.String())
return nil, fmt.Errorf("failed to execute sdk request 'obs.PutBucketCustomDomain': %w", err)
}
return &DeployResult{}, nil
}
func createSDKClient(accessKeyId, secretAccessKey, region, bucket string) (*obssdk.Client, error) {
client, err := obssdk.NewClient("",
obssdk.WithAkSk(accessKeyId, secretAccessKey),
obssdk.WithRegion(region),
obssdk.WithBucket(bucket),
)
if err != nil {
return nil, err
}
return client, nil
}
@@ -0,0 +1,50 @@
package obs
import (
"context"
"encoding/xml"
"fmt"
"net/http"
"net/url"
)
type PutBucketCustomDomainRequest struct {
XMLName xml.Name `json:"-" xml:"CustomDomainConfiguration"`
CustomDomain string `json:"-" xml:"-"`
Name string `json:"Name,omitempty" xml:"Name,omitempty"`
CertificateId string `json:"CertificateId,omitempty" xml:"CertificateId,omitempty"`
Certificate string `json:"Certificate,omitempty" xml:"Certificate,omitempty"`
CertificateChain string `json:"CertificateChain,omitempty" xml:"CertificateChain,omitempty"`
PrivateKey string `json:"PrivateKey,omitempty" xml:"PrivateKey,omitempty"`
}
type PutBucketCustomDomainResponse struct {
sdkResponseBase
}
func (c *Client) PutBucketCustomDomain(bucket string, req *PutBucketCustomDomainRequest) (*PutBucketCustomDomainResponse, error) {
return c.PutBucketCustomDomainWithContext(context.Background(), bucket, req)
}
func (c *Client) PutBucketCustomDomainWithContext(ctx context.Context, bucket string, req *PutBucketCustomDomainRequest) (*PutBucketCustomDomainResponse, error) {
if bucket == "" {
return nil, fmt.Errorf("sdkerr: bad request: unset bucket")
}
if req.CustomDomain == "" {
return nil, fmt.Errorf("sdkerr: bad request: unset customdomain")
}
httpreq, err := c.newRequest(http.MethodPut, "/?customdomain="+url.QueryEscape(req.CustomDomain), req)
if err != nil {
return nil, err
} else {
httpreq.SetContext(ctx)
}
result := &PutBucketCustomDomainResponse{}
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
return result, err
}
return result, nil
}
+217
View File
@@ -0,0 +1,217 @@
package obs
import (
"crypto/hmac"
"crypto/md5"
"crypto/sha1"
"encoding/base64"
"encoding/xml"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/certimate-go/certimate/internal/app"
)
type Client struct {
rc *resty.Client
}
func NewClient(endpoint string, optFns ...OptionsFunc) (*Client, error) {
opts := &Options{}
for _, fn := range optFns {
fn(opts)
}
if opts.Region == "" {
return nil, fmt.Errorf("sdkerr: unset region")
}
if opts.AccessKeyId == "" {
return nil, fmt.Errorf("sdkerr: unset accessKeyId")
}
if opts.SecretAccessKey == "" {
return nil, fmt.Errorf("sdkerr: unset secretAccessKey")
}
if endpoint == "" {
if opts.Bucket == "" {
endpoint = fmt.Sprintf("https://obs.%s.myhuaweicloud.com", url.PathEscape(opts.Region))
} else {
endpoint = fmt.Sprintf("https://%s.obs.%s.myhuaweicloud.com", url.PathEscape(opts.Bucket), url.PathEscape(opts.Region))
}
} else {
if baseUrl, err := url.Parse(endpoint); err != nil {
return nil, fmt.Errorf("sdkerr: invalid endpoint: %w", err)
} else if baseUrl.Scheme == "" {
endpoint = "https://" + endpoint
}
}
restyClient := resty.New().
SetBaseURL(endpoint).
SetHeader("User-Agent", app.AppUserAgent).
SetPreRequestHook(func(c *resty.Client, req *http.Request) error {
// API 签名机制:
// https://support.huaweicloud.com/api-obs/obs_04_0010.html
canonicalizedHeaders := ""
if len(req.Header) > 0 {
keys := make([]string, 0, len(req.Header))
for key := range req.Header {
key = strings.ToLower(key)
if strings.HasPrefix(key, "x-obs-") {
keys = append(keys, key)
}
}
sort.Strings(keys)
for _, key := range keys {
value := strings.TrimSpace(req.Header.Get(key))
canonicalizedHeaders += fmt.Sprintf("%s:%s", key, url.QueryEscape(value))
canonicalizedHeaders += "\n"
}
}
bucketName := opts.Bucket
objectName := strings.Trim(req.URL.Path, "/")
canonicalizedResource := fmt.Sprintf("/%s/%s", bucketName, objectName)
if bucketName == "" && objectName == "" {
canonicalizedResource = "/"
}
if len(req.URL.Query()) > 0 {
query := req.URL.Query()
keys := make([]string, 0, len(query))
for key := range query {
keys = append(keys, key)
}
sort.Strings(keys)
for i, key := range keys {
if i == 0 {
canonicalizedResource += "?"
} else {
canonicalizedResource += "&"
}
value := query.Get(key)
if value == "" {
canonicalizedResource += key
} else {
canonicalizedResource += fmt.Sprintf("%s=%s", strings.ToLower(key), url.QueryEscape(value))
}
}
}
method := strings.ToUpper(req.Method)
dateStr := time.Now().UTC().Format(http.TimeFormat)
contentMd5 := req.Header.Get("Content-MD5")
contentType := req.Header.Get("Content-Type")
stringToSign := fmt.Sprintf("%s\n%s\n%s\n%s\n%s%s", method, contentMd5, contentType, dateStr, canonicalizedHeaders, canonicalizedResource)
println("stringToSign:", stringToSign)
h := hmac.New(sha1.New, []byte(opts.SecretAccessKey))
h.Write([]byte(stringToSign))
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
req.Header.Set("Authorization", fmt.Sprintf("OBS %s:%s", opts.AccessKeyId, signature))
req.Header.Set("Date", dateStr)
return nil
})
return &Client{rc: restyClient}, nil
}
func (c *Client) SetTimeout(timeout time.Duration) *Client {
c.rc.SetTimeout(timeout)
return c
}
func (c *Client) newRequest(method string, path string, params any) (*resty.Request, error) {
if method == "" {
return nil, fmt.Errorf("sdkerr: unset method")
}
if path == "" {
return nil, fmt.Errorf("sdkerr: unset path")
}
requestUrl, err := url.Parse(path)
if err != nil {
return nil, fmt.Errorf("sdkerr: invalid path: %w", err)
} else if requestUrl.IsAbs() {
return nil, fmt.Errorf("sdkerr: path should be relative")
}
payloadStr := ""
contentType := ""
if params != nil {
payloadb, err := xml.Marshal(params)
if err != nil {
return nil, err
}
payloadStr = string(payloadb)
contentType = "application/xml"
}
payloadMd5 := md5.Sum([]byte(payloadStr))
payloadMd5Encoded := base64.StdEncoding.EncodeToString(payloadMd5[:])
req := c.rc.R()
req.Method = method
req.URL = requestUrl.Path
req.QueryParam = requestUrl.Query()
req.SetHeader("Content-MD5", payloadMd5Encoded)
req.SetHeader("Content-Type", contentType)
req.SetBody(payloadStr)
return req, nil
}
func (c *Client) doRequest(req *resty.Request) (*resty.Response, error) {
if req == nil {
return nil, fmt.Errorf("sdkerr: nil request")
}
// WARN:
// PLEASE DO NOT USE `req.SetBody` HERE! USE `newRequest` INSTEAD.
// PLEASE DO NOT USE `req.SetResult` or `req.SetError` HERE! USE `doRequestWithResult` INSTEAD.
resp, err := req.Send()
if err != nil {
return resp, fmt.Errorf("sdkerr: failed to send request: %w", err)
} else if resp.IsError() {
return resp, fmt.Errorf("sdkerr: unexpected status code: %d (resp: %s)", resp.StatusCode(), resp.String())
}
return resp, nil
}
func (c *Client) doRequestWithResult(req *resty.Request, res sdkResponse) (*resty.Response, error) {
if req == nil {
return nil, fmt.Errorf("sdkerr: nil request")
}
resp, err := c.doRequest(req)
if err != nil {
if resp != nil {
xml.Unmarshal(resp.Body(), &res)
}
return resp, err
}
if len(resp.Body()) != 0 {
if err := xml.Unmarshal(resp.Body(), &res); err != nil {
return resp, fmt.Errorf("sdkerr: failed to unmarshal response: %w (resp: %s)", err, resp.String())
}
}
return resp, nil
}
+29
View File
@@ -0,0 +1,29 @@
package obs
type Options struct {
AccessKeyId string
SecretAccessKey string
Region string
Bucket string
}
type OptionsFunc func(*Options)
func WithAkSk(ak, sk string) OptionsFunc {
return func(o *Options) {
o.AccessKeyId = ak
o.SecretAccessKey = sk
}
}
func WithRegion(region string) OptionsFunc {
return func(o *Options) {
o.Region = region
}
}
func WithBucket(bucket string) OptionsFunc {
return func(o *Options) {
o.Bucket = bucket
}
}
+7
View File
@@ -0,0 +1,7 @@
package obs
type sdkResponse interface{}
type sdkResponseBase struct{}
var _ sdkResponse = (*sdkResponseBase)(nil)