Merge pull request #426 from fudiwei/feat/new-workflow

feat: support ARI
This commit is contained in:
Yoan.liu 2025-01-20 09:46:32 +08:00 committed by GitHub
commit 101d55bafa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 100 additions and 63 deletions

View File

@ -14,10 +14,11 @@ import (
"github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/lego"
"golang.org/x/exp/slices"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/utils/slices" uslices "github.com/usual2970/certimate/internal/pkg/utils/slices"
"github.com/usual2970/certimate/internal/repository" "github.com/usual2970/certimate/internal/repository"
) )
@ -45,7 +46,9 @@ type applicantOptions struct {
DnsPropagationTimeout int32 DnsPropagationTimeout int32
DnsTTL int32 DnsTTL int32
DisableFollowCNAME bool DisableFollowCNAME bool
DisableARI bool
SkipBeforeExpiryDays int32 SkipBeforeExpiryDays int32
ReplacedARICertId string
} }
func NewWithApplyNode(node *domain.WorkflowNode) (Applicant, error) { func NewWithApplyNode(node *domain.WorkflowNode) (Applicant, error) {
@ -54,32 +57,49 @@ func NewWithApplyNode(node *domain.WorkflowNode) (Applicant, error) {
} }
nodeConfig := node.GetConfigForApply() nodeConfig := node.GetConfigForApply()
accessRepo := repository.NewAccessRepository()
access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId)
if err != nil {
return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err)
}
accessConfig, err := access.UnmarshalConfigToMap()
if err != nil {
return nil, fmt.Errorf("failed to unmarshal access config: %w", err)
}
options := &applicantOptions{ options := &applicantOptions{
Domains: slices.Filter(strings.Split(nodeConfig.Domains, ";"), func(s string) bool { return s != "" }), Domains: uslices.Filter(strings.Split(nodeConfig.Domains, ";"), func(s string) bool { return s != "" }),
ContactEmail: nodeConfig.ContactEmail, ContactEmail: nodeConfig.ContactEmail,
Provider: domain.ApplyDNSProviderType(nodeConfig.Provider), Provider: domain.ApplyDNSProviderType(nodeConfig.Provider),
ProviderAccessConfig: accessConfig,
ProviderApplyConfig: nodeConfig.ProviderConfig, ProviderApplyConfig: nodeConfig.ProviderConfig,
KeyAlgorithm: nodeConfig.KeyAlgorithm, KeyAlgorithm: nodeConfig.KeyAlgorithm,
Nameservers: slices.Filter(strings.Split(nodeConfig.Nameservers, ";"), func(s string) bool { return s != "" }), Nameservers: uslices.Filter(strings.Split(nodeConfig.Nameservers, ";"), func(s string) bool { return s != "" }),
DnsPropagationTimeout: nodeConfig.DnsPropagationTimeout, DnsPropagationTimeout: nodeConfig.DnsPropagationTimeout,
DnsTTL: nodeConfig.DnsTTL, DnsTTL: nodeConfig.DnsTTL,
DisableFollowCNAME: nodeConfig.DisableFollowCNAME, DisableFollowCNAME: nodeConfig.DisableFollowCNAME,
DisableARI: nodeConfig.DisableARI,
SkipBeforeExpiryDays: nodeConfig.SkipBeforeExpiryDays, SkipBeforeExpiryDays: nodeConfig.SkipBeforeExpiryDays,
} }
accessRepo := repository.NewAccessRepository()
if access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId); err != nil {
return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err)
} else {
accessConfig, err := access.UnmarshalConfigToMap()
if err != nil {
return nil, fmt.Errorf("failed to unmarshal access config: %w", err)
}
options.ProviderAccessConfig = accessConfig
}
certRepo := repository.NewCertificateRepository()
lastCertificate, _ := certRepo.GetByWorkflowNodeId(context.Background(), node.Id)
if lastCertificate != nil {
newCertSan := slices.Clone(options.Domains)
oldCertSan := strings.Split(lastCertificate.SubjectAltNames, ";")
slices.Sort(newCertSan)
slices.Sort(oldCertSan)
if slices.Equal(newCertSan, oldCertSan) {
lastCertX509, _ := certcrypto.ParsePEMCertificate([]byte(lastCertificate.Certificate))
if lastCertX509 != nil {
replacedARICertId, _ := certificate.MakeARICertID(lastCertX509)
options.ReplacedARICertId = replacedARICertId
}
}
}
applicant, err := createApplicant(options) applicant, err := createApplicant(options)
if err != nil { if err != nil {
return nil, err return nil, err
@ -109,7 +129,7 @@ func apply(challengeProvider challenge.Provider, options *applicantOptions) (*Ap
sslProviderConfig.Provider = defaultSSLProvider sslProviderConfig.Provider = defaultSSLProvider
} }
myUser, err := newAcmeUser(sslProviderConfig.Provider, options.ContactEmail) acmeUser, err := newAcmeUser(sslProviderConfig.Provider, options.ContactEmail)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -118,39 +138,42 @@ func apply(challengeProvider challenge.Provider, options *applicantOptions) (*Ap
// link: https://github.com/go-acme/lego/issues/1867 // link: https://github.com/go-acme/lego/issues/1867
os.Setenv("LEGO_DISABLE_CNAME_SUPPORT", strconv.FormatBool(options.DisableFollowCNAME)) os.Setenv("LEGO_DISABLE_CNAME_SUPPORT", strconv.FormatBool(options.DisableFollowCNAME))
config := lego.NewConfig(myUser) // Create an ACME client config
config := lego.NewConfig(acmeUser)
// This CA URL is configured for a local dev instance of Boulder running in Docker in a VM.
config.CADirURL = sslProviderUrls[sslProviderConfig.Provider] config.CADirURL = sslProviderUrls[sslProviderConfig.Provider]
config.Certificate.KeyType = parseKeyAlgorithm(options.KeyAlgorithm) config.Certificate.KeyType = parseKeyAlgorithm(options.KeyAlgorithm)
// A client facilitates communication with the CA server. // Create an ACME client
client, err := lego.NewClient(config) client, err := lego.NewClient(config)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Set the DNS01 challenge provider
challengeOptions := make([]dns01.ChallengeOption, 0) challengeOptions := make([]dns01.ChallengeOption, 0)
if len(options.Nameservers) > 0 { if len(options.Nameservers) > 0 {
challengeOptions = append(challengeOptions, dns01.AddRecursiveNameservers(dns01.ParseNameservers(options.Nameservers))) challengeOptions = append(challengeOptions, dns01.AddRecursiveNameservers(dns01.ParseNameservers(options.Nameservers)))
challengeOptions = append(challengeOptions, dns01.DisableAuthoritativeNssPropagationRequirement()) challengeOptions = append(challengeOptions, dns01.DisableAuthoritativeNssPropagationRequirement())
} }
client.Challenge.SetDNS01Provider(challengeProvider, challengeOptions...) client.Challenge.SetDNS01Provider(challengeProvider, challengeOptions...)
// New users will need to register // New users need to register first
if !myUser.hasRegistration() { if !acmeUser.hasRegistration() {
reg, err := registerAcmeUser(client, sslProviderConfig, myUser) reg, err := registerAcmeUser(client, sslProviderConfig, acmeUser)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to register: %w", err) return nil, fmt.Errorf("failed to register: %w", err)
} }
myUser.Registration = reg acmeUser.Registration = reg
} }
// Obtain a certificate
certRequest := certificate.ObtainRequest{ certRequest := certificate.ObtainRequest{
Domains: options.Domains, Domains: options.Domains,
Bundle: true, Bundle: true,
} }
if !options.DisableARI {
certRequest.ReplacesCertID = options.ReplacedARICertId
}
certResource, err := client.Certificate.Obtain(certRequest) certResource, err := client.Certificate.Obtain(certRequest)
if err != nil { if err != nil {
return nil, err return nil, err
@ -162,7 +185,7 @@ func apply(challengeProvider challenge.Provider, options *applicantOptions) (*Ap
PrivateKey: strings.TrimSpace(string(certResource.PrivateKey)), PrivateKey: strings.TrimSpace(string(certResource.PrivateKey)),
ACMECertUrl: certResource.CertURL, ACMECertUrl: certResource.CertURL,
ACMECertStableUrl: certResource.CertStableURL, ACMECertStableUrl: certResource.CertStableURL,
CSR: string(certResource.CSR), CSR: strings.TrimSpace(string(certResource.CSR)),
}, nil }, nil
} }

View File

@ -68,6 +68,7 @@ type WorkflowNodeConfigForApply struct {
DnsPropagationTimeout int32 `json:"dnsPropagationTimeout"` // DNS 传播超时时间(默认取决于提供商) DnsPropagationTimeout int32 `json:"dnsPropagationTimeout"` // DNS 传播超时时间(默认取决于提供商)
DnsTTL int32 `json:"dnsTTL"` // DNS TTL默认取决于提供商 DnsTTL int32 `json:"dnsTTL"` // DNS TTL默认取决于提供商
DisableFollowCNAME bool `json:"disableFollowCNAME"` // 是否禁用 CNAME 跟随 DisableFollowCNAME bool `json:"disableFollowCNAME"` // 是否禁用 CNAME 跟随
DisableARI bool `json:"disableARI"` // 是否禁用 ARI
SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays"` // 证书到期前多少天前跳过续期默认值30 SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays"` // 证书到期前多少天前跳过续期默认值30
} }
@ -124,6 +125,7 @@ func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply {
DnsPropagationTimeout: n.getConfigValueAsInt32("dnsPropagationTimeout"), DnsPropagationTimeout: n.getConfigValueAsInt32("dnsPropagationTimeout"),
DnsTTL: n.getConfigValueAsInt32("dnsTTL"), DnsTTL: n.getConfigValueAsInt32("dnsTTL"),
DisableFollowCNAME: n.getConfigValueAsBool("disableFollowCNAME"), DisableFollowCNAME: n.getConfigValueAsBool("disableFollowCNAME"),
DisableARI: n.getConfigValueAsBool("disableARI"),
SkipBeforeExpiryDays: skipBeforeExpiryDays, SkipBeforeExpiryDays: skipBeforeExpiryDays,
} }
} }

View File

@ -2,6 +2,7 @@ package nodeprocessor
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"time" "time"
@ -131,8 +132,9 @@ func (a *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workflo
lastCertificate, _ := a.certRepo.GetByWorkflowNodeId(ctx, a.node.Id) lastCertificate, _ := a.certRepo.GetByWorkflowNodeId(ctx, a.node.Id)
renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24 renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24
if lastCertificate != nil && time.Until(lastCertificate.ExpireAt) > renewalInterval { expirationTime := time.Until(lastCertificate.ExpireAt)
return true, "已申请过证书,且证书尚未临近过期" if lastCertificate != nil && expirationTime > renewalInterval {
return true, fmt.Sprintf("已申请过证书,且证书尚未临近过期(到期尚余 %d 天,距 %d 天时续期)", int(expirationTime.Hours()/24), currentNodeConfig.SkipBeforeExpiryDays)
} }
} }

View File

@ -57,7 +57,6 @@ const MULTIPLE_INPUT_DELIMITER = ";";
const initFormModel = (): ApplyNodeConfigFormFieldValues => { const initFormModel = (): ApplyNodeConfigFormFieldValues => {
return { return {
keyAlgorithm: "RSA2048", keyAlgorithm: "RSA2048",
disableFollowCNAME: true,
skipBeforeExpiryDays: 20, skipBeforeExpiryDays: 20,
}; };
}; };
@ -105,11 +104,11 @@ const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeCon
]) ])
.nullish(), .nullish(),
disableFollowCNAME: z.boolean().nullish(), disableFollowCNAME: z.boolean().nullish(),
disableARI: z.boolean().nullish(),
skipBeforeExpiryDays: z skipBeforeExpiryDays: z
.number({ message: t("workflow_node.apply.form.skip_before_expiry_days.placeholder") }) .number({ message: t("workflow_node.apply.form.skip_before_expiry_days.placeholder") })
.int(t("workflow_node.apply.form.skip_before_expiry_days.placeholder")) .int(t("workflow_node.apply.form.skip_before_expiry_days.placeholder"))
.gte(1, t("workflow_node.apply.form.skip_before_expiry_days.placeholder")) .gte(1, t("workflow_node.apply.form.skip_before_expiry_days.placeholder")),
.lte(60, t("workflow_node.apply.form.skip_before_expiry_days.placeholder")),
}); });
const formRule = createSchemaFieldRule(formSchema); const formRule = createSchemaFieldRule(formSchema);
const { form: formInst, formProps } = useAntdForm({ const { form: formInst, formProps } = useAntdForm({
@ -378,6 +377,15 @@ const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeCon
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item
name="disableARI"
label={t("workflow_node.apply.form.disable_ari.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.apply.form.disable_ari.tooltip") }}></span>}
>
<Switch />
</Form.Item>
</Form> </Form>
<Divider className="my-1"> <Divider className="my-1">
@ -397,7 +405,7 @@ const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeCon
<InputNumber <InputNumber
className="w-36" className="w-36"
min={1} min={1}
max={60} max={90}
placeholder={t("workflow_node.apply.form.skip_before_expiry_days.placeholder")} placeholder={t("workflow_node.apply.form.skip_before_expiry_days.placeholder")}
addonAfter={t("workflow_node.apply.form.skip_before_expiry_days.unit")} addonAfter={t("workflow_node.apply.form.skip_before_expiry_days.unit")}
/> />

View File

@ -92,11 +92,8 @@ const DeployNode = ({ node, disabled }: DeployNodeProps) => {
pending={formPending} pending={formPending}
onConfirm={handleDrawerConfirm} onConfirm={handleDrawerConfirm}
onOpenChange={(open) => { onOpenChange={(open) => {
setDrawerFooterShow(!!(node.config as WorkflowNodeConfigForDeploy)?.provider);
setDrawerOpen(open); setDrawerOpen(open);
if (!open) {
setDrawerFooterShow(!!(node.config as WorkflowNodeConfigForDeploy)?.provider);
}
}} }}
getFormValues={() => formRef.current!.getFieldsValue()} getFormValues={() => formRef.current!.getFieldsValue()}
> >

View File

@ -342,28 +342,28 @@ const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNode
</Divider> </Divider>
{nestedFormEl} {nestedFormEl}
<Divider className="my-1">
<Typography.Text className="text-xs font-normal" type="secondary">
{t("workflow_node.deploy.form.strategy_config.label")}
</Typography.Text>
</Divider>
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
<Form.Item label={t("workflow_node.deploy.form.skip_on_last_succeeded.label")}>
<Flex align="center" gap={8} wrap="wrap">
<div>{t("workflow_node.deploy.form.skip_on_last_succeeded.prefix")}</div>
<Form.Item name="skipOnLastSucceeded" noStyle rules={[formRule]}>
<Switch
checkedChildren={t("workflow_node.deploy.form.skip_on_last_succeeded.enabled.on")}
unCheckedChildren={t("workflow_node.deploy.form.skip_on_last_succeeded.enabled.off")}
/>
</Form.Item>
<div>{t("workflow_node.deploy.form.skip_on_last_succeeded.suffix")}</div>
</Flex>
</Form.Item>
</Form>
</Show> </Show>
<Divider className="my-1">
<Typography.Text className="text-xs font-normal" type="secondary">
{t("workflow_node.deploy.form.strategy_config.label")}
</Typography.Text>
</Divider>
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
<Form.Item label={t("workflow_node.deploy.form.skip_on_last_succeeded.label")}>
<Flex align="center" gap={8} wrap="wrap">
<div>{t("workflow_node.deploy.form.skip_on_last_succeeded.prefix")}</div>
<Form.Item name="skipOnLastSucceeded" noStyle rules={[formRule]}>
<Switch
checkedChildren={t("workflow_node.deploy.form.skip_on_last_succeeded.enabled.on")}
unCheckedChildren={t("workflow_node.deploy.form.skip_on_last_succeeded.enabled.off")}
/>
</Form.Item>
<div>{t("workflow_node.deploy.form.skip_on_last_succeeded.suffix")}</div>
</Flex>
</Form.Item>
</Form>
</Form.Provider> </Form.Provider>
); );
} }

View File

@ -111,6 +111,7 @@ export type WorkflowNodeConfigForApply = {
dnsPropagationTimeout?: number; dnsPropagationTimeout?: number;
dnsTTL?: number; dnsTTL?: number;
disableFollowCNAME?: boolean; disableFollowCNAME?: boolean;
disableARI?: boolean;
skipBeforeExpiryDays: number; skipBeforeExpiryDays: number;
}; };

View File

@ -51,7 +51,7 @@
"workflow_node.apply.form.key_algorithm.placeholder": "Please select certificate key algorithm", "workflow_node.apply.form.key_algorithm.placeholder": "Please select certificate key algorithm",
"workflow_node.apply.form.nameservers.label": "DNS recursive nameservers (Optional)", "workflow_node.apply.form.nameservers.label": "DNS recursive nameservers (Optional)",
"workflow_node.apply.form.nameservers.placeholder": "Please enter DNS recursive nameservers (separated by semicolons)", "workflow_node.apply.form.nameservers.placeholder": "Please enter DNS recursive nameservers (separated by semicolons)",
"workflow_node.apply.form.nameservers.tooltip": "It determines whether to custom DNS recursive nameservers during ACME DNS-01 authentication. If you don't understand this option, just keep it by default.<a href=\"https://go-acme.github.io/lego/usage/cli/options/index.html#dns-resolvers-and-challenge-verification\" target=\"_blank\">Learn more</a>.", "workflow_node.apply.form.nameservers.tooltip": "It determines whether to custom DNS recursive nameservers during ACME DNS-01 authentication. If you don't understand this option, just keep it by default. <a href=\"https://go-acme.github.io/lego/usage/cli/options/index.html#dns-resolvers-and-challenge-verification\" target=\"_blank\">Learn more</a>.",
"workflow_node.apply.form.nameservers.multiple_input_modal.title": "Change DNS rcursive nameservers", "workflow_node.apply.form.nameservers.multiple_input_modal.title": "Change DNS rcursive nameservers",
"workflow_node.apply.form.nameservers.multiple_input_modal.placeholder": "Please enter DNS recursive nameserver", "workflow_node.apply.form.nameservers.multiple_input_modal.placeholder": "Please enter DNS recursive nameserver",
"workflow_node.apply.form.dns_propagation_timeout.label": "DNS propagation timeout (Optional)", "workflow_node.apply.form.dns_propagation_timeout.label": "DNS propagation timeout (Optional)",
@ -63,7 +63,9 @@
"workflow_node.apply.form.dns_ttl.unit": "seconds", "workflow_node.apply.form.dns_ttl.unit": "seconds",
"workflow_node.apply.form.dns_ttl.tooltip": "It determines the time to live for DNS record during ACME DNS-01 authentication. If you don't understand this option, just keep it by default.<br><br>Leave blank to use the default value provided by the provider.", "workflow_node.apply.form.dns_ttl.tooltip": "It determines the time to live for DNS record during ACME DNS-01 authentication. If you don't understand this option, just keep it by default.<br><br>Leave blank to use the default value provided by the provider.",
"workflow_node.apply.form.disable_follow_cname.label": "Disable CNAME following", "workflow_node.apply.form.disable_follow_cname.label": "Disable CNAME following",
"workflow_node.apply.form.disable_follow_cname.tooltip": "It determines whether to disable CNAME following during ACME DNS-01 authentication. If you don't understand this option, just keep it by default.<a href=\"https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme/#the-advantages-of-a-cname\" target=\"_blank\">Learn more</a>.", "workflow_node.apply.form.disable_follow_cname.tooltip": "It determines whether to disable CNAME following during ACME DNS-01 authentication. If you don't understand this option, just keep it by default. <a href=\"https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme/#the-advantages-of-a-cname\" target=\"_blank\">Learn more</a>.",
"workflow_node.apply.form.disable_ari.label": "Disable ARI",
"workflow_node.apply.form.disable_ari.tooltip": "It determines whether to disable ARI (ACME Renewal Information). If you don't understand this option, just keep it by default. <a href=\"https://letsencrypt.org/2023/03/23/improving-resliiency-and-reliability-with-ari/\" target=\"_blank\">Learn more</a>.",
"workflow_node.apply.form.strategy_config.label": "Strategy settings", "workflow_node.apply.form.strategy_config.label": "Strategy settings",
"workflow_node.apply.form.skip_before_expiry_days.label": "Renewal interval", "workflow_node.apply.form.skip_before_expiry_days.label": "Renewal interval",
"workflow_node.apply.form.skip_before_expiry_days.placeholder": "Please enter renewal interval", "workflow_node.apply.form.skip_before_expiry_days.placeholder": "Please enter renewal interval",

View File

@ -62,12 +62,14 @@
"workflow_node.apply.form.dns_ttl.placeholder": "请输入 DNS 解析 TTL", "workflow_node.apply.form.dns_ttl.placeholder": "请输入 DNS 解析 TTL",
"workflow_node.apply.form.dns_ttl.unit": "秒", "workflow_node.apply.form.dns_ttl.unit": "秒",
"workflow_node.apply.form.dns_ttl.tooltip": "在 ACME DNS-01 认证时 DNS 解析记录的 TTL。如果你不了解此选项的用途保持默认即可。<br><br>为空时,将使用提供商提供的默认值。", "workflow_node.apply.form.dns_ttl.tooltip": "在 ACME DNS-01 认证时 DNS 解析记录的 TTL。如果你不了解此选项的用途保持默认即可。<br><br>为空时,将使用提供商提供的默认值。",
"workflow_node.apply.form.disable_follow_cname.label": "禁止 CNAME 跟随", "workflow_node.apply.form.disable_follow_cname.label": "关闭 CNAME 跟随",
"workflow_node.apply.form.disable_follow_cname.tooltip": "在 ACME DNS-01 认证时是否禁止 CNAME 跟随。如果你不了解该选项的用途,保持默认即可。<a href=\"https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme/#the-advantages-of-a-cname\" target=\"_blank\">点此了解更多</a>。", "workflow_node.apply.form.disable_follow_cname.tooltip": "在 ACME DNS-01 认证时是否关闭 CNAME 跟随。如果你不了解该选项的用途,保持默认即可。<a href=\"https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme/#the-advantages-of-a-cname\" target=\"_blank\">点此了解更多</a>。",
"workflow_node.apply.form.disable_ari.label": "关闭 ARI 续期",
"workflow_node.apply.form.disable_ari.tooltip": "在 ACME 证书续期时是否关闭 ARIACME Renewal Information。如果你不了解该选项的用途保持默认即可。<a href=\"https://letsencrypt.org/2023/03/23/improving-resliiency-and-reliability-with-ari/\" target=\"_blank\">点此了解更多</a>。",
"workflow_node.apply.form.strategy_config.label": "执行策略", "workflow_node.apply.form.strategy_config.label": "执行策略",
"workflow_node.apply.form.skip_before_expiry_days.label": "续期间隔", "workflow_node.apply.form.skip_before_expiry_days.label": "续期间隔",
"workflow_node.apply.form.skip_before_expiry_days.placeholder": "请输入续期间隔", "workflow_node.apply.form.skip_before_expiry_days.placeholder": "请输入续期间隔",
"workflow_node.apply.form.skip_before_expiry_days.prefix": "当上次签发的证书到期时间超过", "workflow_node.apply.form.skip_before_expiry_days.prefix": "当上次签发的证书到期时间超过",
"workflow_node.apply.form.skip_before_expiry_days.suffix": "时,跳过重新申请。", "workflow_node.apply.form.skip_before_expiry_days.suffix": "时,跳过重新申请。",
"workflow_node.apply.form.skip_before_expiry_days.unit": "天", "workflow_node.apply.form.skip_before_expiry_days.unit": "天",
"workflow_node.apply.form.skip_before_expiry_days.tooltip": "注意不要超过 CA 的证书有效期限制,否则证书可能永远不会续期。", "workflow_node.apply.form.skip_before_expiry_days.tooltip": "注意不要超过 CA 的证书有效期限制,否则证书可能永远不会续期。",