diff --git a/internal/certdeploy/deployers/sp_qiniu_cdn.go b/internal/certdeploy/deployers/sp_qiniu_cdn.go index e93f7a7e1..4fde037df 100644 --- a/internal/certdeploy/deployers/sp_qiniu_cdn.go +++ b/internal/certdeploy/deployers/sp_qiniu_cdn.go @@ -17,9 +17,10 @@ func init() { } provider, err := qiniucdn.NewSSLDeployerProvider(&qiniucdn.SSLDeployerProviderConfig{ - AccessKey: credentials.AccessKey, - SecretKey: credentials.SecretKey, - Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"), + AccessKey: credentials.AccessKey, + SecretKey: credentials.SecretKey, + DomainMatchPattern: xmaps.GetString(options.ProviderExtendedConfig, "domainMatchPattern"), + Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"), }) return provider, err }) diff --git a/pkg/core/ssl-deployer/providers/qiniu-cdn/consts.go b/pkg/core/ssl-deployer/providers/qiniu-cdn/consts.go new file mode 100644 index 000000000..a4bbfb19e --- /dev/null +++ b/pkg/core/ssl-deployer/providers/qiniu-cdn/consts.go @@ -0,0 +1,10 @@ +package qiniucdn + +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/qiniu-cdn/qiniu_cdn.go b/pkg/core/ssl-deployer/providers/qiniu-cdn/qiniu_cdn.go index a18816644..c97917b21 100644 --- a/pkg/core/ssl-deployer/providers/qiniu-cdn/qiniu_cdn.go +++ b/pkg/core/ssl-deployer/providers/qiniu-cdn/qiniu_cdn.go @@ -8,10 +8,13 @@ import ( "strings" "github.com/qiniu/go-sdk/v7/auth" + "github.com/samber/lo" "github.com/certimate-go/certimate/pkg/core" sslmgrsp "github.com/certimate-go/certimate/pkg/core/ssl-manager/providers/qiniu-sslcert" qiniusdk "github.com/certimate-go/certimate/pkg/sdk3rd/qiniu" + xcert "github.com/certimate-go/certimate/pkg/utils/cert" + xcerthostname "github.com/certimate-go/certimate/pkg/utils/cert/hostname" ) type SSLDeployerProviderConfig struct { @@ -19,6 +22,9 @@ type SSLDeployerProviderConfig struct { AccessKey string `json:"accessKey"` // 七牛云 SecretKey。 SecretKey string `json:"secretKey"` + // 域名匹配模式。 + // 零值时默认值 [DOMAIN_MATCH_PATTERN_EXACT]。 + DomainMatchPattern string `json:"domainMatchPattern,omitempty"` // 加速域名(支持泛域名)。 Domain string `json:"domain"` } @@ -66,10 +72,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, fmt.Errorf("config `domain` is required") - } - // 上传证书 upres, err := d.sslManager.Upload(ctx, certPEM, privkeyPEM) if err != nil { @@ -78,32 +80,157 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke d.logger.Info("ssl certificate uploaded", slog.Any("result", upres)) } - // "*.example.com" → ".example.com",适配七牛云 CDN 要求的泛域名格式 - domain := strings.TrimPrefix(d.config.Domain, "*") + // 获取待部署的域名列表 + var domains []string + switch d.config.DomainMatchPattern { + case "", DOMAIN_MATCH_PATTERN_EXACT: + { + if d.config.Domain == "" { + return nil, errors.New("config `domain` is required") + } - // 获取域名信息 - // REF: https://developer.qiniu.com/fusion/4246/the-domain-name - getDomainInfoResp, err := d.sdkClient.GetDomainInfo(context.TODO(), domain) - d.logger.Debug("sdk request 'cdn.GetDomainInfo'", slog.String("request.domain", domain), slog.Any("response", getDomainInfoResp)) - if err != nil { - return nil, fmt.Errorf("failed to execute sdk request 'cdn.GetDomainInfo': %w", err) + // "*.example.com" → ".example.com",适配七牛云 CDN 要求的泛域名格式 + domain := strings.TrimPrefix(d.config.Domain, "*") + domains = []string{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) || + strings.TrimPrefix(d.config.Domain, "*") == strings.TrimPrefix(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) } - // 判断域名是否已启用 HTTPS。如果已启用,修改域名证书;否则,启用 HTTPS - // REF: https://developer.qiniu.com/fusion/4246/the-domain-name - if getDomainInfoResp.Https == nil || getDomainInfoResp.Https.CertID == "" { - enableDomainHttpsResp, err := d.sdkClient.EnableDomainHttps(context.TODO(), domain, upres.CertId, true, true) - d.logger.Debug("sdk request 'cdn.EnableDomainHttps'", slog.String("request.domain", domain), slog.String("request.certId", upres.CertId), slog.Any("response", enableDomainHttpsResp)) - if err != nil { - return nil, fmt.Errorf("failed to execute sdk request 'cdn.EnableDomainHttps': %w", err) + // 遍历更新域名证书 + if len(domains) == 0 { + d.logger.Info("no cdn domains to deploy") + } else { + d.logger.Info("found cdn 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, upres.CertId); err != nil { + errs = append(errs, err) + } + } } - } else if getDomainInfoResp.Https.CertID != upres.CertId { - modifyDomainHttpsConfResp, err := d.sdkClient.ModifyDomainHttpsConf(context.TODO(), domain, upres.CertId, getDomainInfoResp.Https.ForceHttps, getDomainInfoResp.Https.Http2Enable) - d.logger.Debug("sdk request 'cdn.ModifyDomainHttpsConf'", slog.String("request.domain", domain), slog.String("request.certId", upres.CertId), slog.Any("response", modifyDomainHttpsConfResp)) - if err != nil { - return nil, fmt.Errorf("failed to execute sdk request 'cdn.ModifyDomainHttpsConf': %w", 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://developer.qiniu.com/fusion/4246/the-domain-name + getDomainListMarker := "" + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + getDomainListResp, err := d.sdkClient.GetDomainList(context.TODO(), getDomainListMarker, 100) + d.logger.Debug("sdk request 'cdn.GetDomainList'", slog.String("request.marker", getDomainListMarker), slog.Any("response", getDomainListResp)) + if err != nil { + return nil, fmt.Errorf("failed to execute sdk request 'cdn.GetDomainList': %w", err) + } + + ignoredStatuses := []string{"frozen", "offlined"} + for _, domainItem := range getDomainListResp.Domains { + if lo.Contains(ignoredStatuses, domainItem.OperatingState) { + continue + } + + domains = append(domains, domainItem.Name) + } + + if len(getDomainListResp.Domains) == 0 || getDomainListResp.Marker == "" { + break + } + + getDomainListMarker = getDomainListResp.Marker + } + + return domains, nil +} + +func (d *SSLDeployerProvider) updateDomainCertificate(ctx context.Context, domain string, cloudCertId string) error { + // 获取域名信息 + // REF: https://developer.qiniu.com/fusion/4246/the-domain-name + getDomainInfoResp, err := d.sdkClient.GetDomainInfo(context.TODO(), domain) + d.logger.Debug("sdk request 'cdn.GetDomainInfo'", slog.String("request.domain", domain), slog.Any("response", getDomainInfoResp)) + if err != nil { + return fmt.Errorf("failed to execute sdk request 'cdn.GetDomainInfo': %w", err) + } + + // 判断域名是否已启用 HTTPS + // 如果已启用,修改域名证书;否则,启用 HTTPS + // REF: https://developer.qiniu.com/fusion/4246/the-domain-name + if getDomainInfoResp.Https == nil || getDomainInfoResp.Https.CertID == "" { + enableDomainHttpsResp, err := d.sdkClient.EnableDomainHttps(context.TODO(), domain, cloudCertId, true, true) + d.logger.Debug("sdk request 'cdn.EnableDomainHttps'", slog.String("request.domain", domain), slog.String("request.certId", cloudCertId), slog.Any("response", enableDomainHttpsResp)) + if err != nil { + return fmt.Errorf("failed to execute sdk request 'cdn.EnableDomainHttps': %w", err) + } + } else if getDomainInfoResp.Https.CertID != cloudCertId { + modifyDomainHttpsConfResp, err := d.sdkClient.ModifyDomainHttpsConf(context.TODO(), domain, cloudCertId, getDomainInfoResp.Https.ForceHttps, getDomainInfoResp.Https.Http2Enable) + d.logger.Debug("sdk request 'cdn.ModifyDomainHttpsConf'", slog.String("request.domain", domain), slog.String("request.certId", cloudCertId), slog.Any("response", modifyDomainHttpsConfResp)) + if err != nil { + return fmt.Errorf("failed to execute sdk request 'cdn.ModifyDomainHttpsConf': %w", err) + } + } + + return nil +} diff --git a/pkg/core/ssl-deployer/providers/qiniu-cdn/qiniu_cdn_test.go b/pkg/core/ssl-deployer/providers/qiniu-cdn/qiniu_cdn_test.go index 5ad633c37..62682b6b0 100644 --- a/pkg/core/ssl-deployer/providers/qiniu-cdn/qiniu_cdn_test.go +++ b/pkg/core/ssl-deployer/providers/qiniu-cdn/qiniu_cdn_test.go @@ -53,9 +53,10 @@ func TestDeploy(t *testing.T) { }, "\n")) deployer, err := provider.NewSSLDeployerProvider(&provider.SSLDeployerProviderConfig{ - AccessKey: fAccessKey, - SecretKey: fSecretKey, - Domain: fDomain, + AccessKey: fAccessKey, + SecretKey: fSecretKey, + DomainMatchPattern: provider.DOMAIN_MATCH_PATTERN_EXACT, + Domain: fDomain, }) if err != nil { t.Errorf("err: %+v", err) diff --git a/pkg/sdk3rd/qiniu/cdn.go b/pkg/sdk3rd/qiniu/cdn.go index 74745d0ba..d658bbeaa 100644 --- a/pkg/sdk3rd/qiniu/cdn.go +++ b/pkg/sdk3rd/qiniu/cdn.go @@ -2,7 +2,9 @@ package qiniu import ( "context" + "fmt" "net/http" + "net/url" "github.com/qiniu/go-sdk/v7/auth" "github.com/qiniu/go-sdk/v7/client" @@ -21,6 +23,37 @@ func NewCdnManager(mac *auth.Credentials) *CdnManager { return &CdnManager{client: client} } +type GetDomainListResponse struct { + Code *int `json:"code,omitempty"` + Error *string `json:"error,omitempty"` + Marker string `json:"marker"` + Domains []*struct { + Name string `json:"name"` + Type string `json:"type"` + CName string `json:"cname"` + OperatingState string `json:"operatingState"` + OperatingStateDesc string `json:"operatingStateDesc"` + CreateAt string `json:"createAt"` + ModifyAt string `json:"modifyAt"` + } `json:"domains"` +} + +func (m *CdnManager) GetDomainList(ctx context.Context, marker string, limit int) (*GetDomainListResponse, error) { + query := url.Values{} + if marker != "" { + query.Set("marker", marker) + } + if limit > 0 { + query.Set("limit", fmt.Sprintf("%d", limit)) + } + + resp := new(GetDomainListResponse) + if err := m.client.Call(ctx, resp, http.MethodGet, "domain?"+query.Encode(), nil); err != nil { + return nil, err + } + return resp, nil +} + type GetDomainInfoResponse struct { Code *int `json:"code,omitempty"` Error *string `json:"error,omitempty"` diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuCDN.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuCDN.tsx index bde644cc2..05156bd29 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuCDN.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderQiniuCDN.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 BizDeployNodeConfigFieldsProviderQiniuCDN = () => { const { i18n, t } = useTranslation(); @@ -15,24 +20,52 @@ const BizDeployNodeConfigFieldsProviderQiniuCDN = () => { [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, + }))} + /> + + + + + + ); }; const getInitialValues = (): Nullish>> => { return { + domainMatchPattern: DOMAIN_MATCH_PATTERN_EXACT, domain: "", }; }; @@ -40,9 +73,29 @@ const getInitialValues = (): Nullish>> => { const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { const { t } = i18n; - return z.object({ - domain: z.string().refine((v) => validDomainName(v, { allowWildcard: true }), t("common.errmsg.domain_invalid")), - }); + return z + .object({ + 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(BizDeployNodeConfigFieldsProviderQiniuCDN, { diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 03397829a..3b2f826e2 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -197,7 +197,7 @@ "workflow_node.deploy.form.shared_domain_match_pattern.option.exact.label": "精确匹配", "workflow_node.deploy.form.shared_domain_match_pattern.option.wildcard.label": "通配符匹配(泛域名)", "workflow_node.deploy.form.shared_domain_match_pattern.option.certsan.label": "根据证书自动匹配", - "workflow_node.deploy.form.shared_domain_match_pattern.help_wildcard": "注意:对于泛解析的站点,精确匹配仅包含该泛解析站点本身、不包括相关子域名站点。", + "workflow_node.deploy.form.shared_domain_match_pattern.help_wildcard": "注意:对于泛解析的站点,泛域名的精确匹配仅包含该泛解析站点本身、不包括相关子域名站点。", "workflow_node.deploy.form.1panel_console_auto_restart.label": "部署后自动重启 1Panel 服务", "workflow_node.deploy.form.1panel_site_node_name.label": "1Panel 子节点名称(可选)", "workflow_node.deploy.form.1panel_site_node_name.placeholder": "请输入 1Panel 子节点名称",