From 88e64717cd277107517d345b934162767efdfae6 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Sun, 20 Oct 2024 16:42:05 +0800 Subject: [PATCH] feat: support using scm service on deployment to huaweicloud cdn --- internal/deployer/huaweicloud_cdn.go | 107 +++++++--- internal/domain/domains.go | 42 ++++ .../pkg/core/uploader/impl/huaweicloud_scm.go | 189 ++++++++++++++++++ internal/pkg/core/uploader/uploader.go | 27 +++ internal/pkg/utils/x509/x509.go | 48 +++++ 5 files changed, 388 insertions(+), 25 deletions(-) create mode 100644 internal/pkg/core/uploader/impl/huaweicloud_scm.go create mode 100644 internal/pkg/core/uploader/uploader.go create mode 100644 internal/pkg/utils/x509/x509.go diff --git a/internal/deployer/huaweicloud_cdn.go b/internal/deployer/huaweicloud_cdn.go index 8b267aa0..65963578 100644 --- a/internal/deployer/huaweicloud_cdn.go +++ b/internal/deployer/huaweicloud_cdn.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/global" cdn "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/cdn/v2" @@ -11,7 +12,7 @@ import ( cdnRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/cdn/v2/region" "certimate/internal/domain" - "certimate/internal/utils/rand" + uploaderImpl "certimate/internal/pkg/core/uploader/impl" ) type HuaweiCloudCDNDeployer struct { @@ -45,11 +46,12 @@ func (d *HuaweiCloudCDNDeployer) Deploy(ctx context.Context) error { return err } - d.infos = append(d.infos, toStr("HuaweiCloudCdnClient 创建成功", nil)) + d.infos = append(d.infos, toStr("SDK 客户端创建成功", nil)) // 查询加速域名配置 + // REF: https://support.huaweicloud.com/api-cdn/ShowDomainFullConfig.html showDomainFullConfigReq := &cdnModel.ShowDomainFullConfigRequest{ - DomainName: getDeployString(d.option.DeployConfig, "domain"), + DomainName: d.option.DeployConfig.GetConfigAsString("domain"), } showDomainFullConfigResp, err := client.ShowDomainFullConfig(showDomainFullConfigReq) if err != nil { @@ -59,19 +61,45 @@ func (d *HuaweiCloudCDNDeployer) Deploy(ctx context.Context) error { d.infos = append(d.infos, toStr("已查询到加速域名配置", showDomainFullConfigResp)) // 更新加速域名配置 - certName := fmt.Sprintf("%s-%s", d.option.DomainId, rand.RandStr(12)) - updateDomainMultiCertificatesReq := &cdnModel.UpdateDomainMultiCertificatesRequest{ - Body: &cdnModel.UpdateDomainMultiCertificatesRequestBody{ - Https: mergeHuaweiCloudCDNConfig(showDomainFullConfigResp.Configs, &cdnModel.UpdateDomainMultiCertificatesRequestBodyContent{ - DomainName: getDeployString(d.option.DeployConfig, "domain"), - HttpsSwitch: 1, - CertName: &certName, - Certificate: &d.option.Certificate.Certificate, - PrivateKey: &d.option.Certificate.PrivateKey, - }), + // REF: https://support.huaweicloud.com/api-cdn/UpdateDomainMultiCertificates.html + updateDomainMultiCertificatesReqBodyContent := &huaweicloudCDNUpdateDomainMultiCertificatesRequestBodyContent{} + updateDomainMultiCertificatesReqBodyContent.DomainName = d.option.DeployConfig.GetConfigAsString("domain") + updateDomainMultiCertificatesReqBodyContent.HttpsSwitch = 1 + var updateDomainMultiCertificatesResp *cdnModel.UpdateDomainMultiCertificatesResponse + if d.option.DeployConfig.GetConfigAsBool("useSCM") { + uploader, err := uploaderImpl.NewHuaweiCloudSCMUploader(&uploaderImpl.HuaweiCloudSCMUploaderConfig{ + Region: "", // TODO: SCM 服务与 CDN 服务的区域不一致,这里暂时不传而是使用默认值,仅支持华为云国内版 + AccessKeyId: access.AccessKeyId, + SecretAccessKey: access.SecretAccessKey, + }) + if err != nil { + return err + } + + // 上传证书到 SCM + uploadResult, err := uploader.Upload(ctx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey) + if err != nil { + return err + } + + d.infos = append(d.infos, toStr("已上传证书", uploadResult)) + + updateDomainMultiCertificatesReqBodyContent.CertificateType = int32Ptr(2) + updateDomainMultiCertificatesReqBodyContent.SCMCertificateId = stringPtr(uploadResult.CertId) + updateDomainMultiCertificatesReqBodyContent.CertName = stringPtr(uploadResult.CertName) + } else { + updateDomainMultiCertificatesReqBodyContent.CertificateType = int32Ptr(0) + updateDomainMultiCertificatesReqBodyContent.CertName = stringPtr(fmt.Sprintf("certimate-%d", time.Now().UnixMilli())) + updateDomainMultiCertificatesReqBodyContent.Certificate = stringPtr(d.option.Certificate.Certificate) + updateDomainMultiCertificatesReqBodyContent.PrivateKey = stringPtr(d.option.Certificate.PrivateKey) + } + updateDomainMultiCertificatesReqBodyContent = mergeHuaweiCloudCDNConfig(showDomainFullConfigResp.Configs, updateDomainMultiCertificatesReqBodyContent) + updateDomainMultiCertificatesReq := &huaweicloudCDNUpdateDomainMultiCertificatesRequest{ + Body: &huaweicloudCDNUpdateDomainMultiCertificatesRequestBody{ + Https: updateDomainMultiCertificatesReqBodyContent, }, } - updateDomainMultiCertificatesResp, err := client.UpdateDomainMultiCertificates(updateDomainMultiCertificatesReq) + updateDomainMultiCertificatesResp, err = executeHuaweiCloudCDNUploadDomainMultiCertificates(client, updateDomainMultiCertificatesReq) if err != nil { return err } @@ -107,25 +135,47 @@ func (d *HuaweiCloudCDNDeployer) createClient(access *domain.HuaweiCloudAccess) return client, nil } -func mergeHuaweiCloudCDNConfig(src *cdnModel.ConfigsGetBody, dest *cdnModel.UpdateDomainMultiCertificatesRequestBodyContent) *cdnModel.UpdateDomainMultiCertificatesRequestBodyContent { +type huaweicloudCDNUpdateDomainMultiCertificatesRequestBodyContent struct { + cdnModel.UpdateDomainMultiCertificatesRequestBodyContent `json:",inline"` + + SCMCertificateId *string `json:"scm_certificate_id,omitempty"` +} + +type huaweicloudCDNUpdateDomainMultiCertificatesRequestBody struct { + Https *huaweicloudCDNUpdateDomainMultiCertificatesRequestBodyContent `json:"https,omitempty"` +} + +type huaweicloudCDNUpdateDomainMultiCertificatesRequest struct { + Body *huaweicloudCDNUpdateDomainMultiCertificatesRequestBody `json:"body,omitempty"` +} + +func executeHuaweiCloudCDNUploadDomainMultiCertificates(client *cdn.CdnClient, request *huaweicloudCDNUpdateDomainMultiCertificatesRequest) (*cdnModel.UpdateDomainMultiCertificatesResponse, error) { + // 华为云官方 SDK 中目前提供的字段缺失,这里暂时先需自定义请求 + // 可能需要等之后 SDK 更新 + + requestDef := cdn.GenReqDefForUpdateDomainMultiCertificates() + + if resp, err := client.HcClient.Sync(request, requestDef); err != nil { + return nil, err + } else { + return resp.(*cdnModel.UpdateDomainMultiCertificatesResponse), nil + } +} + +func mergeHuaweiCloudCDNConfig(src *cdnModel.ConfigsGetBody, dest *huaweicloudCDNUpdateDomainMultiCertificatesRequestBodyContent) *huaweicloudCDNUpdateDomainMultiCertificatesRequestBodyContent { if src == nil { return dest } // 华为云 API 中不传的字段表示使用默认值、而非保留原值,因此这里需要把原配置中的参数重新赋值回去 // 而且蛋疼的是查询接口返回的数据结构和更新接口传入的参数结构不一致,需要做很多转化 - // REF: https://support.huaweicloud.com/api-cdn/ShowDomainFullConfig.html - // REF: https://support.huaweicloud.com/api-cdn/UpdateDomainMultiCertificates.html if *src.OriginProtocol == "follow" { - accessOriginWay := int32(1) - dest.AccessOriginWay = &accessOriginWay + dest.AccessOriginWay = int32Ptr(1) } else if *src.OriginProtocol == "http" { - accessOriginWay := int32(2) - dest.AccessOriginWay = &accessOriginWay + dest.AccessOriginWay = int32Ptr(2) } else if *src.OriginProtocol == "https" { - accessOriginWay := int32(3) - dest.AccessOriginWay = &accessOriginWay + dest.AccessOriginWay = int32Ptr(3) } if src.ForceRedirect != nil { @@ -141,10 +191,17 @@ func mergeHuaweiCloudCDNConfig(src *cdnModel.ConfigsGetBody, dest *cdnModel.Upda if src.Https != nil { if *src.Https.Http2Status == "on" { - http2 := int32(1) - dest.Http2 = &http2 + dest.Http2 = int32Ptr(1) } } return dest } + +func int32Ptr(i int32) *int32 { + return &i +} + +func stringPtr(s string) *string { + return &s +} diff --git a/internal/domain/domains.go b/internal/domain/domains.go index 97fa1d7d..c19aebc3 100644 --- a/internal/domain/domains.go +++ b/internal/domain/domains.go @@ -15,6 +15,48 @@ type DeployConfig struct { Config map[string]any `json:"config"` } +// 以字符串形式获取配置项。 +// +// 入参: +// - key: 配置项的键。 +// +// 出参: +// - 配置项的值。如果配置项不存在或者类型不是字符串,则返回空字符串。 +func (dc *DeployConfig) GetConfigAsString(key string) string { + if dc.Config == nil { + return "" + } + + if value, ok := dc.Config[key]; ok { + if result, ok := value.(string); ok { + return result + } + } + + return "" +} + +// 以布尔形式获取配置项。 +// +// 入参: +// - key: 配置项的键。 +// +// 出参: +// - 配置项的值。如果配置项不存在或者类型不是布尔,则返回 false。 +func (dc *DeployConfig) GetConfigAsBool(key string) bool { + if dc.Config == nil { + return false + } + + if value, ok := dc.Config[key]; ok { + if result, ok := value.(bool); ok { + return result + } + } + + return false +} + type KV struct { Key string `json:"key"` Value string `json:"value"` diff --git a/internal/pkg/core/uploader/impl/huaweicloud_scm.go b/internal/pkg/core/uploader/impl/huaweicloud_scm.go new file mode 100644 index 00000000..09267024 --- /dev/null +++ b/internal/pkg/core/uploader/impl/huaweicloud_scm.go @@ -0,0 +1,189 @@ +package impl + +import ( + "context" + "fmt" + "time" + + "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/basic" + scm "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/scm/v3" + scmModel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/scm/v3/model" + scmRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/scm/v3/region" + + "certimate/internal/pkg/core/uploader" + "certimate/internal/pkg/utils/x509" +) + +type HuaweiCloudSCMUploaderConfig struct { + Region string `json:"region"` + AccessKeyId string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` +} + +type HuaweiCloudSCMUploader struct { + client *scm.ScmClient +} + +func NewHuaweiCloudSCMUploader(config *HuaweiCloudSCMUploaderConfig) (*HuaweiCloudSCMUploader, error) { + client, err := createClient(config.Region, config.AccessKeyId, config.SecretAccessKey) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + return &HuaweiCloudSCMUploader{ + client: client, + }, nil +} + +func (u *HuaweiCloudSCMUploader) Upload(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) { + // 解析证书内容 + newCert, err := x509.ParseCertificateFromPEM(certPem) + if err != nil { + return nil, err + } + + // 遍历查询已有证书,避免重复上传 + // REF: https://support.huaweicloud.com/api-ccm/ListCertificates.html + // REF: https://support.huaweicloud.com/api-ccm/ExportCertificate_0.html + listCertificatesLimit := int32(50) + listCertificatesOffset := int32(0) + for { + listCertificatesReq := &scmModel.ListCertificatesRequest{ +<<<<<<< HEAD + Limit: int32Ptr(listCertificatesLimit), + Offset: int32Ptr(listCertificatesOffset), + SortDir: stringPtr("DESC"), + SortKey: stringPtr("certExpiredTime"), +======= + Limit: int32Ptr(listCertificatesLimit), + Offset: int32Ptr(listCertificatesOffset), +>>>>>>> 1ff10bf989afaa505a3fc2fda668ffcded815d09 + } + listCertificatesResp, err := u.client.ListCertificates(listCertificatesReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request 'scm.ListCertificates': %w", err) + } + + if listCertificatesResp.Certificates != nil { + for _, certDetail := range *listCertificatesResp.Certificates { + exportCertificateReq := &scmModel.ExportCertificateRequest{ + CertificateId: certDetail.Id, + } + exportCertificateResp, err := u.client.ExportCertificate(exportCertificateReq) + if err != nil { + if exportCertificateResp != nil && exportCertificateResp.HttpStatusCode == 404 { + continue + } + return nil, fmt.Errorf("failed to execute request 'scm.ExportCertificate': %w", err) + } + +<<<<<<< HEAD + var isSameCert bool + if *exportCertificateResp.Certificate == certPem { + isSameCert = true + } else { + cert, err := x509.ParseCertificateFromPEM(*exportCertificateResp.Certificate) + if err != nil { + continue + } + + isSameCert = x509.EqualCertificate(cert, newCert) + } + + // 如果已存在相同证书,直接返回已有的证书信息 + if isSameCert { +======= + cert, err := x509.ParseCertificateFromPEM(*exportCertificateResp.Certificate) + if err != nil { + continue + } + + if x509.EqualCertificate(cert, newCert) { + // 如果已存在相同证书,直接返回已有的证书信息 +>>>>>>> 1ff10bf989afaa505a3fc2fda668ffcded815d09 + return &uploader.UploadResult{ + CertId: certDetail.Id, + CertName: certDetail.Name, + }, nil + } + } + } + + if listCertificatesResp.Certificates == nil || len(*listCertificatesResp.Certificates) < int(listCertificatesLimit) { + break + } + + listCertificatesOffset += listCertificatesLimit +<<<<<<< HEAD + if listCertificatesOffset >= 999 { // 避免无限获取 + break + } +======= +>>>>>>> 1ff10bf989afaa505a3fc2fda668ffcded815d09 + } + + // 生成证书名(需符合华为云命名规则) + var certId, certName string + certName = fmt.Sprintf("certimate-%d", time.Now().UnixMilli()) + + // 上传新证书 + // REF: https://support.huaweicloud.com/api-ccm/ImportCertificate.html + importCertificateReq := &scmModel.ImportCertificateRequest{ + Body: &scmModel.ImportCertificateRequestBody{ + Name: certName, + Certificate: certPem, + PrivateKey: privkeyPem, + }, + } + importCertificateResp, err := u.client.ImportCertificate(importCertificateReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request 'scm.ImportCertificate': %w", err) + } + + certId = *importCertificateResp.CertificateId + return &uploader.UploadResult{ + CertId: certId, + CertName: certName, + }, nil +} + +func createClient(region, accessKeyId, secretAccessKey string) (*scm.ScmClient, error) { + auth, err := basic.NewCredentialsBuilder(). + WithAk(accessKeyId). + WithSk(secretAccessKey). + SafeBuild() + if err != nil { + return nil, err + } + + if region == "" { + region = "cn-north-4" // SCM 服务默认区域:华北北京四 + } + + hcRegion, err := scmRegion.SafeValueOf(region) + if err != nil { + return nil, err + } + + hcClient, err := scm.ScmClientBuilder(). + WithRegion(hcRegion). + WithCredential(auth). + SafeBuild() + if err != nil { + return nil, err + } + + client := scm.NewScmClient(hcClient) + return client, nil +} + +func int32Ptr(i int32) *int32 { + return &i +} +<<<<<<< HEAD + +func stringPtr(s string) *string { + return &s +} +======= +>>>>>>> 1ff10bf989afaa505a3fc2fda668ffcded815d09 diff --git a/internal/pkg/core/uploader/uploader.go b/internal/pkg/core/uploader/uploader.go new file mode 100644 index 00000000..ccabeaef --- /dev/null +++ b/internal/pkg/core/uploader/uploader.go @@ -0,0 +1,27 @@ +package uploader + +import "context" + +// 表示定义证书上传者的抽象类型接口。 +// 云服务商通常会提供 SSL 证书管理服务,可供用户集中管理证书。 +// 注意与 `Deployer` 区分,“上传”通常为“部署”的前置操作。 +type Uploader interface { + // 上传证书。 + // + // 入参: + // - ctx: + // - certPem:证书 PEM 内容 + // - privkeyPem:私钥 PEM 内容 + // + // 出参: + // - res: + // - err: + Upload(ctx context.Context, certPem string, privkeyPem string) (res *UploadResult, err error) +} + +// 表示证书上传结果的数据结构,包含上传后的证书 ID、名称和其他数据。 +type UploadResult struct { + CertId string `json:"certId"` + CertName string `json:"certName"` + CertData map[string]interface{} `json:"certData,omitempty"` +} diff --git a/internal/pkg/utils/x509/x509.go b/internal/pkg/utils/x509/x509.go new file mode 100644 index 00000000..ca467478 --- /dev/null +++ b/internal/pkg/utils/x509/x509.go @@ -0,0 +1,48 @@ +package x509 + +import ( + "crypto/x509" + "encoding/pem" + "fmt" +) + +// 从 PEM 编码的证书字符串解析并返回一个 x509.Certificate 对象。 +// +// 入参: +// - certPem: 证书 PEM 内容。 +// +// 出参: +// - cert: +// - err: +func ParseCertificateFromPEM(certPem string) (cert *x509.Certificate, err error) { + pemData := []byte(certPem) + + block, _ := pem.Decode(pemData) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + cert, err = x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + + return cert, nil +} + +// 比较两个 x509.Certificate 对象,判断它们是否是同一张证书。 +// 注意,这不是精确比较,而只是基于证书序列号和数字签名的快速判断,但对于权威 CA 签发的证书来说不会存在误判。 +// +// 入参: +// - a: 待比较的第一个 x509.Certificate 对象。 +// - b: 待比较的第二个 x509.Certificate 对象。 +// +// 出参: +// - 是否相同。 +func EqualCertificate(a, b *x509.Certificate) bool { + return string(a.Signature) == string(b.Signature) && + a.SignatureAlgorithm == b.SignatureAlgorithm && + a.SerialNumber.String() == b.SerialNumber.String() && + a.Issuer.SerialNumber == b.Issuer.SerialNumber && + a.Subject.SerialNumber == b.Subject.SerialNumber +}