diff --git a/internal/certdeploy/deployers/sp_aliyun_vod.go b/internal/certdeploy/deployers/sp_aliyun_vod.go index 3c59db40..f7e23133 100644 --- a/internal/certdeploy/deployers/sp_aliyun_vod.go +++ b/internal/certdeploy/deployers/sp_aliyun_vod.go @@ -17,11 +17,12 @@ func init() { } provider, err := aliyunvod.NewSSLDeployerProvider(&aliyunvod.SSLDeployerProviderConfig{ - AccessKeyId: credentials.AccessKeyId, - AccessKeySecret: credentials.AccessKeySecret, - ResourceGroupId: credentials.ResourceGroupId, - Region: xmaps.GetString(options.ProviderExtendedConfig, "region"), - Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"), + AccessKeyId: credentials.AccessKeyId, + AccessKeySecret: credentials.AccessKeySecret, + ResourceGroupId: credentials.ResourceGroupId, + Region: xmaps.GetString(options.ProviderExtendedConfig, "region"), + DomainMatchPattern: xmaps.GetString(options.ProviderExtendedConfig, "domainMatchPattern"), + Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"), }) return provider, err }) diff --git a/pkg/core/ssl-deployer/providers/aliyun-live/aliyun_live.go b/pkg/core/ssl-deployer/providers/aliyun-live/aliyun_live.go index 4386e752..e0bdb827 100644 --- a/pkg/core/ssl-deployer/providers/aliyun-live/aliyun_live.go +++ b/pkg/core/ssl-deployer/providers/aliyun-live/aliyun_live.go @@ -70,10 +70,6 @@ func (d *SSLDeployerProvider) SetLogger(logger *slog.Logger) { } func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*core.SSLDeployResult, error) { - if d.config.Domain == "" { - return nil, errors.New("config `domain` is required") - } - // 获取待部署的域名列表 var domains []string switch d.config.DomainMatchPattern { diff --git a/pkg/core/ssl-deployer/providers/aliyun-vod/aliyun_vod.go b/pkg/core/ssl-deployer/providers/aliyun-vod/aliyun_vod.go index ea99b801..dbd85973 100644 --- a/pkg/core/ssl-deployer/providers/aliyun-vod/aliyun_vod.go +++ b/pkg/core/ssl-deployer/providers/aliyun-vod/aliyun_vod.go @@ -17,6 +17,8 @@ import ( "github.com/certimate-go/certimate/pkg/core" "github.com/certimate-go/certimate/pkg/core/ssl-deployer/providers/aliyun-vod/internal" sslmgrsp "github.com/certimate-go/certimate/pkg/core/ssl-manager/providers/aliyun-cas" + xcert "github.com/certimate-go/certimate/pkg/utils/cert" + xcerthostname "github.com/certimate-go/certimate/pkg/utils/cert/hostname" ) type SSLDeployerProviderConfig struct { @@ -28,7 +30,10 @@ type SSLDeployerProviderConfig struct { ResourceGroupId string `json:"resourceGroupId,omitempty"` // 阿里云地域。 Region string `json:"region"` - // 点播加速域名(不支持泛域名)。 + // 域名匹配模式。 + // 零值时默认值 [DOMAIN_MATCH_PATTERN_EXACT]。 + DomainMatchPattern string `json:"domainMatchPattern,omitempty"` + // 点播加速域名(支持泛域名)。 Domain string `json:"domain"` } @@ -80,26 +85,143 @@ func (d *SSLDeployerProvider) SetLogger(logger *slog.Logger) { } func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*core.SSLDeployResult, error) { - if d.config.Domain == "" { - return nil, errors.New("config `domain` is required") + // 获取待部署的域名列表 + var domains []string + switch d.config.DomainMatchPattern { + case "", DOMAIN_MATCH_PATTERN_EXACT: + { + if d.config.Domain == "" { + return nil, errors.New("config `domain` is required") + } + + domains = []string{d.config.Domain} + } + + case DOMAIN_MATCH_PATTERN_WILDCARD: + { + if d.config.Domain == "" { + return nil, errors.New("config `domain` is required") + } + + if strings.HasPrefix(d.config.Domain, "*.") { + domainCandidates, err := d.getAllDomains(ctx) + if err != nil { + return nil, err + } + + domains = lo.Filter(domainCandidates, func(domain string, _ int) bool { + return xcerthostname.IsMatch(d.config.Domain, domain) + }) + if len(domains) == 0 { + return nil, errors.New("could not find any domains matched by wildcard") + } + } else { + domains = []string{d.config.Domain} + } + } + + case DOMAIN_MATCH_PATTERN_CERTSAN: + { + certX509, err := xcert.ParseCertificateFromPEM(certPEM) + if err != nil { + return nil, err + } + + domainCandidates, err := d.getAllDomains(ctx) + if err != nil { + return nil, err + } + + domains = lo.Filter(domainCandidates, func(domain string, _ int) bool { + return certX509.VerifyHostname(domain) == nil + }) + if len(domains) == 0 { + return nil, errors.New("could not find any domains matched by certificate") + } + } + + default: + return nil, fmt.Errorf("unsupported domain match pattern: '%s'", d.config.DomainMatchPattern) } - // 上传证书 - upres, err := d.sslManager.Upload(ctx, certPEM, privkeyPEM) - if err != nil { - return nil, fmt.Errorf("failed to upload certificate file: %w", err) + // 遍历更新域名证书 + if len(domains) == 0 { + d.logger.Info("no vod domains to deploy") } else { - d.logger.Info("ssl certificate uploaded", slog.Any("result", upres)) + d.logger.Info("found vod domains to deploy", slog.Any("domains", domains)) + var errs []error + + for _, domain := range domains { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + if err := d.updateDomainCertificate(ctx, domain, certPEM, privkeyPEM); err != nil { + errs = append(errs, err) + } + } + } + + if len(errs) > 0 { + return nil, errors.Join(errs...) + } } + return &core.SSLDeployResult{}, nil +} + +func (d *SSLDeployerProvider) getAllDomains(ctx context.Context) ([]string, error) { + domains := make([]string, 0) + + // 查询加速域名列表 + // REF: https://help.aliyun.com/zh/live/developer-reference/api-live-2016-11-01-describeliveuserdomains + describeVodUserDomainsPageNumber := 1 + describeVodUserDomainsPageSize := 50 + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + describeVodUserDomainsReq := &alivod.DescribeVodUserDomainsRequest{ + DomainStatus: tea.String("online"), + PageNumber: tea.Int32(int32(describeVodUserDomainsPageNumber)), + PageSize: tea.Int32(int32(describeVodUserDomainsPageSize)), + } + describeVodUserDomainsResp, err := d.sdkClient.DescribeVodUserDomainsWithContext(ctx, describeVodUserDomainsReq, &dara.RuntimeOptions{}) + d.logger.Debug("sdk request 'vod.DescribeVodUserDomains'", slog.Any("request", describeVodUserDomainsReq), slog.Any("response", describeVodUserDomainsResp)) + if err != nil { + return nil, fmt.Errorf("failed to execute sdk request 'vod.DescribeLiveUserDomains': %w", err) + } + + if describeVodUserDomainsResp.Body == nil || describeVodUserDomainsResp.Body.Domains == nil { + break + } + + for _, domainItem := range describeVodUserDomainsResp.Body.Domains.PageData { + domains = append(domains, tea.StringValue(domainItem.DomainName)) + } + + if len(describeVodUserDomainsResp.Body.Domains.PageData) < describeVodUserDomainsPageSize { + break + } + + describeVodUserDomainsPageNumber++ + } + + return domains, nil +} + +func (d *SSLDeployerProvider) updateDomainCertificate(ctx context.Context, domain string, cloudCertId, cloudCertName string) error { // 设置域名证书 // REF: https://help.aliyun.com/zh/vod/developer-reference/api-vod-2017-03-21-setvoddomainsslcertificate - certId, _ := strconv.ParseInt(upres.CertId, 10, 64) + certId, _ := strconv.ParseInt(cloudCertId, 10, 64) setVodDomainSSLCertificateReq := &alivod.SetVodDomainSSLCertificateRequest{ DomainName: tea.String(d.config.Domain), CertType: tea.String("cas"), CertId: tea.Int64(certId), - CertName: tea.String(upres.CertName), + CertName: tea.String(cloudCertName), CertRegion: lo. If(d.config.Region == "" || strings.HasPrefix(d.config.Region, "cn-"), tea.String("cn-hangzhou")). Else(tea.String("ap-southeast-1")), @@ -108,10 +230,10 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke setVodDomainSSLCertificateResp, err := d.sdkClient.SetVodDomainSSLCertificateWithContext(ctx, setVodDomainSSLCertificateReq, &dara.RuntimeOptions{}) d.logger.Debug("sdk request 'live.SetVodDomainSSLCertificate'", slog.Any("request", setVodDomainSSLCertificateReq), slog.Any("response", setVodDomainSSLCertificateResp)) if err != nil { - return nil, fmt.Errorf("failed to execute sdk request 'live.SetVodDomainSSLCertificate': %w", err) + return fmt.Errorf("failed to execute sdk request 'live.SetVodDomainSSLCertificate': %w", err) } - return &core.SSLDeployResult{}, nil + return nil } func createSDKClient(accessKeyId, accessKeySecret, region string) (*internal.VodClient, error) { diff --git a/pkg/core/ssl-deployer/providers/aliyun-vod/aliyun_vod_test.go b/pkg/core/ssl-deployer/providers/aliyun-vod/aliyun_vod_test.go index 3cf7ec28..de18f144 100644 --- a/pkg/core/ssl-deployer/providers/aliyun-vod/aliyun_vod_test.go +++ b/pkg/core/ssl-deployer/providers/aliyun-vod/aliyun_vod_test.go @@ -57,10 +57,11 @@ func TestDeploy(t *testing.T) { }, "\n")) deployer, err := provider.NewSSLDeployerProvider(&provider.SSLDeployerProviderConfig{ - AccessKeyId: fAccessKeyId, - AccessKeySecret: fAccessKeySecret, - Region: fRegion, - Domain: fDomain, + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + Region: fRegion, + DomainMatchPattern: provider.DOMAIN_MATCH_PATTERN_EXACT, + Domain: fDomain, }) if err != nil { t.Errorf("err: %+v", err) diff --git a/pkg/core/ssl-deployer/providers/aliyun-vod/consts.go b/pkg/core/ssl-deployer/providers/aliyun-vod/consts.go new file mode 100644 index 00000000..110fd2a5 --- /dev/null +++ b/pkg/core/ssl-deployer/providers/aliyun-vod/consts.go @@ -0,0 +1,10 @@ +package aliyunvod + +const ( + // 匹配模式:精确匹配。 + DOMAIN_MATCH_PATTERN_EXACT = "exact" + // 匹配模式:通配符匹配。 + DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard" + // 匹配模式:证书 SAN 匹配。 + DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" +) diff --git a/pkg/core/ssl-deployer/providers/aliyun-vod/internal/client.go b/pkg/core/ssl-deployer/providers/aliyun-vod/internal/client.go index a5acc041..ad23a53a 100644 --- a/pkg/core/ssl-deployer/providers/aliyun-vod/internal/client.go +++ b/pkg/core/ssl-deployer/providers/aliyun-vod/internal/client.go @@ -35,6 +35,68 @@ func (client *VodClient) Init(config *openapiutil.Config) (_err error) { return nil } +func (client *VodClient) DescribeVodUserDomainsWithContext(ctx context.Context, request *alivod.DescribeVodUserDomainsRequest, runtime *dara.RuntimeOptions) (_result *alivod.DescribeVodUserDomainsResponse, _err error) { + _err = request.Validate() + if _err != nil { + return _result, _err + } + query := map[string]interface{}{} + + if !dara.IsNil(request.DomainName) { + query["DomainName"] = request.DomainName + } + + if !dara.IsNil(request.DomainSearchType) { + query["DomainSearchType"] = request.DomainSearchType + } + + if !dara.IsNil(request.DomainStatus) { + query["DomainStatus"] = request.DomainStatus + } + + if !dara.IsNil(request.OwnerId) { + query["OwnerId"] = request.OwnerId + } + + if !dara.IsNil(request.PageNumber) { + query["PageNumber"] = request.PageNumber + } + + if !dara.IsNil(request.PageSize) { + query["PageSize"] = request.PageSize + } + + if !dara.IsNil(request.SecurityToken) { + query["SecurityToken"] = request.SecurityToken + } + + if !dara.IsNil(request.Tag) { + query["Tag"] = request.Tag + } + + req := &openapiutil.OpenApiRequest{ + Query: openapiutil.Query(query), + } + params := &openapiutil.Params{ + Action: dara.String("DescribeVodUserDomains"), + Version: dara.String("2017-03-21"), + Protocol: dara.String("HTTPS"), + Pathname: dara.String("/"), + Method: dara.String("POST"), + AuthType: dara.String("AK"), + Style: dara.String("RPC"), + ReqBodyType: dara.String("formData"), + BodyType: dara.String("json"), + } + _result = &alivod.DescribeVodUserDomainsResponse{} + _body, _err := client.CallApiWithCtx(ctx, params, req, runtime) + if _err != nil { + return _result, _err + } + _err = dara.Convert(_body, &_result) + return _result, _err +} + func (client *VodClient) SetVodDomainSSLCertificateWithContext(ctx context.Context, request *alivod.SetVodDomainSSLCertificateRequest, runtime *dara.RuntimeOptions) (_result *alivod.SetVodDomainSSLCertificateResponse, _err error) { _err = request.Validate() if _err != nil { diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderAliyunVOD.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderAliyunVOD.tsx index 168bf278..06e7bd39 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderAliyunVOD.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderAliyunVOD.tsx @@ -1,12 +1,17 @@ import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input } from "antd"; +import { Form, Input, Radio } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; +import Show from "@/components/Show"; import { validDomainName } from "@/utils/validators"; import { useFormNestedFieldsContext } from "./_context"; +const DOMAIN_MATCH_PATTERN_EXACT = "exact" as const; +const DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard" as const; +const DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" as const; + const BizDeployNodeConfigFieldsProviderAliyunVOD = () => { const { i18n, t } = useTranslation(); @@ -15,8 +20,11 @@ const BizDeployNodeConfigFieldsProviderAliyunVOD = () => { [parentNamePath]: getSchema({ i18n }), }); const formRule = createSchemaFieldRule(formSchema); + const formInst = Form.useFormInstance(); const initialValues = getInitialValues(); + const fieldDomainMatchPattern = Form.useWatch([parentNamePath, "domainMatchPattern"], { form: formInst, preserve: true }); + return ( <> { + ) : ( + void 0 + ) + } rules={[formRule]} > - + ({ + key: s, + label: t(`workflow_node.deploy.form.shared_domain_match_pattern.option.${s}.label`), + value: s, + }))} + /> + + + + + + ); }; @@ -44,6 +76,7 @@ const BizDeployNodeConfigFieldsProviderAliyunVOD = () => { const getInitialValues = (): Nullish>> => { return { region: "", + domainMatchPattern: DOMAIN_MATCH_PATTERN_EXACT, domain: "", }; }; @@ -51,10 +84,30 @@ const getInitialValues = (): Nullish>> => { const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { const { t } = i18n; - return z.object({ - region: z.string().nonempty(t("workflow_node.deploy.form.aliyun_vod_region.placeholder")), - domain: z.string().refine((v) => validDomainName(v), t("common.errmsg.domain_invalid")), - }); + return z + .object({ + region: z.string().nonempty(t("workflow_node.deploy.form.aliyun_vod_region.placeholder")), + domainMatchPattern: z.string().nonempty(t("workflow_node.deploy.form.shared_domain_match_pattern.placeholder")).default(DOMAIN_MATCH_PATTERN_EXACT), + domain: z.string().nullish(), + }) + .superRefine((values, ctx) => { + if (values.domainMatchPattern) { + switch (values.domainMatchPattern) { + case DOMAIN_MATCH_PATTERN_EXACT: + case DOMAIN_MATCH_PATTERN_WILDCARD: + { + if (!validDomainName(values.domain!, { allowWildcard: true })) { + ctx.addIssue({ + code: "custom", + message: t("common.errmsg.domain_invalid"), + path: ["domain"], + }); + } + } + break; + } + } + }); }; const _default = Object.assign(BizDeployNodeConfigFieldsProviderAliyunVOD, {