保留初始化通知配置并保护新通知凭据
This commit is contained in:
parent
efb405ea11
commit
3d1afab22e
@ -60,19 +60,20 @@ func (err *CompleteError) Unwrap() error {
|
||||
}
|
||||
|
||||
type CompleteRequest struct {
|
||||
DatabaseVerificationToken string `json:"database_verification_token"`
|
||||
DNSVerificationToken string `json:"dns_verification_token"`
|
||||
AdminUsername string `json:"admin_username"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
AdminPassword string `json:"admin_password"`
|
||||
ServerPort int `json:"server_port"`
|
||||
WebHostname string `json:"web_hostname"`
|
||||
WebPublicIP string `json:"web_public_ip"`
|
||||
WebEnabled bool `json:"web_enabled"`
|
||||
HTTPSMode string `json:"https_mode"`
|
||||
ACMEEnvironment string `json:"acme_environment"`
|
||||
PropagationTimeoutSecs int `json:"propagation_timeout_secs"`
|
||||
PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"`
|
||||
DatabaseVerificationToken string `json:"database_verification_token"`
|
||||
DNSVerificationToken string `json:"dns_verification_token"`
|
||||
AdminUsername string `json:"admin_username"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
AdminPassword string `json:"admin_password"`
|
||||
ServerPort int `json:"server_port"`
|
||||
WebHostname string `json:"web_hostname"`
|
||||
WebPublicIP string `json:"web_public_ip"`
|
||||
WebEnabled bool `json:"web_enabled"`
|
||||
HTTPSMode string `json:"https_mode"`
|
||||
ACMEEnvironment string `json:"acme_environment"`
|
||||
PropagationTimeoutSecs int `json:"propagation_timeout_secs"`
|
||||
PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"`
|
||||
Notification *NotificationInput `json:"notification,omitempty"`
|
||||
}
|
||||
|
||||
type CompleteResult struct {
|
||||
@ -245,7 +246,17 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques
|
||||
candidate := completer.base
|
||||
candidate.Normalize()
|
||||
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,
|
||||
ServerPort: request.ServerPort, DNSProvider: dnsSession.ProviderID,
|
||||
DNSCredentialRef: dnsSession.CredentialRef, DNSVerified: true,
|
||||
@ -254,7 +265,9 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques
|
||||
CertificateRoot: candidate.Paths.Certificate,
|
||||
WebHostname: request.WebHostname, WebPublicIP: request.WebPublicIP,
|
||||
WebEnabled: request.WebEnabled, HTTPSMode: request.HTTPSMode,
|
||||
}); err != nil {
|
||||
}
|
||||
applyNotificationInput(&input, notification)
|
||||
if err := setup.Apply(&candidate, input); err != nil {
|
||||
return result, completeError(CompleteErrorInput, "初始化参数校验失败", err)
|
||||
}
|
||||
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 {
|
||||
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))
|
||||
if strings.HasPrefix(host, "[") && candidate.Server.Port == 443 {
|
||||
host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
|
||||
}
|
||||
}
|
||||
return (&url.URL{Scheme: "https", Host: host, Path: "/"}).String()
|
||||
}
|
||||
|
||||
202
backend/internal/bootstrap/notification.go
Normal file
202
backend/internal/bootstrap/notification.go
Normal 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
|
||||
}
|
||||
197
backend/internal/bootstrap/notification_test.go
Normal file
197
backend/internal/bootstrap/notification_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -62,6 +62,7 @@ func TestBootstrapCompleteRejectsInvalidBodiesBeforeSideEffects(t *testing.T) {
|
||||
name, body, contentType string
|
||||
}{
|
||||
{"unknown", `{"extra":true}`, "application/json"},
|
||||
{"credential_reference", `{"notification":{"http_credential_ref":"secret://notification/other"}}`, "application/json"},
|
||||
{"trailing", `{} {}`, "application/json"},
|
||||
{"trailing_null", `{} null`, "application/json"},
|
||||
{"null", `null`, "application/json"},
|
||||
@ -134,6 +135,7 @@ func TestBootstrapCompleteUnavailableAndInvalidResult(t *testing.T) {
|
||||
func TestBootstrapCompleteCommitsAndClearsCookie(t *testing.T) {
|
||||
fixture := newCompletionFixture(t, "admin.example.test")
|
||||
request := fixture.request("admin.example.test")
|
||||
request.Notification = completionNotificationInput()
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
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/" {
|
||||
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()
|
||||
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 {
|
||||
|
||||
@ -382,10 +382,11 @@ func (c *Config) Normalize() {
|
||||
if c.Notification.TimeoutSecs == 0 {
|
||||
c.Notification.TimeoutSecs = 10
|
||||
}
|
||||
if c.Notification.RetryCount == 0 {
|
||||
// 空数组表示明确关闭重试,只有旧配置未提供间隔字段时才补默认值。
|
||||
if c.Notification.RetryCount == 0 && c.Notification.RetryIntervalsSecs == nil {
|
||||
c.Notification.RetryCount = 3
|
||||
}
|
||||
if len(c.Notification.RetryIntervalsSecs) == 0 {
|
||||
if c.Notification.RetryIntervalsSecs == nil {
|
||||
c.Notification.RetryIntervalsSecs = []int{30, 120, 600}
|
||||
}
|
||||
if c.Notification.HTTPHeaders == nil {
|
||||
|
||||
@ -347,3 +347,26 @@ func TestLoadWithEnvRejectsInvalidInteger(t *testing.T) {
|
||||
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("缺失通知重试配置的历史配置仍需补齐默认值")
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,7 +174,7 @@ func Apply(cfg *config.Config, input Input) error {
|
||||
cfg.Notification.RetryCount = input.EmailRetryCount
|
||||
}
|
||||
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)
|
||||
if len(cfg.Notification.Recipients) == 0 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user