保留初始化通知配置并保护新通知凭据

This commit is contained in:
xuans 2026-09-16 19:19:24 +08:00
parent efb405ea11
commit 3d1afab22e
7 changed files with 458 additions and 23 deletions

View File

@ -60,19 +60,20 @@ func (err *CompleteError) Unwrap() error {
} }
type CompleteRequest struct { type CompleteRequest struct {
DatabaseVerificationToken string `json:"database_verification_token"` DatabaseVerificationToken string `json:"database_verification_token"`
DNSVerificationToken string `json:"dns_verification_token"` DNSVerificationToken string `json:"dns_verification_token"`
AdminUsername string `json:"admin_username"` AdminUsername string `json:"admin_username"`
AdminEmail string `json:"admin_email"` AdminEmail string `json:"admin_email"`
AdminPassword string `json:"admin_password"` AdminPassword string `json:"admin_password"`
ServerPort int `json:"server_port"` ServerPort int `json:"server_port"`
WebHostname string `json:"web_hostname"` WebHostname string `json:"web_hostname"`
WebPublicIP string `json:"web_public_ip"` WebPublicIP string `json:"web_public_ip"`
WebEnabled bool `json:"web_enabled"` WebEnabled bool `json:"web_enabled"`
HTTPSMode string `json:"https_mode"` HTTPSMode string `json:"https_mode"`
ACMEEnvironment string `json:"acme_environment"` ACMEEnvironment string `json:"acme_environment"`
PropagationTimeoutSecs int `json:"propagation_timeout_secs"` PropagationTimeoutSecs int `json:"propagation_timeout_secs"`
PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"` PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"`
Notification *NotificationInput `json:"notification,omitempty"`
} }
type CompleteResult struct { type CompleteResult struct {
@ -245,7 +246,17 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques
candidate := completer.base candidate := completer.base
candidate.Normalize() candidate.Normalize()
candidate.ACME.Environment = request.ACMEEnvironment candidate.ACME.Environment = request.ACMEEnvironment
if err := setup.Apply(&candidate, setup.Input{ notification, discardNotification, err := completer.prepareNotification(ctx, candidate.Notification, request.Notification)
defer func() {
if !committed && discardNotification != nil {
completer.cleanupCredential(discardNotification, "失败初始化的通知临时凭据回收未确认")
}
}()
if err != nil {
return result, err
}
candidate.Notification = notification
input := setup.Input{
AdminUsername: request.AdminUsername, AdminEmail: request.AdminEmail, AdminPassword: request.AdminPassword, AdminUsername: request.AdminUsername, AdminEmail: request.AdminEmail, AdminPassword: request.AdminPassword,
ServerPort: request.ServerPort, DNSProvider: dnsSession.ProviderID, ServerPort: request.ServerPort, DNSProvider: dnsSession.ProviderID,
DNSCredentialRef: dnsSession.CredentialRef, DNSVerified: true, DNSCredentialRef: dnsSession.CredentialRef, DNSVerified: true,
@ -254,7 +265,9 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques
CertificateRoot: candidate.Paths.Certificate, CertificateRoot: candidate.Paths.Certificate,
WebHostname: request.WebHostname, WebPublicIP: request.WebPublicIP, WebHostname: request.WebHostname, WebPublicIP: request.WebPublicIP,
WebEnabled: request.WebEnabled, HTTPSMode: request.HTTPSMode, WebEnabled: request.WebEnabled, HTTPSMode: request.HTTPSMode,
}); err != nil { }
applyNotificationInput(&input, notification)
if err := setup.Apply(&candidate, input); err != nil {
return result, completeError(CompleteErrorInput, "初始化参数校验失败", err) return result, completeError(CompleteErrorInput, "初始化参数校验失败", err)
} }
candidate.Database = databaseSession.Config candidate.Database = databaseSession.Config
@ -489,11 +502,8 @@ func managementURL(candidate config.Config) string {
if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil && ip.To4() == nil { if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil && ip.To4() == nil {
host = "[" + ip.String() + "]" host = "[" + ip.String() + "]"
} }
if candidate.Server.Port != 443 { if candidate.Server.HTTPSMode != "proxy" && candidate.Server.Port != 443 {
host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(candidate.Server.Port)) host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(candidate.Server.Port))
if strings.HasPrefix(host, "[") && candidate.Server.Port == 443 {
host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
}
} }
return (&url.URL{Scheme: "https", Host: host, Path: "/"}).String() return (&url.URL{Scheme: "https", Host: host, Path: "/"}).String()
} }

View File

@ -0,0 +1,202 @@
package bootstrap
import (
"context"
"errors"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
// NotificationInput 不接受客户端指定凭据引用;新密码只写入加密存储。
// 省略整个通知对象时保留原配置,提交对象时按完整表单应用配置。
type NotificationInput struct {
HTTPEnabled bool `json:"http_enabled"`
SMTPEnabled bool `json:"smtp_enabled"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HTTPPath string `json:"http_path"`
HTTPLoginPath string `json:"http_login_path"`
HTTPAuthMode string `json:"http_auth_mode"`
HTTPCredentials map[string]string `json:"http_credentials"`
HTTPHeaders map[string]string `json:"http_headers"`
EventMode string `json:"event_mode"`
IncludeDeploymentDetails bool `json:"include_deployment_details"`
IncludeCertificateSerial bool `json:"include_certificate_serial"`
IncludeManagementURL bool `json:"include_management_url"`
SenderName string `json:"sender_name"`
SenderEmail string `json:"sender_email"`
Recipients []string `json:"recipients"`
TimeoutSecs int `json:"timeout_secs"`
RetryCount int `json:"retry_count"`
RetryIntervalsSecs []int `json:"retry_intervals_secs"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPUsername string `json:"smtp_username"`
SMTPPassword string `json:"smtp_password"`
SMTPMode string `json:"smtp_mode"`
SMTPAllowPlaintextAuth bool `json:"smtp_allow_plaintext_auth"`
}
func (completer *Completer) prepareNotification(ctx context.Context, current config.NotificationConfig, input *NotificationInput) (config.NotificationConfig, func() error, error) {
if input == nil {
return current, nil, nil
}
candidate := config.NotificationConfig{
HTTPEnabled: input.HTTPEnabled, SMTPEnabled: input.SMTPEnabled,
Provider: strings.TrimSpace(input.Provider), Endpoint: strings.TrimSpace(input.Endpoint),
HTTPPath: strings.TrimSpace(input.HTTPPath), HTTPLoginPath: strings.TrimSpace(input.HTTPLoginPath),
HTTPAuthMode: strings.ToLower(strings.TrimSpace(input.HTTPAuthMode)), HTTPHeaders: input.HTTPHeaders,
EventMode: strings.TrimSpace(input.EventMode), IncludeDeploymentDetails: input.IncludeDeploymentDetails,
IncludeCertificateSerial: input.IncludeCertificateSerial, IncludeManagementURL: input.IncludeManagementURL,
SenderName: strings.TrimSpace(input.SenderName), SenderEmail: strings.TrimSpace(input.SenderEmail),
Recipients: input.Recipients, TimeoutSecs: input.TimeoutSecs,
RetryCount: input.RetryCount, RetryIntervalsSecs: input.RetryIntervalsSecs,
SMTPHost: strings.TrimSpace(input.SMTPHost), SMTPPort: input.SMTPPort,
SMTPUsername: strings.TrimSpace(input.SMTPUsername), SMTPMode: strings.ToLower(strings.TrimSpace(input.SMTPMode)),
SMTPAllowPlaintextAuth: input.SMTPAllowPlaintextAuth,
}
if candidate.HTTPAuthMode == "" || !candidate.HTTPEnabled {
candidate.HTTPAuthMode = "none"
}
if candidate.HTTPPath == "" {
candidate.HTTPPath = "/api/email/send"
}
if candidate.HTTPLoginPath == "" {
candidate.HTTPLoginPath = "/api/login"
}
if candidate.EventMode == "" {
candidate.EventMode = "detailed"
}
if candidate.SenderName == "" {
candidate.SenderName = "Scert"
}
if candidate.TimeoutSecs == 0 {
candidate.TimeoutSecs = 10
}
if candidate.SMTPPort == 0 {
candidate.SMTPPort = 587
}
if candidate.SMTPMode == "" {
candidate.SMTPMode = "starttls"
}
if candidate.RetryCount < 0 || len(candidate.RetryIntervalsSecs) != candidate.RetryCount || candidate.TimeoutSecs < 1 {
return candidate, nil, completeError(CompleteErrorInput, "通知超时或重试参数无效", nil)
}
if candidate.RetryCount == 0 {
candidate.RetryIntervalsSecs = []int{}
}
if candidate.HTTPEnabled && candidate.Endpoint == "" {
return candidate, nil, completeError(CompleteErrorInput, "启用 HTTP 通知时必须配置服务地址", nil)
}
if candidate.SMTPEnabled && (candidate.SMTPHost == "" || candidate.SMTPUsername == "" || input.SMTPPassword == "" ||
(candidate.SMTPMode == "plain" && !candidate.SMTPAllowPlaintextAuth)) {
return candidate, nil, completeError(CompleteErrorInput, "SMTP 通知主机、账号、密码或连接安全参数无效", nil)
}
credentials := make(map[string]map[string]string)
if candidate.HTTPEnabled && candidate.HTTPAuthMode != "none" {
values, err := notificationCredentials(candidate.HTTPAuthMode, input.HTTPCredentials)
if err != nil {
return candidate, nil, completeError(CompleteErrorInput, "HTTP 通知认证凭据无效", err)
}
credentials["http"] = values
}
if candidate.SMTPEnabled {
credentials["smtp"] = map[string]string{"username": candidate.SMTPUsername, "password": input.SMTPPassword}
}
if len(credentials) == 0 {
return candidate, nil, nil
}
store, ok := completer.credentials.(DatabaseSecretStore)
if !ok {
return candidate, nil, completeError(CompleteErrorInternal, "通知加密凭据存储尚未就绪", nil)
}
references := make([]string, 0, len(credentials))
cleanup := sync.OnceValue(func() error {
var failures []error
for _, reference := range references {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
failures = append(failures, store.Delete(cleanupCtx, reference))
cancel()
}
return errors.Join(failures...)
})
for _, kind := range []string{"http", "smtp"} {
values, exists := credentials[kind]
if !exists {
continue
}
token, err := randomToken(24)
if err != nil {
return candidate, cleanup, completeError(CompleteErrorInternal, "生成通知凭据引用失败", err)
}
reference := "secret://notification/" + token
references = append(references, reference)
if err := store.Put(ctx, reference, values); err != nil {
return candidate, cleanup, completeError(CompleteErrorDependency, "加密保存通知凭据失败", err)
}
if kind == "http" {
candidate.HTTPCredentialRef = reference
} else {
candidate.SMTPCredentialRef = reference
}
}
return candidate, cleanup, nil
}
func notificationCredentials(mode string, input map[string]string) (map[string]string, error) {
first := func(keys ...string) string {
for _, key := range keys {
if value := input[key]; strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
switch mode {
case "bearer":
if value := first("token", "access_token"); value != "" {
return map[string]string{"token": value}, nil
}
case "hmac_sha256":
if value := first("secret", "hmac_secret", "key", "token"); value != "" {
return map[string]string{"secret": value}, nil
}
case "session":
if account := first("account", "username", "identifier"); account != "" && input["password"] != "" {
return map[string]string{"account": account, "password": input["password"]}, nil
}
}
return nil, errors.New("通知认证方式或必填凭据无效")
}
func applyNotificationInput(input *setup.Input, notification config.NotificationConfig) {
input.EmailHTTPEnabled = notification.HTTPEnabled
input.EmailSMTPEnabled = notification.SMTPEnabled
input.EmailSMTPHost = notification.SMTPHost
input.EmailSMTPPort = notification.SMTPPort
input.EmailSMTPUsername = notification.SMTPUsername
input.EmailSMTPCredentialRef = notification.SMTPCredentialRef
input.EmailSMTPMode = notification.SMTPMode
input.EmailSMTPAllowPlaintextAuth = notification.SMTPAllowPlaintextAuth
input.EmailProvider = notification.Provider
input.EmailEndpoint = notification.Endpoint
input.EmailHTTPPath = notification.HTTPPath
input.EmailHTTPLoginPath = notification.HTTPLoginPath
input.EmailHTTPAuthMode = notification.HTTPAuthMode
input.EmailHTTPCredentialRef = notification.HTTPCredentialRef
input.EmailHTTPHeaders = notification.HTTPHeaders
input.EmailEventMode = notification.EventMode
input.EmailIncludeDeploymentDetails = notification.IncludeDeploymentDetails
input.EmailIncludeCertificateSerial = notification.IncludeCertificateSerial
input.EmailIncludeManagementURL = notification.IncludeManagementURL
input.EmailSenderName = notification.SenderName
input.EmailSenderEmail = notification.SenderEmail
input.EmailRecipients = notification.Recipients
input.EmailTimeoutSecs = notification.TimeoutSecs
input.EmailRetryCount = notification.RetryCount
input.EmailRetryIntervalsSecs = notification.RetryIntervalsSecs
}

View File

@ -0,0 +1,197 @@
package bootstrap
import (
"context"
"errors"
"os"
"reflect"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
func completionNotificationInput() *NotificationInput {
return &NotificationInput{
HTTPEnabled: true, Endpoint: "https://mail.example.test", HTTPAuthMode: "session",
HTTPCredentials: map[string]string{"account": "mail-owner", "password": "private-http-password"},
SMTPEnabled: true, SMTPHost: "smtp.example.test", SMTPUsername: "smtp-owner", SMTPPassword: "private-smtp-password",
IncludeManagementURL: true, IncludeCertificateSerial: true,
Recipients: []string{"notify@example.test"}, TimeoutSecs: 15, RetryCount: 0, RetryIntervalsSecs: []int{},
}
}
func TestCompleterPreservesExistingNotificationWhenOmitted(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
fixture.base.Notification.HTTPEnabled = true
fixture.base.Notification.Endpoint = "https://mail.example.test"
fixture.base.Notification.HTTPAuthMode = "bearer"
fixture.base.Notification.HTTPCredentialRef = "secret://notification/existing"
fixture.base.Notification.SenderEmail = "sender@example.test"
fixture.base.Notification.Recipients = []string{"notify@example.test"}
fixture.base.Notification.HTTPHeaders = map[string]string{"X-Client": "Scert"}
fixture.base.Notification.IncludeDeploymentDetails = true
if _, err := fixture.completer(nil).Complete(t.Context(), fixture.request("admin.example.test")); err != nil {
t.Fatal(err)
}
stored, err := config.Load(fixture.configPath)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(stored.Notification, fixture.base.Notification) {
t.Fatalf("省略通知表单时原有通知设置发生丢失:%+v", stored.Notification)
}
}
func TestCompleterStoresNotificationCredentialsWithoutPlaintext(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
request := fixture.request("admin.example.test")
request.Notification = completionNotificationInput()
if _, err := fixture.completer(nil).Complete(t.Context(), request); err != nil {
t.Fatal(err)
}
stored, err := config.Load(fixture.configPath)
if err != nil {
t.Fatal(err)
}
if !stored.Notification.HTTPEnabled || !stored.Notification.SMTPEnabled || stored.Notification.RetryCount != 0 ||
len(stored.Notification.RetryIntervalsSecs) != 0 || !stored.Notification.IncludeCertificateSerial {
t.Fatalf("通知选项未完整保存:%+v", stored.Notification)
}
httpCredentials, err := fixture.store.Get(t.Context(), stored.Notification.HTTPCredentialRef)
if err != nil || httpCredentials["password"] != "private-http-password" {
t.Fatalf("HTTP 通知凭据未进入敏感存储:%v", err)
}
smtpCredentials, err := fixture.store.Get(t.Context(), stored.Notification.SMTPCredentialRef)
if err != nil || smtpCredentials["password"] != "private-smtp-password" {
t.Fatalf("SMTP 通知凭据未进入敏感存储:%v", err)
}
data, err := os.ReadFile(fixture.configPath)
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{"private-http-password", "private-smtp-password"} {
if strings.Contains(string(data), secret) {
t.Fatal("正式配置包含通知明文密码")
}
}
}
func TestCompleterReclaimsOnlyNewNotificationCredentialsOnFailure(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
existing := "secret://notification/existing"
if err := fixture.store.Put(t.Context(), existing, map[string]string{"token": "existing-token"}); err != nil {
t.Fatal(err)
}
request := fixture.request("admin.example.test")
request.Notification = completionNotificationInput()
_, err := fixture.completer(func(dependencies *CompleteDependencies) {
dependencies.SaveConfig = func(config.Config, string) error { return errors.New("save failed") }
}).Complete(t.Context(), request)
if err == nil {
t.Fatal("保存失败未返回错误")
}
fixture.store.mu.Lock()
defer fixture.store.mu.Unlock()
if len(fixture.store.values) != 1 || fixture.store.values[existing]["token"] != "existing-token" {
t.Fatalf("回滚必须保留已有凭据并回收新凭据:剩余=%d", len(fixture.store.values))
}
}
type failingNotificationStore struct {
*completionMemorySecrets
puts int
}
func (store *failingNotificationStore) Put(ctx context.Context, reference string, values map[string]string) error {
store.puts++
if err := store.completionMemorySecrets.Put(ctx, reference, values); err != nil {
return err
}
if store.puts == 2 {
return errors.New("partial write failure")
}
return nil
}
func TestNotificationPartialWriteFailureReclaimsAllCreatedReferences(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
store := &failingNotificationStore{completionMemorySecrets: fixture.store}
request := fixture.request("admin.example.test")
request.Notification = completionNotificationInput()
_, err := fixture.completer(func(dependencies *CompleteDependencies) {
dependencies.Credentials = store
}).Complete(t.Context(), request)
if err == nil || store.puts != 2 || fixture.issuer.calls != 0 {
t.Fatalf("部分写入失败未中止初始化:%v", err)
}
fixture.store.mu.Lock()
defer fixture.store.mu.Unlock()
if len(fixture.store.values) != 0 {
t.Fatal("部分写入失败遗留通知或 DNS 临时凭据")
}
}
func TestNotificationAuthenticationModes(t *testing.T) {
for _, item := range []struct {
mode string
input map[string]string
key, value string
}{
{"bearer", map[string]string{"access_token": "test-token"}, "token", "test-token"},
{"hmac_sha256", map[string]string{"hmac_secret": "test-key"}, "secret", "test-key"},
{"session", map[string]string{"username": "owner", "password": "test-password"}, "account", "owner"},
} {
t.Run(item.mode, func(t *testing.T) {
values, err := notificationCredentials(item.mode, item.input)
if err != nil || values[item.key] != item.value {
t.Fatalf("通知凭据兼容字段转换不正确:%v", err)
}
if _, err := notificationCredentials(item.mode, nil); err == nil {
t.Fatal("缺少通知认证凭据未拒绝")
}
})
}
}
func TestNotificationRejectsInvalidSettingsBeforeWritingSecrets(t *testing.T) {
for _, edit := range []func(*NotificationInput){
func(input *NotificationInput) { input.Endpoint = "" },
func(input *NotificationInput) { input.RetryCount = -1 },
func(input *NotificationInput) { input.SMTPPassword = "" },
func(input *NotificationInput) { input.SMTPMode = "plain" },
func(input *NotificationInput) { input.HTTPCredentials = nil },
} {
fixture := newCompletionFixture(t, "admin.example.test")
input := completionNotificationInput()
edit(input)
_, cleanup, err := fixture.completer(nil).prepareNotification(t.Context(), fixture.base.Notification, input)
if err == nil || cleanup != nil {
t.Fatal("无效通知配置不得写入敏感存储")
}
}
}
func TestManagementURLHandlesDirectAndProxyEntrypoints(t *testing.T) {
for _, item := range []struct {
hostname, ip, mode string
port int
want string
}{
{"admin.example.test", "", "direct", 443, "https://admin.example.test/"},
{"admin.example.test", "", "direct", 8443, "https://admin.example.test:8443/"},
{"admin.example.test", "", "proxy", 8080, "https://admin.example.test/"},
{"", "203.0.113.70", "direct", 8443, "https://203.0.113.70:8443/"},
{"", "2001:db8::70", "direct", 443, "https://[2001:db8::70]/"},
{"", "2001:db8::70", "direct", 8443, "https://[2001:db8::70]:8443/"},
{"", "2001:db8::70", "proxy", 8080, "https://[2001:db8::70]/"},
} {
candidate := config.Config{
Web: config.WebConfig{Hostname: item.hostname, PublicIP: item.ip},
Server: config.ServerConfig{Port: item.port, HTTPSMode: item.mode},
}
if actual := managementURL(candidate); actual != item.want {
t.Fatalf("管理入口不正确:实际=%s,预期=%s", actual, item.want)
}
}
}

View File

@ -62,6 +62,7 @@ func TestBootstrapCompleteRejectsInvalidBodiesBeforeSideEffects(t *testing.T) {
name, body, contentType string name, body, contentType string
}{ }{
{"unknown", `{"extra":true}`, "application/json"}, {"unknown", `{"extra":true}`, "application/json"},
{"credential_reference", `{"notification":{"http_credential_ref":"secret://notification/other"}}`, "application/json"},
{"trailing", `{} {}`, "application/json"}, {"trailing", `{} {}`, "application/json"},
{"trailing_null", `{} null`, "application/json"}, {"trailing_null", `{} null`, "application/json"},
{"null", `null`, "application/json"}, {"null", `null`, "application/json"},
@ -134,6 +135,7 @@ func TestBootstrapCompleteUnavailableAndInvalidResult(t *testing.T) {
func TestBootstrapCompleteCommitsAndClearsCookie(t *testing.T) { func TestBootstrapCompleteCommitsAndClearsCookie(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test") fixture := newCompletionFixture(t, "admin.example.test")
request := fixture.request("admin.example.test") request := fixture.request("admin.example.test")
request.Notification = completionNotificationInput()
body, err := json.Marshal(request) body, err := json.Marshal(request)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -149,7 +151,7 @@ func TestBootstrapCompleteCommitsAndClearsCookie(t *testing.T) {
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || !result.Completed || result.ManagementURL != "https://admin.example.test:8443/" { if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || !result.Completed || result.ManagementURL != "https://admin.example.test:8443/" {
t.Fatalf("管理入口响应不正确:%+v,%v", result, err) t.Fatalf("管理入口响应不正确:%+v,%v", result, err)
} }
assertResponseExcludes(t, response, request.AdminPassword, request.DatabaseVerificationToken, request.DNSVerificationToken, "secret://", "test-secret") assertResponseExcludes(t, response, request.AdminPassword, request.DatabaseVerificationToken, request.DNSVerificationToken, "secret://", "test-secret", "private-http-password", "private-smtp-password")
cookies := response.Result().Cookies() cookies := response.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != SessionCookieName || cookies[0].Value != "" || cookies[0].MaxAge != -1 || if len(cookies) != 1 || cookies[0].Name != SessionCookieName || cookies[0].Value != "" || cookies[0].MaxAge != -1 ||
!cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].Path != "/api/v1/bootstrap" || cookies[0].SameSite != http.SameSiteStrictMode { !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].Path != "/api/v1/bootstrap" || cookies[0].SameSite != http.SameSiteStrictMode {

View File

@ -382,10 +382,11 @@ func (c *Config) Normalize() {
if c.Notification.TimeoutSecs == 0 { if c.Notification.TimeoutSecs == 0 {
c.Notification.TimeoutSecs = 10 c.Notification.TimeoutSecs = 10
} }
if c.Notification.RetryCount == 0 { // 空数组表示明确关闭重试,只有旧配置未提供间隔字段时才补默认值。
if c.Notification.RetryCount == 0 && c.Notification.RetryIntervalsSecs == nil {
c.Notification.RetryCount = 3 c.Notification.RetryCount = 3
} }
if len(c.Notification.RetryIntervalsSecs) == 0 { if c.Notification.RetryIntervalsSecs == nil {
c.Notification.RetryIntervalsSecs = []int{30, 120, 600} c.Notification.RetryIntervalsSecs = []int{30, 120, 600}
} }
if c.Notification.HTTPHeaders == nil { if c.Notification.HTTPHeaders == nil {

View File

@ -347,3 +347,26 @@ func TestLoadWithEnvRejectsInvalidInteger(t *testing.T) {
t.Fatal("无效端口环境变量未被拒绝") t.Fatal("无效端口环境变量未被拒绝")
} }
} }
func TestSaveAndLoadPreservesDisabledNotificationRetries(t *testing.T) {
directory := t.TempDir()
cfg := Default(directory)
cfg.Notification.RetryCount = 0
cfg.Notification.RetryIntervalsSecs = []int{}
path := filepath.Join(directory, "scert.json")
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
loaded, err := Load(path)
if err != nil {
t.Fatal(err)
}
if loaded.Notification.RetryCount != 0 || len(loaded.Notification.RetryIntervalsSecs) != 0 {
t.Fatal("显式关闭通知重试不得在重启时恢复默认重试次数")
}
cfg.Notification.RetryIntervalsSecs = nil
cfg.Normalize()
if cfg.Notification.RetryCount != 3 || len(cfg.Notification.RetryIntervalsSecs) != 3 {
t.Fatal("缺失通知重试配置的历史配置仍需补齐默认值")
}
}

View File

@ -174,7 +174,7 @@ func Apply(cfg *config.Config, input Input) error {
cfg.Notification.RetryCount = input.EmailRetryCount cfg.Notification.RetryCount = input.EmailRetryCount
} }
if input.EmailRetryIntervalsSecs != nil { if input.EmailRetryIntervalsSecs != nil {
cfg.Notification.RetryIntervalsSecs = append([]int(nil), input.EmailRetryIntervalsSecs...) cfg.Notification.RetryIntervalsSecs = append([]int{}, input.EmailRetryIntervalsSecs...)
} }
cfg.Notification.Recipients = cleanRecipients(input.EmailRecipients) cfg.Notification.Recipients = cleanRecipients(input.EmailRecipients)
if len(cfg.Notification.Recipients) == 0 { if len(cfg.Notification.Recipients) == 0 {