diff --git a/internal/certmgmt/deployers/sp_huaweicloud_obs.go b/internal/certmgmt/deployers/sp_huaweicloud_obs.go index 04358f5e1..10eb04f6e 100644 --- a/internal/certmgmt/deployers/sp_huaweicloud_obs.go +++ b/internal/certmgmt/deployers/sp_huaweicloud_obs.go @@ -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 }) diff --git a/pkg/core/deployer/providers/huaweicloud-obs/huaweicloud_obs.go b/pkg/core/deployer/providers/huaweicloud-obs/huaweicloud_obs.go index c22c9ac72..ff6b3c717 100644 --- a/pkg/core/deployer/providers/huaweicloud-obs/huaweicloud_obs.go +++ b/pkg/core/deployer/providers/huaweicloud-obs/huaweicloud_obs.go @@ -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(` - - %s - %s - %s - %s -`, - 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 +} diff --git a/pkg/sdk3rd/huaweicloud/obs/api_put_bucket_customdomain.go b/pkg/sdk3rd/huaweicloud/obs/api_put_bucket_customdomain.go new file mode 100644 index 000000000..5cc2d39d9 --- /dev/null +++ b/pkg/sdk3rd/huaweicloud/obs/api_put_bucket_customdomain.go @@ -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 +} diff --git a/pkg/sdk3rd/huaweicloud/obs/client.go b/pkg/sdk3rd/huaweicloud/obs/client.go new file mode 100644 index 000000000..ffcb469d4 --- /dev/null +++ b/pkg/sdk3rd/huaweicloud/obs/client.go @@ -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 +} diff --git a/pkg/sdk3rd/huaweicloud/obs/options.go b/pkg/sdk3rd/huaweicloud/obs/options.go new file mode 100644 index 000000000..b6ec50a3a --- /dev/null +++ b/pkg/sdk3rd/huaweicloud/obs/options.go @@ -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 + } +} diff --git a/pkg/sdk3rd/huaweicloud/obs/types.go b/pkg/sdk3rd/huaweicloud/obs/types.go new file mode 100644 index 000000000..6704c97e4 --- /dev/null +++ b/pkg/sdk3rd/huaweicloud/obs/types.go @@ -0,0 +1,7 @@ +package obs + +type sdkResponse interface{} + +type sdkResponseBase struct{} + +var _ sdkResponse = (*sdkResponseBase)(nil)