diff --git a/internal/applicant/volcengine.go b/internal/applicant/volcengine.go index 93f88a99..3ab91741 100644 --- a/internal/applicant/volcengine.go +++ b/internal/applicant/volcengine.go @@ -20,10 +20,10 @@ func NewVolcengine(option *ApplyOption) Applicant { } func (a *volcengine) Apply() (*Certificate, error) { - access := &domain.VolcengineAccess{} + access := &domain.VolcEngineAccess{} json.Unmarshal([]byte(a.option.Access), access) - os.Setenv("VOLC_ACCESSKEY", access.AccessKeyID) + os.Setenv("VOLC_ACCESSKEY", access.AccessKeyId) os.Setenv("VOLC_SECRETKEY", access.SecretAccessKey) os.Setenv("VOLC_PROPAGATION_TIMEOUT", fmt.Sprintf("%d", a.option.Timeout)) dnsProvider, err := volcengineDns.NewDNSProvider() diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 74fbfac2..11fd6a9e 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -1,21 +1,15 @@ package deployer import ( - "bytes" "context" "encoding/json" - "encoding/pem" "errors" "fmt" - "time" - "github.com/pavlo-v-chernykh/keystore-go/v4" "github.com/pocketbase/pocketbase/models" - "software.sslmate.com/src/go-pkcs12" "github.com/usual2970/certimate/internal/applicant" "github.com/usual2970/certimate/internal/domain" - "github.com/usual2970/certimate/internal/pkg/utils/x509" "github.com/usual2970/certimate/internal/repository" ) @@ -34,15 +28,15 @@ const ( targetHuaweiCloudCDN = "huaweicloud-cdn" targetHuaweiCloudELB = "huaweicloud-elb" targetBaiduCloudCDN = "baiducloud-cdn" + targetVolcEngineLive = "volcengine-live" + targetVolcEngineCDN = "volcengine-cdn" + targetBytePlusCDN = "byteplus-cdn" targetQiniuCdn = "qiniu-cdn" targetDogeCloudCdn = "dogecloud-cdn" targetLocal = "local" targetSSH = "ssh" targetWebhook = "webhook" targetK8sSecret = "k8s-secret" - targetVolcengineLive = "volcengine-live" - targetVolcengineCDN = "volcengine-cdn" - targetByteplusCDN = "byteplus-cdn" ) type DeployerOption struct { @@ -162,11 +156,11 @@ func getWithTypeAndOption(deployType string, option *DeployerOption) (Deployer, return NewWebhookDeployer(option) case targetK8sSecret: return NewK8sSecretDeployer(option) - case targetVolcengineLive: + case targetVolcEngineLive: return NewVolcengineLiveDeployer(option) - case targetVolcengineCDN: + case targetVolcEngineCDN: return NewVolcengineCDNDeployer(option) - case targetByteplusCDN: + case targetBytePlusCDN: return NewByteplusCDNDeployer(option) } return nil, errors.New("unsupported deploy target") @@ -179,57 +173,3 @@ func toStr(tag string, data any) string { byts, _ := json.Marshal(data) return tag + ":" + string(byts) } - -func convertPEMToPFX(certificate string, privateKey string, password string) ([]byte, error) { - cert, err := x509.ParseCertificateFromPEM(certificate) - if err != nil { - return nil, err - } - - privkey, err := x509.ParsePKCS1PrivateKeyFromPEM(privateKey) - if err != nil { - return nil, err - } - - pfxData, err := pkcs12.LegacyRC2.Encode(privkey, cert, nil, password) - if err != nil { - return nil, err - } - - return pfxData, nil -} - -func convertPEMToJKS(certificate string, privateKey string, alias string, keypass string, storepass string) ([]byte, error) { - certBlock, _ := pem.Decode([]byte(certificate)) - if certBlock == nil { - return nil, errors.New("failed to decode certificate PEM") - } - - privkeyBlock, _ := pem.Decode([]byte(privateKey)) - if privkeyBlock == nil { - return nil, errors.New("failed to decode private key PEM") - } - - ks := keystore.New() - entry := keystore.PrivateKeyEntry{ - CreationTime: time.Now(), - PrivateKey: privkeyBlock.Bytes, - CertificateChain: []keystore.Certificate{ - { - Type: "X509", - Content: certBlock.Bytes, - }, - }, - } - - if err := ks.SetPrivateKeyEntry(alias, entry, []byte(keypass)); err != nil { - return nil, err - } - - var buf bytes.Buffer - if err := ks.Store(&buf, []byte(storepass)); err != nil { - return nil, err - } - - return buf.Bytes(), nil -} diff --git a/internal/deployer/factory.go b/internal/deployer/factory.go new file mode 100644 index 00000000..fee6f628 --- /dev/null +++ b/internal/deployer/factory.go @@ -0,0 +1,374 @@ +package deployer + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/core/deployer" + providerAliyunAlb "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-alb" + providerAliyunCdn "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-cdn" + providerAliyunClb "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-clb" + providerAliyunDcdn "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-dcdn" + providerAliyunNlb "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-nlb" + providerAliyunOss "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-oss" + providerBaiduCloudCdn "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/baiducloud-cdn" + providerBytePlusCdn "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/byteplus-cdn" + providerDogeCdn "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/dogecloud-cdn" + providerHuaweiCloudCdn "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/huaweicloud-cdn" + providerHuaweiCloudElb "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/huaweicloud-elb" + providerK8sSecret "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/k8s-secret" + providerLocal "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/local" + providerQiniuCdn "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/qiniu-cdn" + providerSSH "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/ssh" + providerTencentCloudCdn "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-cdn" + providerTencentCloudClb "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-clb" + providerTencentCloudCos "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-cos" + providerTencentCloudEcdn "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-ecdn" + providerTencentCloudTeo "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-teo" + providerVolcEngineCdn "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/volcengine-cdn" + providerVolcEngineLive "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/volcengine-live" + providerWebhook "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/webhook" + "github.com/usual2970/certimate/internal/pkg/utils/maps" +) + +// TODO: 该方法目前未实际使用,将在后续迭代中替换 +func createDeployer(target string, accessConfig string, deployConfig map[string]any) (deployer.Deployer, deployer.Logger, error) { + logger := deployer.NewDefaultLogger() + + switch target { + case targetAliyunALB, targetAliyunCDN, targetAliyunCLB, targetAliyunDCDN, targetAliyunNLB, targetAliyunOSS: + { + access := &domain.AliyunAccess{} + if err := json.Unmarshal([]byte(accessConfig), access); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal access config: %w", err) + } + + switch target { + case targetAliyunALB: + deployer, err := providerAliyunAlb.NewWithLogger(&providerAliyunAlb.AliyunALBDeployerConfig{ + AccessKeyId: access.AccessKeyId, + AccessKeySecret: access.AccessKeySecret, + Region: maps.GetValueAsString(deployConfig, "region"), + ResourceType: providerAliyunAlb.DeployResourceType(maps.GetValueAsString(deployConfig, "resourceType")), + LoadbalancerId: maps.GetValueAsString(deployConfig, "loadbalancerId"), + ListenerId: maps.GetValueAsString(deployConfig, "listenerId"), + }, logger) + return deployer, logger, err + + case targetAliyunCDN: + deployer, err := providerAliyunCdn.NewWithLogger(&providerAliyunCdn.AliyunCDNDeployerConfig{ + AccessKeyId: access.AccessKeyId, + AccessKeySecret: access.AccessKeySecret, + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + + case targetAliyunCLB: + deployer, err := providerAliyunClb.NewWithLogger(&providerAliyunClb.AliyunCLBDeployerConfig{ + AccessKeyId: access.AccessKeyId, + AccessKeySecret: access.AccessKeySecret, + Region: maps.GetValueAsString(deployConfig, "region"), + ResourceType: providerAliyunClb.DeployResourceType(maps.GetValueAsString(deployConfig, "resourceType")), + LoadbalancerId: maps.GetValueAsString(deployConfig, "loadbalancerId"), + ListenerPort: maps.GetValueAsInt32(deployConfig, "listenerPort"), + }, logger) + return deployer, logger, err + + case targetAliyunDCDN: + deployer, err := providerAliyunDcdn.NewWithLogger(&providerAliyunDcdn.AliyunDCDNDeployerConfig{ + AccessKeyId: access.AccessKeyId, + AccessKeySecret: access.AccessKeySecret, + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + + case targetAliyunNLB: + deployer, err := providerAliyunNlb.NewWithLogger(&providerAliyunNlb.AliyunNLBDeployerConfig{ + AccessKeyId: access.AccessKeyId, + AccessKeySecret: access.AccessKeySecret, + Region: maps.GetValueAsString(deployConfig, "region"), + ResourceType: providerAliyunNlb.DeployResourceType(maps.GetValueAsString(deployConfig, "resourceType")), + LoadbalancerId: maps.GetValueAsString(deployConfig, "loadbalancerId"), + ListenerId: maps.GetValueAsString(deployConfig, "listenerId"), + }, logger) + return deployer, logger, err + + case targetAliyunOSS: + deployer, err := providerAliyunOss.NewWithLogger(&providerAliyunOss.AliyunOSSDeployerConfig{ + AccessKeyId: access.AccessKeyId, + AccessKeySecret: access.AccessKeySecret, + Region: maps.GetValueAsString(deployConfig, "region"), + Bucket: maps.GetValueAsString(deployConfig, "bucket"), + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + + default: + break + } + } + + case targetBaiduCloudCDN: + { + access := &domain.BaiduCloudAccess{} + if err := json.Unmarshal([]byte(accessConfig), access); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal access config: %w", err) + } + + deployer, err := providerBaiduCloudCdn.NewWithLogger(&providerBaiduCloudCdn.BaiduCloudCDNDeployerConfig{ + AccessKeyId: access.AccessKeyId, + SecretAccessKey: access.SecretAccessKey, + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + } + + case targetBytePlusCDN: + { + access := &domain.ByteplusAccess{} + if err := json.Unmarshal([]byte(accessConfig), access); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal access config: %w", err) + } + + deployer, err := providerBytePlusCdn.NewWithLogger(&providerBytePlusCdn.BytePlusCDNDeployerConfig{ + AccessKey: access.AccessKey, + SecretKey: access.SecretKey, + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + } + + case targetDogeCloudCdn: + { + access := &domain.DogeCloudAccess{} + if err := json.Unmarshal([]byte(accessConfig), access); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal access config: %w", err) + } + + deployer, err := providerDogeCdn.NewWithLogger(&providerDogeCdn.DogeCloudCDNDeployerConfig{ + AccessKey: access.AccessKey, + SecretKey: access.SecretKey, + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + } + + case targetHuaweiCloudCDN, targetHuaweiCloudELB: + { + access := &domain.HuaweiCloudAccess{} + if err := json.Unmarshal([]byte(accessConfig), access); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal access config: %w", err) + } + + switch target { + case targetHuaweiCloudCDN: + deployer, err := providerHuaweiCloudCdn.NewWithLogger(&providerHuaweiCloudCdn.HuaweiCloudCDNDeployerConfig{ + AccessKeyId: access.AccessKeyId, + SecretAccessKey: access.SecretAccessKey, + Region: maps.GetValueAsString(deployConfig, "region"), + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + + case targetHuaweiCloudELB: + deployer, err := providerHuaweiCloudElb.NewWithLogger(&providerHuaweiCloudElb.HuaweiCloudELBDeployerConfig{ + AccessKeyId: access.AccessKeyId, + SecretAccessKey: access.SecretAccessKey, + Region: maps.GetValueAsString(deployConfig, "region"), + ResourceType: providerHuaweiCloudElb.DeployResourceType(maps.GetValueAsString(deployConfig, "resourceType")), + CertificateId: maps.GetValueAsString(deployConfig, "certificateId"), + LoadbalancerId: maps.GetValueAsString(deployConfig, "loadbalancerId"), + ListenerId: maps.GetValueAsString(deployConfig, "listenerId"), + }, logger) + return deployer, logger, err + + default: + break + } + } + + case targetLocal: + { + deployer, err := providerLocal.NewWithLogger(&providerLocal.LocalDeployerConfig{ + ShellEnv: providerLocal.ShellEnvType(maps.GetValueAsString(deployConfig, "shellEnv")), + PreCommand: maps.GetValueAsString(deployConfig, "preCommand"), + PostCommand: maps.GetValueAsString(deployConfig, "postCommand"), + OutputFormat: providerLocal.OutputFormatType(maps.GetValueOrDefaultAsString(deployConfig, "format", "PEM")), + OutputCertPath: maps.GetValueAsString(deployConfig, "certPath"), + OutputKeyPath: maps.GetValueAsString(deployConfig, "keyPath"), + PfxPassword: maps.GetValueAsString(deployConfig, "pfxPassword"), + JksAlias: maps.GetValueAsString(deployConfig, "jksAlias"), + JksKeypass: maps.GetValueAsString(deployConfig, "jksKeypass"), + JksStorepass: maps.GetValueAsString(deployConfig, "jksStorepass"), + }, logger) + return deployer, logger, err + } + + case targetK8sSecret: + { + access := &domain.KubernetesAccess{} + if err := json.Unmarshal([]byte(accessConfig), access); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal access config: %w", err) + } + + deployer, err := providerK8sSecret.NewWithLogger(&providerK8sSecret.K8sSecretDeployerConfig{ + KubeConfig: access.KubeConfig, + Namespace: maps.GetValueOrDefaultAsString(deployConfig, "namespace", "default"), + SecretName: maps.GetValueAsString(deployConfig, "secretName"), + SecretDataKeyForCrt: maps.GetValueOrDefaultAsString(deployConfig, "secretDataKeyForCrt", "tls.crt"), + SecretDataKeyForKey: maps.GetValueOrDefaultAsString(deployConfig, "secretDataKeyForKey", "tls.key"), + }, logger) + return deployer, logger, err + } + + case targetQiniuCdn: + { + access := &domain.QiniuAccess{} + if err := json.Unmarshal([]byte(accessConfig), access); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal access config: %w", err) + } + + deployer, err := providerQiniuCdn.NewWithLogger(&providerQiniuCdn.QiniuCDNDeployerConfig{ + AccessKey: access.AccessKey, + SecretKey: access.SecretKey, + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + } + + case targetSSH: + { + access := &domain.SSHAccess{} + if err := json.Unmarshal([]byte(accessConfig), access); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal access config: %w", err) + } + + sshPort, _ := strconv.ParseInt(access.Port, 10, 32) + deployer, err := providerSSH.NewWithLogger(&providerSSH.SshDeployerConfig{ + SshHost: access.Host, + SshPort: int32(sshPort), + SshUsername: access.Username, + SshPassword: access.Password, + SshKey: access.Key, + SshKeyPassphrase: access.KeyPassphrase, + PreCommand: maps.GetValueAsString(deployConfig, "preCommand"), + PostCommand: maps.GetValueAsString(deployConfig, "postCommand"), + OutputFormat: providerSSH.OutputFormatType(maps.GetValueOrDefaultAsString(deployConfig, "format", "PEM")), + OutputCertPath: maps.GetValueAsString(deployConfig, "certPath"), + OutputKeyPath: maps.GetValueAsString(deployConfig, "keyPath"), + PfxPassword: maps.GetValueAsString(deployConfig, "pfxPassword"), + JksAlias: maps.GetValueAsString(deployConfig, "jksAlias"), + JksKeypass: maps.GetValueAsString(deployConfig, "jksKeypass"), + JksStorepass: maps.GetValueAsString(deployConfig, "jksStorepass"), + }, logger) + return deployer, logger, err + } + + case targetTencentCDN, targetTencentCLB, targetTencentCOS, targetTencentECDN, targetTencentTEO: + { + access := &domain.TencentAccess{} + if err := json.Unmarshal([]byte(accessConfig), access); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal access config: %w", err) + } + + switch target { + case targetTencentCDN: + deployer, err := providerTencentCloudCdn.NewWithLogger(&providerTencentCloudCdn.TencentCloudCDNDeployerConfig{ + SecretId: access.SecretId, + SecretKey: access.SecretKey, + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + + case targetTencentCLB: + deployer, err := providerTencentCloudClb.NewWithLogger(&providerTencentCloudClb.TencentCloudCLBDeployerConfig{ + SecretId: access.SecretId, + SecretKey: access.SecretKey, + Region: maps.GetValueAsString(deployConfig, "region"), + ResourceType: providerTencentCloudClb.DeployResourceType(maps.GetValueAsString(deployConfig, "resourceType")), + LoadbalancerId: maps.GetValueAsString(deployConfig, "loadbalancerId"), + ListenerId: maps.GetValueAsString(deployConfig, "listenerId"), + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + + case targetTencentCOS: + deployer, err := providerTencentCloudCos.NewWithLogger(&providerTencentCloudCos.TencentCloudCOSDeployerConfig{ + SecretId: access.SecretId, + SecretKey: access.SecretKey, + Region: maps.GetValueAsString(deployConfig, "region"), + Bucket: maps.GetValueAsString(deployConfig, "bucket"), + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + + case targetTencentECDN: + deployer, err := providerTencentCloudEcdn.NewWithLogger(&providerTencentCloudEcdn.TencentCloudECDNDeployerConfig{ + SecretId: access.SecretId, + SecretKey: access.SecretKey, + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + + case targetTencentTEO: + deployer, err := providerTencentCloudTeo.NewWithLogger(&providerTencentCloudTeo.TencentCloudTEODeployerConfig{ + SecretId: access.SecretId, + SecretKey: access.SecretKey, + ZoneId: maps.GetValueAsString(deployConfig, "zoneId"), + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + + default: + break + } + } + + case targetVolcEngineCDN, targetVolcEngineLive: + { + access := &domain.VolcEngineAccess{} + if err := json.Unmarshal([]byte(accessConfig), access); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal access config: %w", err) + } + + switch target { + case targetVolcEngineCDN: + deployer, err := providerVolcEngineCdn.NewWithLogger(&providerVolcEngineCdn.VolcEngineCDNDeployerConfig{ + AccessKey: access.AccessKey, + SecretKey: access.SecretKey, + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + + case targetVolcEngineLive: + deployer, err := providerVolcEngineLive.NewWithLogger(&providerVolcEngineLive.VolcEngineLiveDeployerConfig{ + AccessKey: access.AccessKey, + SecretKey: access.SecretKey, + Domain: maps.GetValueAsString(deployConfig, "domain"), + }, logger) + return deployer, logger, err + + default: + break + } + } + + case targetWebhook: + { + access := &domain.WebhookAccess{} + if err := json.Unmarshal([]byte(accessConfig), access); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal access config: %w", err) + } + + deployer, err := providerWebhook.NewWithLogger(&providerWebhook.WebhookDeployerConfig{ + Url: access.Url, + Variables: nil, // TODO: 尚未实现 + }, logger) + return deployer, logger, err + } + } + + return nil, nil, fmt.Errorf("unsupported deployer target: %s", target) +} diff --git a/internal/deployer/huaweicloud_elb.go b/internal/deployer/huaweicloud_elb.go index b7658660..ccc6f9f5 100644 --- a/internal/deployer/huaweicloud_elb.go +++ b/internal/deployer/huaweicloud_elb.go @@ -5,8 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "sort" - "strings" "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/basic" "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/global" @@ -17,6 +15,7 @@ import ( hcIamModel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/iam/v3/model" hcIamRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/iam/v3/region" xerrors "github.com/pkg/errors" + "golang.org/x/exp/slices" "github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/pkg/core/uploader" @@ -163,9 +162,6 @@ func (u *HuaweiCloudELBDeployer) getSdkProjectId(accessKeyId, secretAccessKey, r } client := hcIam.NewIamClient(hcClient) - if err != nil { - return "", err - } request := &hcIamModel.KeystoneListProjectsRequest{ Name: ®ion, @@ -352,11 +348,7 @@ func (d *HuaweiCloudELBDeployer) modifyListenerCertificate(ctx context.Context, newCertificate := showNewCertificateResp.Certificate if oldCertificate.SubjectAlternativeNames != nil && newCertificate.SubjectAlternativeNames != nil { - oldCertificateSans := oldCertificate.SubjectAlternativeNames - newCertificateSans := newCertificate.SubjectAlternativeNames - sort.Strings(*oldCertificateSans) - sort.Strings(*newCertificateSans) - if strings.Join(*oldCertificateSans, ";") == strings.Join(*newCertificateSans, ";") { + if slices.Equal(*oldCertificate.SubjectAlternativeNames, *newCertificate.SubjectAlternativeNames) { continue } } else { diff --git a/internal/deployer/local.go b/internal/deployer/local.go index 7562e2f6..3c693d30 100644 --- a/internal/deployer/local.go +++ b/internal/deployer/local.go @@ -11,6 +11,7 @@ import ( xerrors "github.com/pkg/errors" "github.com/usual2970/certimate/internal/pkg/utils/fs" + "github.com/usual2970/certimate/internal/pkg/utils/x509" ) type LocalDeployer struct { @@ -73,7 +74,7 @@ func (d *LocalDeployer) Deploy(ctx context.Context) error { d.infos = append(d.infos, toStr("保存私钥成功", nil)) case certFormatPFX: - pfxData, err := convertPEMToPFX( + pfxData, err := x509.TransformCertificateFromPEMToPFX( d.option.Certificate.Certificate, d.option.Certificate.PrivateKey, d.option.DeployConfig.GetConfigAsString("pfxPassword"), @@ -89,7 +90,7 @@ func (d *LocalDeployer) Deploy(ctx context.Context) error { d.infos = append(d.infos, toStr("保存证书成功", nil)) case certFormatJKS: - jksData, err := convertPEMToJKS( + jksData, err := x509.TransformCertificateFromPEMToJKS( d.option.Certificate.Certificate, d.option.Certificate.PrivateKey, d.option.DeployConfig.GetConfigAsString("jksAlias"), diff --git a/internal/deployer/ssh.go b/internal/deployer/ssh.go index 8a5bfa1d..96f8bdd2 100644 --- a/internal/deployer/ssh.go +++ b/internal/deployer/ssh.go @@ -14,6 +14,7 @@ import ( "golang.org/x/crypto/ssh" "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/utils/x509" ) type SSHDeployer struct { @@ -78,7 +79,7 @@ func (d *SSHDeployer) Deploy(ctx context.Context) error { d.infos = append(d.infos, toStr("SSH 上传私钥成功", nil)) case certFormatPFX: - pfxData, err := convertPEMToPFX( + pfxData, err := x509.TransformCertificateFromPEMToPFX( d.option.Certificate.Certificate, d.option.Certificate.PrivateKey, d.option.DeployConfig.GetConfigAsString("pfxPassword"), @@ -94,7 +95,7 @@ func (d *SSHDeployer) Deploy(ctx context.Context) error { d.infos = append(d.infos, toStr("SSH 上传证书成功", nil)) case certFormatJKS: - jksData, err := convertPEMToJKS( + jksData, err := x509.TransformCertificateFromPEMToJKS( d.option.Certificate.Certificate, d.option.Certificate.PrivateKey, d.option.DeployConfig.GetConfigAsString("jksAlias"), diff --git a/internal/deployer/volcengine_cdn.go b/internal/deployer/volcengine_cdn.go index 6955716f..6ba8a23d 100644 --- a/internal/deployer/volcengine_cdn.go +++ b/internal/deployer/volcengine_cdn.go @@ -22,15 +22,15 @@ type VolcengineCDNDeployer struct { } func NewVolcengineCDNDeployer(option *DeployerOption) (Deployer, error) { - access := &domain.VolcengineAccess{} + access := &domain.VolcEngineAccess{} if err := json.Unmarshal([]byte(option.Access), access); err != nil { return nil, xerrors.Wrap(err, "failed to get access") } client := cdn.NewInstance() - client.Client.SetAccessKey(access.AccessKeyID) + client.Client.SetAccessKey(access.AccessKeyId) client.Client.SetSecretKey(access.SecretAccessKey) - uploader, err := volcenginecdn.New(&volcenginecdn.VolcengineCDNUploaderConfig{ - AccessKeyId: access.AccessKeyID, + uploader, err := volcenginecdn.New(&volcenginecdn.VolcEngineCDNUploaderConfig{ + AccessKeyId: access.AccessKeyId, AccessKeySecret: access.SecretAccessKey, }) if err != nil { diff --git a/internal/deployer/volcengine_live.go b/internal/deployer/volcengine_live.go index f456bb83..1795d79f 100644 --- a/internal/deployer/volcengine_live.go +++ b/internal/deployer/volcengine_live.go @@ -24,17 +24,17 @@ type VolcengineLiveDeployer struct { } func NewVolcengineLiveDeployer(option *DeployerOption) (Deployer, error) { - access := &domain.VolcengineAccess{} + access := &domain.VolcEngineAccess{} if err := json.Unmarshal([]byte(option.Access), access); err != nil { return nil, xerrors.Wrap(err, "failed to get access") } client := live.NewInstance() client.SetCredential(base.Credentials{ - AccessKeyID: access.AccessKeyID, + AccessKeyID: access.AccessKeyId, SecretAccessKey: access.SecretAccessKey, }) - uploader, err := volcenginelive.New(&volcenginelive.VolcengineLiveUploaderConfig{ - AccessKeyId: access.AccessKeyID, + uploader, err := volcenginelive.New(&volcenginelive.VolcEngineLiveUploaderConfig{ + AccessKeyId: access.AccessKeyId, AccessKeySecret: access.SecretAccessKey, }) if err != nil { diff --git a/internal/domain/access.go b/internal/domain/access.go index a0721de2..f040c5ca 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -27,8 +27,8 @@ type AliyunAccess struct { } type ByteplusAccess struct { - AccessKey string - SecretKey string + AccessKey string `json:"accessKey"` + SecretKey string `json:"secretKey"` } type TencentAccess struct { @@ -48,9 +48,9 @@ type BaiduCloudAccess struct { } type AwsAccess struct { - Region string `json:"region"` AccessKeyId string `json:"accessKeyId"` SecretAccessKey string `json:"secretAccessKey"` + Region string `json:"region"` HostedZoneId string `json:"hostedZoneId"` } @@ -82,9 +82,14 @@ type PdnsAccess struct { ApiKey string `json:"apiKey"` } -type VolcengineAccess struct { - AccessKeyID string - SecretAccessKey string +type VolcEngineAccess struct { + AccessKey string `json:"accessKey"` + SecretKey string `json:"secretKey"` + + // Deprecated: Use [AccessKey] and [SecretKey] instead in the future + AccessKeyId string `json:"accessKeyId"` + // Deprecated: Use [AccessKey] and [SecretKey] instead in the future + SecretAccessKey string `json:"secretAccessKey"` } type HttpreqAccess struct { diff --git a/internal/domain/domains.go b/internal/domain/domains.go index 2bc07074..6a228fff 100644 --- a/internal/domain/domains.go +++ b/internal/domain/domains.go @@ -41,7 +41,7 @@ func (dc *DeployConfig) GetConfigAsString(key string) string { // - defaultValue: 默认值。 // // 出参: -// - 配置项的值。如果配置项不存在或者类型不是字符串,则返回默认值。 +// - 配置项的值。如果配置项不存在、类型不是字符串或者值为零值,则返回默认值。 func (dc *DeployConfig) GetConfigOrDefaultAsString(key string, defaultValue string) string { return maps.GetValueOrDefaultAsString(dc.Config, key, defaultValue) } @@ -64,7 +64,7 @@ func (dc *DeployConfig) GetConfigAsInt32(key string) int32 { // - defaultValue: 默认值。 // // 出参: -// - 配置项的值。如果配置项不存在或者类型不是 32 位整数,则返回默认值。 +// - 配置项的值。如果配置项不存在、类型不是 32 位整数或者值为零值,则返回默认值。 func (dc *DeployConfig) GetConfigOrDefaultAsInt32(key string, defaultValue int32) int32 { return maps.GetValueOrDefaultAsInt32(dc.Config, key, defaultValue) } diff --git a/internal/notify/factory.go b/internal/notify/factory.go index ccdd5389..e244b071 100644 --- a/internal/notify/factory.go +++ b/internal/notify/factory.go @@ -1,24 +1,24 @@ package notify import ( - "errors" + "fmt" "github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/pkg/core/notifier" - notifierBark "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/bark" - notifierDingTalk "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/dingtalk" - notifierEmail "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/email" - notifierLark "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/lark" - notifierServerChan "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/serverchan" - notifierTelegram "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/telegram" - notifierWebhook "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/webhook" + providerBark "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/bark" + providerDingTalk "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/dingtalk" + providerEmail "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/email" + providerLark "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/lark" + providerServerChan "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/serverchan" + providerTelegram "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/telegram" + providerWebhook "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/webhook" "github.com/usual2970/certimate/internal/pkg/utils/maps" ) func createNotifier(channel string, channelConfig map[string]any) (notifier.Notifier, error) { switch channel { case domain.NotifyChannelEmail: - return notifierEmail.New(¬ifierEmail.EmailNotifierConfig{ + return providerEmail.New(&providerEmail.EmailNotifierConfig{ SmtpHost: maps.GetValueAsString(channelConfig, "smtpHost"), SmtpPort: maps.GetValueAsInt32(channelConfig, "smtpPort"), SmtpTLS: maps.GetValueOrDefaultAsBool(channelConfig, "smtpTLS", true), @@ -29,38 +29,38 @@ func createNotifier(channel string, channelConfig map[string]any) (notifier.Noti }) case domain.NotifyChannelWebhook: - return notifierWebhook.New(¬ifierWebhook.WebhookNotifierConfig{ + return providerWebhook.New(&providerWebhook.WebhookNotifierConfig{ Url: maps.GetValueAsString(channelConfig, "url"), }) case domain.NotifyChannelDingtalk: - return notifierDingTalk.New(¬ifierDingTalk.DingTalkNotifierConfig{ + return providerDingTalk.New(&providerDingTalk.DingTalkNotifierConfig{ AccessToken: maps.GetValueAsString(channelConfig, "accessToken"), Secret: maps.GetValueAsString(channelConfig, "secret"), }) case domain.NotifyChannelLark: - return notifierLark.New(¬ifierLark.LarkNotifierConfig{ + return providerLark.New(&providerLark.LarkNotifierConfig{ WebhookUrl: maps.GetValueAsString(channelConfig, "webhookUrl"), }) case domain.NotifyChannelTelegram: - return notifierTelegram.New(¬ifierTelegram.TelegramNotifierConfig{ + return providerTelegram.New(&providerTelegram.TelegramNotifierConfig{ ApiToken: maps.GetValueAsString(channelConfig, "apiToken"), ChatId: maps.GetValueAsInt64(channelConfig, "chatId"), }) case domain.NotifyChannelServerChan: - return notifierServerChan.New(¬ifierServerChan.ServerChanNotifierConfig{ + return providerServerChan.New(&providerServerChan.ServerChanNotifierConfig{ Url: maps.GetValueAsString(channelConfig, "url"), }) case domain.NotifyChannelBark: - return notifierBark.New(¬ifierBark.BarkNotifierConfig{ + return providerBark.New(&providerBark.BarkNotifierConfig{ DeviceKey: maps.GetValueAsString(channelConfig, "deviceKey"), ServerUrl: maps.GetValueAsString(channelConfig, "serverUrl"), }) } - return nil, errors.New("unsupported notifier channel") + return nil, fmt.Errorf("unsupported notifier channel: %s", channelConfig) } diff --git a/internal/pkg/core/deployer/deployer.go b/internal/pkg/core/deployer/deployer.go new file mode 100644 index 00000000..b7b839fe --- /dev/null +++ b/internal/pkg/core/deployer/deployer.go @@ -0,0 +1,24 @@ +package deployer + +import "context" + +// 表示定义证书部署器的抽象类型接口。 +// 注意与 `Uploader` 区分,“部署”通常为“上传”的后置操作。 +type Deployer interface { + // 部署证书。 + // + // 入参: + // - ctx:上下文。 + // - certPem:证书 PEM 内容。 + // - privkeyPem:私钥 PEM 内容。 + // + // 出参: + // - res:部署结果。 + // - err: 错误。 + Deploy(ctx context.Context, certPem string, privkeyPem string) (res *DeployResult, err error) +} + +// 表示证书部署结果的数据结构。 +type DeployResult struct { + DeploymentData map[string]any `json:"deploymentData,omitempty"` +} diff --git a/internal/pkg/core/deployer/logger.go b/internal/pkg/core/deployer/logger.go new file mode 100644 index 00000000..8a8b2f32 --- /dev/null +++ b/internal/pkg/core/deployer/logger.go @@ -0,0 +1,117 @@ +package deployer + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// 表示定义证书部署器的日志记录器的抽象类型接口。 +type Logger interface { + // 追加一条日志记录。 + // 该方法会将 `data` 以 JSON 序列化后拼接到 `tag` 结尾。 + // + // 入参: + // - tag:标签。 + // - data:数据。 + Logt(tag string, data ...any) + + // 追加一条日志记录。 + // 该方法会将 `args` 以 `format` 格式化。 + // + // 入参: + // - format:格式化字符串。 + // - args:格式化参数。 + Logf(format string, args ...any) + + // 获取所有日志记录。 + GetRecords() []string + + // 清空所有日志记录。 + FlushRecords() +} + +// 表示默认的日志记录器类型。 +type DefaultLogger struct { + records []string +} + +var _ Logger = (*DefaultLogger)(nil) + +func (l *DefaultLogger) Logt(tag string, data ...any) { + l.ensureInitialized() + + temp := make([]string, len(data)+1) + temp[0] = tag + for i, v := range data { + s := "" + if v == nil { + s = "" + } else { + switch reflect.ValueOf(v).Kind() { + case reflect.String: + s = v.(string) + case reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + s = fmt.Sprintf("%v", v) + default: + jsonData, _ := json.Marshal(v) + s = string(jsonData) + } + } + + temp[i+1] = s + } + + l.records = append(l.records, strings.Join(temp, ": ")) +} + +func (l *DefaultLogger) Logf(format string, args ...any) { + l.ensureInitialized() + + l.records = append(l.records, fmt.Sprintf(format, args...)) +} + +func (l *DefaultLogger) GetRecords() []string { + l.ensureInitialized() + + temp := make([]string, len(l.records)) + copy(temp, l.records) + return temp +} + +func (l *DefaultLogger) FlushRecords() { + l.records = make([]string, 0) +} + +func (l *DefaultLogger) ensureInitialized() { + if l.records == nil { + l.records = make([]string, 0) + } +} + +func NewDefaultLogger() *DefaultLogger { + return &DefaultLogger{ + records: make([]string, 0), + } +} + +// 表示空的日志记录器类型。 +// 该日志记录器不会执行任何操作。 +type NilLogger struct{} + +var _ Logger = (*NilLogger)(nil) + +func (l *NilLogger) Logt(string, ...any) {} +func (l *NilLogger) Logf(string, ...any) {} +func (l *NilLogger) GetRecords() []string { + return make([]string, 0) +} +func (l *NilLogger) FlushRecords() {} + +func NewNilLogger() *NilLogger { + return &NilLogger{} +} diff --git a/internal/pkg/core/deployer/logger_test.go b/internal/pkg/core/deployer/logger_test.go new file mode 100644 index 00000000..35021a99 --- /dev/null +++ b/internal/pkg/core/deployer/logger_test.go @@ -0,0 +1,56 @@ +package deployer_test + +import ( + "testing" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" +) + +/* +Shell command to run this test: + + go test -v logger_test.go +*/ +func TestLogger(t *testing.T) { + t.Run("Logger_Appendt", func(t *testing.T) { + logger := deployer.NewDefaultLogger() + + logger.Logt("test") + logger.Logt("test_nil", nil) + logger.Logt("test_int", 1024) + logger.Logt("test_string", "certimate") + logger.Logt("test_map", map[string]interface{}{"key": "value"}) + logger.Logt("test_struct", struct{ Name string }{Name: "certimate"}) + logger.Logt("test_slice", []string{"certimate"}) + t.Log(logger.GetRecords()) + if len(logger.GetRecords()) != 7 { + t.Errorf("expected 7 records, got %d", len(logger.GetRecords())) + } + + logger.FlushRecords() + if len(logger.GetRecords()) != 0 { + t.Errorf("expected 0 records, got %d", len(logger.GetRecords())) + } + }) + + t.Run("Logger_Appendf", func(t *testing.T) { + logger := deployer.NewDefaultLogger() + + logger.Logf("test") + logger.Logf("test_nil: %v", nil) + logger.Logf("test_int: %v", 1024) + logger.Logf("test_string: %v", "certimate") + logger.Logf("test_map: %v", map[string]interface{}{"key": "value"}) + logger.Logf("test_struct: %v", struct{ Name string }{Name: "certimate"}) + logger.Logf("test_slice: %v", []string{"certimate"}) + t.Log(logger.GetRecords()) + if len(logger.GetRecords()) != 7 { + t.Errorf("expected 7 records, got %d", len(logger.GetRecords())) + } + + logger.FlushRecords() + if len(logger.GetRecords()) != 0 { + t.Errorf("expected 0 records, got %d", len(logger.GetRecords())) + } + }) +} diff --git a/internal/pkg/core/deployer/providers/aliyun-alb/aliyun_alb.go b/internal/pkg/core/deployer/providers/aliyun-alb/aliyun_alb.go new file mode 100644 index 00000000..cdef4ee8 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-alb/aliyun_alb.go @@ -0,0 +1,289 @@ +package aliyunalb + +import ( + "context" + "errors" + "fmt" + "strings" + + aliyunAlb "github.com/alibabacloud-go/alb-20200616/v2/client" + aliyunOpen "github.com/alibabacloud-go/darabonba-openapi/v2/client" + "github.com/alibabacloud-go/tea/tea" + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerCas "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/aliyun-cas" +) + +type AliyunALBDeployerConfig struct { + // 阿里云 AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // 阿里云 AccessKeySecret。 + AccessKeySecret string `json:"accessKeySecret"` + // 阿里云地域。 + Region string `json:"region"` + // 部署资源类型。 + ResourceType DeployResourceType `json:"resourceType"` + // 负载均衡实例 ID。 + // 部署资源类型为 [DEPLOY_RESOURCE_LOADBALANCER] 时必填。 + LoadbalancerId string `json:"loadbalancerId,omitempty"` + // 负载均衡监听 ID。 + // 部署资源类型为 [DEPLOY_RESOURCE_LISTENER] 时必填。 + ListenerId string `json:"listenerId,omitempty"` +} + +type AliyunALBDeployer struct { + config *AliyunALBDeployerConfig + logger deployer.Logger + sdkClient *aliyunAlb.Client + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*AliyunALBDeployer)(nil) + +func New(config *AliyunALBDeployerConfig) (*AliyunALBDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *AliyunALBDeployerConfig, logger deployer.Logger) (*AliyunALBDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client, err := createSdkClient(config.AccessKeyId, config.AccessKeySecret, config.Region) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk client") + } + + aliyunCasRegion := config.Region + if aliyunCasRegion != "" { + // 阿里云 CAS 服务接入点是独立于 ALB 服务的 + // 国内版固定接入点:华东一杭州 + // 国际版固定接入点:亚太东南一新加坡 + if !strings.HasPrefix(aliyunCasRegion, "cn-") { + aliyunCasRegion = "ap-southeast-1" + } else { + aliyunCasRegion = "cn-hangzhou" + } + } + uploader, err := providerCas.New(&providerCas.AliyunCASUploaderConfig{ + AccessKeyId: config.AccessKeyId, + AccessKeySecret: config.AccessKeySecret, + Region: aliyunCasRegion, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &AliyunALBDeployer{ + logger: logger, + config: config, + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *AliyunALBDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 CAS + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 根据部署资源类型决定部署方式 + switch d.config.ResourceType { + case DEPLOY_RESOURCE_LOADBALANCER: + if err := d.deployToLoadbalancer(ctx, upres.CertId); err != nil { + return nil, err + } + + case DEPLOY_RESOURCE_LISTENER: + if err := d.deployToListener(ctx, upres.CertId); err != nil { + return nil, err + } + + default: + return nil, fmt.Errorf("unsupported resource type: %s", d.config.ResourceType) + } + + return &deployer.DeployResult{}, nil +} + +func (d *AliyunALBDeployer) deployToLoadbalancer(ctx context.Context, cloudCertId string) error { + if d.config.LoadbalancerId == "" { + return errors.New("config `loadbalancerId` is required") + } + + listenerIds := make([]string, 0) + + // 查询负载均衡实例的详细信息 + // REF: https://help.aliyun.com/zh/slb/application-load-balancer/developer-reference/api-alb-2020-06-16-getloadbalancerattribute + getLoadBalancerAttributeReq := &aliyunAlb.GetLoadBalancerAttributeRequest{ + LoadBalancerId: tea.String(d.config.LoadbalancerId), + } + getLoadBalancerAttributeResp, err := d.sdkClient.GetLoadBalancerAttribute(getLoadBalancerAttributeReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'alb.GetLoadBalancerAttribute'") + } + + d.logger.Logt("已查询到 ALB 负载均衡实例", getLoadBalancerAttributeResp) + + // 查询 HTTPS 监听列表 + // REF: https://help.aliyun.com/zh/slb/application-load-balancer/developer-reference/api-alb-2020-06-16-listlisteners + listListenersPage := 1 + listListenersLimit := int32(100) + var listListenersToken *string = nil + for { + listListenersReq := &aliyunAlb.ListListenersRequest{ + MaxResults: tea.Int32(listListenersLimit), + NextToken: listListenersToken, + LoadBalancerIds: []*string{tea.String(d.config.LoadbalancerId)}, + ListenerProtocol: tea.String("HTTPS"), + } + listListenersResp, err := d.sdkClient.ListListeners(listListenersReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'alb.ListListeners'") + } + + if listListenersResp.Body.Listeners != nil { + for _, listener := range listListenersResp.Body.Listeners { + listenerIds = append(listenerIds, *listener.ListenerId) + } + } + + if len(listListenersResp.Body.Listeners) == 0 || listListenersResp.Body.NextToken == nil { + break + } else { + listListenersToken = listListenersResp.Body.NextToken + listListenersPage += 1 + } + } + + d.logger.Logt("已查询到 ALB 负载均衡实例下的全部 HTTPS 监听", listenerIds) + + // 查询 QUIC 监听列表 + // REF: https://help.aliyun.com/zh/slb/application-load-balancer/developer-reference/api-alb-2020-06-16-listlisteners + listListenersPage = 1 + listListenersToken = nil + for { + listListenersReq := &aliyunAlb.ListListenersRequest{ + MaxResults: tea.Int32(listListenersLimit), + NextToken: listListenersToken, + LoadBalancerIds: []*string{tea.String(d.config.LoadbalancerId)}, + ListenerProtocol: tea.String("QUIC"), + } + listListenersResp, err := d.sdkClient.ListListeners(listListenersReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'alb.ListListeners'") + } + + if listListenersResp.Body.Listeners != nil { + for _, listener := range listListenersResp.Body.Listeners { + listenerIds = append(listenerIds, *listener.ListenerId) + } + } + + if len(listListenersResp.Body.Listeners) == 0 || listListenersResp.Body.NextToken == nil { + break + } else { + listListenersToken = listListenersResp.Body.NextToken + listListenersPage += 1 + } + } + + d.logger.Logt("已查询到 ALB 负载均衡实例下的全部 QUIC 监听", listenerIds) + + // 批量更新监听证书 + var errs []error + for _, listenerId := range listenerIds { + if err := d.updateListenerCertificate(ctx, listenerId, cloudCertId); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (d *AliyunALBDeployer) deployToListener(ctx context.Context, cloudCertId string) error { + if d.config.ListenerId == "" { + return errors.New("config `listenerId` is required") + } + + // 更新监听 + if err := d.updateListenerCertificate(ctx, d.config.ListenerId, cloudCertId); err != nil { + return err + } + + return nil +} + +func (d *AliyunALBDeployer) updateListenerCertificate(ctx context.Context, cloudListenerId string, cloudCertId string) error { + // 查询监听的属性 + // REF: https://help.aliyun.com/zh/slb/application-load-balancer/developer-reference/api-alb-2020-06-16-getlistenerattribute + getListenerAttributeReq := &aliyunAlb.GetListenerAttributeRequest{ + ListenerId: tea.String(cloudListenerId), + } + getListenerAttributeResp, err := d.sdkClient.GetListenerAttribute(getListenerAttributeReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'alb.GetListenerAttribute'") + } + + d.logger.Logt("已查询到 ALB 监听配置", getListenerAttributeResp) + + // 修改监听的属性 + // REF: https://help.aliyun.com/zh/slb/application-load-balancer/developer-reference/api-alb-2020-06-16-updatelistenerattribute + updateListenerAttributeReq := &aliyunAlb.UpdateListenerAttributeRequest{ + ListenerId: tea.String(cloudListenerId), + Certificates: []*aliyunAlb.UpdateListenerAttributeRequestCertificates{{ + CertificateId: tea.String(cloudCertId), + }}, + } + updateListenerAttributeResp, err := d.sdkClient.UpdateListenerAttribute(updateListenerAttributeReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'alb.UpdateListenerAttribute'") + } + + d.logger.Logt("已更新 ALB 监听配置", updateListenerAttributeResp) + + // TODO: #347 + + return nil +} + +func createSdkClient(accessKeyId, accessKeySecret, region string) (*aliyunAlb.Client, error) { + if region == "" { + region = "cn-hangzhou" + } + + // 接入点一览 https://www.alibabacloud.com/help/zh/slb/application-load-balancer/developer-reference/api-alb-2020-06-16-endpoint + var endpoint string + switch region { + case "cn-hangzhou-finance": + endpoint = "alb.cn-hangzhou.aliyuncs.com" + default: + endpoint = fmt.Sprintf("alb.%s.aliyuncs.com", region) + } + + config := &aliyunOpen.Config{ + AccessKeyId: tea.String(accessKeyId), + AccessKeySecret: tea.String(accessKeySecret), + Endpoint: tea.String(endpoint), + } + + client, err := aliyunAlb.NewClient(config) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/aliyun-alb/aliyun_alb_test.go b/internal/pkg/core/deployer/providers/aliyun-alb/aliyun_alb_test.go new file mode 100644 index 00000000..143b65b4 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-alb/aliyun_alb_test.go @@ -0,0 +1,118 @@ +package aliyunalb_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-alb" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKeyId string + fAccessKeySecret string + fRegion string + fLoadbalancerId string + fListenerId string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_ALIYUNALB_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "") + flag.StringVar(&fAccessKeySecret, argsPrefix+"ACCESSKEYSECRET", "", "") + flag.StringVar(&fRegion, argsPrefix+"REGION", "", "") + flag.StringVar(&fLoadbalancerId, argsPrefix+"LOADBALANCERID", "", "") + flag.StringVar(&fListenerId, argsPrefix+"LISTENERID", "", "") +} + +/* +Shell command to run this test: + + go test -v aliyun_alb_test.go -args \ + --CERTIMATE_DEPLOYER_ALIYUNALB_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNALB_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNALB_ACCESSKEYID="your-access-key-id" \ + --CERTIMATE_DEPLOYER_ALIYUNALB_ACCESSKEYSECRET="your-access-key-secret" \ + --CERTIMATE_DEPLOYER_ALIYUNALB_REGION="cn-hangzhou" \ + --CERTIMATE_DEPLOYER_ALIYUNALB_LOADBALANCERID="your-alb-instance-id" \ + --CERTIMATE_DEPLOYER_ALIYUNALB_LISTENERID="your-alb-listener-id" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy_ToLoadbalancer", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("ACCESSKEYSECRET: %v", fAccessKeySecret), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LOADBALANCERID: %v", fLoadbalancerId), + }, "\n")) + + deployer, err := provider.New(&provider.AliyunALBDeployerConfig{ + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_LOADBALANCER, + LoadbalancerId: fLoadbalancerId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) + + t.Run("Deploy_ToListener", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("ACCESSKEYSECRET: %v", fAccessKeySecret), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LISTENERID: %v", fListenerId), + }, "\n")) + + deployer, err := provider.New(&provider.AliyunALBDeployerConfig{ + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_LISTENER, + ListenerId: fListenerId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/aliyun-alb/defines.go b/internal/pkg/core/deployer/providers/aliyun-alb/defines.go new file mode 100644 index 00000000..927e990a --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-alb/defines.go @@ -0,0 +1,10 @@ +package aliyunalb + +type DeployResourceType string + +const ( + // 资源类型:部署到指定负载均衡器。 + DEPLOY_RESOURCE_LOADBALANCER = DeployResourceType("loadbalancer") + // 资源类型:部署到指定监听器。 + DEPLOY_RESOURCE_LISTENER = DeployResourceType("listener") +) diff --git a/internal/pkg/core/deployer/providers/aliyun-cdn/aliyun_cdn.go b/internal/pkg/core/deployer/providers/aliyun-cdn/aliyun_cdn.go new file mode 100644 index 00000000..85005e8c --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-cdn/aliyun_cdn.go @@ -0,0 +1,93 @@ +package aliyuncdn + +import ( + "context" + "errors" + "fmt" + "time" + + aliyunCdn "github.com/alibabacloud-go/cdn-20180510/v5/client" + aliyunOpen "github.com/alibabacloud-go/darabonba-openapi/v2/client" + "github.com/alibabacloud-go/tea/tea" + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" +) + +type AliyunCDNDeployerConfig struct { + // 阿里云 AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // 阿里云 AccessKeySecret。 + AccessKeySecret string `json:"accessKeySecret"` + // 加速域名(不支持泛域名)。 + Domain string `json:"domain"` +} + +type AliyunCDNDeployer struct { + config *AliyunCDNDeployerConfig + logger deployer.Logger + sdkClient *aliyunCdn.Client +} + +var _ deployer.Deployer = (*AliyunCDNDeployer)(nil) + +func New(config *AliyunCDNDeployerConfig) (*AliyunCDNDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *AliyunCDNDeployerConfig, logger deployer.Logger) (*AliyunCDNDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client, err := createSdkClient(config.AccessKeyId, config.AccessKeySecret) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk client") + } + + return &AliyunCDNDeployer{ + logger: logger, + config: config, + sdkClient: client, + }, nil +} + +func (d *AliyunCDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 设置 CDN 域名域名证书 + // REF: https://help.aliyun.com/zh/cdn/developer-reference/api-cdn-2018-05-10-setcdndomainsslcertificate + setCdnDomainSSLCertificateReq := &aliyunCdn.SetCdnDomainSSLCertificateRequest{ + DomainName: tea.String(d.config.Domain), + CertName: tea.String(fmt.Sprintf("certimate-%d", time.Now().UnixMilli())), + CertType: tea.String("upload"), + SSLProtocol: tea.String("on"), + SSLPub: tea.String(certPem), + SSLPri: tea.String(privkeyPem), + } + setCdnDomainSSLCertificateResp, err := d.sdkClient.SetCdnDomainSSLCertificate(setCdnDomainSSLCertificateReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.SetCdnDomainSSLCertificate'") + } + + d.logger.Logt("已设置 CDN 域名证书", setCdnDomainSSLCertificateResp) + + return &deployer.DeployResult{}, nil +} + +func createSdkClient(accessKeyId, accessKeySecret string) (*aliyunCdn.Client, error) { + config := &aliyunOpen.Config{ + AccessKeyId: tea.String(accessKeyId), + AccessKeySecret: tea.String(accessKeySecret), + Endpoint: tea.String("cdn.aliyuncs.com"), + } + + client, err := aliyunCdn.NewClient(config) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/aliyun-cdn/aliyun_cdn_test.go b/internal/pkg/core/deployer/providers/aliyun-cdn/aliyun_cdn_test.go new file mode 100644 index 00000000..66464aa1 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-cdn/aliyun_cdn_test.go @@ -0,0 +1,75 @@ +package aliyuncdn_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-cdn" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKeyId string + fAccessKeySecret string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_ALIYUNCDN_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "") + flag.StringVar(&fAccessKeySecret, argsPrefix+"ACCESSKEYSECRET", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v aliyun_cdn_test.go -args \ + --CERTIMATE_DEPLOYER_ALIYUNCDN_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNCDN_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNCDN_ACCESSKEYID="your-access-key-id" \ + --CERTIMATE_DEPLOYER_ALIYUNCDN_ACCESSKEYSECRET="your-access-key-secret" \ + --CERTIMATE_DEPLOYER_ALIYUNCDN_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("ACCESSKEYSECRET: %v", fAccessKeySecret), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.AliyunCDNDeployerConfig{ + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/aliyun-clb/aliyun_clb.go b/internal/pkg/core/deployer/providers/aliyun-clb/aliyun_clb.go new file mode 100644 index 00000000..380c8581 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-clb/aliyun_clb.go @@ -0,0 +1,291 @@ +package aliyunclb + +import ( + "context" + "errors" + "fmt" + + aliyunOpen "github.com/alibabacloud-go/darabonba-openapi/v2/client" + aliyunSlb "github.com/alibabacloud-go/slb-20140515/v4/client" + "github.com/alibabacloud-go/tea/tea" + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerSlb "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/aliyun-slb" +) + +type AliyunCLBDeployerConfig struct { + // 阿里云 AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // 阿里云 AccessKeySecret。 + AccessKeySecret string `json:"accessKeySecret"` + // 阿里云地域。 + Region string `json:"region"` + // 部署资源类型。 + ResourceType DeployResourceType `json:"resourceType"` + // 负载均衡实例 ID。 + // 部署资源类型为 [DEPLOY_RESOURCE_LOADBALANCER]、[DEPLOY_RESOURCE_LISTENER] 时必填。 + LoadbalancerId string `json:"loadbalancerId,omitempty"` + // 负载均衡监听端口。 + // 部署资源类型为 [DEPLOY_RESOURCE_LISTENER] 时必填。 + ListenerPort int32 `json:"listenerPort,omitempty"` +} + +type AliyunCLBDeployer struct { + config *AliyunCLBDeployerConfig + logger deployer.Logger + sdkClient *aliyunSlb.Client + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*AliyunCLBDeployer)(nil) + +func New(config *AliyunCLBDeployerConfig) (*AliyunCLBDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *AliyunCLBDeployerConfig, logger deployer.Logger) (*AliyunCLBDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client, err := createSdkClient(config.AccessKeyId, config.AccessKeySecret, config.Region) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk client") + } + + uploader, err := providerSlb.New(&providerSlb.AliyunSLBUploaderConfig{ + AccessKeyId: config.AccessKeyId, + AccessKeySecret: config.AccessKeySecret, + Region: config.Region, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &AliyunCLBDeployer{ + logger: logger, + config: config, + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *AliyunCLBDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 SLB + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 根据部署资源类型决定部署方式 + switch d.config.ResourceType { + case DEPLOY_RESOURCE_LOADBALANCER: + if err := d.deployToLoadbalancer(ctx, upres.CertId); err != nil { + return nil, err + } + + case DEPLOY_RESOURCE_LISTENER: + if err := d.deployToListener(ctx, upres.CertId); err != nil { + return nil, err + } + + default: + return nil, fmt.Errorf("unsupported resource type: %s", d.config.ResourceType) + } + + return &deployer.DeployResult{}, nil +} + +func (d *AliyunCLBDeployer) deployToLoadbalancer(ctx context.Context, cloudCertId string) error { + if d.config.LoadbalancerId == "" { + return errors.New("config `loadbalancerId` is required") + } + + listenerPorts := make([]int32, 0) + + // 查询负载均衡实例的详细信息 + // REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-describeloadbalancerattribute + describeLoadBalancerAttributeReq := &aliyunSlb.DescribeLoadBalancerAttributeRequest{ + RegionId: tea.String(d.config.Region), + LoadBalancerId: tea.String(d.config.LoadbalancerId), + } + describeLoadBalancerAttributeResp, err := d.sdkClient.DescribeLoadBalancerAttribute(describeLoadBalancerAttributeReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'slb.DescribeLoadBalancerAttribute'") + } + + d.logger.Logt("已查询到 CLB 负载均衡实例", describeLoadBalancerAttributeResp) + + // 查询 HTTPS 监听列表 + // REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-describeloadbalancerlisteners + listListenersPage := 1 + listListenersLimit := int32(100) + var listListenersToken *string = nil + for { + describeLoadBalancerListenersReq := &aliyunSlb.DescribeLoadBalancerListenersRequest{ + RegionId: tea.String(d.config.Region), + MaxResults: tea.Int32(listListenersLimit), + NextToken: listListenersToken, + LoadBalancerId: []*string{tea.String(d.config.LoadbalancerId)}, + ListenerProtocol: tea.String("https"), + } + describeLoadBalancerListenersResp, err := d.sdkClient.DescribeLoadBalancerListeners(describeLoadBalancerListenersReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'slb.DescribeLoadBalancerListeners'") + } + + if describeLoadBalancerListenersResp.Body.Listeners != nil { + for _, listener := range describeLoadBalancerListenersResp.Body.Listeners { + listenerPorts = append(listenerPorts, *listener.ListenerPort) + } + } + + if len(describeLoadBalancerListenersResp.Body.Listeners) == 0 || describeLoadBalancerListenersResp.Body.NextToken == nil { + break + } else { + listListenersToken = describeLoadBalancerListenersResp.Body.NextToken + listListenersPage += 1 + } + } + + d.logger.Logt("已查询到 CLB 负载均衡实例下的全部 HTTPS 监听", listenerPorts) + + // 批量更新监听证书 + var errs []error + for _, listenerPort := range listenerPorts { + if err := d.updateListenerCertificate(ctx, d.config.LoadbalancerId, listenerPort, cloudCertId); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (d *AliyunCLBDeployer) deployToListener(ctx context.Context, cloudCertId string) error { + if d.config.LoadbalancerId == "" { + return errors.New("config `loadbalancerId` is required") + } + if d.config.ListenerPort == 0 { + return errors.New("config `listenerPort` is required") + } + + // 更新监听 + if err := d.updateListenerCertificate(ctx, d.config.LoadbalancerId, d.config.ListenerPort, cloudCertId); err != nil { + return err + } + + return nil +} + +func (d *AliyunCLBDeployer) updateListenerCertificate(ctx context.Context, cloudLoadbalancerId string, cloudListenerPort int32, cloudCertId string) error { + // 查询监听配置 + // REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-describeloadbalancerhttpslistenerattribute + describeLoadBalancerHTTPSListenerAttributeReq := &aliyunSlb.DescribeLoadBalancerHTTPSListenerAttributeRequest{ + LoadBalancerId: tea.String(cloudLoadbalancerId), + ListenerPort: tea.Int32(cloudListenerPort), + } + describeLoadBalancerHTTPSListenerAttributeResp, err := d.sdkClient.DescribeLoadBalancerHTTPSListenerAttribute(describeLoadBalancerHTTPSListenerAttributeReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'slb.DescribeLoadBalancerHTTPSListenerAttribute'") + } + + d.logger.Logt("已查询到 CLB HTTPS 监听配置", describeLoadBalancerHTTPSListenerAttributeResp) + + // 查询扩展域名 + // REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-describedomainextensions + describeDomainExtensionsReq := &aliyunSlb.DescribeDomainExtensionsRequest{ + RegionId: tea.String(d.config.Region), + LoadBalancerId: tea.String(cloudLoadbalancerId), + ListenerPort: tea.Int32(cloudListenerPort), + } + describeDomainExtensionsResp, err := d.sdkClient.DescribeDomainExtensions(describeDomainExtensionsReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'slb.DescribeDomainExtensions'") + } + + d.logger.Logt("已查询到 CLB 扩展域名", describeDomainExtensionsResp) + + // 遍历修改扩展域名 + // REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-setdomainextensionattribute + // + // 这里仅修改跟被替换证书一致的扩展域名 + if describeDomainExtensionsResp.Body.DomainExtensions != nil && describeDomainExtensionsResp.Body.DomainExtensions.DomainExtension != nil { + for _, domainExtension := range describeDomainExtensionsResp.Body.DomainExtensions.DomainExtension { + if *domainExtension.ServerCertificateId != *describeLoadBalancerHTTPSListenerAttributeResp.Body.ServerCertificateId { + continue + } + + setDomainExtensionAttributeReq := &aliyunSlb.SetDomainExtensionAttributeRequest{ + RegionId: tea.String(d.config.Region), + DomainExtensionId: tea.String(*domainExtension.DomainExtensionId), + ServerCertificateId: tea.String(cloudCertId), + } + _, err := d.sdkClient.SetDomainExtensionAttribute(setDomainExtensionAttributeReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'slb.SetDomainExtensionAttribute'") + } + } + } + + // 修改监听配置 + // REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-setloadbalancerhttpslistenerattribute + // + // 注意修改监听配置要放在修改扩展域名之后 + setLoadBalancerHTTPSListenerAttributeReq := &aliyunSlb.SetLoadBalancerHTTPSListenerAttributeRequest{ + RegionId: tea.String(d.config.Region), + LoadBalancerId: tea.String(cloudLoadbalancerId), + ListenerPort: tea.Int32(cloudListenerPort), + ServerCertificateId: tea.String(cloudCertId), + } + setLoadBalancerHTTPSListenerAttributeResp, err := d.sdkClient.SetLoadBalancerHTTPSListenerAttribute(setLoadBalancerHTTPSListenerAttributeReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'slb.SetLoadBalancerHTTPSListenerAttribute'") + } + + d.logger.Logt("已更新 CLB HTTPS 监听配置", setLoadBalancerHTTPSListenerAttributeResp) + + return nil +} + +func createSdkClient(accessKeyId, accessKeySecret, region string) (*aliyunSlb.Client, error) { + if region == "" { + region = "cn-hangzhou" // CLB(SLB) 服务默认区域:华东一杭州 + } + + // 接入点一览 https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-endpoint + var endpoint string + switch region { + case + "cn-hangzhou", + "cn-hangzhou-finance", + "cn-shanghai-finance-1", + "cn-shenzhen-finance-1": + endpoint = "slb.aliyuncs.com" + default: + endpoint = fmt.Sprintf("slb.%s.aliyuncs.com", region) + } + + config := &aliyunOpen.Config{ + AccessKeyId: tea.String(accessKeyId), + AccessKeySecret: tea.String(accessKeySecret), + Endpoint: tea.String(endpoint), + } + + client, err := aliyunSlb.NewClient(config) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/aliyun-clb/aliyun_clb_test.go b/internal/pkg/core/deployer/providers/aliyun-clb/aliyun_clb_test.go new file mode 100644 index 00000000..f9aa800d --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-clb/aliyun_clb_test.go @@ -0,0 +1,120 @@ +package aliyunclb_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-clb" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKeyId string + fAccessKeySecret string + fRegion string + fLoadbalancerId string + fListenerPort int +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_ALIYUNCLB_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "") + flag.StringVar(&fAccessKeySecret, argsPrefix+"ACCESSKEYSECRET", "", "") + flag.StringVar(&fRegion, argsPrefix+"REGION", "", "") + flag.StringVar(&fLoadbalancerId, argsPrefix+"LOADBALANCERID", "", "") + flag.IntVar(&fListenerPort, argsPrefix+"LISTENERPORT", 443, "") +} + +/* +Shell command to run this test: + + go test -v aliyun_clb_test.go -args \ + --CERTIMATE_DEPLOYER_ALIYUNCLB_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNCLB_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNCLB_ACCESSKEYID="your-access-key-id" \ + --CERTIMATE_DEPLOYER_ALIYUNCLB_ACCESSKEYSECRET="your-access-key-secret" \ + --CERTIMATE_DEPLOYER_ALIYUNCLB_REGION="cn-hangzhou" \ + --CERTIMATE_DEPLOYER_ALIYUNCLB_LOADBALANCERID="your-clb-instance-id" \ + --CERTIMATE_DEPLOYER_ALIYUNCLB_LISTENERPORT=443 +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy_ToLoadbalancer", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("ACCESSKEYSECRET: %v", fAccessKeySecret), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LOADBALANCERID: %v", fLoadbalancerId), + }, "\n")) + + deployer, err := provider.New(&provider.AliyunCLBDeployerConfig{ + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_LOADBALANCER, + LoadbalancerId: fLoadbalancerId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) + + t.Run("Deploy_ToListener", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("ACCESSKEYSECRET: %v", fAccessKeySecret), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LOADBALANCERID: %v", fLoadbalancerId), + fmt.Sprintf("LISTENERPORT: %v", fListenerPort), + }, "\n")) + + deployer, err := provider.New(&provider.AliyunCLBDeployerConfig{ + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_LISTENER, + LoadbalancerId: fLoadbalancerId, + ListenerPort: int32(fListenerPort), + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/aliyun-clb/defines.go b/internal/pkg/core/deployer/providers/aliyun-clb/defines.go new file mode 100644 index 00000000..4a02ab20 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-clb/defines.go @@ -0,0 +1,10 @@ +package aliyunclb + +type DeployResourceType string + +const ( + // 资源类型:部署到指定负载均衡器。 + DEPLOY_RESOURCE_LOADBALANCER = DeployResourceType("loadbalancer") + // 资源类型:部署到指定监听器。 + DEPLOY_RESOURCE_LISTENER = DeployResourceType("listener") +) diff --git a/internal/pkg/core/deployer/providers/aliyun-dcdn/aliyun_dcdn.go b/internal/pkg/core/deployer/providers/aliyun-dcdn/aliyun_dcdn.go new file mode 100644 index 00000000..41f21362 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-dcdn/aliyun_dcdn.go @@ -0,0 +1,97 @@ +package aliyundcdn + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + aliyunOpen "github.com/alibabacloud-go/darabonba-openapi/v2/client" + aliyunDcdn "github.com/alibabacloud-go/dcdn-20180115/v3/client" + "github.com/alibabacloud-go/tea/tea" + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" +) + +type AliyunDCDNDeployerConfig struct { + // 阿里云 AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // 阿里云 AccessKeySecret。 + AccessKeySecret string `json:"accessKeySecret"` + // 加速域名(支持泛域名)。 + Domain string `json:"domain"` +} + +type AliyunDCDNDeployer struct { + config *AliyunDCDNDeployerConfig + logger deployer.Logger + sdkClient *aliyunDcdn.Client +} + +var _ deployer.Deployer = (*AliyunDCDNDeployer)(nil) + +func New(config *AliyunDCDNDeployerConfig) (*AliyunDCDNDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *AliyunDCDNDeployerConfig, logger deployer.Logger) (*AliyunDCDNDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client, err := createSdkClient(config.AccessKeyId, config.AccessKeySecret) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk client") + } + + return &AliyunDCDNDeployer{ + logger: logger, + config: config, + sdkClient: client, + }, nil +} + +func (d *AliyunDCDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // "*.example.com" → ".example.com",适配阿里云 DCDN 要求的泛域名格式 + domain := strings.TrimPrefix(d.config.Domain, "*") + + // 配置域名证书 + // REF: https://help.aliyun.com/zh/edge-security-acceleration/dcdn/developer-reference/api-dcdn-2018-01-15-setdcdndomainsslcertificate + setDcdnDomainSSLCertificateReq := &aliyunDcdn.SetDcdnDomainSSLCertificateRequest{ + DomainName: tea.String(domain), + CertName: tea.String(fmt.Sprintf("certimate-%d", time.Now().UnixMilli())), + CertType: tea.String("upload"), + SSLProtocol: tea.String("on"), + SSLPub: tea.String(certPem), + SSLPri: tea.String(privkeyPem), + } + setDcdnDomainSSLCertificateResp, err := d.sdkClient.SetDcdnDomainSSLCertificate(setDcdnDomainSSLCertificateReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'dcdn.SetDcdnDomainSSLCertificate'") + } + + d.logger.Logt("已配置 DCDN 域名证书", setDcdnDomainSSLCertificateResp) + + return &deployer.DeployResult{}, nil +} + +func createSdkClient(accessKeyId, accessKeySecret string) (*aliyunDcdn.Client, error) { + config := &aliyunOpen.Config{ + AccessKeyId: tea.String(accessKeyId), + AccessKeySecret: tea.String(accessKeySecret), + Endpoint: tea.String("dcdn.aliyuncs.com"), + } + + client, err := aliyunDcdn.NewClient(config) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/aliyun-dcdn/aliyun_dcdn_test.go b/internal/pkg/core/deployer/providers/aliyun-dcdn/aliyun_dcdn_test.go new file mode 100644 index 00000000..8fdddf80 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-dcdn/aliyun_dcdn_test.go @@ -0,0 +1,75 @@ +package aliyundcdn_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-dcdn" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKeyId string + fAccessKeySecret string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_ALIYUNDCDN_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "") + flag.StringVar(&fAccessKeySecret, argsPrefix+"ACCESSKEYSECRET", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v aliyun_dcdn_test.go -args \ + --CERTIMATE_DEPLOYER_ALIYUNDCDN_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNDCDN_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNDCDN_ACCESSKEYID="your-access-key-id" \ + --CERTIMATE_DEPLOYER_ALIYUNDCDN_ACCESSKEYSECRET="your-access-key-secret" \ + --CERTIMATE_DEPLOYER_ALIYUNDCDN_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("ACCESSKEYSECRET: %v", fAccessKeySecret), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.AliyunDCDNDeployerConfig{ + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/aliyun-nlb/aliyun_nlb.go b/internal/pkg/core/deployer/providers/aliyun-nlb/aliyun_nlb.go new file mode 100644 index 00000000..93ef6bf1 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-nlb/aliyun_nlb.go @@ -0,0 +1,251 @@ +package aliyunnlb + +import ( + "context" + "errors" + "fmt" + "strings" + + aliyunOpen "github.com/alibabacloud-go/darabonba-openapi/v2/client" + aliyunNlb "github.com/alibabacloud-go/nlb-20220430/v2/client" + "github.com/alibabacloud-go/tea/tea" + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerCas "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/aliyun-cas" +) + +type AliyunNLBDeployerConfig struct { + // 阿里云 AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // 阿里云 AccessKeySecret。 + AccessKeySecret string `json:"accessKeySecret"` + // 阿里云地域。 + Region string `json:"region"` + // 部署资源类型。 + ResourceType DeployResourceType `json:"resourceType"` + // 负载均衡实例 ID。 + // 部署资源类型为 [DEPLOY_RESOURCE_LOADBALANCER] 时必填。 + LoadbalancerId string `json:"loadbalancerId,omitempty"` + // 负载均衡监听 ID。 + // 部署资源类型为 [DEPLOY_RESOURCE_LISTENER] 时必填。 + ListenerId string `json:"listenerId,omitempty"` +} + +type AliyunNLBDeployer struct { + config *AliyunNLBDeployerConfig + logger deployer.Logger + sdkClient *aliyunNlb.Client + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*AliyunNLBDeployer)(nil) + +func New(config *AliyunNLBDeployerConfig) (*AliyunNLBDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *AliyunNLBDeployerConfig, logger deployer.Logger) (*AliyunNLBDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client, err := createSdkClient(config.AccessKeyId, config.AccessKeySecret, config.Region) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk client") + } + + aliyunCasRegion := config.Region + if aliyunCasRegion != "" { + // 阿里云 CAS 服务接入点是独立于 NLB 服务的 + // 国内版固定接入点:华东一杭州 + // 国际版固定接入点:亚太东南一新加坡 + if !strings.HasPrefix(aliyunCasRegion, "cn-") { + aliyunCasRegion = "ap-southeast-1" + } else { + aliyunCasRegion = "cn-hangzhou" + } + } + uploader, err := providerCas.New(&providerCas.AliyunCASUploaderConfig{ + AccessKeyId: config.AccessKeyId, + AccessKeySecret: config.AccessKeySecret, + Region: aliyunCasRegion, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &AliyunNLBDeployer{ + logger: logger, + config: config, + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *AliyunNLBDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 CAS + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 根据部署资源类型决定部署方式 + switch d.config.ResourceType { + case DEPLOY_RESOURCE_LOADBALANCER: + if err := d.deployToLoadbalancer(ctx, upres.CertId); err != nil { + return nil, err + } + + case DEPLOY_RESOURCE_LISTENER: + if err := d.deployToListener(ctx, upres.CertId); err != nil { + return nil, err + } + + default: + return nil, fmt.Errorf("unsupported resource type: %s", d.config.ResourceType) + } + + return &deployer.DeployResult{}, nil +} + +func (d *AliyunNLBDeployer) deployToLoadbalancer(ctx context.Context, cloudCertId string) error { + if d.config.LoadbalancerId == "" { + return errors.New("config `loadbalancerId` is required") + } + + listenerIds := make([]string, 0) + + // 查询负载均衡实例的详细信息 + // REF: https://help.aliyun.com/zh/slb/network-load-balancer/developer-reference/api-nlb-2022-04-30-getloadbalancerattribute + getLoadBalancerAttributeReq := &aliyunNlb.GetLoadBalancerAttributeRequest{ + LoadBalancerId: tea.String(d.config.LoadbalancerId), + } + getLoadBalancerAttributeResp, err := d.sdkClient.GetLoadBalancerAttribute(getLoadBalancerAttributeReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'nlb.GetLoadBalancerAttribute'") + } + + d.logger.Logt("已查询到 NLB 负载均衡实例", getLoadBalancerAttributeResp) + + // 查询 TCPSSL 监听列表 + // REF: https://help.aliyun.com/zh/slb/network-load-balancer/developer-reference/api-nlb-2022-04-30-listlisteners + listListenersPage := 1 + listListenersLimit := int32(100) + var listListenersToken *string = nil + for { + listListenersReq := &aliyunNlb.ListListenersRequest{ + MaxResults: tea.Int32(listListenersLimit), + NextToken: listListenersToken, + LoadBalancerIds: []*string{tea.String(d.config.LoadbalancerId)}, + ListenerProtocol: tea.String("TCPSSL"), + } + listListenersResp, err := d.sdkClient.ListListeners(listListenersReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'nlb.ListListeners'") + } + + if listListenersResp.Body.Listeners != nil { + for _, listener := range listListenersResp.Body.Listeners { + listenerIds = append(listenerIds, *listener.ListenerId) + } + } + + if len(listListenersResp.Body.Listeners) == 0 || listListenersResp.Body.NextToken == nil { + break + } else { + listListenersToken = listListenersResp.Body.NextToken + listListenersPage += 1 + } + } + + d.logger.Logt("已查询到 NLB 负载均衡实例下的全部 TCPSSL 监听", listenerIds) + + // 批量更新监听证书 + var errs []error + for _, listenerId := range listenerIds { + if err := d.updateListenerCertificate(ctx, listenerId, cloudCertId); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (d *AliyunNLBDeployer) deployToListener(ctx context.Context, cloudCertId string) error { + if d.config.ListenerId == "" { + return errors.New("config `listenerId` is required") + } + + // 更新监听 + if err := d.updateListenerCertificate(ctx, d.config.ListenerId, cloudCertId); err != nil { + return err + } + + return nil +} + +func (d *AliyunNLBDeployer) updateListenerCertificate(ctx context.Context, cloudListenerId string, cloudCertId string) error { + // 查询监听的属性 + // REF: https://help.aliyun.com/zh/slb/network-load-balancer/developer-reference/api-nlb-2022-04-30-getlistenerattribute + getListenerAttributeReq := &aliyunNlb.GetListenerAttributeRequest{ + ListenerId: tea.String(cloudListenerId), + } + getListenerAttributeResp, err := d.sdkClient.GetListenerAttribute(getListenerAttributeReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'nlb.GetListenerAttribute'") + } + + d.logger.Logt("已查询到 NLB 监听配置", getListenerAttributeResp) + + // 修改监听的属性 + // REF: https://help.aliyun.com/zh/slb/network-load-balancer/developer-reference/api-nlb-2022-04-30-updatelistenerattribute + updateListenerAttributeReq := &aliyunNlb.UpdateListenerAttributeRequest{ + ListenerId: tea.String(cloudListenerId), + CertificateIds: []*string{tea.String(cloudCertId)}, + } + updateListenerAttributeResp, err := d.sdkClient.UpdateListenerAttribute(updateListenerAttributeReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'nlb.UpdateListenerAttribute'") + } + + d.logger.Logt("已更新 NLB 监听配置", updateListenerAttributeResp) + + return nil +} + +func createSdkClient(accessKeyId, accessKeySecret, region string) (*aliyunNlb.Client, error) { + if region == "" { + region = "cn-hangzhou" // NLB 服务默认区域:华东一杭州 + } + + // 接入点一览 https://help.aliyun.com/zh/slb/network-load-balancer/developer-reference/api-nlb-2022-04-30-endpoint + var endpoint string + switch region { + default: + endpoint = fmt.Sprintf("nlb.%s.aliyuncs.com", region) + } + + config := &aliyunOpen.Config{ + AccessKeyId: tea.String(accessKeyId), + AccessKeySecret: tea.String(accessKeySecret), + Endpoint: tea.String(endpoint), + } + + client, err := aliyunNlb.NewClient(config) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/aliyun-nlb/aliyun_nlb_test.go b/internal/pkg/core/deployer/providers/aliyun-nlb/aliyun_nlb_test.go new file mode 100644 index 00000000..9c976dd9 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-nlb/aliyun_nlb_test.go @@ -0,0 +1,119 @@ +package aliyunnlb_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-nlb" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKeyId string + fAccessKeySecret string + fRegion string + fLoadbalancerId string + fListenerId string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_ALIYUNNLB_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "") + flag.StringVar(&fAccessKeySecret, argsPrefix+"ACCESSKEYSECRET", "", "") + flag.StringVar(&fRegion, argsPrefix+"REGION", "", "") + flag.StringVar(&fLoadbalancerId, argsPrefix+"LOADBALANCERID", "", "") + flag.StringVar(&fListenerId, argsPrefix+"LISTENERID", "", "") +} + +/* +Shell command to run this test: + + go test -v aliyun_nlb_test.go -args \ + --CERTIMATE_DEPLOYER_ALIYUNNLB_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNNLB_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNNLB_ACCESSKEYID="your-access-key-id" \ + --CERTIMATE_DEPLOYER_ALIYUNNLB_ACCESSKEYSECRET="your-access-key-secret" \ + --CERTIMATE_DEPLOYER_ALIYUNNLB_REGION="cn-hangzhou" \ + --CERTIMATE_DEPLOYER_ALIYUNNLB_LOADBALANCERID="your-nlb-instance-id" \ + --CERTIMATE_DEPLOYER_ALIYUNNLB_LISTENERID="your-nlb-listener-id" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy_ToLoadbalancer", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("ACCESSKEYSECRET: %v", fAccessKeySecret), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LOADBALANCERID: %v", fLoadbalancerId), + }, "\n")) + + deployer, err := provider.New(&provider.AliyunNLBDeployerConfig{ + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_LOADBALANCER, + LoadbalancerId: fLoadbalancerId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) + + t.Run("Deploy_ToListener", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("ACCESSKEYSECRET: %v", fAccessKeySecret), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LOADBALANCERID: %v", fLoadbalancerId), + fmt.Sprintf("LISTENERID: %v", fListenerId), + }, "\n")) + + deployer, err := provider.New(&provider.AliyunNLBDeployerConfig{ + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_LISTENER, + ListenerId: fListenerId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/aliyun-nlb/defines.go b/internal/pkg/core/deployer/providers/aliyun-nlb/defines.go new file mode 100644 index 00000000..14e3c2b2 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-nlb/defines.go @@ -0,0 +1,10 @@ +package aliyunnlb + +type DeployResourceType string + +const ( + // 资源类型:部署到指定负载均衡器。 + DEPLOY_RESOURCE_LOADBALANCER = DeployResourceType("loadbalancer") + // 资源类型:部署到指定监听器。 + DEPLOY_RESOURCE_LISTENER = DeployResourceType("listener") +) diff --git a/internal/pkg/core/deployer/providers/aliyun-oss/aliyun_oss.go b/internal/pkg/core/deployer/providers/aliyun-oss/aliyun_oss.go new file mode 100644 index 00000000..656c9e10 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-oss/aliyun_oss.go @@ -0,0 +1,112 @@ +package aliyunoss + +import ( + "context" + "errors" + "fmt" + + "github.com/aliyun/aliyun-oss-go-sdk/oss" + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" +) + +type AliyunOSSDeployerConfig struct { + // 阿里云 AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // 阿里云 AccessKeySecret。 + AccessKeySecret string `json:"accessKeySecret"` + // 阿里云地域。 + Region string `json:"region"` + // 存储桶名。 + Bucket string `json:"bucket"` + // 自定义域名(不支持泛域名)。 + Domain string `json:"domain"` +} + +type AliyunOSSDeployer struct { + config *AliyunOSSDeployerConfig + logger deployer.Logger + sdkClient *oss.Client +} + +var _ deployer.Deployer = (*AliyunOSSDeployer)(nil) + +func New(config *AliyunOSSDeployerConfig) (*AliyunOSSDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *AliyunOSSDeployerConfig, logger deployer.Logger) (*AliyunOSSDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client, err := createSdkClient(config.AccessKeyId, config.AccessKeySecret, config.Region) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk client") + } + + return &AliyunOSSDeployer{ + logger: logger, + config: config, + sdkClient: client, + }, nil +} + +func (d *AliyunOSSDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + if d.config.Bucket == "" { + return nil, errors.New("config `bucket` is required") + } + if d.config.Domain == "" { + return nil, errors.New("config `domain` is required") + } + + // 为存储空间绑定自定义域名 + // REF: https://help.aliyun.com/zh/oss/developer-reference/putcname + err := d.sdkClient.PutBucketCnameWithCertificate(d.config.Bucket, oss.PutBucketCname{ + Cname: d.config.Domain, + CertificateConfiguration: &oss.CertificateConfiguration{ + Certificate: certPem, + PrivateKey: privkeyPem, + Force: true, + }, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'oss.PutBucketCnameWithCertificate'") + } + + return &deployer.DeployResult{}, nil +} + +func createSdkClient(accessKeyId, accessKeySecret, region string) (*oss.Client, error) { + // 接入点一览 https://help.aliyun.com/zh/oss/user-guide/regions-and-endpoints + var endpoint string + switch region { + case "": + endpoint = "oss.aliyuncs.com" + case + "cn-hzjbp", + "cn-hzjbp-a", + "cn-hzjbp-b": + endpoint = "oss-cn-hzjbp-a-internal.aliyuncs.com" + case + "cn-shanghai-finance-1", + "cn-shenzhen-finance-1", + "cn-beijing-finance-1", + "cn-north-2-gov-1": + endpoint = fmt.Sprintf("oss-%s-internal.aliyuncs.com", region) + default: + endpoint = fmt.Sprintf("oss-%s.aliyuncs.com", region) + } + + client, err := oss.New(endpoint, accessKeyId, accessKeySecret) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/aliyun-oss/aliyun_oss_test.go b/internal/pkg/core/deployer/providers/aliyun-oss/aliyun_oss_test.go new file mode 100644 index 00000000..afb31853 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aliyun-oss/aliyun_oss_test.go @@ -0,0 +1,85 @@ +package aliyunoss_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-oss" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKeyId string + fAccessKeySecret string + fRegion string + fBucket string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_ALIYUNOSS_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "") + flag.StringVar(&fAccessKeySecret, argsPrefix+"ACCESSKEYSECRET", "", "") + flag.StringVar(&fRegion, argsPrefix+"REGION", "", "") + flag.StringVar(&fBucket, argsPrefix+"BUCKET", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v aliyun_oss_test.go -args \ + --CERTIMATE_DEPLOYER_ALIYUNOSS_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNOSS_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_ALIYUNOSS_ACCESSKEYID="your-access-key-id" \ + --CERTIMATE_DEPLOYER_ALIYUNOSS_ACCESSKEYSECRET="your-access-key-secret" \ + --CERTIMATE_DEPLOYER_ALIYUNOSS_REGION="cn-hangzhou" \ + --CERTIMATE_DEPLOYER_ALIYUNOSS_BUCKET="your-oss-bucket" \ + --CERTIMATE_DEPLOYER_ALIYUNOSS_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("ACCESSKEYSECRET: %v", fAccessKeySecret), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("BUCKET: %v", fBucket), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.AliyunOSSDeployerConfig{ + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + Region: fRegion, + Bucket: fBucket, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/baiducloud-cdn/baiducloud_cdn.go b/internal/pkg/core/deployer/providers/baiducloud-cdn/baiducloud_cdn.go new file mode 100644 index 00000000..28d6bed5 --- /dev/null +++ b/internal/pkg/core/deployer/providers/baiducloud-cdn/baiducloud_cdn.go @@ -0,0 +1,86 @@ +package baiducloudcdn + +import ( + "context" + "errors" + "fmt" + "time" + + bceCdn "github.com/baidubce/bce-sdk-go/services/cdn" + bceCdnApi "github.com/baidubce/bce-sdk-go/services/cdn/api" + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" +) + +type BaiduCloudCDNDeployerConfig struct { + // 百度智能云 AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // 百度智能云 SecretAccessKey。 + SecretAccessKey string `json:"secretAccessKey"` + // 加速域名(不支持泛域名)。 + Domain string `json:"domain"` +} + +type BaiduCloudCDNDeployer struct { + config *BaiduCloudCDNDeployerConfig + logger deployer.Logger + sdkClient *bceCdn.Client +} + +var _ deployer.Deployer = (*BaiduCloudCDNDeployer)(nil) + +func New(config *BaiduCloudCDNDeployerConfig) (*BaiduCloudCDNDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *BaiduCloudCDNDeployerConfig, logger deployer.Logger) (*BaiduCloudCDNDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client, err := createSdkClient(config.AccessKeyId, config.SecretAccessKey) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk client") + } + + return &BaiduCloudCDNDeployer{ + logger: logger, + config: config, + sdkClient: client, + }, nil +} + +func (d *BaiduCloudCDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 修改域名证书 + // REF: https://cloud.baidu.com/doc/CDN/s/qjzuz2hp8 + putCertResp, err := d.sdkClient.PutCert( + d.config.Domain, + &bceCdnApi.UserCertificate{ + CertName: fmt.Sprintf("certimate-%d", time.Now().UnixMilli()), + ServerData: certPem, + PrivateData: privkeyPem, + }, + "ON", + ) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.PutCert'") + } + + d.logger.Logt("已修改域名证书", putCertResp) + + return &deployer.DeployResult{}, nil +} + +func createSdkClient(accessKeyId, secretAccessKey string) (*bceCdn.Client, error) { + client, err := bceCdn.NewClient(accessKeyId, secretAccessKey, "") + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/baiducloud-cdn/baiducloud_cdn_test.go b/internal/pkg/core/deployer/providers/baiducloud-cdn/baiducloud_cdn_test.go new file mode 100644 index 00000000..e3398505 --- /dev/null +++ b/internal/pkg/core/deployer/providers/baiducloud-cdn/baiducloud_cdn_test.go @@ -0,0 +1,75 @@ +package baiducloudcdn_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/baiducloud-cdn" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKeyId string + fSecretAccessKey string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_BAIDUCLOUDCDN_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "") + flag.StringVar(&fSecretAccessKey, argsPrefix+"SECRETACCESSKEY", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v baiducloud_cdn_test.go -args \ + --CERTIMATE_DEPLOYER_BAIDUCLOUDCDN_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_BAIDUCLOUDCDN_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_BAIDUCLOUDCDN_ACCESSKEYID="your-access-key-id" \ + --CERTIMATE_DEPLOYER_BAIDUCLOUDCDN_SECRETACCESSKEY="your-secret-access-key" \ + --CERTIMATE_DEPLOYER_BAIDUCLOUDCDN_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("SECRETACCESSKEY: %v", fSecretAccessKey), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.BaiduCloudCDNDeployerConfig{ + AccessKeyId: fAccessKeyId, + SecretAccessKey: fSecretAccessKey, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/byteplus-cdn/byteplus_cdn.go b/internal/pkg/core/deployer/providers/byteplus-cdn/byteplus_cdn.go new file mode 100644 index 00000000..27650e05 --- /dev/null +++ b/internal/pkg/core/deployer/providers/byteplus-cdn/byteplus_cdn.go @@ -0,0 +1,136 @@ +package bytepluscdn + +import ( + "context" + "errors" + "fmt" + "strings" + + bpCdn "github.com/byteplus-sdk/byteplus-sdk-golang/service/cdn" + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerCdn "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/byteplus-cdn" +) + +type BytePlusCDNDeployerConfig struct { + // BytePlus AccessKey。 + AccessKey string `json:"accessKey"` + // BytePlus SecretKey。 + SecretKey string `json:"secretKey"` + // 加速域名(支持泛域名)。 + Domain string `json:"domain"` +} + +type BytePlusCDNDeployer struct { + config *BytePlusCDNDeployerConfig + logger deployer.Logger + sdkClient *bpCdn.CDN + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*BytePlusCDNDeployer)(nil) + +func New(config *BytePlusCDNDeployerConfig) (*BytePlusCDNDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *BytePlusCDNDeployerConfig, logger deployer.Logger) (*BytePlusCDNDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client := bpCdn.NewInstance() + client.Client.SetAccessKey(config.AccessKey) + client.Client.SetSecretKey(config.SecretKey) + + uploader, err := providerCdn.New(&providerCdn.ByteplusCDNUploaderConfig{ + AccessKey: config.AccessKey, + SecretKey: config.SecretKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &BytePlusCDNDeployer{ + logger: logger, + config: config, + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *BytePlusCDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 CDN + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + domains := make([]string, 0) + if strings.HasPrefix(d.config.Domain, "*.") { + // 获取指定证书可关联的域名 + // REF: https://docs.byteplus.com/en/docs/byteplus-cdn/reference-describecertconfig-9ea17 + describeCertConfigReq := &bpCdn.DescribeCertConfigRequest{ + CertId: upres.CertId, + } + describeCertConfigResp, err := d.sdkClient.DescribeCertConfig(describeCertConfigReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.DescribeCertConfig'") + } + + if describeCertConfigResp.Result.CertNotConfig != nil { + for i := range describeCertConfigResp.Result.CertNotConfig { + domains = append(domains, describeCertConfigResp.Result.CertNotConfig[i].Domain) + } + } + + if describeCertConfigResp.Result.OtherCertConfig != nil { + for i := range describeCertConfigResp.Result.OtherCertConfig { + domains = append(domains, describeCertConfigResp.Result.OtherCertConfig[i].Domain) + } + } + + if len(domains) == 0 { + if len(describeCertConfigResp.Result.SpecifiedCertConfig) > 0 { + // 所有可关联的域名都配置了该证书,跳过部署 + } else { + return nil, xerrors.New("domain not found") + } + } + } else { + domains = append(domains, d.config.Domain) + } + + if len(domains) > 0 { + var errs []error + + for _, domain := range domains { + // 关联证书与加速域名 + // REF: https://docs.byteplus.com/en/docs/byteplus-cdn/reference-batchdeploycert + batchDeployCertReq := &bpCdn.BatchDeployCertRequest{ + CertId: upres.CertId, + Domain: domain, + } + batchDeployCertResp, err := d.sdkClient.BatchDeployCert(batchDeployCertReq) + if err != nil { + errs = append(errs, err) + } else { + d.logger.Logt(fmt.Sprintf("已关联证书到域名 %s", domain), batchDeployCertResp) + } + } + + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + } + + return &deployer.DeployResult{}, nil +} diff --git a/internal/pkg/core/deployer/providers/byteplus-cdn/byteplus_cdn_test.go b/internal/pkg/core/deployer/providers/byteplus-cdn/byteplus_cdn_test.go new file mode 100644 index 00000000..0c411062 --- /dev/null +++ b/internal/pkg/core/deployer/providers/byteplus-cdn/byteplus_cdn_test.go @@ -0,0 +1,75 @@ +package bytepluscdn_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/byteplus-cdn" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKey string + fSecretKey string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_BYTEPLUSCDN_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKey, argsPrefix+"ACCESSKEY", "", "") + flag.StringVar(&fSecretKey, argsPrefix+"SECRETKEY", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v byteplus_cdn_test.go -args \ + --CERTIMATE_DEPLOYER_BYTEPLUSCDN_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_BYTEPLUSCDN_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_BYTEPLUSCDN_ACCESSKEY="your-access-key" \ + --CERTIMATE_DEPLOYER_BYTEPLUSCDN_SECRETKEY="your-secret-key" \ + --CERTIMATE_DEPLOYER_BYTEPLUSCDN_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEY: %v", fAccessKey), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.BytePlusCDNDeployerConfig{ + AccessKey: fAccessKey, + SecretKey: fSecretKey, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn.go b/internal/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn.go new file mode 100644 index 00000000..20ef5904 --- /dev/null +++ b/internal/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn.go @@ -0,0 +1,85 @@ +package dogecloudcdn + +import ( + "context" + "errors" + "strconv" + + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerDoge "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/dogecloud" + dogesdk "github.com/usual2970/certimate/internal/pkg/vendors/dogecloud-sdk" +) + +type DogeCloudCDNDeployerConfig struct { + // 多吉云 AccessKey。 + AccessKey string `json:"accessKey"` + // 多吉云 SecretKey。 + SecretKey string `json:"secretKey"` + // 加速域名(不支持泛域名)。 + Domain string `json:"domain"` +} + +type DogeCloudCDNDeployer struct { + config *DogeCloudCDNDeployerConfig + logger deployer.Logger + sdkClient *dogesdk.Client + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*DogeCloudCDNDeployer)(nil) + +func New(config *DogeCloudCDNDeployerConfig) (*DogeCloudCDNDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *DogeCloudCDNDeployerConfig, logger deployer.Logger) (*DogeCloudCDNDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client := dogesdk.NewClient(config.AccessKey, config.SecretKey) + + uploader, err := providerDoge.New(&providerDoge.DogeCloudUploaderConfig{ + AccessKey: config.AccessKey, + SecretKey: config.SecretKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &DogeCloudCDNDeployer{ + logger: logger, + config: config, + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *DogeCloudCDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 CDN + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 绑定证书 + // REF: https://docs.dogecloud.com/cdn/api-cert-bind + bindCdnCertId, _ := strconv.ParseInt(upres.CertId, 10, 64) + bindCdnCertResp, err := d.sdkClient.BindCdnCertWithDomain(bindCdnCertId, d.config.Domain) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.BindCdnCert'") + } + + d.logger.Logt("已绑定证书", bindCdnCertResp) + + return &deployer.DeployResult{}, nil +} diff --git a/internal/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn_test.go b/internal/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn_test.go new file mode 100644 index 00000000..2375e372 --- /dev/null +++ b/internal/pkg/core/deployer/providers/dogecloud-cdn/dogecloud_cdn_test.go @@ -0,0 +1,75 @@ +package dogecloudcdn_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/dogecloud-cdn" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKey string + fSecretKey string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_DOGECLOUDCDN_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKey, argsPrefix+"ACCESSKEY", "", "") + flag.StringVar(&fSecretKey, argsPrefix+"SECRETKEY", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v dogecloud_cdn_test.go -args \ + --CERTIMATE_DEPLOYER_DOGECLOUDCDN_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_DOGECLOUDCDN_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_DOGECLOUDCDN_ACCESSKEY="your-access-key" \ + --CERTIMATE_DEPLOYER_DOGECLOUDCDN_SECRETKEY="your-secret-key" \ + --CERTIMATE_DEPLOYER_DOGECLOUDCDN_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEY: %v", fAccessKey), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.DogeCloudCDNDeployerConfig{ + AccessKey: fAccessKey, + SecretKey: fSecretKey, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/huaweicloud-cdn/huaweicloud_cdn.go b/internal/pkg/core/deployer/providers/huaweicloud-cdn/huaweicloud_cdn.go new file mode 100644 index 00000000..9c7131d1 --- /dev/null +++ b/internal/pkg/core/deployer/providers/huaweicloud-cdn/huaweicloud_cdn.go @@ -0,0 +1,152 @@ +package huaweicloudcdn + +import ( + "context" + "errors" + + "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/global" + hcCdn "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/cdn/v2" + hcCdnModel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/cdn/v2/model" + hcCdnRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/cdn/v2/region" + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerScm "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/huaweicloud-scm" + "github.com/usual2970/certimate/internal/pkg/utils/cast" + huaweicloudsdk "github.com/usual2970/certimate/internal/pkg/vendors/huaweicloud-cdn-sdk" +) + +type HuaweiCloudCDNDeployerConfig struct { + // 华为云 AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // 华为云 SecretAccessKey。 + SecretAccessKey string `json:"secretAccessKey"` + // 华为云地域。 + Region string `json:"region"` + // 加速域名(不支持泛域名)。 + Domain string `json:"domain"` +} + +type HuaweiCloudCDNDeployer struct { + config *HuaweiCloudCDNDeployerConfig + logger deployer.Logger + sdkClient *huaweicloudsdk.Client + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*HuaweiCloudCDNDeployer)(nil) + +func New(config *HuaweiCloudCDNDeployerConfig) (*HuaweiCloudCDNDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *HuaweiCloudCDNDeployerConfig, logger deployer.Logger) (*HuaweiCloudCDNDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client, err := createSdkClient( + config.AccessKeyId, + config.SecretAccessKey, + config.Region, + ) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk client") + } + + uploader, err := providerScm.New(&providerScm.HuaweiCloudSCMUploaderConfig{ + AccessKeyId: config.AccessKeyId, + SecretAccessKey: config.SecretAccessKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &HuaweiCloudCDNDeployer{ + logger: logger, + config: config, + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *HuaweiCloudCDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 SCM + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 查询加速域名配置 + // REF: https://support.huaweicloud.com/api-cdn/ShowDomainFullConfig.html + showDomainFullConfigReq := &hcCdnModel.ShowDomainFullConfigRequest{ + DomainName: d.config.Domain, + } + showDomainFullConfigResp, err := d.sdkClient.ShowDomainFullConfig(showDomainFullConfigReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.ShowDomainFullConfig'") + } + + d.logger.Logt("已查询到加速域名配置", showDomainFullConfigResp) + + // 更新加速域名配置 + // REF: https://support.huaweicloud.com/api-cdn/UpdateDomainMultiCertificates.html + // REF: https://support.huaweicloud.com/usermanual-cdn/cdn_01_0306.html + updateDomainMultiCertificatesReqBodyContent := &huaweicloudsdk.UpdateDomainMultiCertificatesExRequestBodyContent{} + updateDomainMultiCertificatesReqBodyContent.DomainName = d.config.Domain + updateDomainMultiCertificatesReqBodyContent.HttpsSwitch = 1 + updateDomainMultiCertificatesReqBodyContent.CertificateType = cast.Int32Ptr(2) + updateDomainMultiCertificatesReqBodyContent.SCMCertificateId = cast.StringPtr(upres.CertId) + updateDomainMultiCertificatesReqBodyContent.CertName = cast.StringPtr(upres.CertName) + updateDomainMultiCertificatesReqBodyContent = updateDomainMultiCertificatesReqBodyContent.MergeConfig(showDomainFullConfigResp.Configs) + updateDomainMultiCertificatesReq := &huaweicloudsdk.UpdateDomainMultiCertificatesExRequest{ + Body: &huaweicloudsdk.UpdateDomainMultiCertificatesExRequestBody{ + Https: updateDomainMultiCertificatesReqBodyContent, + }, + } + updateDomainMultiCertificatesResp, err := d.sdkClient.UploadDomainMultiCertificatesEx(updateDomainMultiCertificatesReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.UploadDomainMultiCertificatesEx'") + } + + d.logger.Logt("已更新加速域名配置", updateDomainMultiCertificatesResp) + + return &deployer.DeployResult{}, nil +} + +func createSdkClient(accessKeyId, secretAccessKey, region string) (*huaweicloudsdk.Client, error) { + if region == "" { + region = "cn-north-1" // CDN 服务默认区域:华北一北京 + } + + auth, err := global.NewCredentialsBuilder(). + WithAk(accessKeyId). + WithSk(secretAccessKey). + SafeBuild() + if err != nil { + return nil, err + } + + hcRegion, err := hcCdnRegion.SafeValueOf(region) + if err != nil { + return nil, err + } + + hcClient, err := hcCdn.CdnClientBuilder(). + WithRegion(hcRegion). + WithCredential(auth). + SafeBuild() + if err != nil { + return nil, err + } + + client := huaweicloudsdk.NewClient(hcClient) + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/huaweicloud-cdn/huaweicloud_cdn_test.go b/internal/pkg/core/deployer/providers/huaweicloud-cdn/huaweicloud_cdn_test.go new file mode 100644 index 00000000..d50e3ea5 --- /dev/null +++ b/internal/pkg/core/deployer/providers/huaweicloud-cdn/huaweicloud_cdn_test.go @@ -0,0 +1,80 @@ +package huaweicloudcdn_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/huaweicloud-cdn" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKeyId string + fSecretAccessKey string + fRegion string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_HUAWEICLOUDCDN_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "") + flag.StringVar(&fSecretAccessKey, argsPrefix+"SECRETACCESSKEY", "", "") + flag.StringVar(&fRegion, argsPrefix+"REGION", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v huaweicloud_cdn_test.go -args \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDCDN_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDCDN_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDCDN_ACCESSKEYID="your-access-key-id" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDCDN_SECRETACCESSKEY="your-secret-access-key" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDCDN_REGION="cn-north-1" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDCDN_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("SECRETACCESSKEY: %v", fSecretAccessKey), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.HuaweiCloudCDNDeployerConfig{ + AccessKeyId: fAccessKeyId, + SecretAccessKey: fSecretAccessKey, + Region: fRegion, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/huaweicloud-elb/defines.go b/internal/pkg/core/deployer/providers/huaweicloud-elb/defines.go new file mode 100644 index 00000000..093ab829 --- /dev/null +++ b/internal/pkg/core/deployer/providers/huaweicloud-elb/defines.go @@ -0,0 +1,12 @@ +package huaweicloudelb + +type DeployResourceType string + +const ( + // 资源类型:替换指定证书。 + DEPLOY_RESOURCE_CERTIFICATE = DeployResourceType("certificate") + // 资源类型:部署到指定负载均衡器。 + DEPLOY_RESOURCE_LOADBALANCER = DeployResourceType("loadbalancer") + // 资源类型:部署到指定监听器。 + DEPLOY_RESOURCE_LISTENER = DeployResourceType("listener") +) diff --git a/internal/pkg/core/deployer/providers/huaweicloud-elb/huaweicloud_elb.go b/internal/pkg/core/deployer/providers/huaweicloud-elb/huaweicloud_elb.go new file mode 100644 index 00000000..7cbc4f12 --- /dev/null +++ b/internal/pkg/core/deployer/providers/huaweicloud-elb/huaweicloud_elb.go @@ -0,0 +1,395 @@ +package huaweicloudelb + +import ( + "context" + "errors" + "fmt" + + "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/basic" + "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/global" + hcElb "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/elb/v3" + hcElbModel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/elb/v3/model" + hcElbRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/elb/v3/region" + hcIam "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/iam/v3" + hcIamModel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/iam/v3/model" + hcIamRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/iam/v3/region" + xerrors "github.com/pkg/errors" + "golang.org/x/exp/slices" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerElb "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/huaweicloud-elb" + "github.com/usual2970/certimate/internal/pkg/utils/cast" +) + +type HuaweiCloudELBDeployerConfig struct { + // 华为云 AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // 华为云 SecretAccessKey。 + SecretAccessKey string `json:"secretAccessKey"` + // 华为云地域。 + Region string `json:"region"` + // 部署资源类型。 + ResourceType DeployResourceType `json:"resourceType"` + // 证书 ID。 + // 部署资源类型为 [DEPLOY_RESOURCE_CERTIFICATE] 时必填。 + CertificateId string `json:"certificateId,omitempty"` + // 负载均衡器 ID。 + // 部署资源类型为 [DEPLOY_RESOURCE_LOADBALANCER] 时必填。 + LoadbalancerId string `json:"loadbalancerId,omitempty"` + // 负载均衡监听 ID。 + // 部署资源类型为 [DEPLOY_RESOURCE_LISTENER] 时必填。 + ListenerId string `json:"listenerId,omitempty"` +} + +type HuaweiCloudELBDeployer struct { + config *HuaweiCloudELBDeployerConfig + logger deployer.Logger + sdkClient *hcElb.ElbClient + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*HuaweiCloudELBDeployer)(nil) + +func New(config *HuaweiCloudELBDeployerConfig) (*HuaweiCloudELBDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *HuaweiCloudELBDeployerConfig, logger deployer.Logger) (*HuaweiCloudELBDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client, err := createSdkClient(config.AccessKeyId, config.SecretAccessKey, config.Region) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk client") + } + + uploader, err := providerElb.New(&providerElb.HuaweiCloudELBUploaderConfig{ + AccessKeyId: config.AccessKeyId, + SecretAccessKey: config.SecretAccessKey, + Region: config.Region, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &HuaweiCloudELBDeployer{ + logger: logger, + config: config, + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *HuaweiCloudELBDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 SCM + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 根据部署资源类型决定部署方式 + switch d.config.ResourceType { + case DEPLOY_RESOURCE_CERTIFICATE: + if err := d.deployToCertificate(ctx, certPem, privkeyPem); err != nil { + return nil, err + } + + case DEPLOY_RESOURCE_LOADBALANCER: + if err := d.deployToLoadbalancer(ctx, certPem, privkeyPem); err != nil { + return nil, err + } + + case DEPLOY_RESOURCE_LISTENER: + if err := d.deployToListener(ctx, certPem, privkeyPem); err != nil { + return nil, err + } + + default: + return nil, fmt.Errorf("unsupported resource type: %s", d.config.ResourceType) + } + + return &deployer.DeployResult{}, nil +} + +func (d *HuaweiCloudELBDeployer) deployToCertificate(ctx context.Context, certPem string, privkeyPem string) error { + if d.config.CertificateId == "" { + return errors.New("config `certificateId` is required") + } + + // 更新证书 + // REF: https://support.huaweicloud.com/api-elb/UpdateCertificate.html + updateCertificateReq := &hcElbModel.UpdateCertificateRequest{ + CertificateId: d.config.CertificateId, + Body: &hcElbModel.UpdateCertificateRequestBody{ + Certificate: &hcElbModel.UpdateCertificateOption{ + Certificate: cast.StringPtr(certPem), + PrivateKey: cast.StringPtr(privkeyPem), + }, + }, + } + updateCertificateResp, err := d.sdkClient.UpdateCertificate(updateCertificateReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'elb.UpdateCertificate'") + } + + d.logger.Logt("已更新 ELB 证书", updateCertificateResp) + + return nil +} + +func (d *HuaweiCloudELBDeployer) deployToLoadbalancer(ctx context.Context, certPem string, privkeyPem string) error { + if d.config.LoadbalancerId == "" { + return errors.New("config `loadbalancerId` is required") + } + + listenerIds := make([]string, 0) + + // 查询负载均衡器详情 + // REF: https://support.huaweicloud.com/api-elb/ShowLoadBalancer.html + showLoadBalancerReq := &hcElbModel.ShowLoadBalancerRequest{ + LoadbalancerId: d.config.LoadbalancerId, + } + showLoadBalancerResp, err := d.sdkClient.ShowLoadBalancer(showLoadBalancerReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'elb.ShowLoadBalancer'") + } + + d.logger.Logt("已查询到 ELB 负载均衡器", showLoadBalancerResp) + + // 查询监听器列表 + // REF: https://support.huaweicloud.com/api-elb/ListListeners.html + listListenersLimit := int32(2000) + var listListenersMarker *string = nil + for { + listListenersReq := &hcElbModel.ListListenersRequest{ + Limit: cast.Int32Ptr(listListenersLimit), + Marker: listListenersMarker, + Protocol: &[]string{"HTTPS", "TERMINATED_HTTPS"}, + LoadbalancerId: &[]string{showLoadBalancerResp.Loadbalancer.Id}, + } + listListenersResp, err := d.sdkClient.ListListeners(listListenersReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'elb.ListListeners'") + } + + if listListenersResp.Listeners != nil { + for _, listener := range *listListenersResp.Listeners { + listenerIds = append(listenerIds, listener.Id) + } + } + + if listListenersResp.Listeners == nil || len(*listListenersResp.Listeners) < int(listListenersLimit) { + break + } else { + listListenersMarker = listListenersResp.PageInfo.NextMarker + } + } + + d.logger.Logt("已查询到 ELB 负载均衡器下的监听器", listenerIds) + + // 上传证书到 SCM + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 批量更新监听器证书 + var errs []error + for _, listenerId := range listenerIds { + if err := d.modifyListenerCertificate(ctx, listenerId, upres.CertId); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (d *HuaweiCloudELBDeployer) deployToListener(ctx context.Context, certPem string, privkeyPem string) error { + if d.config.ListenerId == "" { + return errors.New("config `listenerId` is required") + } + + // 上传证书到 SCM + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 更新监听器证书 + if err := d.modifyListenerCertificate(ctx, d.config.ListenerId, upres.CertId); err != nil { + return err + } + + return nil +} + +func (d *HuaweiCloudELBDeployer) modifyListenerCertificate(ctx context.Context, cloudListenerId string, cloudCertId string) error { + // 查询监听器详情 + // REF: https://support.huaweicloud.com/api-elb/ShowListener.html + showListenerReq := &hcElbModel.ShowListenerRequest{ + ListenerId: cloudListenerId, + } + showListenerResp, err := d.sdkClient.ShowListener(showListenerReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'elb.ShowListener'") + } + + d.logger.Logt("已查询到 ELB 监听器", showListenerResp) + + // 更新监听器 + // REF: https://support.huaweicloud.com/api-elb/UpdateListener.html + updateListenerReq := &hcElbModel.UpdateListenerRequest{ + ListenerId: cloudListenerId, + Body: &hcElbModel.UpdateListenerRequestBody{ + Listener: &hcElbModel.UpdateListenerOption{ + DefaultTlsContainerRef: cast.StringPtr(cloudCertId), + }, + }, + } + if showListenerResp.Listener.SniContainerRefs != nil { + if len(showListenerResp.Listener.SniContainerRefs) > 0 { + // 如果开启 SNI,需替换同 SAN 的证书 + sniCertIds := make([]string, 0) + sniCertIds = append(sniCertIds, cloudCertId) + + listOldCertificateReq := &hcElbModel.ListCertificatesRequest{ + Id: &showListenerResp.Listener.SniContainerRefs, + } + listOldCertificateResp, err := d.sdkClient.ListCertificates(listOldCertificateReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'elb.ListCertificates'") + } + + showNewCertificateReq := &hcElbModel.ShowCertificateRequest{ + CertificateId: cloudCertId, + } + showNewCertificateResp, err := d.sdkClient.ShowCertificate(showNewCertificateReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'elb.ShowCertificate'") + } + + for _, certificate := range *listOldCertificateResp.Certificates { + oldCertificate := certificate + newCertificate := showNewCertificateResp.Certificate + + if oldCertificate.SubjectAlternativeNames != nil && newCertificate.SubjectAlternativeNames != nil { + if slices.Equal(*oldCertificate.SubjectAlternativeNames, *newCertificate.SubjectAlternativeNames) { + continue + } + } else { + if oldCertificate.Domain == newCertificate.Domain { + continue + } + } + + sniCertIds = append(sniCertIds, certificate.Id) + } + + updateListenerReq.Body.Listener.SniContainerRefs = &sniCertIds + } + + if showListenerResp.Listener.SniMatchAlgo != "" { + updateListenerReq.Body.Listener.SniMatchAlgo = cast.StringPtr(showListenerResp.Listener.SniMatchAlgo) + } + } + updateListenerResp, err := d.sdkClient.UpdateListener(updateListenerReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'elb.UpdateListener'") + } + + d.logger.Logt("已更新 ELB 监听器", updateListenerResp) + + return nil +} + +func createSdkClient(accessKeyId, secretAccessKey, region string) (*hcElb.ElbClient, error) { + if region == "" { + region = "cn-north-4" // ELB 服务默认区域:华北四北京 + } + + projectId, err := getSdkProjectId(accessKeyId, secretAccessKey, region) + if err != nil { + return nil, err + } + + auth, err := basic.NewCredentialsBuilder(). + WithAk(accessKeyId). + WithSk(secretAccessKey). + WithProjectId(projectId). + SafeBuild() + if err != nil { + return nil, err + } + + hcRegion, err := hcElbRegion.SafeValueOf(region) + if err != nil { + return nil, err + } + + hcClient, err := hcElb.ElbClientBuilder(). + WithRegion(hcRegion). + WithCredential(auth). + SafeBuild() + if err != nil { + return nil, err + } + + client := hcElb.NewElbClient(hcClient) + return client, nil +} + +func getSdkProjectId(accessKeyId, secretAccessKey, region string) (string, error) { + if region == "" { + region = "cn-north-4" // IAM 服务默认区域:华北四北京 + } + + auth, err := global.NewCredentialsBuilder(). + WithAk(accessKeyId). + WithSk(secretAccessKey). + SafeBuild() + if err != nil { + return "", err + } + + hcRegion, err := hcIamRegion.SafeValueOf(region) + if err != nil { + return "", err + } + + hcClient, err := hcIam.IamClientBuilder(). + WithRegion(hcRegion). + WithCredential(auth). + SafeBuild() + if err != nil { + return "", err + } + + client := hcIam.NewIamClient(hcClient) + + request := &hcIamModel.KeystoneListProjectsRequest{ + Name: ®ion, + } + response, err := client.KeystoneListProjects(request) + if err != nil { + return "", err + } else if response.Projects == nil || len(*response.Projects) == 0 { + return "", errors.New("no project found") + } + + return (*response.Projects)[0].Id, nil +} diff --git a/internal/pkg/core/deployer/providers/huaweicloud-elb/huaweicloud_elb_test.go b/internal/pkg/core/deployer/providers/huaweicloud-elb/huaweicloud_elb_test.go new file mode 100644 index 00000000..f2e573ca --- /dev/null +++ b/internal/pkg/core/deployer/providers/huaweicloud-elb/huaweicloud_elb_test.go @@ -0,0 +1,155 @@ +package huaweicloudelb_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/huaweicloud-elb" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKeyId string + fSecretAccessKey string + fRegion string + fCertificateId string + fLoadbalancerId string + fListenerId string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_HUAWEICLOUDELB_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "") + flag.StringVar(&fSecretAccessKey, argsPrefix+"SECRETACCESSKEY", "", "") + flag.StringVar(&fRegion, argsPrefix+"REGION", "", "") + flag.StringVar(&fCertificateId, argsPrefix+"CERTIFICATEID", "", "") + flag.StringVar(&fLoadbalancerId, argsPrefix+"LOADBALANCERID", "", "") + flag.StringVar(&fListenerId, argsPrefix+"LISTENERID", "", "") +} + +/* +Shell command to run this test: + + go test -v huaweicloud_elb_test.go -args \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDELB_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDELB_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDELB_ACCESSKEYID="your-access-key-id" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDELB_SECRETACCESSKEY="your-secret-access-key" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDELB_REGION="cn-north-1" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDELB_CERTIFICATEID="your-elb-cert-id" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDELB_LOADBALANCERID="your-elb-loadbalancer-id" \ + --CERTIMATE_DEPLOYER_HUAWEICLOUDELB_LISTENERID="your-elb-listener-id" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy_ToCertificate", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("SECRETACCESSKEY: %v", fSecretAccessKey), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("CERTIFICATEID: %v", fCertificateId), + }, "\n")) + + deployer, err := provider.New(&provider.HuaweiCloudELBDeployerConfig{ + AccessKeyId: fAccessKeyId, + SecretAccessKey: fSecretAccessKey, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_CERTIFICATE, + CertificateId: fCertificateId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) + + t.Run("Deploy_ToLoadbalancer", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("SECRETACCESSKEY: %v", fSecretAccessKey), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LOADBALANCERID: %v", fLoadbalancerId), + }, "\n")) + + deployer, err := provider.New(&provider.HuaweiCloudELBDeployerConfig{ + AccessKeyId: fAccessKeyId, + SecretAccessKey: fSecretAccessKey, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_LOADBALANCER, + LoadbalancerId: fLoadbalancerId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) + + t.Run("Deploy_ToListenerId", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("SECRETACCESSKEY: %v", fSecretAccessKey), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LISTENERID: %v", fListenerId), + }, "\n")) + + deployer, err := provider.New(&provider.HuaweiCloudELBDeployerConfig{ + AccessKeyId: fAccessKeyId, + SecretAccessKey: fSecretAccessKey, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_LISTENER, + ListenerId: fListenerId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/k8s-secret/k8s_secret.go b/internal/pkg/core/deployer/providers/k8s-secret/k8s_secret.go new file mode 100644 index 00000000..f14fe1f6 --- /dev/null +++ b/internal/pkg/core/deployer/providers/k8s-secret/k8s_secret.go @@ -0,0 +1,160 @@ +package k8ssecret + +import ( + "context" + "errors" + "strings" + + xerrors "github.com/pkg/errors" + k8sCore "k8s.io/api/core/v1" + k8sMeta "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/utils/x509" +) + +type K8sSecretDeployerConfig struct { + // kubeconfig 文件内容。 + KubeConfig string `json:"kubeConfig,omitempty"` + // K8s 命名空间。 + Namespace string `json:"namespace,omitempty"` + // K8s Secret 名称。 + SecretName string `json:"secretName"` + // K8s Secret 中用于存放证书的 Key。 + SecretDataKeyForCrt string `json:"secretDataKeyForCrt,omitempty"` + // K8s Secret 中用于存放私钥的 Key。 + SecretDataKeyForKey string `json:"secretDataKeyForKey,omitempty"` +} + +type K8sSecretDeployer struct { + config *K8sSecretDeployerConfig + logger deployer.Logger +} + +var _ deployer.Deployer = (*K8sSecretDeployer)(nil) + +func New(config *K8sSecretDeployerConfig) (*K8sSecretDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *K8sSecretDeployerConfig, logger deployer.Logger) (*K8sSecretDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + return &K8sSecretDeployer{ + logger: logger, + config: config, + }, nil +} + +func (d *K8sSecretDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + if d.config.Namespace == "" { + return nil, errors.New("config `namespace` is required") + } + if d.config.SecretName == "" { + return nil, errors.New("config `secretName` is required") + } + if d.config.SecretDataKeyForCrt == "" { + return nil, errors.New("config `secretDataKeyForCrt` is required") + } + if d.config.SecretDataKeyForKey == "" { + return nil, errors.New("config `secretDataKeyForKey` is required") + } + + certX509, err := x509.ParseCertificateFromPEM(certPem) + if err != nil { + return nil, err + } + + // 连接 + client, err := createK8sClient(d.config.KubeConfig) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create k8s client") + } + + var secretPayload *k8sCore.Secret + secretAnnotations := map[string]string{ + "certimate/common-name": certX509.Subject.CommonName, + "certimate/subject-sn": certX509.Subject.SerialNumber, + "certimate/subject-alt-names": strings.Join(certX509.DNSNames, ","), + "certimate/issuer-sn": certX509.Issuer.SerialNumber, + "certimate/issuer-org": strings.Join(certX509.Issuer.Organization, ","), + } + + // 获取 Secret 实例,如果不存在则创建 + secretPayload, err = client.CoreV1().Secrets(d.config.Namespace).Get(context.TODO(), d.config.SecretName, k8sMeta.GetOptions{}) + if err != nil { + secretPayload = &k8sCore.Secret{ + TypeMeta: k8sMeta.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: k8sMeta.ObjectMeta{ + Name: d.config.SecretName, + Annotations: secretAnnotations, + }, + Type: k8sCore.SecretType("kubernetes.io/tls"), + } + secretPayload.Data = make(map[string][]byte) + secretPayload.Data[d.config.SecretDataKeyForCrt] = []byte(certPem) + secretPayload.Data[d.config.SecretDataKeyForKey] = []byte(privkeyPem) + + _, err = client.CoreV1().Secrets(d.config.Namespace).Create(context.TODO(), secretPayload, k8sMeta.CreateOptions{}) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create k8s secret") + } else { + d.logger.Logf("k8s secret created", secretPayload) + return &deployer.DeployResult{}, nil + } + } + + // 更新 Secret 实例 + secretPayload.Type = k8sCore.SecretType("kubernetes.io/tls") + if secretPayload.ObjectMeta.Annotations == nil { + secretPayload.ObjectMeta.Annotations = secretAnnotations + } else { + for k, v := range secretAnnotations { + secretPayload.ObjectMeta.Annotations[k] = v + } + } + secretPayload, err = client.CoreV1().Secrets(d.config.Namespace).Update(context.TODO(), secretPayload, k8sMeta.UpdateOptions{}) + if err != nil { + return nil, xerrors.Wrap(err, "failed to update k8s secret") + } + + d.logger.Logf("k8s secret updated", secretPayload) + + return &deployer.DeployResult{}, nil +} + +func createK8sClient(kubeConfig string) (*kubernetes.Clientset, error) { + var config *rest.Config + var err error + if kubeConfig == "" { + config, err = rest.InClusterConfig() + } else { + kubeConfig, err := clientcmd.NewClientConfigFromBytes([]byte(kubeConfig)) + if err != nil { + return nil, err + } + config, err = kubeConfig.ClientConfig() + } + if err != nil { + return nil, err + } + + client, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/k8s-secret/k8s_secret_test.go b/internal/pkg/core/deployer/providers/k8s-secret/k8s_secret_test.go new file mode 100644 index 00000000..ac47deba --- /dev/null +++ b/internal/pkg/core/deployer/providers/k8s-secret/k8s_secret_test.go @@ -0,0 +1,80 @@ +package k8ssecret_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/k8s-secret" +) + +var ( + fInputCertPath string + fInputKeyPath string + fNamespace string + fSecretName string + fSecretDataKeyForCrt string + fSecretDataKeyForKey string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_K8SSECRET_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fNamespace, argsPrefix+"NAMESPACE", "default", "") + flag.StringVar(&fSecretName, argsPrefix+"SECRETNAME", "", "") + flag.StringVar(&fSecretDataKeyForCrt, argsPrefix+"SECRETDATAKEYFORCRT", "tls.crt", "") + flag.StringVar(&fSecretDataKeyForKey, argsPrefix+"SECRETDATAKEYFORKEY", "tls.key", "") +} + +/* +Shell command to run this test: + + go test -v k8s_secret_test.go -args \ + --CERTIMATE_DEPLOYER_K8SSECRET_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_K8SSECRET_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_K8SSECRET_NAMESPACE="default" \ + --CERTIMATE_DEPLOYER_K8SSECRET_SECRETNAME="secret" \ + --CERTIMATE_DEPLOYER_K8SSECRET_SECRETDATAKEYFORCRT="tls.crt" \ + --CERTIMATE_DEPLOYER_K8SSECRET_SECRETDATAKEYFORKEY="tls.key" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("NAMESPACE: %v", fNamespace), + fmt.Sprintf("SECRETNAME: %v", fSecretName), + fmt.Sprintf("SECRETDATAKEYFORCRT: %v", fSecretDataKeyForCrt), + fmt.Sprintf("SECRETDATAKEYFORKEY: %v", fSecretDataKeyForKey), + }, "\n")) + + deployer, err := provider.New(&provider.K8sSecretDeployerConfig{ + Namespace: fNamespace, + SecretName: fSecretName, + SecretDataKeyForCrt: fSecretDataKeyForCrt, + SecretDataKeyForKey: fSecretDataKeyForKey, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/local/defines.go b/internal/pkg/core/deployer/providers/local/defines.go new file mode 100644 index 00000000..5b3118d8 --- /dev/null +++ b/internal/pkg/core/deployer/providers/local/defines.go @@ -0,0 +1,17 @@ +package local + +type OutputFormatType string + +const ( + OUTPUT_FORMAT_PEM = OutputFormatType("PEM") + OUTPUT_FORMAT_PFX = OutputFormatType("PFX") + OUTPUT_FORMAT_JKS = OutputFormatType("JKS") +) + +type ShellEnvType string + +const ( + SHELL_ENV_SH = ShellEnvType("sh") + SHELL_ENV_CMD = ShellEnvType("cmd") + SHELL_ENV_POWERSHELL = ShellEnvType("powershell") +) diff --git a/internal/pkg/core/deployer/providers/local/local.go b/internal/pkg/core/deployer/providers/local/local.go new file mode 100644 index 00000000..d4eb7a66 --- /dev/null +++ b/internal/pkg/core/deployer/providers/local/local.go @@ -0,0 +1,178 @@ +package local + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "runtime" + + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/utils/fs" + "github.com/usual2970/certimate/internal/pkg/utils/x509" +) + +type LocalDeployerConfig struct { + // Shell 执行环境。 + // 零值时默认根据操作系统决定。 + ShellEnv ShellEnvType `json:"shellEnv,omitempty"` + // 前置命令。 + PreCommand string `json:"preCommand,omitempty"` + // 后置命令。 + PostCommand string `json:"postCommand,omitempty"` + // 输出证书格式。 + OutputFormat OutputFormatType `json:"outputFormat,omitempty"` + // 输出证书文件路径。 + OutputCertPath string `json:"outputCertPath,omitempty"` + // 输出私钥文件路径。 + OutputKeyPath string `json:"outputKeyPath,omitempty"` + // PFX 导出密码。 + // 证书格式为 PFX 时必填。 + PfxPassword string `json:"pfxPassword,omitempty"` + // JKS 别名。 + // 证书格式为 JKS 时必填。 + JksAlias string `json:"jksAlias,omitempty"` + // JKS 密钥密码。 + // 证书格式为 JKS 时必填。 + JksKeypass string `json:"jksKeypass,omitempty"` + // JKS 存储密码。 + // 证书格式为 JKS 时必填。 + JksStorepass string `json:"jksStorepass,omitempty"` +} + +type LocalDeployer struct { + config *LocalDeployerConfig + logger deployer.Logger +} + +var _ deployer.Deployer = (*LocalDeployer)(nil) + +func New(config *LocalDeployerConfig) (*LocalDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *LocalDeployerConfig, logger deployer.Logger) (*LocalDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + return &LocalDeployer{ + logger: logger, + config: config, + }, nil +} + +func (d *LocalDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 执行前置命令 + if d.config.PreCommand != "" { + stdout, stderr, err := execCommand(d.config.ShellEnv, d.config.PreCommand) + if err != nil { + return nil, xerrors.Wrapf(err, "failed to run pre-command, stdout: %s, stderr: %s", stdout, stderr) + } + + d.logger.Logt("pre-command executed", stdout) + } + + // 写入证书和私钥文件 + switch d.config.OutputFormat { + case OUTPUT_FORMAT_PEM: + if err := fs.WriteFileString(d.config.OutputCertPath, certPem); err != nil { + return nil, xerrors.Wrap(err, "failed to save certificate file") + } + + d.logger.Logt("certificate file saved") + + if err := fs.WriteFileString(d.config.OutputKeyPath, privkeyPem); err != nil { + return nil, xerrors.Wrap(err, "failed to save private key file") + } + + d.logger.Logt("private key file saved") + + case OUTPUT_FORMAT_PFX: + pfxData, err := x509.TransformCertificateFromPEMToPFX(certPem, privkeyPem, d.config.PfxPassword) + if err != nil { + return nil, xerrors.Wrap(err, "failed to transform certificate to PFX") + } + + d.logger.Logt("certificate transformed to PFX") + + if err := fs.WriteFile(d.config.OutputCertPath, pfxData); err != nil { + return nil, xerrors.Wrap(err, "failed to save certificate file") + } + + d.logger.Logt("certificate file saved") + + case OUTPUT_FORMAT_JKS: + jksData, err := x509.TransformCertificateFromPEMToJKS(certPem, privkeyPem, d.config.JksAlias, d.config.JksKeypass, d.config.JksStorepass) + if err != nil { + return nil, xerrors.Wrap(err, "failed to transform certificate to JKS") + } + + d.logger.Logt("certificate transformed to JKS") + + if err := fs.WriteFile(d.config.OutputCertPath, jksData); err != nil { + return nil, xerrors.Wrap(err, "failed to save certificate file") + } + + d.logger.Logt("certificate file uploaded") + + default: + return nil, fmt.Errorf("unsupported output format: %s", d.config.OutputFormat) + } + + // 执行后置命令 + if d.config.PostCommand != "" { + stdout, stderr, err := execCommand(d.config.ShellEnv, d.config.PostCommand) + if err != nil { + return nil, xerrors.Wrapf(err, "failed to run command, stdout: %s, stderr: %s", stdout, stderr) + } + + d.logger.Logt("post-command executed", stdout) + } + + return &deployer.DeployResult{}, nil +} + +func execCommand(shellEnv ShellEnvType, command string) (string, string, error) { + var cmd *exec.Cmd + + switch shellEnv { + case SHELL_ENV_SH: + cmd = exec.Command("sh", "-c", command) + + case SHELL_ENV_CMD: + cmd = exec.Command("cmd", "/C", command) + + case SHELL_ENV_POWERSHELL: + cmd = exec.Command("powershell", "-Command", command) + + case "": + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/C", command) + } else { + cmd = exec.Command("sh", "-c", command) + } + + default: + return "", "", fmt.Errorf("unsupported shell env: %s", shellEnv) + } + + var stdoutBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + err := cmd.Run() + if err != nil { + return "", "", xerrors.Wrap(err, "failed to execute shell command") + } + + return stdoutBuf.String(), stderrBuf.String(), nil +} diff --git a/internal/pkg/core/deployer/providers/local/local_test.go b/internal/pkg/core/deployer/providers/local/local_test.go new file mode 100644 index 00000000..18838c63 --- /dev/null +++ b/internal/pkg/core/deployer/providers/local/local_test.go @@ -0,0 +1,186 @@ +package local_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/local" +) + +var ( + fInputCertPath string + fInputKeyPath string + fOutputCertPath string + fOutputKeyPath string + fPfxPassword string + fJksAlias string + fJksKeypass string + fJksStorepass string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_LOCAL_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fOutputCertPath, argsPrefix+"OUTPUTCERTPATH", "", "") + flag.StringVar(&fOutputKeyPath, argsPrefix+"OUTPUTKEYPATH", "", "") + flag.StringVar(&fPfxPassword, argsPrefix+"PFXPASSWORD", "", "") + flag.StringVar(&fJksAlias, argsPrefix+"JKSALIAS", "", "") + flag.StringVar(&fJksKeypass, argsPrefix+"JKSKEYPASS", "", "") + flag.StringVar(&fJksStorepass, argsPrefix+"JKSSTOREPASS", "", "") +} + +/* +Shell command to run this test: + + go test -v local_test.go -args \ + --CERTIMATE_DEPLOYER_LOCAL_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_LOCAL_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_LOCAL_OUTPUTCERTPATH="/path/to/your-output-cert" \ + --CERTIMATE_DEPLOYER_LOCAL_OUTPUTKEYPATH="/path/to/your-output-key" \ + --CERTIMATE_DEPLOYER_LOCAL_PFXPASSWORD="your-pfx-password" \ + --CERTIMATE_DEPLOYER_LOCAL_JKSALIAS="your-jks-alias" \ + --CERTIMATE_DEPLOYER_LOCAL_JKSKEYPASS="your-jks-keypass" \ + --CERTIMATE_DEPLOYER_LOCAL_JKSSTOREPASS="your-jks-storepass" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy_PEM", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("OUTPUTCERTPATH: %v", fOutputCertPath), + fmt.Sprintf("OUTPUTKEYPATH: %v", fOutputKeyPath), + }, "\n")) + + deployer, err := provider.New(&provider.LocalDeployerConfig{ + OutputCertPath: fOutputCertPath, + OutputKeyPath: fOutputKeyPath, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fstat1, err := os.Stat(fOutputCertPath) + if err != nil { + t.Errorf("err: %+v", err) + return + } else if fstat1.Size() == 0 { + t.Errorf("err: empty output certificate file") + return + } + + fstat2, err := os.Stat(fOutputKeyPath) + if err != nil { + t.Errorf("err: %+v", err) + return + } else if fstat2.Size() == 0 { + t.Errorf("err: empty output private key file") + return + } + + t.Logf("ok: %v", res) + }) + + t.Run("Deploy_PFX", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("OUTPUTCERTPATH: %v", fOutputCertPath), + fmt.Sprintf("OUTPUTKEYPATH: %v", fOutputKeyPath), + fmt.Sprintf("PFXPASSWORD: %v", fPfxPassword), + }, "\n")) + + deployer, err := provider.New(&provider.LocalDeployerConfig{ + OutputFormat: provider.OUTPUT_FORMAT_PFX, + OutputCertPath: fOutputCertPath, + OutputKeyPath: fOutputKeyPath, + PfxPassword: fPfxPassword, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fstat, err := os.Stat(fOutputCertPath) + if err != nil { + t.Errorf("err: %+v", err) + return + } else if fstat.Size() == 0 { + t.Errorf("err: empty output certificate file") + return + } + + t.Logf("ok: %v", res) + }) + + t.Run("Deploy_JKS", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("OUTPUTCERTPATH: %v", fOutputCertPath), + fmt.Sprintf("OUTPUTKEYPATH: %v", fOutputKeyPath), + fmt.Sprintf("JKSALIAS: %v", fJksAlias), + fmt.Sprintf("JKSKEYPASS: %v", fJksKeypass), + fmt.Sprintf("JKSSTOREPASS: %v", fJksStorepass), + }, "\n")) + + deployer, err := provider.New(&provider.LocalDeployerConfig{ + OutputFormat: provider.OUTPUT_FORMAT_JKS, + OutputCertPath: fOutputCertPath, + OutputKeyPath: fOutputKeyPath, + JksAlias: fJksAlias, + JksKeypass: fJksKeypass, + JksStorepass: fJksStorepass, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fstat, err := os.Stat(fOutputCertPath) + if err != nil { + t.Errorf("err: %+v", err) + return + } else if fstat.Size() == 0 { + t.Errorf("err: empty output certificate file") + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn.go b/internal/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn.go new file mode 100644 index 00000000..7871bf17 --- /dev/null +++ b/internal/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn.go @@ -0,0 +1,106 @@ +package qiniucdn + +import ( + "context" + "errors" + "strings" + + xerrors "github.com/pkg/errors" + "github.com/qiniu/go-sdk/v7/auth" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerQiniu "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/qiniu-sslcert" + qiniusdk "github.com/usual2970/certimate/internal/pkg/vendors/qiniu-sdk" +) + +type QiniuCDNDeployerConfig struct { + // 七牛云 AccessKey。 + AccessKey string `json:"accessKey"` + // 七牛云 SecretKey。 + SecretKey string `json:"secretKey"` + // 加速域名(支持泛域名)。 + Domain string `json:"domain"` +} + +type QiniuCDNDeployer struct { + config *QiniuCDNDeployerConfig + logger deployer.Logger + sdkClient *qiniusdk.Client + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*QiniuCDNDeployer)(nil) + +func New(config *QiniuCDNDeployerConfig) (*QiniuCDNDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *QiniuCDNDeployerConfig, logger deployer.Logger) (*QiniuCDNDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client := qiniusdk.NewClient(auth.New(config.AccessKey, config.SecretKey)) + + uploader, err := providerQiniu.New(&providerQiniu.QiniuSSLCertUploaderConfig{ + AccessKey: config.AccessKey, + SecretKey: config.SecretKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &QiniuCDNDeployer{ + logger: logger, + config: config, + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *QiniuCDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 CDN + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // "*.example.com" → ".example.com",适配七牛云 CDN 要求的泛域名格式 + domain := strings.TrimPrefix(d.config.Domain, "*") + + // 获取域名信息 + // REF: https://developer.qiniu.com/fusion/4246/the-domain-name + getDomainInfoResp, err := d.sdkClient.GetDomainInfo(domain) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.GetDomainInfo'") + } + + d.logger.Logt("已获取域名信息", getDomainInfoResp) + + // 判断域名是否已启用 HTTPS。如果已启用,修改域名证书;否则,启用 HTTPS + // REF: https://developer.qiniu.com/fusion/4246/the-domain-name + if getDomainInfoResp.Https != nil && getDomainInfoResp.Https.CertID != "" { + modifyDomainHttpsConfResp, err := d.sdkClient.ModifyDomainHttpsConf(domain, upres.CertId, getDomainInfoResp.Https.ForceHttps, getDomainInfoResp.Https.Http2Enable) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.ModifyDomainHttpsConf'") + } + + d.logger.Logt("已修改域名证书", modifyDomainHttpsConfResp) + } else { + enableDomainHttpsResp, err := d.sdkClient.EnableDomainHttps(domain, upres.CertId, true, true) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.EnableDomainHttps'") + } + + d.logger.Logt("已将域名升级为 HTTPS", enableDomainHttpsResp) + } + + return &deployer.DeployResult{}, nil +} diff --git a/internal/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn_test.go b/internal/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn_test.go new file mode 100644 index 00000000..df4ee008 --- /dev/null +++ b/internal/pkg/core/deployer/providers/qiniu-cdn/qiniu_cdn_test.go @@ -0,0 +1,75 @@ +package qiniucdn_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/qiniu-cdn" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKey string + fSecretKey string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_QINIUCDN_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKey, argsPrefix+"ACCESSKEY", "", "") + flag.StringVar(&fSecretKey, argsPrefix+"SECRETKEY", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v qiniu_cdn_test.go -args \ + --CERTIMATE_DEPLOYER_QINIUCDN_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_QINIUCDN_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_QINIUCDN_ACCESSKEY="your-access-key" \ + --CERTIMATE_DEPLOYER_QINIUCDN_SECRETKEY="your-secret-key" \ + --CERTIMATE_DEPLOYER_QINIUCDN_DOMAIN="example.com" \ +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEY: %v", fAccessKey), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.QiniuCDNDeployerConfig{ + AccessKey: fAccessKey, + SecretKey: fSecretKey, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/ssh/defines.go b/internal/pkg/core/deployer/providers/ssh/defines.go new file mode 100644 index 00000000..6f30871b --- /dev/null +++ b/internal/pkg/core/deployer/providers/ssh/defines.go @@ -0,0 +1,9 @@ +package ssh + +type OutputFormatType string + +const ( + OUTPUT_FORMAT_PEM = OutputFormatType("PEM") + OUTPUT_FORMAT_PFX = OutputFormatType("PFX") + OUTPUT_FORMAT_JKS = OutputFormatType("JKS") +) diff --git a/internal/pkg/core/deployer/providers/ssh/ssh.go b/internal/pkg/core/deployer/providers/ssh/ssh.go new file mode 100644 index 00000000..27e3fc1a --- /dev/null +++ b/internal/pkg/core/deployer/providers/ssh/ssh.go @@ -0,0 +1,252 @@ +package ssh + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + + xerrors "github.com/pkg/errors" + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/utils/x509" +) + +type SshDeployerConfig struct { + // SSH 主机。 + // 零值时默认为 "localhost"。 + SshHost string `json:"sshHost,omitempty"` + // SSH 端口。 + // 零值时默认为 22。 + SshPort int32 `json:"sshPort,omitempty"` + // SSH 登录用户名。 + SshUsername string `json:"sshUsername,omitempty"` + // SSH 登录密码。 + SshPassword string `json:"sshPassword,omitempty"` + // SSH 登录私钥。 + SshKey string `json:"sshKey,omitempty"` + // SSH 登录私钥口令。 + SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` + // 前置命令。 + PreCommand string `json:"preCommand,omitempty"` + // 后置命令。 + PostCommand string `json:"postCommand,omitempty"` + // 输出证书格式。 + OutputFormat OutputFormatType `json:"outputFormat,omitempty"` + // 输出证书文件路径。 + OutputCertPath string `json:"outputCertPath,omitempty"` + // 输出私钥文件路径。 + OutputKeyPath string `json:"outputKeyPath,omitempty"` + // PFX 导出密码。 + // 证书格式为 PFX 时必填。 + PfxPassword string `json:"pfxPassword,omitempty"` + // JKS 别名。 + // 证书格式为 JKS 时必填。 + JksAlias string `json:"jksAlias,omitempty"` + // JKS 密钥密码。 + // 证书格式为 JKS 时必填。 + JksKeypass string `json:"jksKeypass,omitempty"` + // JKS 存储密码。 + // 证书格式为 JKS 时必填。 + JksStorepass string `json:"jksStorepass,omitempty"` +} + +type SshDeployer struct { + config *SshDeployerConfig + logger deployer.Logger +} + +var _ deployer.Deployer = (*SshDeployer)(nil) + +func New(config *SshDeployerConfig) (*SshDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *SshDeployerConfig, logger deployer.Logger) (*SshDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + return &SshDeployer{ + logger: logger, + config: config, + }, nil +} + +func (d *SshDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 连接 + client, err := createSshClient( + d.config.SshHost, + d.config.SshPort, + d.config.SshUsername, + d.config.SshPassword, + d.config.SshKey, + d.config.SshKeyPassphrase, + ) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssh client") + } + defer client.Close() + + d.logger.Logt("SSH connected") + + // 执行前置命令 + if d.config.PreCommand != "" { + stdout, stderr, err := execSshCommand(client, d.config.PreCommand) + if err != nil { + return nil, xerrors.Wrapf(err, "failed to run pre-command: stdout: %s, stderr: %s", stdout, stderr) + } + + d.logger.Logt("SSH pre-command executed", stdout) + } + + // 上传证书和私钥文件 + switch d.config.OutputFormat { + case OUTPUT_FORMAT_PEM: + if err := writeSftpFileString(client, d.config.OutputCertPath, certPem); err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded") + + if err := writeSftpFileString(client, d.config.OutputKeyPath, privkeyPem); err != nil { + return nil, xerrors.Wrap(err, "failed to upload private key file") + } + + d.logger.Logt("private key file uploaded") + + case OUTPUT_FORMAT_PFX: + pfxData, err := x509.TransformCertificateFromPEMToPFX(certPem, privkeyPem, d.config.PfxPassword) + if err != nil { + return nil, xerrors.Wrap(err, "failed to transform certificate to PFX") + } + + d.logger.Logt("certificate transformed to PFX") + + if err := writeSftpFile(client, d.config.OutputCertPath, pfxData); err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded") + + case OUTPUT_FORMAT_JKS: + jksData, err := x509.TransformCertificateFromPEMToJKS(certPem, privkeyPem, d.config.JksAlias, d.config.JksKeypass, d.config.JksStorepass) + if err != nil { + return nil, xerrors.Wrap(err, "failed to transform certificate to JKS") + } + + d.logger.Logt("certificate transformed to JKS") + + if err := writeSftpFile(client, d.config.OutputCertPath, jksData); err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded") + + default: + return nil, fmt.Errorf("unsupported output format: %s", d.config.OutputFormat) + } + + // 执行后置命令 + if d.config.PostCommand != "" { + stdout, stderr, err := execSshCommand(client, d.config.PostCommand) + if err != nil { + return nil, xerrors.Wrapf(err, "failed to run command, stdout: %s, stderr: %s", stdout, stderr) + } + + d.logger.Logt("SSH post-command executed", stdout) + } + + return &deployer.DeployResult{}, nil +} + +func createSshClient(host string, port int32, username string, password string, key string, keyPassphrase string) (*ssh.Client, error) { + if host == "" { + host = "localhost" + } + + if port == 0 { + port = 22 + } + + var authMethod ssh.AuthMethod + if key != "" { + var signer ssh.Signer + var err error + + if keyPassphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(keyPassphrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(key)) + } + + if err != nil { + return nil, err + } + authMethod = ssh.PublicKeys(signer) + } else { + authMethod = ssh.Password(password) + } + + return ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{authMethod}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) +} + +func execSshCommand(sshCli *ssh.Client, command string) (string, string, error) { + session, err := sshCli.NewSession() + if err != nil { + return "", "", err + } + + defer session.Close() + var stdoutBuf bytes.Buffer + session.Stdout = &stdoutBuf + var stderrBuf bytes.Buffer + session.Stderr = &stderrBuf + err = session.Run(command) + if err != nil { + return "", "", err + } + + return stdoutBuf.String(), stderrBuf.String(), nil +} + +func writeSftpFileString(sshCli *ssh.Client, path string, content string) error { + return writeSftpFile(sshCli, path, []byte(content)) +} + +func writeSftpFile(sshCli *ssh.Client, path string, data []byte) error { + sftpCli, err := sftp.NewClient(sshCli) + if err != nil { + return xerrors.Wrap(err, "failed to create sftp client") + } + defer sftpCli.Close() + + if err := sftpCli.MkdirAll(filepath.Dir(path)); err != nil { + return xerrors.Wrap(err, "failed to create remote directory") + } + + file, err := sftpCli.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC) + if err != nil { + return xerrors.Wrap(err, "failed to open remote file") + } + defer file.Close() + + _, err = file.Write(data) + if err != nil { + return xerrors.Wrap(err, "failed to write to remote file") + } + + return nil +} diff --git a/internal/pkg/core/deployer/providers/ssh/ssh_test.go b/internal/pkg/core/deployer/providers/ssh/ssh_test.go new file mode 100644 index 00000000..f1c25e63 --- /dev/null +++ b/internal/pkg/core/deployer/providers/ssh/ssh_test.go @@ -0,0 +1,90 @@ +package ssh_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/ssh" +) + +var ( + fInputCertPath string + fInputKeyPath string + fSshHost string + fSshPort int + fSshUsername string + fSshPassword string + fOutputCertPath string + fOutputKeyPath string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_SSH_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fSshHost, argsPrefix+"SSHHOST", "", "") + flag.IntVar(&fSshPort, argsPrefix+"SSHPORT", 0, "") + flag.StringVar(&fSshUsername, argsPrefix+"SSHUSERNAME", "", "") + flag.StringVar(&fSshPassword, argsPrefix+"SSHPASSWORD", "", "") + flag.StringVar(&fOutputCertPath, argsPrefix+"OUTPUTCERTPATH", "", "") + flag.StringVar(&fOutputKeyPath, argsPrefix+"OUTPUTKEYPATH", "", "") +} + +/* +Shell command to run this test: + + go test -v ssh_test.go -args \ + --CERTIMATE_DEPLOYER_SSH_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_SSH_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_SSH_SSHHOST="localhost" \ + --CERTIMATE_DEPLOYER_SSH_SSHPORT=22 \ + --CERTIMATE_DEPLOYER_SSH_SSHUSERNAME="root" \ + --CERTIMATE_DEPLOYER_SSH_SSHPASSWORD="password" \ + --CERTIMATE_DEPLOYER_SSH_OUTPUTCERTPATH="/path/to/your-output-cert.pem" \ + --CERTIMATE_DEPLOYER_SSH_OUTPUTKEYPATH="/path/to/your-output-key.pem" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("SSHHOST: %v", fSshHost), + fmt.Sprintf("SSHPORT: %v", fSshPort), + fmt.Sprintf("SSHUSERNAME: %v", fSshUsername), + fmt.Sprintf("SSHPASSWORD: %v", fSshPassword), + fmt.Sprintf("OUTPUTCERTPATH: %v", fOutputCertPath), + fmt.Sprintf("OUTPUTKEYPATH: %v", fOutputKeyPath), + }, "\n")) + + deployer, err := provider.New(&provider.SshDeployerConfig{ + SshHost: fSshHost, + SshPort: int32(fSshPort), + SshUsername: fSshUsername, + SshPassword: fSshPassword, + OutputCertPath: fOutputCertPath, + OutputKeyPath: fOutputKeyPath, + }) + if err != nil { + t.Errorf("err: %+v", err) + panic(err) + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + panic(err) + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/tencentcloud-cdn/tencentcloud_cdn.go b/internal/pkg/core/deployer/providers/tencentcloud-cdn/tencentcloud_cdn.go new file mode 100644 index 00000000..68475257 --- /dev/null +++ b/internal/pkg/core/deployer/providers/tencentcloud-cdn/tencentcloud_cdn.go @@ -0,0 +1,198 @@ +package tencentcloudcdn + +import ( + "context" + "errors" + "strings" + + xerrors "github.com/pkg/errors" + tcCdn "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn/v20180606" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" + tcSsl "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl/v20191205" + "golang.org/x/exp/slices" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerSsl "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/tencentcloud-ssl" +) + +type TencentCloudCDNDeployerConfig struct { + // 腾讯云 SecretId。 + SecretId string `json:"secretId"` + // 腾讯云 SecretKey。 + SecretKey string `json:"secretKey"` + // 加速域名(支持泛域名)。 + Domain string `json:"domain"` +} + +type TencentCloudCDNDeployer struct { + config *TencentCloudCDNDeployerConfig + logger deployer.Logger + sdkClients *tencentCloudCDNDeployerSdkClients + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*TencentCloudCDNDeployer)(nil) + +type tencentCloudCDNDeployerSdkClients struct { + ssl *tcSsl.Client + cdn *tcCdn.Client +} + +func New(config *TencentCloudCDNDeployerConfig) (*TencentCloudCDNDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *TencentCloudCDNDeployerConfig, logger deployer.Logger) (*TencentCloudCDNDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + clients, err := createSdkClients(config.SecretId, config.SecretKey) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk clients") + } + + uploader, err := providerSsl.New(&providerSsl.TencentCloudSSLUploaderConfig{ + SecretId: config.SecretId, + SecretKey: config.SecretKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &TencentCloudCDNDeployer{ + logger: logger, + config: config, + sdkClients: clients, + sslUploader: uploader, + }, nil +} + +func (d *TencentCloudCDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 SSL + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 获取待部署的 CDN 实例 + // 如果是泛域名,根据证书匹配 CDN 实例 + instanceIds := make([]string, 0) + if strings.HasPrefix(d.config.Domain, "*.") { + domains, err := d.getDomainsByCertificateId(upres.CertId) + if err != nil { + return nil, err + } + + instanceIds = domains + } else { + instanceIds = append(instanceIds, d.config.Domain) + } + + // 跳过已部署的 CDN 实例 + if len(instanceIds) > 0 { + deployedDomains, err := d.getDeployedDomainsByCertificateId(upres.CertId) + if err != nil { + return nil, err + } + + temp := make([]string, 0) + for _, instanceId := range instanceIds { + if !slices.Contains(deployedDomains, instanceId) { + temp = append(temp, instanceId) + } + } + instanceIds = temp + } + + if len(instanceIds) == 0 { + d.logger.Logt("已部署过或没有要部署的 CDN 实例") + } else { + // 证书部署到 CDN 实例 + // REF: https://cloud.tencent.com/document/product/400/91667 + deployCertificateInstanceReq := tcSsl.NewDeployCertificateInstanceRequest() + deployCertificateInstanceReq.CertificateId = common.StringPtr(upres.CertId) + deployCertificateInstanceReq.ResourceType = common.StringPtr("cdn") + deployCertificateInstanceReq.Status = common.Int64Ptr(1) + deployCertificateInstanceReq.InstanceIdList = common.StringPtrs(instanceIds) + deployCertificateInstanceResp, err := d.sdkClients.ssl.DeployCertificateInstance(deployCertificateInstanceReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'ssl.DeployCertificateInstance'") + } + + d.logger.Logt("已部署证书到云资源实例", deployCertificateInstanceResp.Response) + } + + return &deployer.DeployResult{}, nil +} + +func (d *TencentCloudCDNDeployer) getDomainsByCertificateId(cloudCertId string) ([]string, error) { + // 获取证书中的可用域名 + // REF: https://cloud.tencent.com/document/product/228/42491 + describeCertDomainsReq := tcCdn.NewDescribeCertDomainsRequest() + describeCertDomainsReq.CertId = common.StringPtr(cloudCertId) + describeCertDomainsReq.Product = common.StringPtr("cdn") + describeCertDomainsResp, err := d.sdkClients.cdn.DescribeCertDomains(describeCertDomainsReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.DescribeCertDomains'") + } + + domains := make([]string, 0) + if describeCertDomainsResp.Response.Domains == nil { + for _, domain := range describeCertDomainsResp.Response.Domains { + domains = append(domains, *domain) + } + } + + return domains, nil +} + +func (d *TencentCloudCDNDeployer) getDeployedDomainsByCertificateId(cloudCertId string) ([]string, error) { + // 根据证书查询关联 CDN 域名 + // REF: https://cloud.tencent.com/document/product/400/62674 + describeDeployedResourcesReq := tcSsl.NewDescribeDeployedResourcesRequest() + describeDeployedResourcesReq.CertificateIds = common.StringPtrs([]string{cloudCertId}) + describeDeployedResourcesReq.ResourceType = common.StringPtr("cdn") + describeDeployedResourcesResp, err := d.sdkClients.ssl.DescribeDeployedResources(describeDeployedResourcesReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.DescribeDeployedResources'") + } + + domains := make([]string, 0) + if describeDeployedResourcesResp.Response.DeployedResources != nil { + for _, deployedResource := range describeDeployedResourcesResp.Response.DeployedResources { + for _, resource := range deployedResource.Resources { + domains = append(domains, *resource) + } + } + } + + return domains, nil +} + +func createSdkClients(secretId, secretKey string) (*tencentCloudCDNDeployerSdkClients, error) { + credential := common.NewCredential(secretId, secretKey) + + sslClient, err := tcSsl.NewClient(credential, "", profile.NewClientProfile()) + if err != nil { + return nil, err + } + + cdnClient, err := tcCdn.NewClient(credential, "", profile.NewClientProfile()) + if err != nil { + return nil, err + } + + return &tencentCloudCDNDeployerSdkClients{ + ssl: sslClient, + cdn: cdnClient, + }, nil +} diff --git a/internal/pkg/core/deployer/providers/tencentcloud-cdn/tencentcloud_cdn_test.go b/internal/pkg/core/deployer/providers/tencentcloud-cdn/tencentcloud_cdn_test.go new file mode 100644 index 00000000..70b3c59d --- /dev/null +++ b/internal/pkg/core/deployer/providers/tencentcloud-cdn/tencentcloud_cdn_test.go @@ -0,0 +1,75 @@ +package tencentcloudcdn_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-cdn" +) + +var ( + fInputCertPath string + fInputKeyPath string + fSecretId string + fSecretKey string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_TENCENTCLOUDCDN_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fSecretId, argsPrefix+"SECRETID", "", "") + flag.StringVar(&fSecretKey, argsPrefix+"SECRETKEY", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v tencentcloud_cdn_test.go -args \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCDN_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCDN_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCDN_SECRETID="your-secret-id" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCDN_SECRETKEY="your-secret-key" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCDN_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("SECRETID: %v", fSecretId), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.TencentCloudCDNDeployerConfig{ + SecretId: fSecretId, + SecretKey: fSecretKey, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/tencentcloud-clb/defines.go b/internal/pkg/core/deployer/providers/tencentcloud-clb/defines.go new file mode 100644 index 00000000..47eedfb0 --- /dev/null +++ b/internal/pkg/core/deployer/providers/tencentcloud-clb/defines.go @@ -0,0 +1,14 @@ +package tencentcloudclb + +type DeployResourceType string + +const ( + // 资源类型:通过 SSL 服务部署到云资源实例。 + DEPLOY_RESOURCE_USE_SSLDEPLOY = DeployResourceType("ssl-deploy") + // 资源类型:部署到指定负载均衡器。 + DEPLOY_RESOURCE_LOADBALANCER = DeployResourceType("loadbalancer") + // 资源类型:部署到指定监听器。 + DEPLOY_RESOURCE_LISTENER = DeployResourceType("listener") + // 资源类型:部署到指定转发规则域名。 + DEPLOY_RESOURCE_RULEDOMAIN = DeployResourceType("ruledomain") +) diff --git a/internal/pkg/core/deployer/providers/tencentcloud-clb/tencentcloud_clb.go b/internal/pkg/core/deployer/providers/tencentcloud-clb/tencentcloud_clb.go new file mode 100644 index 00000000..990e266d --- /dev/null +++ b/internal/pkg/core/deployer/providers/tencentcloud-clb/tencentcloud_clb.go @@ -0,0 +1,305 @@ +package tencentcloudclb + +import ( + "context" + "errors" + "fmt" + + xerrors "github.com/pkg/errors" + tcClb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb/v20180317" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" + tcSsl "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl/v20191205" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerSsl "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/tencentcloud-ssl" +) + +type TencentCloudCLBDeployerConfig struct { + // 腾讯云 SecretId。 + SecretId string `json:"secretId"` + // 腾讯云 SecretKey。 + SecretKey string `json:"secretKey"` + // 腾讯云地域。 + Region string `json:"region"` + // 部署资源类型。 + ResourceType DeployResourceType `json:"resourceType"` + // 负载均衡器 ID。 + // 部署资源类型为 [DEPLOY_RESOURCE_SSLDEPLOY]、[DEPLOY_RESOURCE_LOADBALANCER]、[DEPLOY_RESOURCE_RULEDOMAIN] 时必填。 + LoadbalancerId string `json:"loadbalancerId,omitempty"` + // 负载均衡监听 ID。 + // 部署资源类型为 [DEPLOY_RESOURCE_SSLDEPLOY]、[DEPLOY_RESOURCE_LOADBALANCER]、[DEPLOY_RESOURCE_LISTENER]、[DEPLOY_RESOURCE_RULEDOMAIN] 时必填。 + ListenerId string `json:"listenerId,omitempty"` + // SNI 域名或七层转发规则域名(支持泛域名)。 + // 部署资源类型为 [DEPLOY_RESOURCE_SSLDEPLOY] 时选填;部署资源类型为 [DEPLOY_RESOURCE_RULEDOMAIN] 时必填。 + Domain string `json:"domain,omitempty"` +} + +type TencentCloudCLBDeployer struct { + config *TencentCloudCLBDeployerConfig + logger deployer.Logger + sdkClients *wSdkClients + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*TencentCloudCLBDeployer)(nil) + +type wSdkClients struct { + ssl *tcSsl.Client + clb *tcClb.Client +} + +func New(config *TencentCloudCLBDeployerConfig) (*TencentCloudCLBDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *TencentCloudCLBDeployerConfig, logger deployer.Logger) (*TencentCloudCLBDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + clients, err := createSdkClients(config.SecretId, config.SecretKey, config.Region) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk clients") + } + + uploader, err := providerSsl.New(&providerSsl.TencentCloudSSLUploaderConfig{ + SecretId: config.SecretId, + SecretKey: config.SecretKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &TencentCloudCLBDeployer{ + logger: logger, + config: config, + sdkClients: clients, + sslUploader: uploader, + }, nil +} + +func (d *TencentCloudCLBDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 SSL + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 根据部署资源类型决定部署方式 + switch d.config.ResourceType { + case DEPLOY_RESOURCE_USE_SSLDEPLOY: + if err := d.deployToInstanceUseSsl(ctx, upres.CertId); err != nil { + return nil, err + } + + case DEPLOY_RESOURCE_LOADBALANCER: + if err := d.deployToLoadbalancer(ctx, upres.CertId); err != nil { + return nil, err + } + + case DEPLOY_RESOURCE_LISTENER: + if err := d.deployToListener(ctx, upres.CertId); err != nil { + return nil, err + } + + case DEPLOY_RESOURCE_RULEDOMAIN: + if err := d.deployToRuleDomain(ctx, upres.CertId); err != nil { + return nil, err + } + + default: + return nil, fmt.Errorf("unsupported resource type: %s", d.config.ResourceType) + } + + return &deployer.DeployResult{}, nil +} + +func (d *TencentCloudCLBDeployer) deployToInstanceUseSsl(ctx context.Context, cloudCertId string) error { + if d.config.LoadbalancerId == "" { + return errors.New("config `loadbalancerId` is required") + } + if d.config.ListenerId == "" { + return errors.New("config `listenerId` is required") + } + + // 证书部署到 CLB 实例 + // REF: https://cloud.tencent.com/document/product/400/91667 + deployCertificateInstanceReq := tcSsl.NewDeployCertificateInstanceRequest() + deployCertificateInstanceReq.CertificateId = common.StringPtr(cloudCertId) + deployCertificateInstanceReq.ResourceType = common.StringPtr("clb") + deployCertificateInstanceReq.Status = common.Int64Ptr(1) + if d.config.Domain == "" { + // 未开启 SNI,只需指定到监听器 + deployCertificateInstanceReq.InstanceIdList = common.StringPtrs([]string{fmt.Sprintf("%s|%s", d.config.LoadbalancerId, d.config.ListenerId)}) + } else { + // 开启 SNI,需指定到域名(支持泛域名) + deployCertificateInstanceReq.InstanceIdList = common.StringPtrs([]string{fmt.Sprintf("%s|%s|%s", d.config.LoadbalancerId, d.config.ListenerId, d.config.Domain)}) + } + deployCertificateInstanceResp, err := d.sdkClients.ssl.DeployCertificateInstance(deployCertificateInstanceReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'ssl.DeployCertificateInstance'") + } + + d.logger.Logt("已部署证书到云资源实例", deployCertificateInstanceResp.Response) + + return nil +} + +func (d *TencentCloudCLBDeployer) deployToLoadbalancer(ctx context.Context, cloudCertId string) error { + if d.config.LoadbalancerId == "" { + return errors.New("config `loadbalancerId` is required") + } + + listenerIds := make([]string, 0) + + // 查询监听器列表 + // REF: https://cloud.tencent.com/document/api/214/30686 + describeListenersReq := tcClb.NewDescribeListenersRequest() + describeListenersReq.LoadBalancerId = common.StringPtr(d.config.LoadbalancerId) + describeListenersResp, err := d.sdkClients.clb.DescribeListeners(describeListenersReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'clb.DescribeListeners'") + } else { + if describeListenersResp.Response.Listeners != nil { + for _, listener := range describeListenersResp.Response.Listeners { + if listener.Protocol == nil || (*listener.Protocol != "HTTPS" && *listener.Protocol != "TCP_SSL" && *listener.Protocol != "QUIC") { + continue + } + + listenerIds = append(listenerIds, *listener.ListenerId) + } + } + } + + d.logger.Logt("已查询到负载均衡器下的监听器", listenerIds) + + // 批量更新监听器证书 + if len(listenerIds) > 0 { + var errs []error + + for _, listenerId := range listenerIds { + if err := d.modifyListenerCertificate(ctx, d.config.LoadbalancerId, listenerId, cloudCertId); err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + } + + return nil +} + +func (d *TencentCloudCLBDeployer) deployToListener(ctx context.Context, cloudCertId string) error { + if d.config.LoadbalancerId == "" { + return errors.New("config `loadbalancerId` is required") + } + if d.config.ListenerId == "" { + return errors.New("config `listenerId` is required") + } + + // 更新监听器证书 + if err := d.modifyListenerCertificate(ctx, d.config.LoadbalancerId, d.config.ListenerId, cloudCertId); err != nil { + return err + } + + return nil +} + +func (d *TencentCloudCLBDeployer) deployToRuleDomain(ctx context.Context, cloudCertId string) error { + if d.config.LoadbalancerId == "" { + return errors.New("config `loadbalancerId` is required") + } + if d.config.ListenerId == "" { + return errors.New("config `listenerId` is required") + } + if d.config.Domain == "" { + return errors.New("config `domain` is required") + } + + // 修改负载均衡七层监听器转发规则的域名级别属性 + // REF: https://cloud.tencent.com/document/api/214/38092 + modifyDomainAttributesReq := tcClb.NewModifyDomainAttributesRequest() + modifyDomainAttributesReq.LoadBalancerId = common.StringPtr(d.config.LoadbalancerId) + modifyDomainAttributesReq.ListenerId = common.StringPtr(d.config.ListenerId) + modifyDomainAttributesReq.Domain = common.StringPtr(d.config.Domain) + modifyDomainAttributesReq.Certificate = &tcClb.CertificateInput{ + SSLMode: common.StringPtr("UNIDIRECTIONAL"), + CertId: common.StringPtr(cloudCertId), + } + modifyDomainAttributesResp, err := d.sdkClients.clb.ModifyDomainAttributes(modifyDomainAttributesReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'clb.ModifyDomainAttributes'") + } + + d.logger.Logt("已修改七层监听器转发规则的域名级别属性", modifyDomainAttributesResp.Response) + + return nil +} + +func (d *TencentCloudCLBDeployer) modifyListenerCertificate(ctx context.Context, cloudLoadbalancerId, cloudListenerId, cloudCertId string) error { + // 查询监听器列表 + // REF: https://cloud.tencent.com/document/api/214/30686 + describeListenersReq := tcClb.NewDescribeListenersRequest() + describeListenersReq.LoadBalancerId = common.StringPtr(cloudLoadbalancerId) + describeListenersReq.ListenerIds = common.StringPtrs([]string{cloudListenerId}) + describeListenersResp, err := d.sdkClients.clb.DescribeListeners(describeListenersReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'clb.DescribeListeners'") + } + if len(describeListenersResp.Response.Listeners) == 0 { + return errors.New("listener not found") + } + + d.logger.Logt("已查询到监听器属性", describeListenersResp.Response) + + // 修改监听器属性 + // REF: https://cloud.tencent.com/document/product/214/30681 + modifyListenerReq := tcClb.NewModifyListenerRequest() + modifyListenerReq.LoadBalancerId = common.StringPtr(cloudLoadbalancerId) + modifyListenerReq.ListenerId = common.StringPtr(cloudListenerId) + modifyListenerReq.Certificate = &tcClb.CertificateInput{CertId: common.StringPtr(cloudCertId)} + if describeListenersResp.Response.Listeners[0].Certificate != nil && describeListenersResp.Response.Listeners[0].Certificate.SSLMode != nil { + modifyListenerReq.Certificate.SSLMode = describeListenersResp.Response.Listeners[0].Certificate.SSLMode + modifyListenerReq.Certificate.CertCaId = describeListenersResp.Response.Listeners[0].Certificate.CertCaId + } else { + modifyListenerReq.Certificate.SSLMode = common.StringPtr("UNIDIRECTIONAL") + } + modifyListenerResp, err := d.sdkClients.clb.ModifyListener(modifyListenerReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'clb.ModifyListener'") + } + + d.logger.Logt("已修改监听器属性", modifyListenerResp.Response) + + return nil +} + +func createSdkClients(secretId, secretKey, region string) (*wSdkClients, error) { + credential := common.NewCredential(secretId, secretKey) + + // 注意虽然官方文档中地域无需指定,但实际需要部署到 CLB 时必传 + sslClient, err := tcSsl.NewClient(credential, region, profile.NewClientProfile()) + if err != nil { + return nil, err + } + + clbClient, err := tcClb.NewClient(credential, region, profile.NewClientProfile()) + if err != nil { + return nil, err + } + + return &wSdkClients{ + ssl: sslClient, + clb: clbClient, + }, nil +} diff --git a/internal/pkg/core/deployer/providers/tencentcloud-clb/tencentcloud_clb_test.go b/internal/pkg/core/deployer/providers/tencentcloud-clb/tencentcloud_clb_test.go new file mode 100644 index 00000000..fe0cf023 --- /dev/null +++ b/internal/pkg/core/deployer/providers/tencentcloud-clb/tencentcloud_clb_test.go @@ -0,0 +1,199 @@ +package tencentcloudclb_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-clb" +) + +var ( + fInputCertPath string + fInputKeyPath string + fSecretId string + fSecretKey string + fRegion string + fLoadbalancerId string + fListenerId string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_TENCENTCLOUDCDN_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fSecretId, argsPrefix+"SECRETID", "", "") + flag.StringVar(&fSecretKey, argsPrefix+"SECRETKEY", "", "") + flag.StringVar(&fRegion, argsPrefix+"REGION", "", "") + flag.StringVar(&fLoadbalancerId, argsPrefix+"LOADBALANCERID", "", "") + flag.StringVar(&fListenerId, argsPrefix+"LISTENERID", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v tencentcloud_clb_test.go -args \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCLB_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCLB_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCLB_SECRETID="your-secret-id" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCLB_SECRETKEY="your-secret-key" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCLB_REGION="ap-guangzhou" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCLB_LOADBALANCERID="your-clb-lb-id" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCLB_LISTENERID="your-clb-lbl-id" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCLB_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy_UseSslDeploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("SECRETID: %v", fSecretId), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LOADBALANCERID: %v", fLoadbalancerId), + fmt.Sprintf("LISTENERID: %v", fListenerId), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.TencentCloudCLBDeployerConfig{ + SecretId: fSecretId, + SecretKey: fSecretKey, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_USE_SSLDEPLOY, + LoadbalancerId: fLoadbalancerId, + ListenerId: fListenerId, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) + + t.Run("Deploy_ToLoadbalancer", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("SECRETID: %v", fSecretId), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LOADBALANCERID: %v", fLoadbalancerId), + }, "\n")) + + deployer, err := provider.New(&provider.TencentCloudCLBDeployerConfig{ + SecretId: fSecretId, + SecretKey: fSecretKey, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_LOADBALANCER, + LoadbalancerId: fLoadbalancerId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) + + t.Run("Deploy_ToListener", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("SECRETID: %v", fSecretId), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LOADBALANCERID: %v", fLoadbalancerId), + fmt.Sprintf("LISTENERID: %v", fListenerId), + }, "\n")) + + deployer, err := provider.New(&provider.TencentCloudCLBDeployerConfig{ + SecretId: fSecretId, + SecretKey: fSecretKey, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_LISTENER, + LoadbalancerId: fLoadbalancerId, + ListenerId: fListenerId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) + + t.Run("Deploy_ToRuleDomain", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("SECRETID: %v", fSecretId), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("LOADBALANCERID: %v", fLoadbalancerId), + fmt.Sprintf("LISTENERID: %v", fListenerId), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.TencentCloudCLBDeployerConfig{ + SecretId: fSecretId, + SecretKey: fSecretKey, + Region: fRegion, + ResourceType: provider.DEPLOY_RESOURCE_RULEDOMAIN, + LoadbalancerId: fLoadbalancerId, + ListenerId: fListenerId, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/tencentcloud-cos/tencentcloud_cos.go b/internal/pkg/core/deployer/providers/tencentcloud-cos/tencentcloud_cos.go new file mode 100644 index 00000000..88f2ad60 --- /dev/null +++ b/internal/pkg/core/deployer/providers/tencentcloud-cos/tencentcloud_cos.go @@ -0,0 +1,115 @@ +package tencentcloudcdn + +import ( + "context" + "errors" + "fmt" + + xerrors "github.com/pkg/errors" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" + tcSsl "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl/v20191205" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerSsl "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/tencentcloud-ssl" +) + +type TencentCloudCOSDeployerConfig struct { + // 腾讯云 SecretId。 + SecretId string `json:"secretId"` + // 腾讯云 SecretKey。 + SecretKey string `json:"secretKey"` + // 腾讯云地域。 + Region string `json:"region"` + // 存储桶名。 + Bucket string `json:"bucket"` + // 自定义域名(不支持泛域名)。 + Domain string `json:"domain"` +} + +type TencentCloudCOSDeployer struct { + config *TencentCloudCOSDeployerConfig + logger deployer.Logger + sdkClient *tcSsl.Client + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*TencentCloudCOSDeployer)(nil) + +func New(config *TencentCloudCOSDeployerConfig) (*TencentCloudCOSDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *TencentCloudCOSDeployerConfig, logger deployer.Logger) (*TencentCloudCOSDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client, err := createSdkClient(config.SecretId, config.SecretKey, config.Region) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk clients") + } + + uploader, err := providerSsl.New(&providerSsl.TencentCloudSSLUploaderConfig{ + SecretId: config.SecretId, + SecretKey: config.SecretKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &TencentCloudCOSDeployer{ + logger: logger, + config: config, + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *TencentCloudCOSDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + if d.config.Bucket == "" { + return nil, errors.New("config `bucket` is required") + } + if d.config.Domain == "" { + return nil, errors.New("config `domain` is required") + } + + // 上传证书到 SSL + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 证书部署到 COS 实例 + // REF: https://cloud.tencent.com/document/product/400/91667 + deployCertificateInstanceReq := tcSsl.NewDeployCertificateInstanceRequest() + deployCertificateInstanceReq.CertificateId = common.StringPtr(upres.CertId) + deployCertificateInstanceReq.ResourceType = common.StringPtr("cos") + deployCertificateInstanceReq.Status = common.Int64Ptr(1) + deployCertificateInstanceReq.InstanceIdList = common.StringPtrs([]string{fmt.Sprintf("%s#%s#%s", d.config.Region, d.config.Bucket, d.config.Domain)}) + deployCertificateInstanceResp, err := d.sdkClient.DeployCertificateInstance(deployCertificateInstanceReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'ssl.DeployCertificateInstance'") + } + + d.logger.Logt("已部署证书到云资源实例", deployCertificateInstanceResp.Response) + + return &deployer.DeployResult{}, nil +} + +func createSdkClient(secretId, secretKey, region string) (*tcSsl.Client, error) { + credential := common.NewCredential(secretId, secretKey) + client, err := tcSsl.NewClient(credential, region, profile.NewClientProfile()) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/tencentcloud-cos/tencentcloud_cos_test.go b/internal/pkg/core/deployer/providers/tencentcloud-cos/tencentcloud_cos_test.go new file mode 100644 index 00000000..5f105efe --- /dev/null +++ b/internal/pkg/core/deployer/providers/tencentcloud-cos/tencentcloud_cos_test.go @@ -0,0 +1,85 @@ +package tencentcloudcdn_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-cos" +) + +var ( + fInputCertPath string + fInputKeyPath string + fSecretId string + fSecretKey string + fRegion string + fBucket string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_TENCENTCLOUDCOS_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fSecretId, argsPrefix+"SECRETID", "", "") + flag.StringVar(&fSecretKey, argsPrefix+"SECRETKEY", "", "") + flag.StringVar(&fRegion, argsPrefix+"REGION", "", "") + flag.StringVar(&fBucket, argsPrefix+"BUCKET", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v tencentcloud_cos_test.go -args \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCOS_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCOS_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCOS_SECRETID="your-secret-id" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCOS_SECRETKEY="your-secret-key" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCOS_REGION="ap-guangzhou" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCOS_BUCKET="your-cos-bucket" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDCOS_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("SECRETID: %v", fSecretId), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("REGION: %v", fRegion), + fmt.Sprintf("BUCKET: %v", fBucket), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.TencentCloudCOSDeployerConfig{ + SecretId: fSecretId, + SecretKey: fSecretKey, + Region: fRegion, + Bucket: fBucket, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/tencentcloud-ecdn/tencentcloud_ecdn.go b/internal/pkg/core/deployer/providers/tencentcloud-ecdn/tencentcloud_ecdn.go new file mode 100644 index 00000000..b5be93ba --- /dev/null +++ b/internal/pkg/core/deployer/providers/tencentcloud-ecdn/tencentcloud_ecdn.go @@ -0,0 +1,158 @@ +package tencentcloudecdn + +import ( + "context" + "errors" + "strings" + + xerrors "github.com/pkg/errors" + tcCdn "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn/v20180606" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" + tcSsl "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl/v20191205" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerSsl "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/tencentcloud-ssl" +) + +type TencentCloudECDNDeployerConfig struct { + // 腾讯云 SecretId。 + SecretId string `json:"secretId"` + // 腾讯云 SecretKey。 + SecretKey string `json:"secretKey"` + // 加速域名(支持泛域名)。 + Domain string `json:"domain"` +} + +type TencentCloudECDNDeployer struct { + config *TencentCloudECDNDeployerConfig + logger deployer.Logger + sdkClients *wSdkClients + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*TencentCloudECDNDeployer)(nil) + +type wSdkClients struct { + ssl *tcSsl.Client + cdn *tcCdn.Client +} + +func New(config *TencentCloudECDNDeployerConfig) (*TencentCloudECDNDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *TencentCloudECDNDeployerConfig, logger deployer.Logger) (*TencentCloudECDNDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + clients, err := createSdkClients(config.SecretId, config.SecretKey) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk clients") + } + + uploader, err := providerSsl.New(&providerSsl.TencentCloudSSLUploaderConfig{ + SecretId: config.SecretId, + SecretKey: config.SecretKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &TencentCloudECDNDeployer{ + logger: logger, + config: config, + sdkClients: clients, + sslUploader: uploader, + }, nil +} + +func (d *TencentCloudECDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 SSL + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 获取待部署的 CDN 实例 + // 如果是泛域名,根据证书匹配 CDN 实例 + instanceIds := make([]string, 0) + if strings.HasPrefix(d.config.Domain, "*.") { + domains, err := d.getDomainsByCertificateId(upres.CertId) + if err != nil { + return nil, err + } + + instanceIds = domains + } else { + instanceIds = append(instanceIds, d.config.Domain) + } + + if len(instanceIds) == 0 { + d.logger.Logt("已部署过或没有要部署的 ECDN 实例") + } else { + // 证书部署到 ECDN 实例 + // REF: https://cloud.tencent.com/document/product/400/91667 + deployCertificateInstanceReq := tcSsl.NewDeployCertificateInstanceRequest() + deployCertificateInstanceReq.CertificateId = common.StringPtr(upres.CertId) + deployCertificateInstanceReq.ResourceType = common.StringPtr("ecdn") + deployCertificateInstanceReq.Status = common.Int64Ptr(1) + deployCertificateInstanceReq.InstanceIdList = common.StringPtrs(instanceIds) + deployCertificateInstanceResp, err := d.sdkClients.ssl.DeployCertificateInstance(deployCertificateInstanceReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'ssl.DeployCertificateInstance'") + } + + d.logger.Logt("已部署证书到云资源实例", deployCertificateInstanceResp.Response) + } + + return &deployer.DeployResult{}, nil +} + +func (d *TencentCloudECDNDeployer) getDomainsByCertificateId(cloudCertId string) ([]string, error) { + // 获取证书中的可用域名 + // REF: https://cloud.tencent.com/document/product/228/42491 + describeCertDomainsReq := tcCdn.NewDescribeCertDomainsRequest() + describeCertDomainsReq.CertId = common.StringPtr(cloudCertId) + describeCertDomainsReq.Product = common.StringPtr("ecdn") + describeCertDomainsResp, err := d.sdkClients.cdn.DescribeCertDomains(describeCertDomainsReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.DescribeCertDomains'") + } + + domains := make([]string, 0) + if describeCertDomainsResp.Response.Domains == nil { + for _, domain := range describeCertDomainsResp.Response.Domains { + domains = append(domains, *domain) + } + } + + return domains, nil +} + +func createSdkClients(secretId, secretKey string) (*wSdkClients, error) { + credential := common.NewCredential(secretId, secretKey) + + sslClient, err := tcSsl.NewClient(credential, "", profile.NewClientProfile()) + if err != nil { + return nil, err + } + + cdnClient, err := tcCdn.NewClient(credential, "", profile.NewClientProfile()) + if err != nil { + return nil, err + } + + return &wSdkClients{ + ssl: sslClient, + cdn: cdnClient, + }, nil +} diff --git a/internal/pkg/core/deployer/providers/tencentcloud-ecdn/tencentcloud_ecdn_test.go b/internal/pkg/core/deployer/providers/tencentcloud-ecdn/tencentcloud_ecdn_test.go new file mode 100644 index 00000000..95d617cf --- /dev/null +++ b/internal/pkg/core/deployer/providers/tencentcloud-ecdn/tencentcloud_ecdn_test.go @@ -0,0 +1,75 @@ +package tencentcloudecdn_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-ecdn" +) + +var ( + fInputCertPath string + fInputKeyPath string + fSecretId string + fSecretKey string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_TENCENTCLOUDECDN_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fSecretId, argsPrefix+"SECRETID", "", "") + flag.StringVar(&fSecretKey, argsPrefix+"SECRETKEY", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v tencentcloud_ecdn_test.go -args \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDECDN_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDECDN_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDECDN_SECRETID="your-secret-id" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDECDN_SECRETKEY="your-secret-key" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDECDN_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("SECRETID: %v", fSecretId), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.TencentCloudECDNDeployerConfig{ + SecretId: fSecretId, + SecretKey: fSecretKey, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/tencentcloud-teo/tencentcloud_teo.go b/internal/pkg/core/deployer/providers/tencentcloud-teo/tencentcloud_teo.go new file mode 100644 index 00000000..96c301ab --- /dev/null +++ b/internal/pkg/core/deployer/providers/tencentcloud-teo/tencentcloud_teo.go @@ -0,0 +1,124 @@ +package tencentcloudeteo + +import ( + "context" + "errors" + + xerrors "github.com/pkg/errors" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" + tcSsl "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl/v20191205" + tcTeo "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo/v20220901" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerSsl "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/tencentcloud-ssl" +) + +type TencentCloudTEODeployerConfig struct { + // 腾讯云 SecretId。 + SecretId string `json:"secretId"` + // 腾讯云 SecretKey。 + SecretKey string `json:"secretKey"` + // 站点 ID。 + ZoneId string `json:"zoneId"` + // 加速域名(不支持泛域名)。 + Domain string `json:"domain"` +} + +type TencentCloudTEODeployer struct { + config *TencentCloudTEODeployerConfig + logger deployer.Logger + sdkClients *wSdkClients + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*TencentCloudTEODeployer)(nil) + +type wSdkClients struct { + ssl *tcSsl.Client + teo *tcTeo.Client +} + +func New(config *TencentCloudTEODeployerConfig) (*TencentCloudTEODeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *TencentCloudTEODeployerConfig, logger deployer.Logger) (*TencentCloudTEODeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + clients, err := createSdkClients(config.SecretId, config.SecretKey) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk clients") + } + + uploader, err := providerSsl.New(&providerSsl.TencentCloudSSLUploaderConfig{ + SecretId: config.SecretId, + SecretKey: config.SecretKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &TencentCloudTEODeployer{ + logger: logger, + config: config, + sdkClients: clients, + sslUploader: uploader, + }, nil +} + +func (d *TencentCloudTEODeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + if d.config.ZoneId == "" { + return nil, xerrors.New("config `zoneId` is required") + } + + // 上传证书到 SSL + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + // 配置域名证书 + // REF: https://cloud.tencent.com/document/product/1552/80764 + modifyHostsCertificateReq := tcTeo.NewModifyHostsCertificateRequest() + modifyHostsCertificateReq.ZoneId = common.StringPtr(d.config.ZoneId) + modifyHostsCertificateReq.Mode = common.StringPtr("sslcert") + modifyHostsCertificateReq.Hosts = common.StringPtrs([]string{d.config.Domain}) + modifyHostsCertificateReq.ServerCertInfo = []*tcTeo.ServerCertInfo{{CertId: common.StringPtr(upres.CertId)}} + modifyHostsCertificateResp, err := d.sdkClients.teo.ModifyHostsCertificate(modifyHostsCertificateReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'teo.ModifyHostsCertificate'") + } + + d.logger.Logt("已配置域名证书", modifyHostsCertificateResp.Response) + + return &deployer.DeployResult{}, nil +} + +func createSdkClients(secretId, secretKey string) (*wSdkClients, error) { + credential := common.NewCredential(secretId, secretKey) + + sslClient, err := tcSsl.NewClient(credential, "", profile.NewClientProfile()) + if err != nil { + return nil, err + } + + teoClient, err := tcTeo.NewClient(credential, "", profile.NewClientProfile()) + if err != nil { + return nil, err + } + + return &wSdkClients{ + ssl: sslClient, + teo: teoClient, + }, nil +} diff --git a/internal/pkg/core/deployer/providers/tencentcloud-teo/tencentcloud_teo_test.go b/internal/pkg/core/deployer/providers/tencentcloud-teo/tencentcloud_teo_test.go new file mode 100644 index 00000000..8d875e55 --- /dev/null +++ b/internal/pkg/core/deployer/providers/tencentcloud-teo/tencentcloud_teo_test.go @@ -0,0 +1,80 @@ +package tencentcloudeteo_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-teo" +) + +var ( + fInputCertPath string + fInputKeyPath string + fSecretId string + fSecretKey string + fZoneId string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_TENCENTCLOUDETEO_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fSecretId, argsPrefix+"SECRETID", "", "") + flag.StringVar(&fSecretKey, argsPrefix+"SECRETKEY", "", "") + flag.StringVar(&fZoneId, argsPrefix+"ZONEID", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v tencentcloud_cdn_test.go -args \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDETEO_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDETEO_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDETEO_SECRETID="your-secret-id" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDETEO_SECRETKEY="your-secret-key" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDETEO_ZONEID="your-zone-id" \ + --CERTIMATE_DEPLOYER_TENCENTCLOUDETEO_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("SECRETID: %v", fSecretId), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("ZONEID: %v", fZoneId), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.TencentCloudTEODeployerConfig{ + SecretId: fSecretId, + SecretKey: fSecretKey, + ZoneId: fZoneId, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/volcengine-cdn/volcengine_cdn.go b/internal/pkg/core/deployer/providers/volcengine-cdn/volcengine_cdn.go new file mode 100644 index 00000000..11906474 --- /dev/null +++ b/internal/pkg/core/deployer/providers/volcengine-cdn/volcengine_cdn.go @@ -0,0 +1,136 @@ +package volcenginecdn + +import ( + "context" + "errors" + "fmt" + "strings" + + xerrors "github.com/pkg/errors" + veCdn "github.com/volcengine/volc-sdk-golang/service/cdn" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerCdn "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/volcengine-cdn" +) + +type VolcEngineCDNDeployerConfig struct { + // 火山引擎 AccessKey。 + AccessKey string `json:"accessKey"` + // 火山引擎 SecretKey。 + SecretKey string `json:"secretKey"` + // 加速域名(支持泛域名)。 + Domain string `json:"domain"` +} + +type VolcEngineCDNDeployer struct { + config *VolcEngineCDNDeployerConfig + logger deployer.Logger + sdkClient *veCdn.CDN + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*VolcEngineCDNDeployer)(nil) + +func New(config *VolcEngineCDNDeployerConfig) (*VolcEngineCDNDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *VolcEngineCDNDeployerConfig, logger deployer.Logger) (*VolcEngineCDNDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client := veCdn.NewInstance() + client.Client.SetAccessKey(config.AccessKey) + client.Client.SetSecretKey(config.SecretKey) + + uploader, err := providerCdn.New(&providerCdn.VolcEngineCDNUploaderConfig{ + AccessKeyId: config.AccessKey, + AccessKeySecret: config.SecretKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &VolcEngineCDNDeployer{ + logger: logger, + config: config, + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *VolcEngineCDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 CDN + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + domains := make([]string, 0) + if strings.HasPrefix(d.config.Domain, "*.") { + // 获取指定证书可关联的域名 + // REF: https://www.volcengine.com/docs/6454/125711 + describeCertConfigReq := &veCdn.DescribeCertConfigRequest{ + CertId: upres.CertId, + } + describeCertConfigResp, err := d.sdkClient.DescribeCertConfig(describeCertConfigReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.DescribeCertConfig'") + } + + if describeCertConfigResp.Result.CertNotConfig != nil { + for i := range describeCertConfigResp.Result.CertNotConfig { + domains = append(domains, describeCertConfigResp.Result.CertNotConfig[i].Domain) + } + } + + if describeCertConfigResp.Result.OtherCertConfig != nil { + for i := range describeCertConfigResp.Result.OtherCertConfig { + domains = append(domains, describeCertConfigResp.Result.OtherCertConfig[i].Domain) + } + } + + if len(domains) == 0 { + if len(describeCertConfigResp.Result.SpecifiedCertConfig) > 0 { + // 所有可关联的域名都配置了该证书,跳过部署 + } else { + return nil, xerrors.New("domain not found") + } + } + } else { + domains = append(domains, d.config.Domain) + } + + if len(domains) > 0 { + var errs []error + + for _, domain := range domains { + // 关联证书与加速域名 + // REF: https://www.volcengine.com/docs/6454/125712 + batchDeployCertReq := &veCdn.BatchDeployCertRequest{ + CertId: upres.CertId, + Domain: domain, + } + batchDeployCertResp, err := d.sdkClient.BatchDeployCert(batchDeployCertReq) + if err != nil { + errs = append(errs, err) + } else { + d.logger.Logt(fmt.Sprintf("已关联证书到域名 %s", domain), batchDeployCertResp) + } + } + + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + } + + return &deployer.DeployResult{}, nil +} diff --git a/internal/pkg/core/deployer/providers/volcengine-cdn/volcengine_cdn_test.go b/internal/pkg/core/deployer/providers/volcengine-cdn/volcengine_cdn_test.go new file mode 100644 index 00000000..e24740be --- /dev/null +++ b/internal/pkg/core/deployer/providers/volcengine-cdn/volcengine_cdn_test.go @@ -0,0 +1,75 @@ +package volcenginecdn_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/volcengine-cdn" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKey string + fSecretKey string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_VOLCENGINECDN_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKey, argsPrefix+"ACCESSKEY", "", "") + flag.StringVar(&fSecretKey, argsPrefix+"SECRETKEY", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v volcengine_cdn_test.go -args \ + --CERTIMATE_DEPLOYER_VOLCENGINECDN_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_VOLCENGINECDN_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_VOLCENGINECDN_ACCESSKEY="your-access-key" \ + --CERTIMATE_DEPLOYER_VOLCENGINECDN_SECRETKEY="your-secret-key" \ + --CERTIMATE_DEPLOYER_VOLCENGINECDN_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEY: %v", fAccessKey), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.VolcEngineCDNDeployerConfig{ + AccessKey: fAccessKey, + SecretKey: fSecretKey, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/volcengine-live/volcengine_live.go b/internal/pkg/core/deployer/providers/volcengine-live/volcengine_live.go new file mode 100644 index 00000000..f492cf89 --- /dev/null +++ b/internal/pkg/core/deployer/providers/volcengine-live/volcengine_live.go @@ -0,0 +1,146 @@ +package volcenginelive + +import ( + "context" + "errors" + "fmt" + "strings" + + xerrors "github.com/pkg/errors" + veLive "github.com/volcengine/volc-sdk-golang/service/live/v20230101" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + providerLive "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/volcengine-live" + "github.com/usual2970/certimate/internal/pkg/utils/cast" +) + +type VolcEngineLiveDeployerConfig struct { + // 火山引擎 AccessKey。 + AccessKey string `json:"accessKey"` + // 火山引擎 SecretKey。 + SecretKey string `json:"secretKey"` + // 加速域名(支持泛域名)。 + Domain string `json:"domain"` +} + +type VolcEngineLiveDeployer struct { + config *VolcEngineLiveDeployerConfig + logger deployer.Logger + sdkClient *veLive.Live + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*VolcEngineLiveDeployer)(nil) + +func New(config *VolcEngineLiveDeployerConfig) (*VolcEngineLiveDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *VolcEngineLiveDeployerConfig, logger deployer.Logger) (*VolcEngineLiveDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + client := veLive.NewInstance() + client.SetAccessKey(config.AccessKey) + client.SetSecretKey(config.SecretKey) + + uploader, err := providerLive.New(&providerLive.VolcEngineLiveUploaderConfig{ + AccessKeyId: config.AccessKey, + AccessKeySecret: config.SecretKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + + return &VolcEngineLiveDeployer{ + logger: logger, + config: config, + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *VolcEngineLiveDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 上传证书到 Live + upres, err := d.sslUploader.Upload(ctx, certPem, privkeyPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to upload certificate file") + } + + d.logger.Logt("certificate file uploaded", upres) + + domains := make([]string, 0) + if strings.HasPrefix(d.config.Domain, "*.") { + listDomainDetailPageNum := int32(1) + listDomainDetailPageSize := int32(1000) + listDomainDetailTotal := 0 + for { + // 查询域名列表 + // REF: https://www.volcengine.com/docs/6469/1186277#%E6%9F%A5%E8%AF%A2%E5%9F%9F%E5%90%8D%E5%88%97%E8%A1%A8 + listDomainDetailReq := &veLive.ListDomainDetailBody{ + PageNum: listDomainDetailPageNum, + PageSize: listDomainDetailPageSize, + } + listDomainDetailResp, err := d.sdkClient.ListDomainDetail(ctx, listDomainDetailReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'live.ListDomainDetail'") + } + + if listDomainDetailResp.Result.DomainList != nil { + for _, item := range listDomainDetailResp.Result.DomainList { + // 仅匹配泛域名的下一级子域名 + wildcardDomain := strings.TrimPrefix(d.config.Domain, "*") + if strings.HasSuffix(item.Domain, wildcardDomain) && !strings.Contains(strings.TrimSuffix(item.Domain, wildcardDomain), ".") { + domains = append(domains, item.Domain) + } + } + } + + listDomainDetailLen := len(listDomainDetailResp.Result.DomainList) + if listDomainDetailLen < int(listDomainDetailPageSize) || int(listDomainDetailResp.Result.Total) <= listDomainDetailTotal+listDomainDetailLen { + break + } else { + listDomainDetailPageNum++ + listDomainDetailTotal += listDomainDetailLen + } + } + + if len(domains) == 0 { + return nil, xerrors.Errorf("未查询到匹配的域名: %s", d.config.Domain) + } + } else { + domains = append(domains, d.config.Domain) + } + + if len(domains) > 0 { + var errs []error + + for _, domain := range domains { + // 绑定证书 + // REF: https://www.volcengine.com/docs/6469/1186278#%E7%BB%91%E5%AE%9A%E8%AF%81%E4%B9%A6 + bindCertReq := &veLive.BindCertBody{ + ChainID: upres.CertId, + Domain: domain, + HTTPS: cast.BoolPtr(true), + } + bindCertResp, err := d.sdkClient.BindCert(ctx, bindCertReq) + if err != nil { + errs = append(errs, err) + } else { + d.logger.Logt(fmt.Sprintf("已绑定证书到域名 %s", domain), bindCertResp) + } + } + + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + } + + return &deployer.DeployResult{}, nil +} diff --git a/internal/pkg/core/deployer/providers/volcengine-live/volcengine_live_test.go b/internal/pkg/core/deployer/providers/volcengine-live/volcengine_live_test.go new file mode 100644 index 00000000..0ad66385 --- /dev/null +++ b/internal/pkg/core/deployer/providers/volcengine-live/volcengine_live_test.go @@ -0,0 +1,75 @@ +package volcenginelive_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/volcengine-live" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKey string + fSecretKey string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_VOLCENGINELIVE_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKey, argsPrefix+"ACCESSKEY", "", "") + flag.StringVar(&fSecretKey, argsPrefix+"SECRETKEY", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v volcengine_live_test.go -args \ + --CERTIMATE_DEPLOYER_VOLCENGINELIVE_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_VOLCENGINELIVE_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_VOLCENGINELIVE_ACCESSKEY="your-access-key" \ + --CERTIMATE_DEPLOYER_VOLCENGINELIVE_SECRETKEY="your-secret-key" \ + --CERTIMATE_DEPLOYER_VOLCENGINELIVE_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("ACCESSKEY: %v", fAccessKey), + fmt.Sprintf("SECRETKEY: %v", fSecretKey), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.New(&provider.VolcEngineLiveDeployerConfig{ + AccessKey: fAccessKey, + SecretKey: fSecretKey, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/webhook/webhook.go b/internal/pkg/core/deployer/providers/webhook/webhook.go new file mode 100644 index 00000000..76fcaaf8 --- /dev/null +++ b/internal/pkg/core/deployer/providers/webhook/webhook.go @@ -0,0 +1,85 @@ +package webhook + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/utils/x509" + xhttp "github.com/usual2970/certimate/internal/utils/http" +) + +type WebhookDeployerConfig struct { + // Webhook URL。 + Url string `json:"url"` + // Webhook 变量字典。 + Variables map[string]string `json:"variables,omitempty"` +} + +type WebhookDeployer struct { + config *WebhookDeployerConfig + logger deployer.Logger +} + +var _ deployer.Deployer = (*WebhookDeployer)(nil) + +func New(config *WebhookDeployerConfig) (*WebhookDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *WebhookDeployerConfig, logger deployer.Logger) (*WebhookDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + return &WebhookDeployer{ + config: config, + logger: logger, + }, nil +} + +type webhookData struct { + SubjectAltNames string `json:"subjectAltNames"` + Certificate string `json:"certificate"` + PrivateKey string `json:"privateKey"` + Variables map[string]string `json:"variables"` +} + +func (d *WebhookDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + certX509, err := x509.ParseCertificateFromPEM(certPem) + if err != nil { + return nil, xerrors.Wrap(err, "failed to parse x509") + } + + data := &webhookData{ + SubjectAltNames: strings.Join(certX509.DNSNames, ","), + Certificate: certPem, + PrivateKey: privkeyPem, + Variables: d.config.Variables, + } + body, _ := json.Marshal(data) + resp, err := xhttp.Req(d.config.Url, http.MethodPost, bytes.NewReader(body), map[string]string{ + "Content-Type": "application/json", + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to send webhook request") + } + + d.logger.Logt("Webhook Response", string(resp)) + + return &deployer.DeployResult{ + DeploymentData: map[string]any{ + "responseText": string(resp), + }, + }, nil +} diff --git a/internal/pkg/core/deployer/providers/webhook/webhook_test.go b/internal/pkg/core/deployer/providers/webhook/webhook_test.go new file mode 100644 index 00000000..a3b36dda --- /dev/null +++ b/internal/pkg/core/deployer/providers/webhook/webhook_test.go @@ -0,0 +1,65 @@ +package webhook_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/webhook" +) + +var ( + fInputCertPath string + fInputKeyPath string + fUrl string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_WEBHOOK_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fUrl, argsPrefix+"URL", "", "") +} + +/* +Shell command to run this test: + + go test -v webhook_test.go -args \ + --CERTIMATE_DEPLOYER_WEBHOOK_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_WEBHOOK_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_WEBHOOK_URL="https://example.com/your-webhook-url" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("URL: %v", fUrl), + }, "\n")) + + deployer, err := provider.New(&provider.WebhookDeployerConfig{ + Url: fUrl, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/notifier/providers/bark/bark_test.go b/internal/pkg/core/notifier/providers/bark/bark_test.go new file mode 100644 index 00000000..3b9bef2d --- /dev/null +++ b/internal/pkg/core/notifier/providers/bark/bark_test.go @@ -0,0 +1,64 @@ +package bark_test + +import ( + "context" + "flag" + "fmt" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/bark" +) + +const ( + mockSubject = "test_subject" + mockMessage = "test_message" +) + +var ( + fServerUrl string + fDeviceKey string +) + +func init() { + argsPrefix := "CERTIMATE_NOTIFIER_BARK_" + + flag.StringVar(&fServerUrl, argsPrefix+"SERVERURL", "", "") + flag.StringVar(&fDeviceKey, argsPrefix+"DEVICEKEY", "", "") +} + +/* +Shell command to run this test: + + go test -v bark_test.go -args \ + --CERTIMATE_NOTIFIER_BARK_SERVERURL="https://example.com/your-server-url" \ + --CERTIMATE_NOTIFIER_BARK_DEVICEKEY="your-device-key" +*/ +func TestNotify(t *testing.T) { + flag.Parse() + + t.Run("Notify", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("SERVERURL: %v", fServerUrl), + fmt.Sprintf("DEVICEKEY: %v", fDeviceKey), + }, "\n")) + + notifier, err := provider.New(&provider.BarkNotifierConfig{ + ServerUrl: fServerUrl, + DeviceKey: fDeviceKey, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + res, err := notifier.Notify(context.Background(), mockSubject, mockMessage) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/notifier/providers/dingtalk/dingtalk_test.go b/internal/pkg/core/notifier/providers/dingtalk/dingtalk_test.go new file mode 100644 index 00000000..ea2c9bc8 --- /dev/null +++ b/internal/pkg/core/notifier/providers/dingtalk/dingtalk_test.go @@ -0,0 +1,63 @@ +package dingtalk_test + +import ( + "context" + "flag" + "fmt" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/dingtalk" +) + +const ( + mockSubject = "test_subject" + mockMessage = "test_message" +) + +var ( + fAccessToken string + fSecret string +) + +func init() { + argsPrefix := "CERTIMATE_NOTIFIER_DINGTALK_" + + flag.StringVar(&fAccessToken, argsPrefix+"ACCESSTOKEN", "", "") + flag.StringVar(&fSecret, argsPrefix+"SECRET", "", "") +} + +/* +Shell command to run this test: + + go test -v dingtalk_test.go -args \ + --CERTIMATE_NOTIFIER_DINGTALK_URL="https://example.com/your-webhook-url" +*/ +func TestNotify(t *testing.T) { + flag.Parse() + + t.Run("Notify", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("ACCESSTOKEN: %v", fAccessToken), + fmt.Sprintf("SECRET: %v", fSecret), + }, "\n")) + + notifier, err := provider.New(&provider.DingTalkNotifierConfig{ + AccessToken: fAccessToken, + Secret: fSecret, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + res, err := notifier.Notify(context.Background(), mockSubject, mockMessage) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/notifier/providers/email/email_test.go b/internal/pkg/core/notifier/providers/email/email_test.go index 1197a1a6..34303d06 100644 --- a/internal/pkg/core/notifier/providers/email/email_test.go +++ b/internal/pkg/core/notifier/providers/email/email_test.go @@ -1,51 +1,89 @@ package email_test import ( - "os" - "strconv" + "context" + "flag" + "fmt" + "strings" "testing" - notifierEmail "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/email" + provider "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/email" ) +const ( + mockSubject = "test_subject" + mockMessage = "test_message" +) + +var ( + fSmtpHost string + fSmtpPort int + fSmtpTLS bool + fUsername string + fPassword string + fSenderAddress string + fReceiverAddress string +) + +func init() { + argsPrefix := "CERTIMATE_NOTIFIER_EMAIL_" + + flag.StringVar(&fSmtpHost, argsPrefix+"SMTPHOST", "", "") + flag.IntVar(&fSmtpPort, argsPrefix+"SMTPPORT", 0, "") + flag.BoolVar(&fSmtpTLS, argsPrefix+"SMTPTLS", false, "") + flag.StringVar(&fUsername, argsPrefix+"USERNAME", "", "") + flag.StringVar(&fPassword, argsPrefix+"PASSWORD", "", "") + flag.StringVar(&fSenderAddress, argsPrefix+"SENDERADDRESS", "", "") + flag.StringVar(&fReceiverAddress, argsPrefix+"RECEIVERADDRESS", "", "") +} + /* Shell command to run this test: - CERTIMATE_NOTIFIER_EMAIL_SMTPPORT=465 \ - CERTIMATE_NOTIFIER_EMAIL_SMTPTLS=true \ - CERTIMATE_NOTIFIER_EMAIL_SMTPHOST="smtp.example.com" \ - CERTIMATE_NOTIFIER_EMAIL_USERNAME="your-username" \ - CERTIMATE_NOTIFIER_EMAIL_PASSWORD="your-password" \ - CERTIMATE_NOTIFIER_EMAIL_SENDERADDRESS="sender@example.com" \ - CERTIMATE_NOTIFIER_EMAIL_RECEIVERADDRESS="receiver@example.com" \ - go test -v -run TestNotify email_test.go + go test -v email_test.go -args \ + --CERTIMATE_NOTIFIER_EMAIL_SMTPHOST="smtp.example.com" \ + --CERTIMATE_NOTIFIER_EMAIL_SMTPPORT=465 \ + --CERTIMATE_NOTIFIER_EMAIL_SMTPTLS=true \ + --CERTIMATE_NOTIFIER_EMAIL_USERNAME="your-username" \ + --CERTIMATE_NOTIFIER_EMAIL_PASSWORD="your-password" \ + --CERTIMATE_NOTIFIER_EMAIL_SENDERADDRESS="sender@example.com" \ + --CERTIMATE_NOTIFIER_EMAIL_RECEIVERADDRESS="receiver@example.com" */ func TestNotify(t *testing.T) { - smtpPort, err := strconv.ParseInt(os.Getenv("CERTIMATE_NOTIFIER_EMAIL_SMTPPORT"), 10, 32) - if err != nil { - t.Errorf("invalid envvar: %+v", err) - panic(err) - } + flag.Parse() - smtpTLS, err := strconv.ParseBool(os.Getenv("CERTIMATE_NOTIFIER_EMAIL_SMTPTLS")) - if err != nil { - t.Errorf("invalid envvar: %+v", err) - panic(err) - } + t.Run("Notify", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("SMTPHOST: %v", fSmtpHost), + fmt.Sprintf("SMTPPORT: %v", fSmtpPort), + fmt.Sprintf("SMTPTLS: %v", fSmtpTLS), + fmt.Sprintf("USERNAME: %v", fUsername), + fmt.Sprintf("PASSWORD: %v", fPassword), + fmt.Sprintf("SENDERADDRESS: %v", fSenderAddress), + fmt.Sprintf("RECEIVERADDRESS: %v", fReceiverAddress), + }, "\n")) - res, err := notifierEmail.New(¬ifierEmail.EmailNotifierConfig{ - SmtpHost: os.Getenv("CERTIMATE_NOTIFIER_EMAIL_SMTPHOST"), - SmtpPort: int32(smtpPort), - SmtpTLS: smtpTLS, - Username: os.Getenv("CERTIMATE_NOTIFIER_EMAIL_USERNAME"), - Password: os.Getenv("CERTIMATE_NOTIFIER_EMAIL_PASSWORD"), - SenderAddress: os.Getenv("CERTIMATE_NOTIFIER_EMAIL_SENDERADDRESS"), - ReceiverAddress: os.Getenv("CERTIMATE_NOTIFIER_EMAIL_RECEIVERADDRESS"), + notifier, err := provider.New(&provider.EmailNotifierConfig{ + SmtpHost: fSmtpHost, + SmtpPort: int32(fSmtpPort), + SmtpTLS: fSmtpTLS, + Username: fUsername, + Password: fPassword, + SenderAddress: fSenderAddress, + ReceiverAddress: fReceiverAddress, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + res, err := notifier.Notify(context.Background(), mockSubject, mockMessage) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) }) - if err != nil { - t.Errorf("invalid envvar: %+v", err) - panic(err) - } - - t.Logf("notify result: %v", res) } diff --git a/internal/pkg/core/notifier/providers/lark/lark_test.go b/internal/pkg/core/notifier/providers/lark/lark_test.go new file mode 100644 index 00000000..b93d3606 --- /dev/null +++ b/internal/pkg/core/notifier/providers/lark/lark_test.go @@ -0,0 +1,57 @@ +package lark_test + +import ( + "context" + "flag" + "fmt" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/lark" +) + +const ( + mockSubject = "test_subject" + mockMessage = "test_message" +) + +var fWebhookUrl string + +func init() { + argsPrefix := "CERTIMATE_NOTIFIER_LARK_" + + flag.StringVar(&fWebhookUrl, argsPrefix+"WEBHOOKURL", "", "") +} + +/* +Shell command to run this test: + + go test -v lark_test.go -args \ + --CERTIMATE_NOTIFIER_LARK_WEBHOOKURL="https://example.com/your-webhook-url" +*/ +func TestNotify(t *testing.T) { + flag.Parse() + + t.Run("Notify", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("WEBHOOKURL: %v", fWebhookUrl), + }, "\n")) + + notifier, err := provider.New(&provider.LarkNotifierConfig{ + WebhookUrl: fWebhookUrl, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + res, err := notifier.Notify(context.Background(), mockSubject, mockMessage) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/notifier/providers/serverchan/serverchan_test.go b/internal/pkg/core/notifier/providers/serverchan/serverchan_test.go new file mode 100644 index 00000000..63407379 --- /dev/null +++ b/internal/pkg/core/notifier/providers/serverchan/serverchan_test.go @@ -0,0 +1,57 @@ +package serverchan_test + +import ( + "context" + "flag" + "fmt" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/serverchan" +) + +const ( + mockSubject = "test_subject" + mockMessage = "test_message" +) + +var fUrl string + +func init() { + argsPrefix := "CERTIMATE_NOTIFIER_SERVERCHAN_" + + flag.StringVar(&fUrl, argsPrefix+"URL", "", "") +} + +/* +Shell command to run this test: + + go test -v serverchan_test.go -args \ + --CERTIMATE_NOTIFIER_SERVERCHAN_URL="https://example.com/your-webhook-url" \ +*/ +func TestNotify(t *testing.T) { + flag.Parse() + + t.Run("Notify", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("URL: %v", fUrl), + }, "\n")) + + notifier, err := provider.New(&provider.ServerChanNotifierConfig{ + Url: fUrl, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + res, err := notifier.Notify(context.Background(), mockSubject, mockMessage) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/notifier/providers/telegram/telegram_test.go b/internal/pkg/core/notifier/providers/telegram/telegram_test.go new file mode 100644 index 00000000..062e2642 --- /dev/null +++ b/internal/pkg/core/notifier/providers/telegram/telegram_test.go @@ -0,0 +1,64 @@ +package telegram_test + +import ( + "context" + "flag" + "fmt" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/telegram" +) + +const ( + mockSubject = "test_subject" + mockMessage = "test_message" +) + +var ( + fApiToken string + fChartId int64 +) + +func init() { + argsPrefix := "CERTIMATE_NOTIFIER_TELEGRAM_" + + flag.StringVar(&fApiToken, argsPrefix+"APITOKEN", "", "") + flag.Int64Var(&fChartId, argsPrefix+"CHATID", 0, "") +} + +/* +Shell command to run this test: + + go test -v telegram_test.go -args \ + --CERTIMATE_NOTIFIER_TELEGRAM_APITOKEN="your-api-token" \ + --CERTIMATE_NOTIFIER_TELEGRAM_CHATID=123456 +*/ +func TestNotify(t *testing.T) { + flag.Parse() + + t.Run("Notify", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("APITOKEN: %v", fApiToken), + fmt.Sprintf("CHATID: %v", fChartId), + }, "\n")) + + notifier, err := provider.New(&provider.TelegramNotifierConfig{ + ApiToken: fApiToken, + ChatId: fChartId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + res, err := notifier.Notify(context.Background(), mockSubject, mockMessage) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/notifier/providers/webhook/webhook_test.go b/internal/pkg/core/notifier/providers/webhook/webhook_test.go new file mode 100644 index 00000000..2f83f8e0 --- /dev/null +++ b/internal/pkg/core/notifier/providers/webhook/webhook_test.go @@ -0,0 +1,57 @@ +package webhook_test + +import ( + "context" + "flag" + "fmt" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/webhook" +) + +const ( + mockSubject = "test_subject" + mockMessage = "test_message" +) + +var fUrl string + +func init() { + argsPrefix := "CERTIMATE_NOTIFIER_WEBHOOK_" + + flag.StringVar(&fUrl, argsPrefix+"URL", "", "") +} + +/* +Shell command to run this test: + + go test -v webhook_test.go -args \ + --CERTIMATE_NOTIFIER_WEBHOOK_URL="https://example.com/your-webhook-url" +*/ +func TestNotify(t *testing.T) { + flag.Parse() + + t.Run("Notify", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("URL: %v", fUrl), + }, "\n")) + + notifier, err := provider.New(&provider.WebhookNotifierConfig{ + Url: fUrl, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + res, err := notifier.Notify(context.Background(), mockSubject, mockMessage) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/uploader/providers/aliyun-cas/aliyun_cas.go b/internal/pkg/core/uploader/providers/aliyun-cas/aliyun_cas.go index 463d10bd..b65cd1d2 100644 --- a/internal/pkg/core/uploader/providers/aliyun-cas/aliyun_cas.go +++ b/internal/pkg/core/uploader/providers/aliyun-cas/aliyun_cas.go @@ -144,11 +144,7 @@ func createSdkClient(accessKeyId, accessKeySecret, region string) (*aliyunCas.Cl region = "cn-hangzhou" // CAS 服务默认区域:华东一杭州 } - aConfig := &aliyunOpen.Config{ - AccessKeyId: tea.String(accessKeyId), - AccessKeySecret: tea.String(accessKeySecret), - } - + // 接入点一览 https://help.aliyun.com/zh/ssl-certificate/developer-reference/endpoints var endpoint string switch region { case "cn-hangzhou": @@ -156,9 +152,14 @@ func createSdkClient(accessKeyId, accessKeySecret, region string) (*aliyunCas.Cl default: endpoint = fmt.Sprintf("cas.%s.aliyuncs.com", region) } - aConfig.Endpoint = tea.String(endpoint) - client, err := aliyunCas.NewClient(aConfig) + config := &aliyunOpen.Config{ + Endpoint: tea.String(endpoint), + AccessKeyId: tea.String(accessKeyId), + AccessKeySecret: tea.String(accessKeySecret), + } + + client, err := aliyunCas.NewClient(config) if err != nil { return nil, err } diff --git a/internal/pkg/core/uploader/providers/aliyun-slb/aliyun_slb.go b/internal/pkg/core/uploader/providers/aliyun-slb/aliyun_slb.go index 9b62361c..f78c85ce 100644 --- a/internal/pkg/core/uploader/providers/aliyun-slb/aliyun_slb.go +++ b/internal/pkg/core/uploader/providers/aliyun-slb/aliyun_slb.go @@ -121,11 +121,7 @@ func createSdkClient(accessKeyId, accessKeySecret, region string) (*aliyunSlb.Cl region = "cn-hangzhou" // SLB 服务默认区域:华东一杭州 } - aConfig := &aliyunOpen.Config{ - AccessKeyId: tea.String(accessKeyId), - AccessKeySecret: tea.String(accessKeySecret), - } - + // 接入点一览 https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-endpoint var endpoint string switch region { case @@ -137,9 +133,14 @@ func createSdkClient(accessKeyId, accessKeySecret, region string) (*aliyunSlb.Cl default: endpoint = fmt.Sprintf("slb.%s.aliyuncs.com", region) } - aConfig.Endpoint = tea.String(endpoint) - client, err := aliyunSlb.NewClient(aConfig) + config := &aliyunOpen.Config{ + Endpoint: tea.String(endpoint), + AccessKeyId: tea.String(accessKeyId), + AccessKeySecret: tea.String(accessKeySecret), + } + + client, err := aliyunSlb.NewClient(config) if err != nil { return nil, err } diff --git a/internal/pkg/core/uploader/providers/byteplus-cdn/byteplus_cdn.go b/internal/pkg/core/uploader/providers/byteplus-cdn/byteplus_cdn.go index f27aebbc..a5364a02 100644 --- a/internal/pkg/core/uploader/providers/byteplus-cdn/byteplus_cdn.go +++ b/internal/pkg/core/uploader/providers/byteplus-cdn/byteplus_cdn.go @@ -2,6 +2,7 @@ package bytepluscdn import ( "context" + "crypto/sha1" "crypto/sha256" "encoding/hex" "errors" @@ -9,9 +10,11 @@ import ( "strings" "time" - "github.com/byteplus-sdk/byteplus-sdk-golang/service/cdn" + bpCdn "github.com/byteplus-sdk/byteplus-sdk-golang/service/cdn" xerrors "github.com/pkg/errors" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + "github.com/usual2970/certimate/internal/pkg/utils/cast" "github.com/usual2970/certimate/internal/pkg/utils/x509" ) @@ -22,7 +25,7 @@ type ByteplusCDNUploaderConfig struct { type ByteplusCDNUploader struct { config *ByteplusCDNUploaderConfig - sdkClient *cdn.CDN + sdkClient *bpCdn.CDN } var _ uploader.Uploader = (*ByteplusCDNUploader)(nil) @@ -32,14 +35,13 @@ func New(config *ByteplusCDNUploaderConfig) (*ByteplusCDNUploader, error) { return nil, errors.New("config is nil") } - instance := cdn.NewInstance() - client := instance.Client - client.SetAccessKey(config.AccessKey) - client.SetSecretKey(config.SecretKey) + client := bpCdn.NewInstance() + client.Client.SetAccessKey(config.AccessKey) + client.Client.SetSecretKey(config.SecretKey) return &ByteplusCDNUploader{ config: config, - sdkClient: instance, + sdkClient: client, }, nil } @@ -49,17 +51,17 @@ func (u *ByteplusCDNUploader) Upload(ctx context.Context, certPem string, privke if err != nil { return nil, err } + // 查询证书列表,避免重复上传 // REF: https://docs.byteplus.com/en/docs/byteplus-cdn/reference-listcertinfo - pageNum := int64(1) - pageSize := int64(100) - certSource := "cert_center" - listCertInfoReq := &cdn.ListCertInfoRequest{ - PageNum: &pageNum, - PageSize: &pageSize, - Source: &certSource, + listCertInfoPageNum := int64(1) + listCertInfoPageSize := int64(100) + listCertInfoTotal := 0 + listCertInfoReq := &bpCdn.ListCertInfoRequest{ + PageNum: cast.Int64Ptr(listCertInfoPageNum), + PageSize: cast.Int64Ptr(listCertInfoPageSize), + Source: cast.StringPtr("cert_center"), } - searchTotal := 0 for { listCertInfoResp, err := u.sdkClient.ListCertInfo(listCertInfoReq) if err != nil { @@ -68,8 +70,10 @@ func (u *ByteplusCDNUploader) Upload(ctx context.Context, certPem string, privke if listCertInfoResp.Result.CertInfo != nil { for _, certDetail := range listCertInfoResp.Result.CertInfo { - hash := sha256.Sum256(certX509.Raw) - isSameCert := strings.EqualFold(hex.EncodeToString(hash[:]), certDetail.CertFingerprint.Sha256) + fingerprintSha1 := sha1.Sum(certX509.Raw) + fingerprintSha256 := sha256.Sum256(certX509.Raw) + isSameCert := strings.EqualFold(hex.EncodeToString(fingerprintSha1[:]), certDetail.CertFingerprint.Sha1) && + strings.EqualFold(hex.EncodeToString(fingerprintSha256[:]), certDetail.CertFingerprint.Sha256) // 如果已存在相同证书,直接返回已有的证书信息 if isSameCert { return &uploader.UploadResult{ @@ -80,23 +84,26 @@ func (u *ByteplusCDNUploader) Upload(ctx context.Context, certPem string, privke } } - searchTotal += len(listCertInfoResp.Result.CertInfo) - if int(listCertInfoResp.Result.Total) > searchTotal { - pageNum++ - } else { + listCertInfoLen := len(listCertInfoResp.Result.CertInfo) + if listCertInfoLen < int(listCertInfoPageSize) || int(listCertInfoResp.Result.Total) <= listCertInfoTotal+listCertInfoLen { break + } else { + listCertInfoPageNum++ + listCertInfoTotal += listCertInfoLen } - } + + // 生成新证书名(需符合 BytePlus 命名规则) var certId, certName string certName = fmt.Sprintf("certimate-%d", time.Now().UnixMilli()) + // 上传新证书 // REF: https://docs.byteplus.com/en/docs/byteplus-cdn/reference-addcertificate - addCertificateReq := &cdn.AddCertificateRequest{ + addCertificateReq := &bpCdn.AddCertificateRequest{ Certificate: certPem, PrivateKey: privkeyPem, - Source: &certSource, - Desc: &certName, + Source: cast.StringPtr("cert_center"), + Desc: cast.StringPtr(certName), } addCertificateResp, err := u.sdkClient.AddCertificate(addCertificateReq) if err != nil { diff --git a/internal/pkg/core/uploader/providers/huaweicloud-elb/huaweicloud_elb.go b/internal/pkg/core/uploader/providers/huaweicloud-elb/huaweicloud_elb.go index 5b6ab376..526d35ef 100644 --- a/internal/pkg/core/uploader/providers/huaweicloud-elb/huaweicloud_elb.go +++ b/internal/pkg/core/uploader/providers/huaweicloud-elb/huaweicloud_elb.go @@ -56,7 +56,7 @@ func New(config *HuaweiCloudELBUploaderConfig) (*HuaweiCloudELBUploader, error) func (u *HuaweiCloudELBUploader) Upload(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) { // 解析证书内容 - newCert, err := x509.ParseCertificateFromPEM(certPem) + certX509, err := x509.ParseCertificateFromPEM(certPem) if err != nil { return nil, err } @@ -83,12 +83,12 @@ func (u *HuaweiCloudELBUploader) Upload(ctx context.Context, certPem string, pri if certDetail.Certificate == certPem { isSameCert = true } else { - cert, err := x509.ParseCertificateFromPEM(certDetail.Certificate) + oldCertX509, err := x509.ParseCertificateFromPEM(certDetail.Certificate) if err != nil { continue } - isSameCert = x509.EqualCertificate(cert, newCert) + isSameCert = x509.EqualCertificate(certX509, oldCertX509) } // 如果已存在相同证书,直接返回已有的证书信息 @@ -205,9 +205,6 @@ func getSdkProjectId(accessKeyId, secretAccessKey, region string) (string, error } client := hcIam.NewIamClient(hcClient) - if err != nil { - return "", err - } request := &hcIamModel.KeystoneListProjectsRequest{ Name: ®ion, diff --git a/internal/pkg/core/uploader/providers/huaweicloud-scm/huaweicloud_scm.go b/internal/pkg/core/uploader/providers/huaweicloud-scm/huaweicloud_scm.go index 45450d9e..6d85fbff 100644 --- a/internal/pkg/core/uploader/providers/huaweicloud-scm/huaweicloud_scm.go +++ b/internal/pkg/core/uploader/providers/huaweicloud-scm/huaweicloud_scm.go @@ -92,12 +92,12 @@ func (u *HuaweiCloudSCMUploader) Upload(ctx context.Context, certPem string, pri if *exportCertificateResp.Certificate == certPem { isSameCert = true } else { - cert, err := x509.ParseCertificateFromPEM(*exportCertificateResp.Certificate) + oldCertX509, err := x509.ParseCertificateFromPEM(*exportCertificateResp.Certificate) if err != nil { continue } - isSameCert = x509.EqualCertificate(certX509, cert) + isSameCert = x509.EqualCertificate(certX509, oldCertX509) } // 如果已存在相同证书,直接返回已有的证书信息 diff --git a/internal/pkg/core/uploader/providers/volcengine-cdn/volcengine_cdn.go b/internal/pkg/core/uploader/providers/volcengine-cdn/volcengine_cdn.go index b53ef4c5..7822a02d 100644 --- a/internal/pkg/core/uploader/providers/volcengine-cdn/volcengine_cdn.go +++ b/internal/pkg/core/uploader/providers/volcengine-cdn/volcengine_cdn.go @@ -2,6 +2,7 @@ package volcenginecdn import ( "context" + "crypto/sha1" "crypto/sha256" "encoding/hex" "errors" @@ -10,56 +11,57 @@ import ( "time" xerrors "github.com/pkg/errors" + veCdn "github.com/volcengine/volc-sdk-golang/service/cdn" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + "github.com/usual2970/certimate/internal/pkg/utils/cast" "github.com/usual2970/certimate/internal/pkg/utils/x509" - "github.com/volcengine/volc-sdk-golang/service/cdn" ) -type VolcengineCDNUploaderConfig struct { +type VolcEngineCDNUploaderConfig struct { AccessKeyId string `json:"accessKeyId"` AccessKeySecret string `json:"accessKeySecret"` } -type VolcengineCDNUploader struct { - config *VolcengineCDNUploaderConfig - sdkClient *cdn.CDN +type VolcEngineCDNUploader struct { + config *VolcEngineCDNUploaderConfig + sdkClient *veCdn.CDN } -var _ uploader.Uploader = (*VolcengineCDNUploader)(nil) +var _ uploader.Uploader = (*VolcEngineCDNUploader)(nil) -func New(config *VolcengineCDNUploaderConfig) (*VolcengineCDNUploader, error) { +func New(config *VolcEngineCDNUploaderConfig) (*VolcEngineCDNUploader, error) { if config == nil { return nil, errors.New("config is nil") } - instance := cdn.NewInstance() - client := instance.Client - client.SetAccessKey(config.AccessKeyId) - client.SetSecretKey(config.AccessKeySecret) + client := veCdn.NewInstance() + client.Client.SetAccessKey(config.AccessKeyId) + client.Client.SetSecretKey(config.AccessKeySecret) - return &VolcengineCDNUploader{ + return &VolcEngineCDNUploader{ config: config, - sdkClient: instance, + sdkClient: client, }, nil } -func (u *VolcengineCDNUploader) Upload(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) { +func (u *VolcEngineCDNUploader) Upload(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) { // 解析证书内容 certX509, err := x509.ParseCertificateFromPEM(certPem) if err != nil { return nil, err } + // 查询证书列表,避免重复上传 // REF: https://www.volcengine.com/docs/6454/125709 - pageNum := int64(1) - pageSize := int64(100) - certSource := "volc_cert_center" - listCertInfoReq := &cdn.ListCertInfoRequest{ - PageNum: &pageNum, - PageSize: &pageSize, - Source: certSource, + listCertInfoPageNum := int64(1) + listCertInfoPageSize := int64(100) + listCertInfoTotal := 0 + listCertInfoReq := &veCdn.ListCertInfoRequest{ + PageNum: cast.Int64Ptr(listCertInfoPageNum), + PageSize: cast.Int64Ptr(listCertInfoPageSize), + Source: "volc_cert_center", } - searchTotal := 0 for { listCertInfoResp, err := u.sdkClient.ListCertInfo(listCertInfoReq) if err != nil { @@ -68,8 +70,10 @@ func (u *VolcengineCDNUploader) Upload(ctx context.Context, certPem string, priv if listCertInfoResp.Result.CertInfo != nil { for _, certDetail := range listCertInfoResp.Result.CertInfo { - hash := sha256.Sum256(certX509.Raw) - isSameCert := strings.EqualFold(hex.EncodeToString(hash[:]), certDetail.CertFingerprint.Sha256) + fingerprintSha1 := sha1.Sum(certX509.Raw) + fingerprintSha256 := sha256.Sum256(certX509.Raw) + isSameCert := strings.EqualFold(hex.EncodeToString(fingerprintSha1[:]), certDetail.CertFingerprint.Sha1) && + strings.EqualFold(hex.EncodeToString(fingerprintSha256[:]), certDetail.CertFingerprint.Sha256) // 如果已存在相同证书,直接返回已有的证书信息 if isSameCert { return &uploader.UploadResult{ @@ -80,24 +84,26 @@ func (u *VolcengineCDNUploader) Upload(ctx context.Context, certPem string, priv } } - searchTotal += len(listCertInfoResp.Result.CertInfo) - if int(listCertInfoResp.Result.Total) > searchTotal { - pageNum++ - } else { + listCertInfoLen := len(listCertInfoResp.Result.CertInfo) + if listCertInfoLen < int(listCertInfoPageSize) || int(listCertInfoResp.Result.Total) <= listCertInfoTotal+listCertInfoLen { break + } else { + listCertInfoPageNum++ + listCertInfoTotal += listCertInfoLen } - } + // 生成新证书名(需符合火山引擎命名规则) var certId, certName string certName = fmt.Sprintf("certimate-%d", time.Now().UnixMilli()) + // 上传新证书 // REF: https://www.volcengine.com/docs/6454/1245763 - addCertificateReq := &cdn.AddCertificateRequest{ + addCertificateReq := &veCdn.AddCertificateRequest{ Certificate: certPem, PrivateKey: privkeyPem, - Source: &certSource, - Desc: &certName, + Source: cast.StringPtr("volc_cert_center"), + Desc: cast.StringPtr(certName), } addCertificateResp, err := u.sdkClient.AddCertificate(addCertificateReq) if err != nil { diff --git a/internal/pkg/core/uploader/providers/volcengine-live/volcengine_live.go b/internal/pkg/core/uploader/providers/volcengine-live/volcengine_live.go index abc24d8c..a9ee7008 100644 --- a/internal/pkg/core/uploader/providers/volcengine-live/volcengine_live.go +++ b/internal/pkg/core/uploader/providers/volcengine-live/volcengine_live.go @@ -8,77 +8,79 @@ import ( "time" xerrors "github.com/pkg/errors" + veLive "github.com/volcengine/volc-sdk-golang/service/live/v20230101" + "github.com/usual2970/certimate/internal/pkg/core/uploader" "github.com/usual2970/certimate/internal/pkg/utils/cast" "github.com/usual2970/certimate/internal/pkg/utils/x509" - live "github.com/volcengine/volc-sdk-golang/service/live/v20230101" ) -type VolcengineLiveUploaderConfig struct { +type VolcEngineLiveUploaderConfig struct { AccessKeyId string `json:"accessKeyId"` AccessKeySecret string `json:"accessKeySecret"` } -type VolcengineLiveUploader struct { - config *VolcengineLiveUploaderConfig - sdkClient *live.Live +type VolcEngineLiveUploader struct { + config *VolcEngineLiveUploaderConfig + sdkClient *veLive.Live } -var _ uploader.Uploader = (*VolcengineLiveUploader)(nil) +var _ uploader.Uploader = (*VolcEngineLiveUploader)(nil) -func New(config *VolcengineLiveUploaderConfig) (*VolcengineLiveUploader, error) { +func New(config *VolcEngineLiveUploaderConfig) (*VolcEngineLiveUploader, error) { if config == nil { return nil, errors.New("config is nil") } - client := live.NewInstance() + client := veLive.NewInstance() client.SetAccessKey(config.AccessKeyId) client.SetSecretKey(config.AccessKeySecret) - return &VolcengineLiveUploader{ + return &VolcEngineLiveUploader{ config: config, sdkClient: client, }, nil } -func (u *VolcengineLiveUploader) Upload(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) { +func (u *VolcEngineLiveUploader) Upload(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) { // 解析证书内容 certX509, err := x509.ParseCertificateFromPEM(certPem) if err != nil { return nil, err } + // 查询证书列表,避免重复上传 // REF: https://www.volcengine.com/docs/6469/1186278#%E6%9F%A5%E8%AF%A2%E8%AF%81%E4%B9%A6%E5%88%97%E8%A1%A8 - listCertReq := &live.ListCertV2Body{} + listCertReq := &veLive.ListCertV2Body{} listCertResp, err := u.sdkClient.ListCertV2(ctx, listCertReq) if err != nil { return nil, xerrors.Wrap(err, "failed to execute sdk request 'live.ListCertV2'") } - if listCertResp.Result.CertList != nil { for _, certDetail := range listCertResp.Result.CertList { - - describeCertDetailSecretReq := &live.DescribeCertDetailSecretV2Body{ - ChainID: cast.StringPtr(certDetail.ChainID), - } // 查询证书详细信息 // REF: https://www.volcengine.com/docs/6469/1186278#%E6%9F%A5%E7%9C%8B%E8%AF%81%E4%B9%A6%E8%AF%A6%E6%83%85 - describeCertDetailSecretResp, detailErr := u.sdkClient.DescribeCertDetailSecretV2(ctx, describeCertDetailSecretReq) - if detailErr != nil { + describeCertDetailSecretReq := &veLive.DescribeCertDetailSecretV2Body{ + ChainID: cast.StringPtr(certDetail.ChainID), + } + describeCertDetailSecretResp, err := u.sdkClient.DescribeCertDetailSecretV2(ctx, describeCertDetailSecretReq) + if err != nil { continue } + var isSameCert bool certificate := strings.Join(describeCertDetailSecretResp.Result.SSL.Chain, "\n\n") if certificate == certPem { isSameCert = true } else { - cert, err := x509.ParseCertificateFromPEM(certificate) + oldCertX509, err := x509.ParseCertificateFromPEM(certificate) if err != nil { continue } - isSameCert = x509.EqualCertificate(cert, certX509) + isSameCert = x509.EqualCertificate(certX509, oldCertX509) } + // 如果已存在相同证书,直接返回已有的证书信息 if isSameCert { return &uploader.UploadResult{ @@ -92,13 +94,14 @@ func (u *VolcengineLiveUploader) Upload(ctx context.Context, certPem string, pri // 生成新证书名(需符合火山引擎命名规则) var certId, certName string certName = fmt.Sprintf("certimate-%d", time.Now().UnixMilli()) + // 上传新证书 // REF: https://www.volcengine.com/docs/6469/1186278#%E6%B7%BB%E5%8A%A0%E8%AF%81%E4%B9%A6 - createCertReq := &live.CreateCertBody{ + createCertReq := &veLive.CreateCertBody{ CertName: &certName, UseWay: "https", ProjectName: cast.StringPtr("default"), - Rsa: live.CreateCertBodyRsa{ + Rsa: veLive.CreateCertBodyRsa{ Prikey: privkeyPem, Pubkey: certPem, }, diff --git a/internal/pkg/utils/maps/maps.go b/internal/pkg/utils/maps/maps.go index 6f3c6fe6..0c80e3a3 100644 --- a/internal/pkg/utils/maps/maps.go +++ b/internal/pkg/utils/maps/maps.go @@ -22,7 +22,7 @@ func GetValueAsString(dict map[string]any, key string) string { // - defaultValue: 默认值。 // // 出参: -// - 字典中键对应的值。如果指定键不存在或者值的类型不是字符串,则返回默认值。 +// - 字典中键对应的值。如果指定键不存在、值的类型不是字符串或者值为零值,则返回默认值。 func GetValueOrDefaultAsString(dict map[string]any, key string, defaultValue string) string { if dict == nil { return defaultValue @@ -30,7 +30,9 @@ func GetValueOrDefaultAsString(dict map[string]any, key string, defaultValue str if value, ok := dict[key]; ok { if result, ok := value.(string); ok { - return result + if result != "" { + return result + } } } @@ -57,7 +59,7 @@ func GetValueAsInt32(dict map[string]any, key string) int32 { // - defaultValue: 默认值。 // // 出参: -// - 字典中键对应的值。如果指定键不存在或者值的类型不是 32 位整数,则返回默认值。 +// - 字典中键对应的值。如果指定键不存在、值的类型不是 32 位整数或者值为零值,则返回默认值。 func GetValueOrDefaultAsInt32(dict map[string]any, key string, defaultValue int32) int32 { if dict == nil { return defaultValue @@ -65,13 +67,17 @@ func GetValueOrDefaultAsInt32(dict map[string]any, key string, defaultValue int3 if value, ok := dict[key]; ok { if result, ok := value.(int32); ok { - return result + if result != 0 { + return result + } } // 兼容字符串类型的值 if str, ok := value.(string); ok { if result, err := strconv.ParseInt(str, 10, 32); err == nil { - return int32(result) + if result != 0 { + return int32(result) + } } } } @@ -99,7 +105,7 @@ func GetValueAsInt64(dict map[string]any, key string) int64 { // - defaultValue: 默认值。 // // 出参: -// - 字典中键对应的值。如果指定键不存在或者值的类型不是 64 位整数,则返回默认值。 +// - 字典中键对应的值。如果指定键不存在、值的类型不是 64 位整数或者值为零值,则返回默认值。 func GetValueOrDefaultAsInt64(dict map[string]any, key string, defaultValue int64) int64 { if dict == nil { return defaultValue @@ -107,13 +113,17 @@ func GetValueOrDefaultAsInt64(dict map[string]any, key string, defaultValue int6 if value, ok := dict[key]; ok { if result, ok := value.(int64); ok { - return result + if result != 0 { + return result + } } // 兼容字符串类型的值 if str, ok := value.(string); ok { if result, err := strconv.ParseInt(str, 10, 64); err == nil { - return result + if result != 0 { + return result + } } } } diff --git a/internal/pkg/utils/x509/transformer.go b/internal/pkg/utils/x509/transformer.go new file mode 100644 index 00000000..6170d88a --- /dev/null +++ b/internal/pkg/utils/x509/transformer.go @@ -0,0 +1,87 @@ +package x509 + +import ( + "bytes" + "encoding/pem" + "errors" + "time" + + "github.com/pavlo-v-chernykh/keystore-go/v4" + "software.sslmate.com/src/go-pkcs12" +) + +// 将 PEM 编码的证书字符串转换为 PFX 格式。 +// +// 入参: +// - certPem: 证书 PEM 内容。 +// - privkeyPem: 私钥 PEM 内容。 +// - pfxPassword: PFX 导出密码。 +// +// 出参: +// - data: PFX 格式的证书数据。 +// - err: 错误。 +func TransformCertificateFromPEMToPFX(certPem string, privkeyPem string, pfxPassword string) ([]byte, error) { + cert, err := ParseCertificateFromPEM(certPem) + if err != nil { + return nil, err + } + + privkey, err := ParsePKCS1PrivateKeyFromPEM(privkeyPem) + if err != nil { + return nil, err + } + + pfxData, err := pkcs12.LegacyRC2.Encode(privkey, cert, nil, pfxPassword) + if err != nil { + return nil, err + } + + return pfxData, nil +} + +// 将 PEM 编码的证书字符串转换为 JKS 格式。 +// +// 入参: +// - certPem: 证书 PEM 内容。 +// - privkeyPem: 私钥 PEM 内容。 +// - jksAlias: JKS 别名。 +// - jksKeypass: JKS 密钥密码。 +// - jksStorepass: JKS 存储密码。 +// +// 出参: +// - data: JKS 格式的证书数据。 +// - err: 错误。 +func TransformCertificateFromPEMToJKS(certPem string, privkeyPem string, jksAlias string, jksKeypass string, jksStorepass string) ([]byte, error) { + certBlock, _ := pem.Decode([]byte(certPem)) + if certBlock == nil { + return nil, errors.New("failed to decode certificate PEM") + } + + privkeyBlock, _ := pem.Decode([]byte(privkeyPem)) + if privkeyBlock == nil { + return nil, errors.New("failed to decode private key PEM") + } + + ks := keystore.New() + entry := keystore.PrivateKeyEntry{ + CreationTime: time.Now(), + PrivateKey: privkeyBlock.Bytes, + CertificateChain: []keystore.Certificate{ + { + Type: "X509", + Content: certBlock.Bytes, + }, + }, + } + + if err := ks.SetPrivateKeyEntry(jksAlias, entry, []byte(jksKeypass)); err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := ks.Store(&buf, []byte(jksStorepass)); err != nil { + return nil, err + } + + return buf.Bytes(), nil +}