diff --git a/README.md b/README.md index 626b900e..249c5e5b 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ make local.run | 华为云 | √ | √ | 可签发在华为云注册的域名;可部署到华为云 CDN、ELB | | 七牛云 | | √ | 可部署到七牛云 CDN | | 多吉云 | | √ | 可部署到多吉云 CDN | -| 火山引擎 | √ | √ | 可签发在火山引擎注册的域名;可部署到火山引擎 Live | +| 火山引擎 | √ | √ | 可签发在火山引擎注册的域名;可部署到火山引擎 Live、CDN | | AWS | √ | | 可签发在 AWS Route53 托管的域名 | | CloudFlare | √ | | 可签发在 CloudFlare 注册的域名;CloudFlare 服务自带 SSL 证书 | | GoDaddy | √ | | 可签发在 GoDaddy 注册的域名 | diff --git a/README_EN.md b/README_EN.md index 08c911ea..035168bf 100644 --- a/README_EN.md +++ b/README_EN.md @@ -78,7 +78,7 @@ password:1234567890 | Huawei Cloud | √ | √ | Supports domains registered on Huawei Cloud; supports deployment to Huawei Cloud CDN, ELB | | Qiniu Cloud | | √ | Supports deployment to Qiniu Cloud CDN | | Doge Cloud | | √ | Supports deployment to Doge Cloud CDN | -| Volcengine | √ | √ | Supports domains registered on Volcengine; supports deployment to Volcengine Live | +| Volcengine | √ | √ | Supports domains registered on Volcengine; supports deployment to Volcengine Live、CDN | | AWS | √ | | Supports domains managed on AWS Route53 | | CloudFlare | √ | | Supports domains registered on CloudFlare; CloudFlare services come with SSL certificates | | GoDaddy | √ | | Supports domains registered on GoDaddy | diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 8a31dab7..fbb582f5 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -41,6 +41,7 @@ const ( targetWebhook = "webhook" targetK8sSecret = "k8s-secret" targetVolcengineLive = "volcengine-live" + targetVolcengineCDN = "volcengine-cdn" ) type DeployerOption struct { @@ -153,6 +154,8 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep return NewK8sSecretDeployer(option) case targetVolcengineLive: return NewVolcengineLiveDeployer(option) + case targetVolcengineCDN: + return NewVolcengineCDNDeployer(option) } return nil, errors.New("unsupported deploy target") } diff --git a/internal/deployer/volcengine_cdn.go b/internal/deployer/volcengine_cdn.go new file mode 100644 index 00000000..66dd0be7 --- /dev/null +++ b/internal/deployer/volcengine_cdn.go @@ -0,0 +1,116 @@ +package deployer + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + volcenginecdn "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/volcengine-cdn" + + xerrors "github.com/pkg/errors" + "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + "github.com/volcengine/volc-sdk-golang/service/cdn" +) + +type VolcengineCDNDeployer struct { + option *DeployerOption + infos []string + sdkClient *cdn.CDN + sslUploader uploader.Uploader +} + +func NewVolcengineCDNDeployer(option *DeployerOption) (Deployer, error) { + 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.SetSecretKey(access.SecretAccessKey) + uploader, err := volcenginecdn.New(&volcenginecdn.VolcengineCDNUploaderConfig{ + AccessKeyId: access.AccessKeyID, + AccessKeySecret: access.SecretAccessKey, + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create ssl uploader") + } + return &VolcengineCDNDeployer{ + option: option, + infos: make([]string, 0), + sdkClient: client, + sslUploader: uploader, + }, nil +} + +func (d *VolcengineCDNDeployer) GetID() string { + return fmt.Sprintf("%s-%s", d.option.AccessRecord.GetString("name"), d.option.AccessRecord.Id) +} + +func (d *VolcengineCDNDeployer) GetInfos() []string { + return d.infos +} + +func (d *VolcengineCDNDeployer) Deploy(ctx context.Context) error { + apiCtx := context.Background() + // 上传证书 + upres, err := d.sslUploader.Upload(apiCtx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey) + if err != nil { + return err + } + + d.infos = append(d.infos, toStr("已上传证书", upres)) + + domains := make([]string, 0) + configDomain := d.option.DeployConfig.GetConfigAsString("domain") + if strings.HasPrefix(configDomain, "*.") { + // 获取证书可以部署的域名 + // REF: https://www.volcengine.com/docs/6454/125711 + describeCertConfigReq := &cdn.DescribeCertConfigRequest{ + CertId: upres.CertId, + } + describeCertConfigResp, err := d.sdkClient.DescribeCertConfig(describeCertConfigReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'cdn.DescribeCertConfig'") + } + for i := range describeCertConfigResp.Result.CertNotConfig { + // 当前未启用 HTTPS 的加速域名列表。 + domains = append(domains, describeCertConfigResp.Result.CertNotConfig[i].Domain) + } + for i := range describeCertConfigResp.Result.OtherCertConfig { + // 已启用了 HTTPS 的加速域名列表。这些加速域名关联的证书不是您指定的证书。 + domains = append(domains, describeCertConfigResp.Result.OtherCertConfig[i].Domain) + } + for i := range describeCertConfigResp.Result.SpecifiedCertConfig { + // 已启用了 HTTPS 的加速域名列表。这些加速域名关联了您指定的证书。 + d.infos = append(d.infos, fmt.Sprintf("%s域名已配置该证书", describeCertConfigResp.Result.SpecifiedCertConfig[i].Domain)) + } + if len(domains) == 0 { + if len(describeCertConfigResp.Result.SpecifiedCertConfig) > 0 { + // 所有匹配的域名都配置了该证书,跳过部署 + return nil + } else { + return xerrors.Errorf("未查询到匹配的域名: %s", configDomain) + } + } + } else { + domains = append(domains, configDomain) + } + // 部署证书 + // REF: https://www.volcengine.com/docs/6454/125712 + for i := range domains { + batchDeployCertReq := &cdn.BatchDeployCertRequest{ + CertId: upres.CertId, + Domain: domains[i], + } + BindCertResp, err := d.sdkClient.BatchDeployCert(batchDeployCertReq) + if err != nil { + return xerrors.Wrap(err, "failed to execute sdk request 'cdn.BatchDeployCert'") + } else { + d.infos = append(d.infos, toStr(fmt.Sprintf("%s域名的证书已修改", domains[i]), BindCertResp)) + } + } + + return nil +} diff --git a/internal/pkg/core/uploader/providers/volcengine-cdn/volcengine_cdn.go b/internal/pkg/core/uploader/providers/volcengine-cdn/volcengine_cdn.go new file mode 100644 index 00000000..5291cf85 --- /dev/null +++ b/internal/pkg/core/uploader/providers/volcengine-cdn/volcengine_cdn.go @@ -0,0 +1,111 @@ +package volcenginecdn + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "time" + + xerrors "github.com/pkg/errors" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + "github.com/usual2970/certimate/internal/pkg/utils/x509" + "github.com/volcengine/volc-sdk-golang/service/cdn" +) + +type VolcengineCDNUploaderConfig struct { + AccessKeyId string `json:"accessKeyId"` + AccessKeySecret string `json:"accessKeySecret"` +} + +type VolcengineCDNUploader struct { + config *VolcengineCDNUploaderConfig + sdkClient *cdn.CDN +} + +var _ uploader.Uploader = (*VolcengineCDNUploader)(nil) + +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) + + return &VolcengineCDNUploader{ + config: config, + sdkClient: instance, + }, nil +} + +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" + listCertReq := &cdn.ListCertInfoRequest{ + PageNum: &pageNum, + PageSize: &pageSize, + Source: certSource, + } + searchTotal := 0 + for { + listCertResp, err := u.sdkClient.ListCertInfo(listCertReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.ListCertInfo'") + } + + if listCertResp.Result.CertInfo != nil { + for _, certDetail := range listCertResp.Result.CertInfo { + hash := sha256.Sum256(certX509.Raw) + isSameCert := hex.EncodeToString(hash[:]) == certDetail.CertFingerprint.Sha256 + // 如果已存在相同证书,直接返回已有的证书信息 + if isSameCert { + return &uploader.UploadResult{ + CertId: certDetail.CertId, + CertName: certDetail.Desc, + }, nil + } + } + } + + searchTotal += len(listCertResp.Result.CertInfo) + if int(listCertResp.Result.Total) > searchTotal { + pageNum++ + } else { + break + } + + } + // 生成新证书名(需符合火山引擎命名规则) + var certId, certName string + certName = fmt.Sprintf("certimate-%d", time.Now().UnixMilli()) + // 上传新证书 + // REF: https://www.volcengine.com/docs/6454/1245763 + addCertificateReq := &cdn.AddCertificateRequest{ + Certificate: certPem, + PrivateKey: privkeyPem, + Source: &certSource, + Desc: &certName, + } + createCertResp, err := u.sdkClient.AddCertificate(addCertificateReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.AddCertificate'") + } + + certId = createCertResp.Result.CertId + return &uploader.UploadResult{ + CertId: certId, + CertName: certName, + }, nil +} diff --git a/ui/src/components/certimate/DeployEditDialog.tsx b/ui/src/components/certimate/DeployEditDialog.tsx index 589db1f2..3545db5f 100644 --- a/ui/src/components/certimate/DeployEditDialog.tsx +++ b/ui/src/components/certimate/DeployEditDialog.tsx @@ -28,6 +28,7 @@ import DeployToSSH from "./DeployToSSH"; import DeployToWebhook from "./DeployToWebhook"; import DeployToKubernetesSecret from "./DeployToKubernetesSecret"; import DeployToVolcengineLive from "./DeployToVolcengineLive" +import DeployToVolcengineCDN from "./DeployToVolcengineCDN" import { deployTargetsMap, type DeployConfig } from "@/domain/domain"; import { accessProvidersMap } from "@/domain/access"; import { useConfigContext } from "@/providers/config"; @@ -178,6 +179,9 @@ const DeployEditDialog = ({ trigger, deployConfig, onSave }: DeployEditDialogPro case "volcengine-live": childComponent = ; break; + case "volcengine-cdn": + childComponent = ; + break; } return ( diff --git a/ui/src/components/certimate/DeployToVolcengineCDN.tsx b/ui/src/components/certimate/DeployToVolcengineCDN.tsx new file mode 100644 index 00000000..ba13dab4 --- /dev/null +++ b/ui/src/components/certimate/DeployToVolcengineCDN.tsx @@ -0,0 +1,68 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { produce } from "immer"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useDeployEditContext } from "./DeployEdit"; + +type DeployToVolcengineCDNConfigParams = { + domain?: string; +}; + +const DeployToVolcengineCDN = () => { + const { t } = useTranslation(); + + const { config, setConfig, errors, setErrors } = useDeployEditContext(); + + useEffect(() => { + if (!config.id) { + setConfig({ + ...config, + config: {}, + }); + } + }, []); + + useEffect(() => { + setErrors({}); + }, []); + + const formSchema = z.object({ + domain: z.string().regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, { + message: t("common.errmsg.domain_invalid"), + }), + }); + + useEffect(() => { + const res = formSchema.safeParse(config.config); + setErrors({ + ...errors, + domain: res.error?.errors?.find((e) => e.path[0] === "domain")?.message, + }); + }, [config]); + + return ( + + + {t("domain.deployment.form.domain.label.wildsupported")} + { + const nv = produce(config, (draft) => { + draft.config ??= {}; + draft.config.domain = e.target.value?.trim(); + }); + setConfig(nv); + }} + /> + {errors?.domain} + + + ); +}; + +export default DeployToVolcengineCDN; diff --git a/ui/src/domain/domain.ts b/ui/src/domain/domain.ts index 65f441a8..9d8d6c2d 100644 --- a/ui/src/domain/domain.ts +++ b/ui/src/domain/domain.ts @@ -93,5 +93,6 @@ export const deployTargetsMap: Map = new Map ["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg"], ["k8s-secret", "common.provider.kubernetes.secret", "/imgs/providers/k8s.svg"], ["volcengine-live", "common.provider.volcengine.live", "/imgs/providers/volcengine.svg"], + ["volcengine-cdn", "common.provider.volcengine.cdn", "/imgs/providers/volcengine.svg"], ].map(([type, name, icon]) => [type, { type, provider: type.split("-")[0], name, icon }]) ); diff --git a/ui/src/i18n/locales/en/nls.common.json b/ui/src/i18n/locales/en/nls.common.json index 7fcaa876..e020be43 100644 --- a/ui/src/i18n/locales/en/nls.common.json +++ b/ui/src/i18n/locales/en/nls.common.json @@ -92,5 +92,6 @@ "common.provider.serverchan": "ServerChan", "common.provider.bark": "Bark", "common.provider.volcengine": "Volcengine", - "common.provider.volcengine.live": "Volcengine - Live" + "common.provider.volcengine.live": "Volcengine - Live", + "common.provider.volcengine.cdn": "Volcengine - CDN" } diff --git a/ui/src/i18n/locales/zh/nls.common.json b/ui/src/i18n/locales/zh/nls.common.json index 11b68f66..ef9f8029 100644 --- a/ui/src/i18n/locales/zh/nls.common.json +++ b/ui/src/i18n/locales/zh/nls.common.json @@ -92,5 +92,6 @@ "common.provider.serverchan": "Server酱", "common.provider.bark": "Bark", "common.provider.volcengine": "火山引擎", - "common.provider.volcengine.live": "火山引擎 - 视频直播" + "common.provider.volcengine.live": "火山引擎 - 视频直播", + "common.provider.volcengine.cdn": "火山引擎 - CDN" }