完善自动续期策略与管理配置

This commit is contained in:
xuans 2026-08-30 21:45:23 +08:00
parent 8158cefc05
commit 4ac123798d
8 changed files with 323 additions and 28 deletions

View File

@ -106,7 +106,14 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
NotifyError: func(err error) { logger.Error("发送邮件通知失败", "error", err) },
CurrentCertificate: certificates.Current,
}
renewal := certificate.NewRenewalScheduler(repo, workflow, cfg.ACME.RenewalThresholdDays)
renewal := certificate.NewRenewalSchedulerWithPolicy(repo, workflow, certificate.RenewalPolicy{
CheckInterval: time.Duration(cfg.ACME.RenewalCheckIntervalDays) * 24 * time.Hour,
NearExpiryThreshold: time.Duration(cfg.ACME.RenewalNearExpiryDays) * 24 * time.Hour,
RenewalThreshold: time.Duration(cfg.ACME.RenewalThresholdDays) * 24 * time.Hour,
UrgentThreshold: time.Duration(cfg.ACME.RenewalUrgentThresholdDays) * 24 * time.Hour,
MaxRetries: cfg.ACME.RenewalMaxRetries,
RetryInterval: time.Duration(cfg.ACME.RenewalRetryIntervalMins) * time.Minute,
})
server := httpapi.New(httpapi.Dependencies{
Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, SSHKeys: sshKeys,
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(),

View File

@ -18,33 +18,52 @@ type CertificateIssuer interface {
Issue(ctx context.Context, domainID string) (IssueResult, error)
}
type RenewalPolicy struct {
CheckInterval time.Duration
NearExpiryThreshold time.Duration
RenewalThreshold time.Duration
UrgentThreshold time.Duration
MaxRetries int
RetryInterval time.Duration
}
type RenewalScheduler struct {
lister DomainLister
issuer CertificateIssuer
threshold time.Duration
now func() time.Time
interval time.Duration
mu sync.Mutex
runMu sync.Mutex
running bool
lister DomainLister
issuer CertificateIssuer
policy RenewalPolicy
now func() time.Time
mu sync.Mutex
runMu sync.Mutex
running bool
}
type RenewalReport struct {
Checked int `json:"checked"`
Selected int `json:"selected"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Errors []string `json:"errors,omitempty"`
Checked int `json:"checked"`
NearExpiry int `json:"near_expiry"`
Selected int `json:"selected"`
Urgent int `json:"urgent"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Errors []string `json:"errors,omitempty"`
}
func NewRenewalScheduler(lister DomainLister, issuer CertificateIssuer, thresholdDays int) *RenewalScheduler {
if thresholdDays < 1 {
thresholdDays = 6
}
return &RenewalScheduler{
lister: lister, issuer: issuer, threshold: time.Duration(thresholdDays) * 24 * time.Hour,
now: time.Now, interval: 24 * time.Hour,
}
return NewRenewalSchedulerWithPolicy(lister, issuer, RenewalPolicy{
CheckInterval: 3 * 24 * time.Hour,
NearExpiryThreshold: 10 * 24 * time.Hour,
RenewalThreshold: time.Duration(thresholdDays) * 24 * time.Hour,
UrgentThreshold: 3 * 24 * time.Hour,
MaxRetries: 3,
RetryInterval: 30 * time.Minute,
})
}
func NewRenewalSchedulerWithPolicy(lister DomainLister, issuer CertificateIssuer, policy RenewalPolicy) *RenewalScheduler {
policy = normalizeRenewalPolicy(policy)
return &RenewalScheduler{lister: lister, issuer: issuer, policy: policy, now: time.Now}
}
func (s *RenewalScheduler) RunOnce(ctx context.Context) (RenewalReport, error) {
@ -65,11 +84,19 @@ func (s *RenewalScheduler) RunOnce(ctx context.Context) (RenewalReport, error) {
if !item.AutoRenew || item.Status == "issuing" {
continue
}
if item.ExpiresAt != nil && item.ExpiresAt.Sub(now) > s.threshold {
remaining := renewalRemaining(item.ExpiresAt, now)
if item.ExpiresAt != nil && remaining <= s.policy.NearExpiryThreshold {
report.NearExpiry++
}
if item.ExpiresAt != nil && remaining > s.policy.RenewalThreshold {
continue
}
report.Selected++
if _, err := s.issuer.Issue(ctx, item.ID); err != nil {
urgent := item.ExpiresAt != nil && remaining <= s.policy.UrgentThreshold
if urgent {
report.Urgent++
}
if err := s.issueWithRetry(ctx, item.ID, urgent); err != nil {
report.Failed++
report.Errors = append(report.Errors, fmt.Sprintf("%s:%v", item.Name, err))
continue
@ -96,16 +123,84 @@ func (s *RenewalScheduler) Start(ctx context.Context) {
s.running = false
s.mu.Unlock()
}()
_, _ = s.RunOnce(ctx)
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
report, err := s.RunOnce(ctx)
for {
interval := s.policy.CheckInterval
if err == nil && report.NearExpiry > 0 {
interval = 24 * time.Hour
}
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-ticker.C:
_, _ = s.RunOnce(ctx)
case <-timer.C:
}
report, err = s.RunOnce(ctx)
}
}()
}
func (s *RenewalScheduler) issueWithRetry(ctx context.Context, domainID string, urgent bool) error {
attempts := 1
if urgent {
attempts = s.policy.MaxRetries
}
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if _, err := s.issuer.Issue(ctx, domainID); err == nil {
return nil
} else {
lastErr = err
}
if attempt == attempts {
break
}
if err := waitForRenewalRetry(ctx, s.policy.RetryInterval); err != nil {
return fmt.Errorf("第 %d 次续期失败,等待重试被取消:%w", attempt, err)
}
}
return fmt.Errorf("续期失败(已尝试 %d 次):%w", attempts, lastErr)
}
func waitForRenewalRetry(ctx context.Context, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func renewalRemaining(expiresAt *time.Time, now time.Time) time.Duration {
if expiresAt == nil {
return 0
}
return expiresAt.Sub(now)
}
func normalizeRenewalPolicy(policy RenewalPolicy) RenewalPolicy {
if policy.CheckInterval <= 0 {
policy.CheckInterval = 3 * 24 * time.Hour
}
if policy.NearExpiryThreshold <= 0 {
policy.NearExpiryThreshold = 10 * 24 * time.Hour
}
if policy.RenewalThreshold <= 0 {
policy.RenewalThreshold = 6 * 24 * time.Hour
}
if policy.UrgentThreshold <= 0 {
policy.UrgentThreshold = 3 * 24 * time.Hour
}
if policy.MaxRetries <= 0 {
policy.MaxRetries = 3
}
if policy.RetryInterval <= 0 {
policy.RetryInterval = 30 * time.Minute
}
return policy
}

View File

@ -53,7 +53,7 @@ func TestRenewalSchedulerSelectsOnlyExpiringAutoRenewDomains(t *testing.T) {
func TestRenewalSchedulerReportsFailures(t *testing.T) {
now := time.Now().UTC()
expires := now.Add(time.Hour)
expires := now.Add(4 * 24 * time.Hour)
item := domain.Domain{ID: "one", Name: "one.example.com", AutoRenew: true, ExpiresAt: &expires}
scheduler := NewRenewalScheduler(renewalLister{items: []domain.Domain{item}}, &renewalService{err: errors.New("模拟续期失败")}, 6)
report, err := scheduler.RunOnce(context.Background())
@ -64,3 +64,54 @@ func TestRenewalSchedulerReportsFailures(t *testing.T) {
t.Fatalf("续期失败结果错误:%+v", report)
}
}
func TestRenewalSchedulerRetriesUrgentRenewal(t *testing.T) {
now := time.Date(2026, 8, 26, 0, 0, 0, 0, time.UTC)
expires := now.Add(2 * 24 * time.Hour)
service := &renewalService{err: errors.New("模拟紧急续期失败")}
scheduler := NewRenewalSchedulerWithPolicy(renewalLister{items: []domain.Domain{{
ID: "urgent", Name: "urgent.example.com", AutoRenew: true, ExpiresAt: &expires,
}}}, service, RenewalPolicy{
CheckInterval: time.Hour,
NearExpiryThreshold: 10 * 24 * time.Hour,
RenewalThreshold: 6 * 24 * time.Hour,
UrgentThreshold: 3 * 24 * time.Hour,
MaxRetries: 3,
RetryInterval: time.Nanosecond,
})
scheduler.now = func() time.Time { return now }
report, err := scheduler.RunOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if report.Selected != 1 || report.Urgent != 1 || report.Failed != 1 || report.Succeeded != 0 {
t.Fatalf("紧急续期报告错误:%+v", report)
}
if len(service.ids) != 3 {
t.Fatalf("紧急续期应按最大重试次数执行:%v", service.ids)
}
}
func TestRenewalSchedulerStopsRetryWhenContextCanceled(t *testing.T) {
now := time.Now().UTC()
expires := now.Add(2 * 24 * time.Hour)
service := &renewalService{err: errors.New("模拟紧急续期失败")}
scheduler := NewRenewalSchedulerWithPolicy(renewalLister{items: []domain.Domain{{
ID: "urgent", Name: "urgent.example.com", AutoRenew: true, ExpiresAt: &expires,
}}}, service, RenewalPolicy{MaxRetries: 3, RetryInterval: time.Hour})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
time.Sleep(5 * time.Millisecond)
cancel()
}()
report, err := scheduler.RunOnce(ctx)
if err != nil {
t.Fatal(err)
}
if report.Failed != 1 || len(service.ids) != 1 {
t.Fatalf("取消后的紧急续期重试报告错误:%+v calls=%d", report, len(service.ids))
}
}

View File

@ -77,7 +77,12 @@ type ACMEConfig struct {
CA string `json:"ca"`
Environment string `json:"environment"`
AccountEmail string `json:"account_email"`
RenewalCheckIntervalDays int `json:"renewal_check_interval_days"`
RenewalNearExpiryDays int `json:"renewal_near_expiry_days"`
RenewalThresholdDays int `json:"renewal_threshold_days"`
RenewalUrgentThresholdDays int `json:"renewal_urgent_threshold_days"`
RenewalMaxRetries int `json:"renewal_max_retries"`
RenewalRetryIntervalMins int `json:"renewal_retry_interval_mins"`
PropagationTimeoutSecs int `json:"propagation_timeout_secs"`
PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"`
}
@ -132,7 +137,12 @@ func Default(baseDir string) Config {
ACME: ACMEConfig{
CA: "letsencrypt",
Environment: "staging",
RenewalCheckIntervalDays: 3,
RenewalNearExpiryDays: 10,
RenewalThresholdDays: 6,
RenewalUrgentThresholdDays: 3,
RenewalMaxRetries: 3,
RenewalRetryIntervalMins: 30,
PropagationTimeoutSecs: 900,
PropagationPollingIntervalSecs: 10,
},
@ -258,7 +268,12 @@ func (c *Config) ApplyEnvironment() error {
target *int
}{
{"SCERT_SERVER_PORT", &c.Server.Port},
{"SCERT_ACME_RENEWAL_CHECK_INTERVAL_DAYS", &c.ACME.RenewalCheckIntervalDays},
{"SCERT_ACME_RENEWAL_NEAR_EXPIRY_DAYS", &c.ACME.RenewalNearExpiryDays},
{"SCERT_ACME_RENEWAL_THRESHOLD_DAYS", &c.ACME.RenewalThresholdDays},
{"SCERT_ACME_RENEWAL_URGENT_THRESHOLD_DAYS", &c.ACME.RenewalUrgentThresholdDays},
{"SCERT_ACME_RENEWAL_MAX_RETRIES", &c.ACME.RenewalMaxRetries},
{"SCERT_ACME_RENEWAL_RETRY_INTERVAL_MINS", &c.ACME.RenewalRetryIntervalMins},
{"SCERT_ACME_PROPAGATION_TIMEOUT_SECS", &c.ACME.PropagationTimeoutSecs},
{"SCERT_ACME_PROPAGATION_POLLING_INTERVAL_SECS", &c.ACME.PropagationPollingIntervalSecs},
}
@ -295,6 +310,24 @@ func (c *Config) Normalize() {
if c.ACME.PropagationTimeoutSecs == 0 {
c.ACME.PropagationTimeoutSecs = 900
}
if c.ACME.RenewalCheckIntervalDays == 0 {
c.ACME.RenewalCheckIntervalDays = 3
}
if c.ACME.RenewalNearExpiryDays == 0 {
c.ACME.RenewalNearExpiryDays = 10
}
if c.ACME.RenewalThresholdDays == 0 {
c.ACME.RenewalThresholdDays = 6
}
if c.ACME.RenewalUrgentThresholdDays == 0 {
c.ACME.RenewalUrgentThresholdDays = 3
}
if c.ACME.RenewalMaxRetries == 0 {
c.ACME.RenewalMaxRetries = 3
}
if c.ACME.RenewalRetryIntervalMins == 0 {
c.ACME.RenewalRetryIntervalMins = 30
}
if c.ACME.PropagationPollingIntervalSecs == 0 {
c.ACME.PropagationPollingIntervalSecs = 10
}
@ -435,6 +468,21 @@ func (c Config) Validate() error {
if c.ACME.RenewalThresholdDays < 1 {
return fmt.Errorf("续期阈值必须大于 0")
}
if c.ACME.RenewalCheckIntervalDays < 1 || c.ACME.RenewalNearExpiryDays < 1 || c.ACME.RenewalUrgentThresholdDays < 1 {
return fmt.Errorf("续期检查阈值必须大于 0")
}
if c.ACME.RenewalUrgentThresholdDays > c.ACME.RenewalThresholdDays {
return fmt.Errorf("紧急续期阈值不能大于自动续期阈值")
}
if c.ACME.RenewalThresholdDays > c.ACME.RenewalNearExpiryDays {
return fmt.Errorf("自动续期阈值不能大于临近到期检查阈值")
}
if c.ACME.RenewalMaxRetries < 1 || c.ACME.RenewalMaxRetries > 10 {
return fmt.Errorf("续期最大重试次数必须在 1 到 10 次之间")
}
if c.ACME.RenewalRetryIntervalMins < 1 || c.ACME.RenewalRetryIntervalMins > 1440 {
return fmt.Errorf("续期重试间隔必须在 1 到 1440 分钟之间")
}
if c.ACME.PropagationTimeoutSecs < 1 || c.ACME.PropagationTimeoutSecs > 86400 {
return fmt.Errorf("DNS 传播等待时间必须在 1 到 86400 秒之间")
}

View File

@ -161,6 +161,52 @@ func TestDefaultACMEPropagationSettings(t *testing.T) {
}
}
func TestDefaultRenewalPolicy(t *testing.T) {
cfg := Default(t.TempDir())
if cfg.ACME.RenewalCheckIntervalDays != 3 || cfg.ACME.RenewalNearExpiryDays != 10 ||
cfg.ACME.RenewalThresholdDays != 6 || cfg.ACME.RenewalUrgentThresholdDays != 3 ||
cfg.ACME.RenewalMaxRetries != 3 || cfg.ACME.RenewalRetryIntervalMins != 30 {
t.Fatalf("默认续期策略不正确:%+v", cfg.ACME)
}
}
func TestNormalizeRestoresMissingRenewalPolicy(t *testing.T) {
cfg := Default(t.TempDir())
cfg.ACME.RenewalCheckIntervalDays = 0
cfg.ACME.RenewalNearExpiryDays = 0
cfg.ACME.RenewalThresholdDays = 0
cfg.ACME.RenewalUrgentThresholdDays = 0
cfg.ACME.RenewalMaxRetries = 0
cfg.ACME.RenewalRetryIntervalMins = 0
cfg.Normalize()
if cfg.ACME.RenewalCheckIntervalDays != 3 || cfg.ACME.RenewalNearExpiryDays != 10 ||
cfg.ACME.RenewalThresholdDays != 6 || cfg.ACME.RenewalUrgentThresholdDays != 3 ||
cfg.ACME.RenewalMaxRetries != 3 || cfg.ACME.RenewalRetryIntervalMins != 30 {
t.Fatalf("旧配置的续期策略未补齐:%+v", cfg.ACME)
}
}
func TestValidateRejectsInvalidRenewalPolicy(t *testing.T) {
cases := []struct {
name string
edit func(*Config)
}{
{name: "urgent threshold", edit: func(cfg *Config) { cfg.ACME.RenewalUrgentThresholdDays = 7 }},
{name: "renewal threshold", edit: func(cfg *Config) { cfg.ACME.RenewalThresholdDays = 11 }},
{name: "retry count", edit: func(cfg *Config) { cfg.ACME.RenewalMaxRetries = 0 }},
{name: "retry interval", edit: func(cfg *Config) { cfg.ACME.RenewalRetryIntervalMins = 0 }},
}
for _, item := range cases {
t.Run(item.name, func(t *testing.T) {
cfg := Default(t.TempDir())
item.edit(&cfg)
if err := cfg.Validate(); err == nil {
t.Fatal("无效续期策略未被拒绝")
}
})
}
}
func TestNormalizeRestoresMissingACMEPropagationSettings(t *testing.T) {
cfg := Default(t.TempDir())
cfg.ACME.PropagationTimeoutSecs = 0

View File

@ -40,7 +40,12 @@ type settingsACMEView struct {
CA string `json:"ca"`
Environment string `json:"environment"`
AccountEmail string `json:"account_email"`
RenewalCheckIntervalDays int `json:"renewal_check_interval_days"`
RenewalNearExpiryDays int `json:"renewal_near_expiry_days"`
RenewalThresholdDays int `json:"renewal_threshold_days"`
RenewalUrgentThresholdDays int `json:"renewal_urgent_threshold_days"`
RenewalMaxRetries int `json:"renewal_max_retries"`
RenewalRetryIntervalMins int `json:"renewal_retry_interval_mins"`
PropagationTimeoutSecs int `json:"propagation_timeout_secs"`
PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"`
}
@ -106,7 +111,12 @@ type settingsACMERequest struct {
CA *string `json:"ca"`
Environment *string `json:"environment"`
AccountEmail *string `json:"account_email"`
RenewalCheckIntervalDays *int `json:"renewal_check_interval_days"`
RenewalNearExpiryDays *int `json:"renewal_near_expiry_days"`
RenewalThresholdDays *int `json:"renewal_threshold_days"`
RenewalUrgentThresholdDays *int `json:"renewal_urgent_threshold_days"`
RenewalMaxRetries *int `json:"renewal_max_retries"`
RenewalRetryIntervalMins *int `json:"renewal_retry_interval_mins"`
PropagationTimeoutSecs *int `json:"propagation_timeout_secs"`
PropagationPollingIntervalSecs *int `json:"propagation_polling_interval_secs"`
}
@ -233,8 +243,14 @@ func settingsViewFromConfig(cfg config.Config) settingsView {
Server: settingsServerView{Host: cfg.Server.Host, Port: cfg.Server.Port, HTTPSMode: cfg.Server.HTTPSMode},
Web: settingsWebView{Enabled: cfg.Web.Enabled, Hostname: cfg.Web.Hostname, PublicIP: cfg.Web.PublicIP},
Paths: settingsPathsView{Certificate: cfg.Paths.Certificate},
ACME: settingsACMEView{CA: cfg.ACME.CA, Environment: cfg.ACME.Environment, AccountEmail: cfg.ACME.AccountEmail, RenewalThresholdDays: cfg.ACME.RenewalThresholdDays, PropagationTimeoutSecs: cfg.ACME.PropagationTimeoutSecs, PropagationPollingIntervalSecs: cfg.ACME.PropagationPollingIntervalSecs},
DNS: settingsDNSView{Provider: cfg.DNS.Provider, VerificationState: cfg.DNS.VerificationState},
ACME: settingsACMEView{
CA: cfg.ACME.CA, Environment: cfg.ACME.Environment, AccountEmail: cfg.ACME.AccountEmail,
RenewalCheckIntervalDays: cfg.ACME.RenewalCheckIntervalDays, RenewalNearExpiryDays: cfg.ACME.RenewalNearExpiryDays,
RenewalThresholdDays: cfg.ACME.RenewalThresholdDays, RenewalUrgentThresholdDays: cfg.ACME.RenewalUrgentThresholdDays,
RenewalMaxRetries: cfg.ACME.RenewalMaxRetries, RenewalRetryIntervalMins: cfg.ACME.RenewalRetryIntervalMins,
PropagationTimeoutSecs: cfg.ACME.PropagationTimeoutSecs, PropagationPollingIntervalSecs: cfg.ACME.PropagationPollingIntervalSecs,
},
DNS: settingsDNSView{Provider: cfg.DNS.Provider, VerificationState: cfg.DNS.VerificationState},
Notification: settingsNotificationView{
HTTPEnabled: cfg.Notification.HTTPEnabled, SMTPEnabled: cfg.Notification.SMTPEnabled, Provider: cfg.Notification.Provider,
SMTPHost: cfg.Notification.SMTPHost, SMTPPort: cfg.Notification.SMTPPort, SMTPUsername: cfg.Notification.SMTPUsername,
@ -293,6 +309,21 @@ func applySettingsRequest(cfg *config.Config, request settingsRequest) {
if request.ACME.RenewalThresholdDays != nil {
cfg.ACME.RenewalThresholdDays = *request.ACME.RenewalThresholdDays
}
if request.ACME.RenewalCheckIntervalDays != nil {
cfg.ACME.RenewalCheckIntervalDays = *request.ACME.RenewalCheckIntervalDays
}
if request.ACME.RenewalNearExpiryDays != nil {
cfg.ACME.RenewalNearExpiryDays = *request.ACME.RenewalNearExpiryDays
}
if request.ACME.RenewalUrgentThresholdDays != nil {
cfg.ACME.RenewalUrgentThresholdDays = *request.ACME.RenewalUrgentThresholdDays
}
if request.ACME.RenewalMaxRetries != nil {
cfg.ACME.RenewalMaxRetries = *request.ACME.RenewalMaxRetries
}
if request.ACME.RenewalRetryIntervalMins != nil {
cfg.ACME.RenewalRetryIntervalMins = *request.ACME.RenewalRetryIntervalMins
}
if request.ACME.PropagationTimeoutSecs != nil {
cfg.ACME.PropagationTimeoutSecs = *request.ACME.PropagationTimeoutSecs
}

View File

@ -224,7 +224,12 @@ export function useDashboard() {
ca: "letsencrypt",
environment: "staging",
account_email: "",
renewal_check_interval_days: 3,
renewal_near_expiry_days: 10,
renewal_threshold_days: 6,
renewal_urgent_threshold_days: 3,
renewal_max_retries: 3,
renewal_retry_interval_mins: 30,
propagation_timeout_secs: 900,
propagation_polling_interval_secs: 10,
},

View File

@ -423,6 +423,18 @@ const {
<label class="field"><span>ACME 环境</span><select v-model="settingsForm.acme.environment"><option value="staging">测试环境</option><option value="production">生产环境</option></select></label>
<label class="field"><span>自动续期阈值(天)</span><input v-model.number="settingsForm.acme.renewal_threshold_days" type="number" min="1" max="365" /></label>
</div>
<div class="form-grid">
<label class="field"><span>常规检查间隔(天)</span><input v-model.number="settingsForm.acme.renewal_check_interval_days" type="number" min="1" max="30" /></label>
<label class="field"><span>临近到期检查阈值(天)</span><input v-model.number="settingsForm.acme.renewal_near_expiry_days" type="number" min="1" max="365" /></label>
</div>
<div class="form-grid">
<label class="field"><span>紧急续期阈值(天)</span><input v-model.number="settingsForm.acme.renewal_urgent_threshold_days" type="number" min="1" max="365" /></label>
<label class="field"><span>紧急续期最大尝试次数</span><input v-model.number="settingsForm.acme.renewal_max_retries" type="number" min="1" max="10" /></label>
</div>
<div class="form-grid">
<label class="field"><span>续期重试间隔(分钟)</span><input v-model.number="settingsForm.acme.renewal_retry_interval_mins" type="number" min="1" max="1440" /></label>
<div class="field-hint">剩余有效期低于自动续期阈值时触发续期;低于紧急阈值时按配置重试。</div>
</div>
<div class="form-grid">
<label class="field"><span>DNS 传播等待(秒)</span><input v-model.number="settingsForm.acme.propagation_timeout_secs" type="number" min="1" /></label>
<label class="field"><span>DNS 检测间隔(秒)</span><input v-model.number="settingsForm.acme.propagation_polling_interval_secs" type="number" min="1" /></label>