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..dd2696af 100644 --- a/README_EN.md +++ b/README_EN.md @@ -71,14 +71,14 @@ password:1234567890 ## List of Supported Providers | Provider | Registration | Deployment | Remarks | -| :-----------: | :----------: | :--------: | ----------------------------------------------------------------------------------------------------------- | +| :-----------: | :----------: | :--------: |-------------------------------------------------------------------------------------------------------------| | Alibaba Cloud | √ | √ | Supports domains registered on Alibaba Cloud; supports deployment to Alibaba Cloud OSS, CDN,SLB | | Tencent Cloud | √ | √ | Supports domains registered on Tencent Cloud; supports deployment to Tencent Cloud COS, CDN, ECDN, CLB, TEO | | Baidu Cloud | | √ | Supports deployment to Baidu Cloud CDN | | 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..6955716f --- /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], + } + batchDeployCertResp, 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]), batchDeployCertResp)) + } + } + + return nil +} diff --git a/internal/deployer/volcengine_live.go b/internal/deployer/volcengine_live.go index 3eee9720..f456bb83 100644 --- a/internal/deployer/volcengine_live.go +++ b/internal/deployer/volcengine_live.go @@ -91,11 +91,11 @@ func (d *VolcengineLiveDeployer) Deploy(ctx context.Context) error { Domain: domains[i], HTTPS: cast.BoolPtr(true), } - BindCertResp, err := d.sdkClient.BindCert(apiCtx, bindCertReq) + bindCertResp, err := d.sdkClient.BindCert(apiCtx, bindCertReq) if err != nil { return xerrors.Wrap(err, "failed to execute sdk request 'live.BindCert'") } else { - d.infos = append(d.infos, toStr(fmt.Sprintf("%s域名的证书已修改", domains[i]), BindCertResp)) + d.infos = append(d.infos, toStr(fmt.Sprintf("%s域名的证书已修改", domains[i]), bindCertResp)) } } diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go new file mode 100644 index 00000000..3266eee2 --- /dev/null +++ b/internal/domain/workflow.go @@ -0,0 +1,41 @@ +package domain + +import "time" + +type Workflow struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Content *WorkflowNode `json:"content"` + Draft *WorkflowNode `json:"draft"` + Enabled bool `json:"enabled"` + HasDraft bool `json:"hasDraft"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +type WorkflowNode struct { + Id string `json:"id"` + Name string `json:"name"` + Next *WorkflowNode `json:"next"` + Config map[string]any `json:"config"` + Input []WorkflowNodeIo `json:"input"` + Output []WorkflowNodeIo `json:"output"` + + Validated bool `json:"validated"` + Type string `json:"type"` + + Branches []WorkflowNode `json:"branches"` +} + +type WorkflowNodeIo struct { + Label string `json:"label"` + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required"` +} + +type WorkflowRunReq struct { + Id string `json:"id"` +} 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..b53ef4c5 --- /dev/null +++ b/internal/pkg/core/uploader/providers/volcengine-cdn/volcengine_cdn.go @@ -0,0 +1,112 @@ +package volcenginecdn + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "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" + listCertInfoReq := &cdn.ListCertInfoRequest{ + PageNum: &pageNum, + PageSize: &pageSize, + Source: certSource, + } + searchTotal := 0 + for { + listCertInfoResp, err := u.sdkClient.ListCertInfo(listCertInfoReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.ListCertInfo'") + } + + 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) + // 如果已存在相同证书,直接返回已有的证书信息 + if isSameCert { + return &uploader.UploadResult{ + CertId: certDetail.CertId, + CertName: certDetail.Desc, + }, nil + } + } + } + + searchTotal += len(listCertInfoResp.Result.CertInfo) + if int(listCertInfoResp.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, + } + addCertificateResp, err := u.sdkClient.AddCertificate(addCertificateReq) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.AddCertificate'") + } + + certId = addCertificateResp.Result.CertId + return &uploader.UploadResult{ + CertId: certId, + CertName: certName, + }, nil +} diff --git a/internal/rest/notify.go b/internal/rest/notify.go index eb8aae48..463bc81d 100644 --- a/internal/rest/notify.go +++ b/internal/rest/notify.go @@ -30,7 +30,7 @@ func NewNotifyHandler(route *echo.Group, service NotifyService) { func (handler *notifyHandler) test(c echo.Context) error { req := &domain.NotifyTestPushReq{} if err := c.Bind(req); err != nil { - return err + return resp.Err(c, err) } if err := handler.service.Test(c.Request().Context(), req); err != nil { diff --git a/internal/rest/workflow.go b/internal/rest/workflow.go new file mode 100644 index 00000000..8d6270ec --- /dev/null +++ b/internal/rest/workflow.go @@ -0,0 +1,39 @@ +package rest + +import ( + "context" + + "github.com/labstack/echo/v5" + "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/utils/resp" +) + +type WorkflowService interface { + Run(ctx context.Context, req *domain.WorkflowRunReq) error +} + +type workflowHandler struct { + service WorkflowService +} + +func NewWorkflowHandler(route *echo.Group, service WorkflowService) { + handler := &workflowHandler{ + service: service, + } + + group := route.Group("/workflow") + + group.POST("/run", handler.run) +} + +func (handler *workflowHandler) run(c echo.Context) error { + req := &domain.WorkflowRunReq{} + if err := c.Bind(req); err != nil { + return resp.Err(c, err) + } + + if err := handler.service.Run(c.Request().Context(), req); err != nil { + return resp.Err(c, err) + } + return resp.Succ(c, nil) +} diff --git a/ui/src/components/certimate/DeployEditDialog.tsx b/ui/src/components/certimate/DeployEditDialog.tsx index caff1447..97ff9c10 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 ( +
+
+ + { + 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 367b62dd..7a4bf4ea 100644 --- a/ui/src/domain/domain.ts +++ b/ui/src/domain/domain.ts @@ -92,6 +92,7 @@ export const deployTargetList: string[][] = [ ["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"], ]; export const deployTargetsMap: Map = new Map( diff --git a/ui/src/domain/version.ts b/ui/src/domain/version.ts index 27245ccf..a65cfc3a 100644 --- a/ui/src/domain/version.ts +++ b/ui/src/domain/version.ts @@ -1 +1 @@ -export const version = "Certimate v0.2.19"; +export const version = "Certimate v0.2.20"; diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 4c0848e1..72967ba6 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -97,7 +97,6 @@ export type WorkflowNode = { input?: WorkflowNodeIo[]; config?: WorkflowNodeConfig; - configured?: boolean; output?: WorkflowNodeIo[]; next?: WorkflowNode | WorkflowBranchNode; @@ -429,3 +428,4 @@ export const workflowNodeDropdownList: WorkflowwNodeDropdwonItem[] = [ }, }, ]; + 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" } diff --git a/ui/src/pages/workflow/index.tsx b/ui/src/pages/workflow/index.tsx index 26519d4b..8cd2970c 100644 --- a/ui/src/pages/workflow/index.tsx +++ b/ui/src/pages/workflow/index.tsx @@ -58,19 +58,19 @@ const Workflow = () => { }, }, { - accessorKey: "executionMethod", + accessorKey: "type", header: "执行方式", cell: ({ row }) => { - const method = row.getValue("executionMethod"); + const method = row.getValue("type"); if (!method) { return "-"; } else if (method === "manual") { return "手动"; } else if (method === "auto") { - const crontab: string = row.getValue("crontab"); + const crontab: string = row.original.crontab ?? ""; return ( -
- 定时 +
+
定时
{crontab}
); @@ -211,3 +211,4 @@ const Workflow = () => { }; export default Workflow; +