diff --git a/backend/cmd/smail-config/main.go b/backend/cmd/smail-config/main.go index ebec166..0cf1e2a 100644 --- a/backend/cmd/smail-config/main.go +++ b/backend/cmd/smail-config/main.go @@ -24,6 +24,7 @@ func run(args []string) error { apply := flags.Bool("apply", false, "确认已停止后端,备份并执行迁移;默认只检查") runtimeData := flags.Bool("runtime-data", false, "将运行数据迁到 data,保留当前五个配置文件") historySource := flags.String("deployment-history", "", "停服复制完成后,从原部署导入配置历史并转换证书引用") + exportTLS := flags.String("export-project-tls", "", "将项目托管证书导出为本机 TLS 入口使用的合并 PEM 文件") if err := flags.Parse(args); err != nil { return err } @@ -34,6 +35,16 @@ func run(args []string) error { if err != nil { return fmt.Errorf("服务根目录无效") } + if *exportTLS != "" { + if *apply || *runtimeData || *historySource != "" || !filepath.IsAbs(*exportTLS) { + return fmt.Errorf("证书导出必须单独执行,目标必须是绝对路径") + } + if err := config.ExportProjectTLS(absolute, filepath.Clean(*exportTLS)); err != nil { + return fmt.Errorf("项目证书导出失败,原有入口证书保留:%w", err) + } + fmt.Println("项目证书同步检查完成。") + return nil + } options := config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true} if *historySource != "" { if !*apply || *runtimeData { diff --git a/backend/config/audit_changes.go b/backend/config/audit_changes.go index 3fad124..55bdb64 100644 --- a/backend/config/audit_changes.go +++ b/backend/config/audit_changes.go @@ -97,7 +97,7 @@ func flattenAudit(result map[string]any, path string, value any) { func auditSensitive(path string) bool { name := strings.ToLower(path[strings.LastIndex(path, ".")+1:]) - for _, word := range []string{"password", "secret", "token", "apikey", "api_key", "credential", "ciphertext", "authorization", "cookie"} { + for _, word := range []string{"password", "secret", "token", "apikey", "api_key", "accesskey", "credential", "ciphertext", "authorization", "cookie"} { if strings.Contains(name, word) { return true } diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go index 28ee8bd..4498f16 100644 --- a/backend/config/manager_v2.go +++ b/backend/config/manager_v2.go @@ -931,11 +931,20 @@ func applySectionPayload(current RuntimeConfig, section Section, payload json.Ra } candidate.Email.Policy.LoginVerificationPolicy = candidate.SMTP.Relay.LoginPolicy case SectionSSL: - var value SSLSectionConfig + var value struct { + SSLSectionConfig + DNSAccessKeyStatus json.RawMessage `json:"dnsAccessKeyStatus"` + DNSSecretKeyStatus json.RawMessage `json:"dnsSecretKeyStatus"` + } if err := ParseStrict(payload, &value); err != nil { return RuntimeConfig{}, err } - candidate.SSL = value + if storedSecretHasMaterial(value.TLS.DNSAccessKey) || storedSecretHasMaterial(value.TLS.DNSSecretKey) { + return RuntimeConfig{}, ErrInvalidSecretChange + } + value.TLS.DNSAccessKey = current.SSL.TLS.DNSAccessKey + value.TLS.DNSSecretKey = current.SSL.TLS.DNSSecretKey + candidate.SSL = value.SSLSectionConfig case SectionPlugins: var value PluginsSectionConfig if err := ParseStrict(payload, &value); err != nil { @@ -1018,6 +1027,17 @@ func applySecretChanges(candidate *RuntimeConfig, section Section, changes map[s return err } candidate.SMTP.Relay.Password = stored + case section == SectionSSL && (name == "tls.dnsAccessKey" || name == "tls.dnsSecretKey"): + target := &candidate.SSL.TLS.DNSAccessKey + if name == "tls.dnsSecretKey" { + target = &candidate.SSL.TLS.DNSSecretKey + } + required := candidate.SSL.TLS.DNSProvider != "" + stored, err := ApplySecretChange(vault, *target, change, SecretPolicy{Required: required, AllowClear: !required, MaxLength: 4096}, now) + if err != nil { + return err + } + *target = stored default: return ErrInvalidSecretChange } diff --git a/backend/config/model.go b/backend/config/model.go index 3249e3b..d200bfd 100644 --- a/backend/config/model.go +++ b/backend/config/model.go @@ -113,6 +113,10 @@ type SSLConfig struct { PrivateKeyRef string `json:"privateKeyRef,omitempty"` PreviousCertificateRef string `json:"previousCertificateRef,omitempty"` PreviousPrivateKeyRef string `json:"previousPrivateKeyRef,omitempty"` + DNSProvider string `json:"dnsProvider,omitempty"` + DNSZone string `json:"dnsZone,omitempty"` + DNSAccessKey StoredSecret `json:"dnsAccessKey,omitempty"` + DNSSecretKey StoredSecret `json:"dnsSecretKey,omitempty"` } type PluginConfig struct { diff --git a/backend/config/runtime_validator.go b/backend/config/runtime_validator.go index 1251b80..485c082 100644 --- a/backend/config/runtime_validator.go +++ b/backend/config/runtime_validator.go @@ -55,6 +55,12 @@ func ValidateRuntimeConfig(runtime RuntimeConfig, options RuntimeValidationOptio if err := validateDomains(runtime.Root.Domains); err != nil { return err } + if tls := runtime.SSL.TLS; tls.DNSProvider != "" && tls.DNSZone != "" { + primary := runtime.Root.Domains.Primary + if primary != tls.DNSZone && !strings.HasSuffix(primary, "."+tls.DNSZone) { + return ErrInvalidSSL + } + } if err := validateMailListeners(runtime.SMTP, runtime.Email); err != nil { return err } diff --git a/backend/config/secret.go b/backend/config/secret.go index dec6beb..bfe85a5 100644 --- a/backend/config/secret.go +++ b/backend/config/secret.go @@ -145,7 +145,7 @@ func OpenStoredSecret(vault *credential.Vault, stored StoredSecret) (string, err // ValidateRuntimeSecrets 验证运行时配置中的持久化 Secret 可以由当前密钥解密。 func ValidateRuntimeSecrets(vault *credential.Vault, runtime RuntimeConfig) error { - for _, stored := range []StoredSecret{runtime.Root.Database.Password, runtime.SMTP.Relay.Password} { + for _, stored := range []StoredSecret{runtime.Root.Database.Password, runtime.SMTP.Relay.Password, runtime.SSL.TLS.DNSAccessKey, runtime.SSL.TLS.DNSSecretKey} { if !stored.Configured() { continue } diff --git a/backend/config/sensitive.go b/backend/config/sensitive.go index 0c4f9cf..f89a338 100644 --- a/backend/config/sensitive.go +++ b/backend/config/sensitive.go @@ -31,6 +31,10 @@ var sensitiveFields = []SensitiveField{ {Path: "plugins.*.secret", Policy: PersistenceEncryptedOnly, Description: "插件 Secret"}, {Path: "plugins.*.api_key", Policy: PersistenceEncryptedOnly, Description: "插件 API 密钥"}, {Path: "ssl.private_key", Policy: PersistenceReferenceOnly, Description: "SSL 私钥材料"}, + {Path: "ssl.tls.dnsAccessKey", Policy: PersistenceEncryptedOnly, Description: "DNS 服务商访问凭据"}, + {Path: "ssl.tls.dnsSecretKey", Policy: PersistenceEncryptedOnly, Description: "DNS 服务商访问密钥"}, + {Path: "tls.dnsAccessKey", Policy: PersistenceEncryptedOnly, Description: "DNS 服务商访问凭据"}, + {Path: "tls.dnsSecretKey", Policy: PersistenceEncryptedOnly, Description: "DNS 服务商访问密钥"}, {Path: "security.encryption_master_key", Policy: PersistenceReferenceOnly, Description: "配置加密主密钥"}, {Path: "security.question_answer", Policy: PersistenceEncryptedOnly, Description: "密保答案原文"}, {Path: "security.offline_recovery_code", Policy: PersistenceMemoryOnly, Description: "离线恢复码原文"}, diff --git a/backend/config/ssl_dns.go b/backend/config/ssl_dns.go new file mode 100644 index 0000000..1b40236 --- /dev/null +++ b/backend/config/ssl_dns.go @@ -0,0 +1,43 @@ +package config + +import "git.pcux.cn/xuans/Smail/utils/credential" + +const DNSProviderAliDNS = "alidns" + +func validateDNSProvider(ssl SSLConfig) error { + if ssl.DNSProvider != "" && ssl.DNSProvider != DNSProviderAliDNS { + return ErrInvalidSSL + } + if ssl.DNSZone != "" { + domain, err := normalizeConfigDomain(ssl.DNSZone) + if err != nil || domain != ssl.DNSZone { + return ErrInvalidSSL + } + } + for _, stored := range []StoredSecret{ssl.DNSAccessKey, ssl.DNSSecretKey} { + if err := ValidateStoredSecret(stored, ssl.DNSProvider != ""); err != nil { + return ErrInvalidSSL + } + } + return nil +} + +// DNSProviderCredentials 只在签发进程内解密,调用方不得记录返回值。 +func DNSProviderCredentials(ssl SSLConfig) (string, string, error) { + if ssl.DNSProvider != DNSProviderAliDNS || validateDNSProvider(ssl) != nil { + return "", "", ErrSecretUnavailable + } + vault, err := credential.NewVault(ROOT_PATH) + if err != nil { + return "", "", ErrSecretUnavailable + } + accessKey, err := OpenStoredSecret(vault, ssl.DNSAccessKey) + if err != nil { + return "", "", ErrSecretUnavailable + } + secretKey, err := OpenStoredSecret(vault, ssl.DNSSecretKey) + if err != nil { + return "", "", ErrSecretUnavailable + } + return accessKey, secretKey, nil +} diff --git a/backend/config/ssl_dns_test.go b/backend/config/ssl_dns_test.go new file mode 100644 index 0000000..f6bf87d --- /dev/null +++ b/backend/config/ssl_dns_test.go @@ -0,0 +1,51 @@ +package config + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "git.pcux.cn/xuans/Smail/utils/credential" +) + +func TestDNSCredentialsEncryptedPreservedAndRedacted(t *testing.T) { + root := t.TempDir() + previousRoot := ROOT_PATH + ROOT_PATH = root + t.Cleanup(func() { ROOT_PATH = previousRoot }) + vault, err := credential.NewVault(root) + if err != nil { t.Fatal(err) } + before := validRuntimeConfig() + candidate := cloneRuntimeConfig(before) + candidate.SSL.TLS.DNSProvider = DNSProviderAliDNS + changes := map[string]SecretChange{ + "tls.dnsAccessKey": {Operation: SecretReplace, Value: &SecretInput{Plaintext: "dns-access-fixture"}}, + "tls.dnsSecretKey": {Operation: SecretReplace, Value: &SecretInput{Plaintext: "dns-secret-fixture"}}, + } + if err := applySecretChanges(&candidate, SectionSSL, changes, vault); err != nil { t.Fatal(err) } + if err := validateDNSProvider(candidate.SSL.TLS); err != nil { t.Fatal(err) } + if err := ValidateRuntimeSecrets(vault, candidate); err != nil { t.Fatal(err) } + access, secret, err := DNSProviderCredentials(candidate.SSL.TLS) + if err != nil || access != "dns-access-fixture" || secret != "dns-secret-fixture" { t.Fatal("DNS credentials did not round trip through vault") } + data, _ := json.Marshal(candidate) + for _, value := range []string{access, secret} { if strings.Contains(string(data), value) { t.Fatal("plaintext persisted") } } + audit, _ := json.Marshal(AuditChanges(before, candidate)) + public, _ := json.Marshal(RedactPublicValue(candidate.SSL)) + for _, data := range [][]byte{audit, public} { + for _, value := range []string{access, secret, candidate.SSL.TLS.DNSAccessKey.Ciphertext, candidate.SSL.TLS.DNSSecretKey.Ciphertext} { + if strings.Contains(string(data), value) { t.Fatal("DNS secret leaked into audit or public view") } + } + } + section := candidate.SSL + section.TLS.DNSAccessKey, section.TLS.DNSSecretKey = StoredSecret{}, StoredSecret{} + payload, _ := json.Marshal(section) + kept, err := applySectionPayload(candidate, SectionSSL, payload, nil, vault) + if err != nil || kept.SSL.TLS.DNSAccessKey != candidate.SSL.TLS.DNSAccessKey || kept.SSL.TLS.DNSSecretKey != candidate.SSL.TLS.DNSSecretKey { t.Fatal("public edit lost stored DNS credentials") } + payload, _ = json.Marshal(candidate.SSL) + if _, err := applySectionPayload(candidate, SectionSSL, payload, nil, vault); !errors.Is(err, ErrInvalidSecretChange) { t.Fatal("direct stored-secret injection accepted") } + clear := map[string]SecretChange{"tls.dnsSecretKey": {Operation: SecretClear, Confirmed: true}} + if err := applySecretChanges(&candidate, SectionSSL, clear, vault); !errors.Is(err, ErrSecretClearDenied) { t.Fatal("enabled DNS provider allowed clearing its secret") } + candidate.SSL.TLS.DNSProvider = "" + if err := applySecretChanges(&candidate, SectionSSL, clear, vault); err != nil { t.Fatal(err) } +} diff --git a/backend/config/ssl_export.go b/backend/config/ssl_export.go new file mode 100644 index 0000000..20b0a01 --- /dev/null +++ b/backend/config/ssl_export.go @@ -0,0 +1,68 @@ +package config + +import ( + "bytes" + "io" + "os" + "path/filepath" + + "git.pcux.cn/xuans/Smail/utils/atomicfile" +) + +// ExportProjectTLS 为本机 TLS 入口导出一个原子更新的证书私钥合并文件。 +// 仅接受项目生成的平铺证书;不允许高权限调用者按配置读取任意服务器路径。 +func ExportProjectTLS(root, destination string) error { + runtime, err := NewConfigLoader(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}).Load() + if err != nil { + return ErrInvalidSSL + } + if EffectiveSSLManagement(runtime.SSL.TLS) != SSLManagementProject || runtime.Root.HTTP.TLSMode == HTTPSTLSModeProxy { + return nil + } + publicPath := filepath.Join(root, filepath.FromSlash(runtime.SSL.TLS.CertificateRef)) + privatePath := filepath.Join(root, filepath.FromSlash(runtime.SSL.TLS.PrivateKeyRef)) + if !flatSSLPair(root, publicPath, privatePath) { + return ErrInvalidSSLReference + } + sourceDirectory := filepath.Join(root, "ssl") + resolvedSource, err := filepath.EvalSymlinks(sourceDirectory) + if err != nil || resolvedSource != sourceDirectory { return ErrUnsafeSSLFile } + storage, err := os.OpenRoot(sourceDirectory) + if err != nil { return ErrUnsafeSSLFile } + defer storage.Close() + read := func(name string) ([]byte, error) { + info, err := storage.Lstat(name) + if err != nil || !info.Mode().IsRegular() || info.Size() > maxManagedSSLFileSize || info.Mode().Perm() & 0077 != 0 { return nil, ErrUnsafeSSLFile } + file, err := storage.Open(name) + if err != nil { return nil, ErrUnsafeSSLFile } + defer file.Close() + opened, err := file.Stat() + if err != nil || !os.SameFile(info, opened) { return nil, ErrUnsafeSSLFile } + data, err := io.ReadAll(io.LimitReader(file, maxManagedSSLFileSize+1)) + if err != nil || len(data) > maxManagedSSLFileSize { return nil, ErrUnsafeSSLFile } + return data, nil + } + certificate, err := read(filepath.Base(publicPath)) + if err != nil { return err } + privateKey, err := read(filepath.Base(privatePath)) + if err != nil { return err } + if err := validateSSLMaterial(privateKey, certificate, nil, validationCertificateDomains(&runtime)); err != nil { return err } + combined := append(append(append([]byte{}, certificate...), '\n'), privateKey...) + // 输出目录由部署管理员创建,拒绝符号链接和其他用户可写目录。 + directory := filepath.Dir(destination) + resolved, err := filepath.EvalSymlinks(directory) + if err != nil || resolved != directory { return ErrUnsafeSSLFile } + info, err := os.Stat(directory) + if err != nil || !info.IsDir() || info.Mode().Perm() & 0022 != 0 { return ErrUnsafeSSLFile } + output, err := os.OpenRoot(directory) + if err != nil { return ErrUnsafeSSLFile } + defer output.Close() + name := filepath.Base(destination) + if info, err := output.Lstat(name); err == nil { + if !info.Mode().IsRegular() || info.Mode().Perm() & 0077 != 0 { return ErrUnsafeSSLFile } + previous, err := output.ReadFile(name) + if err != nil { return ErrUnsafeSSLFile } + if bytes.Equal(previous, combined) { return nil } + } else if !os.IsNotExist(err) { return ErrUnsafeSSLFile } + return atomicfile.WriteRoot(output, name, combined, 0600) +} diff --git a/backend/config/ssl_export_test.go b/backend/config/ssl_export_test.go new file mode 100644 index 0000000..729f737 --- /dev/null +++ b/backend/config/ssl_export_test.go @@ -0,0 +1,45 @@ +package config + +import ( + "bytes" + "crypto/tls" + "os" + "path/filepath" + "testing" + "time" +) + +func TestExportProjectTLSKeepsOldBundleOnInvalidInput(t *testing.T) { + root := t.TempDir() + outputDirectory := t.TempDir() + if err := os.Chmod(outputDirectory, 0700); err != nil { t.Fatal(err) } + destination := filepath.Join(outputDirectory, "tls.pem") + runtime := validRuntimeConfig() + runtime.Root.Domains = DomainConfig{Primary: "example.com", Web: "web.example.com", Accepted: []string{"example.com"}} + key, certificate := testManagedCertificate(t, "example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048, "*.example.com") + ref, err := stageFlatSSLAtRoot(root, ManagedSSLModeDefault, key, certificate, nil, nil) + if err != nil { t.Fatal(err) } + defer releaseManagedSSL(root, SSLConfig{CertificateRef: ref.CertificateRef}) + runtime.SSL.TLS = SSLConfig{Management: SSLManagementProject, Challenge: SSLChallengeDNS, Mode: ManagedSSLModeDefault, DefaultDirectory: "ssl", CertificateRef: ref.CertificateRef, PrivateKeyRef: ref.PrivateKeyRef} + writeRuntimeFixture(t, root, runtime) + if err := ExportProjectTLS(root, destination); err != nil { t.Fatal(err) } + bundle, err := os.ReadFile(destination) + if err != nil { t.Fatal(err) } + if _, err := tls.X509KeyPair(bundle, bundle); err != nil { t.Fatal(err) } + if err := ExportProjectTLS(root, destination); err != nil { t.Fatal(err) } + if err := os.WriteFile(ref.PrivateKeyPath, []byte("invalid"), 0600); err != nil { t.Fatal(err) } + if err := ExportProjectTLS(root, destination); err == nil { t.Fatal("invalid key was exported") } + preserved, _ := os.ReadFile(destination) + if !bytes.Equal(bundle, preserved) { t.Fatal("failed export replaced valid bundle") } + runtime.SSL.TLS.CertificateRef = "ssl/unmanaged.crt" + runtime.SSL.TLS.PrivateKeyRef = "ssl/unmanaged.key" + writeRuntimeFixture(t, root, runtime) + if err := ExportProjectTLS(root, destination); err == nil { t.Fatal("unmanaged input accepted") } + for _, mode := range []string{SSLManagementCustom, SSLManagementWebServer} { + runtime.SSL.TLS.Management = mode + writeRuntimeFixture(t, root, runtime) + if err := ExportProjectTLS(root, destination); err != nil { t.Fatal(err) } + preserved, _ := os.ReadFile(destination) + if !bytes.Equal(bundle, preserved) { t.Fatal("external mode changed exported bundle") } + } +} diff --git a/backend/config/validator.go b/backend/config/validator.go index add0d7b..b4ae863 100644 --- a/backend/config/validator.go +++ b/backend/config/validator.go @@ -173,6 +173,9 @@ func smtpRelayConfigured(smtp SMTPConfig) bool { } func validateSSL(ssl SSLConfig) error { + if err := validateDNSProvider(ssl); err != nil { + return err + } if (ssl.PreviousCertificateRef == "") != (ssl.PreviousPrivateKeyRef == "") { return ErrInvalidSSL } diff --git a/backend/controllers/config.go b/backend/controllers/config.go index 48c9f65..dcfa712 100644 --- a/backend/controllers/config.go +++ b/backend/controllers/config.go @@ -498,7 +498,13 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) ( case config.SectionSSL: value := runtime.SSL value.TLS.Management = config.EffectiveSSLManagement(value.TLS) - return value, nil + accessStatus, secretStatus := value.TLS.DNSAccessKey.Status(), value.TLS.DNSSecretKey.Status() + value.TLS.DNSAccessKey, value.TLS.DNSSecretKey = config.StoredSecret{}, config.StoredSecret{} + return struct { + config.SSLSectionConfig + DNSAccessKeyStatus config.SecretStatus `json:"dnsAccessKeyStatus"` + DNSSecretKeyStatus config.SecretStatus `json:"dnsSecretKeyStatus"` + }{value, accessStatus, secretStatus}, nil case config.SectionPlugins: plugins := make(map[string]any, len(runtime.Plugins.Plugins)) for id, item := range runtime.Plugins.Plugins { diff --git a/backend/controllers/config_public_response_test.go b/backend/controllers/config_public_response_test.go index 1424176..574fc66 100644 --- a/backend/controllers/config_public_response_test.go +++ b/backend/controllers/config_public_response_test.go @@ -39,7 +39,14 @@ func TestPublicConfigSectionsDoNotExposeSecretsAndRemainStrictlyDecodable(t *tes }, }, Email: config.EmailSectionConfig{Meta: config.SectionMeta{Schema: config.CurrentSectionSchema, Generation: 7}}, - SSL: config.SSLSectionConfig{Meta: config.SectionMeta{Schema: config.CurrentSectionSchema, Generation: 7}}, + SSL: config.SSLSectionConfig{ + Meta: config.SectionMeta{Schema: config.CurrentSectionSchema, Generation: 7}, + TLS: config.SSLConfig{ + DNSProvider: config.DNSProviderAliDNS, + DNSAccessKey: config.StoredSecret{Ciphertext: "enc:v1:dns-access", Version: credential.CurrentVersion, UpdatedAt: time.Now().UTC()}, + DNSSecretKey: config.StoredSecret{Ciphertext: "enc:v1:dns-secret", Version: credential.CurrentVersion, UpdatedAt: time.Now().UTC()}, + }, + }, Plugins: config.PluginsSectionConfig{ Meta: config.SectionMeta{Schema: config.CurrentSectionSchema, Generation: 7}, Plugins: map[string]config.PluginInstance{ @@ -103,6 +110,14 @@ func TestPublicConfigSectionsDoNotExposeSecretsAndRemainStrictlyDecodable(t *tes } delete(writableData, "databasePasswordStatus") delete(writableData, "relayPasswordStatus") + if test.section == config.SectionSSL { + for _, name := range []string{"dnsAccessKeyStatus", "dnsSecretKeyStatus"} { + if !strings.Contains(string(writableData[name]), `"configured":true`) { + t.Fatalf("DNS secret status missing: %s", name) + } + delete(writableData, name) + } + } if test.section == config.SectionMail { var relay map[string]json.RawMessage if err := json.Unmarshal(writableData["relay"], &relay); err != nil { diff --git a/backend/services/setup/ssl/dns_automatic.go b/backend/services/setup/ssl/dns_automatic.go new file mode 100644 index 0000000..2b43bd6 --- /dev/null +++ b/backend/services/setup/ssl/dns_automatic.go @@ -0,0 +1,186 @@ +package ssl + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "git.pcux.cn/xuans/Smail/config" + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" +) + +type aliDNSRecord struct { + ID string + Name string + Value string +} + +// 每次签发独立记录创建结果,清理时只删除本实例创建且内容未被修改的记录。 +type aliDNSChallenge struct { + mu sync.Mutex + zone string + accessKey string + secretKey string + endpoint string + client *http.Client + records map[string]aliDNSRecord +} + +func automaticDNSProvider(cfg *config.Config) (challenge.Provider, error) { + if cfg == nil || cfg.EffectiveSSLType() != config.SSLTypeAutoDNS { + return nil, newUserCertificateError("当前配置未启用项目 DNS 证书") + } + if cfg.SSL.DNSProvider == "" { + return GetDnsChallengeInstance(), nil + } + accessKey, secretKey, err := config.DNSProviderCredentials(cfg.SSL) + if err != nil { + return nil, newUserCertificateError("DNS 服务商凭据不可用,请在 SSL 配置中重新设置") + } + zone := cfg.SSL.DNSZone + if zone == "" { + zone = strings.ToLower(strings.TrimSuffix(cfg.Domain, ".")) + } + if cfg.Domain != zone && !strings.HasSuffix(cfg.Domain, "."+zone) { + return nil, newUserCertificateError("DNS 托管区域必须包含邮件主域名") + } + return &aliDNSChallenge{ + zone: zone, accessKey: accessKey, secretKey: secretKey, + endpoint: "https://alidns.aliyuncs.com/", + client: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }}, + records: make(map[string]aliDNSRecord), + }, nil +} + +func (provider *aliDNSChallenge) Timeout() (time.Duration, time.Duration) { + return 5 * time.Minute, 5 * time.Second +} + +func (provider *aliDNSChallenge) record(domain, keyAuth string) (aliDNSRecord, error) { + domain = strings.TrimPrefix(strings.TrimSuffix(strings.ToLower(domain), "."), "*.") + info := dns01.GetChallengeInfo(domain, keyAuth) + fqdn := strings.TrimSuffix(info.EffectiveFQDN, ".") + if !strings.HasSuffix(fqdn, "."+provider.zone) { + return aliDNSRecord{}, errors.New("DNS 挑战不属于配置的托管区域") + } + name := strings.TrimSuffix(fqdn, "."+provider.zone) + if name != "_acme-challenge" && !strings.HasPrefix(name, "_acme-challenge.") { + return aliDNSRecord{}, errors.New("DNS 挑战目标不是允许的 ACME 记录") + } + return aliDNSRecord{Name: name, Value: info.Value}, nil +} + +func (provider *aliDNSChallenge) Present(domain, token, keyAuth string) error { + provider.mu.Lock() + defer provider.mu.Unlock() + key := domain + ":" + keyAuth + if _, exists := provider.records[key]; exists { + return nil + } + record, err := provider.record(domain, keyAuth) + if err != nil { + return err + } + var response struct{ RecordID string `json:"RecordId"` } + err = provider.call("AddDomainRecord", url.Values{ + "DomainName": {provider.zone}, "RR": {record.Name}, "Type": {"TXT"}, + "Value": {record.Value}, "TTL": {"600"}, + }, &response) + if err != nil { + return err + } + if response.RecordID == "" { + return errors.New("DNS 服务商未返回新建记录编号") + } + record.ID = response.RecordID + provider.records[key] = record + return nil +} + +func (provider *aliDNSChallenge) CleanUp(domain, token, keyAuth string) error { + provider.mu.Lock() + defer provider.mu.Unlock() + // 清理使用创建时保存的目标,避免 CNAME 变化后误删或遗漏原记录。 + key := domain + ":" + keyAuth + created, exists := provider.records[key] + if !exists { + return nil + } + var current struct { + RecordID string `json:"RecordId"` + Domain string `json:"DomainName"` + Name string `json:"RR"` + Type string `json:"Type"` + Value string `json:"Value"` + } + if err := provider.call("DescribeDomainRecordInfo", url.Values{"RecordId": {created.ID}}, ¤t); err != nil { + return err + } + if current.RecordID != created.ID || current.Domain != provider.zone || current.Name != created.Name || current.Type != "TXT" || current.Value != created.Value { + return errors.New("DNS 挑战记录已被修改,保留记录并停止自动清理") + } + if err := provider.call("DeleteDomainRecord", url.Values{"RecordId": {created.ID}}, nil); err != nil { + return err + } + delete(provider.records, key) + return nil +} + +func aliDNSPercentEncode(value string) string { + return strings.ReplaceAll(url.QueryEscape(value), "+", "%20") +} + +func (provider *aliDNSChallenge) call(action string, values url.Values, target any) error { + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return errors.New("DNS 请求随机数生成失败") + } + values.Set("Action", action) + values.Set("Version", "2015-01-09") + values.Set("Format", "JSON") + values.Set("AccessKeyId", provider.accessKey) + values.Set("SignatureMethod", "HMAC-SHA1") + values.Set("SignatureVersion", "1.0") + values.Set("SignatureNonce", hex.EncodeToString(nonce)) + values.Set("Timestamp", time.Now().UTC().Format("2006-01-02T15:04:05Z")) + canonical := strings.ReplaceAll(values.Encode(), "+", "%20") + mac := hmac.New(sha1.New, []byte(provider.secretKey+"&")) + _, _ = mac.Write([]byte("POST&%2F&" + aliDNSPercentEncode(canonical))) + values.Set("Signature", base64.StdEncoding.EncodeToString(mac.Sum(nil))) + request, err := http.NewRequest(http.MethodPost, provider.endpoint, strings.NewReader(values.Encode())) + if err != nil { + return errors.New("DNS 请求创建失败") + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := provider.client.Do(request) + if err != nil { + return errors.New("DNS 服务商连接失败或超时") + } + defer response.Body.Close() + data, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) + if err != nil || len(data) > 1<<20 { + return errors.New("DNS 服务商响应无效") + } + var status struct{ Code string `json:"Code"` } + if json.Unmarshal(data, &status) != nil || response.StatusCode != http.StatusOK || status.Code != "" { + // 服务商错误可能包含凭据、签名或请求原文,不能直接交给日志。 + return errors.New("DNS 服务商拒绝请求,请检查凭据、区域和记录管理权限") + } + if target != nil && json.Unmarshal(data, target) != nil { + return errors.New("DNS 服务商响应解析失败") + } + return nil +} diff --git a/backend/services/setup/ssl/dns_automatic_test.go b/backend/services/setup/ssl/dns_automatic_test.go new file mode 100644 index 0000000..b8a90ea --- /dev/null +++ b/backend/services/setup/ssl/dns_automatic_test.go @@ -0,0 +1,96 @@ +package ssl + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "git.pcux.cn/xuans/Smail/config" +) + +func TestAutomaticDNSCleansOnlyOwnedUnchangedRecords(t *testing.T) { + t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true") + var mu sync.Mutex + records := map[string]aliDNSRecord{"existing": {ID: "existing", Name: "_acme-challenge", Value: "unrelated-record"}} + adds, deletes := 0, 0 + failDelete, tamper := false, false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + if r.Method != http.MethodPost || r.ParseForm() != nil || r.Form.Get("AccessKeyId") != "test-access" || r.Form.Get("Signature") == "" { + t.Error("invalid DNS API request") + w.WriteHeader(400) + return + } + id := r.Form.Get("RecordId") + switch r.Form.Get("Action") { + case "AddDomainRecord": + adds++ + id = fmt.Sprint(adds) + records[id] = aliDNSRecord{ID: id, Name: r.Form.Get("RR"), Value: r.Form.Get("Value")} + _ = json.NewEncoder(w).Encode(map[string]string{"RecordId": id}) + case "DescribeDomainRecordInfo": + record := records[id] + if tamper { record.Value = "changed-by-user" } + _ = json.NewEncoder(w).Encode(map[string]string{"RecordId": id, "DomainName": "example.com", "RR": record.Name, "Type": "TXT", "Value": record.Value}) + case "DeleteDomainRecord": + if failDelete { w.WriteHeader(503); _, _ = w.Write([]byte(`{"Code":"Busy"}`)); return } + deletes++ + delete(records, id) + _ = json.NewEncoder(w).Encode(map[string]string{"RecordId": id}) + default: + t.Error("unexpected DNS action") + w.WriteHeader(400) + } + })) + defer server.Close() + provider := &aliDNSChallenge{zone: "example.com", accessKey: "test-access", secretKey: "test-secret", endpoint: server.URL, client: server.Client(), records: map[string]aliDNSRecord{}} + var workers sync.WaitGroup + for _, domain := range []string{"example.com", "*.example.com"} { + for i := 0; i < 3; i++ { + workers.Add(1) + go func(domain string) { + defer workers.Done() + if err := provider.Present(domain, "token", "key-"+domain); err != nil { t.Error(err) } + }(domain) + } + } + workers.Wait() + if adds != 2 || len(records) != 3 { t.Fatalf("same-name TXT records lost or duplicated: adds=%d records=%d", adds, len(records)) } + if err := provider.CleanUp("other.example.com", "token", "unknown"); err != nil || deletes != 0 { t.Fatal("unknown challenge must not touch DNS") } + mu.Lock(); tamper = true; mu.Unlock() + if err := provider.CleanUp("example.com", "token", "key-example.com"); err == nil || deletes != 0 { t.Fatal("modified record must be retained") } + mu.Lock(); tamper = false; failDelete = true; mu.Unlock() + if err := provider.CleanUp("example.com", "token", "key-example.com"); err == nil || len(provider.records) != 2 { t.Fatal("failed deletion must retain ownership for retry") } + mu.Lock(); failDelete = false; mu.Unlock() + if err := provider.CleanUp("example.com", "token", "key-example.com"); err != nil { t.Fatal(err) } + if len(records) != 2 { t.Fatal("cleaning root challenge removed wildcard or unrelated TXT") } + if err := provider.CleanUp("*.example.com", "token", "key-*.example.com"); err != nil { t.Fatal(err) } + if len(records) != 1 || records["existing"].Value != "unrelated-record" { t.Fatal("existing TXT must survive all challenge cleanup") } + if err := provider.CleanUp("*.example.com", "token", "key-*.example.com"); err != nil || deletes != 2 { t.Fatal("cleanup must be idempotent") } +} + +func TestAutomaticDNSRejectsOutsideZoneAndRedactsErrors(t *testing.T) { + t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true") + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(403) + _, _ = w.Write([]byte(`{"Code":"Denied","Message":"test-access test-secret signature"}`)) + })) + defer server.Close() + provider := &aliDNSChallenge{zone: "example.com", accessKey: "test-access", secretKey: "test-secret", endpoint: server.URL, client: server.Client(), records: map[string]aliDNSRecord{}} + if err := provider.Present("unrelated.com", "token", "key"); err == nil || calls != 0 { t.Fatal("out-of-zone write attempted") } + err := provider.Present("example.com", "token", "key") + if err == nil || calls != 1 { t.Fatal("provider refusal not reported") } + for _, secret := range []string{"test-access", "test-secret", "signature"} { + if strings.Contains(err.Error(), secret) { t.Fatal("error leaked provider response") } + } + for _, management := range []string{config.SSLManagementCustom, config.SSLManagementWebServer} { + if _, err := automaticDNSProvider(&config.Config{SSL: config.SSLConfig{Management: management, Challenge: config.SSLChallengeDNS}}); err == nil { t.Fatal("external certificate mode allowed automatic DNS") } + } +} diff --git a/backend/services/setup/ssl/ssl.go b/backend/services/setup/ssl/ssl.go index b8699a2..9aebb00 100644 --- a/backend/services/setup/ssl/ssl.go +++ b/backend/services/setup/ssl/ssl.go @@ -112,11 +112,16 @@ func SetSSL(sslType, priKey, crtKey string) error { } if cfg.SSLType != config.SSLTypeUser { + previousSSL := cfg.SSL clearLegacySSLPaths(cfg) cfg.SSL = config.SSLConfig{ Management: config.SSLManagementProject, Mode: config.ManagedSSLModeDefault, DefaultDirectory: runtimepath.SSLReference(config.ROOT_PATH, "data/ssl"), + DNSProvider: previousSSL.DNSProvider, + DNSZone: previousSSL.DNSZone, + DNSAccessKey: previousSSL.DNSAccessKey, + DNSSecretKey: previousSSL.DNSSecretKey, } if cfg.SSLType == config.SSLTypeAutoDNS { cfg.SSL.Challenge = config.SSLChallengeDNS @@ -174,6 +179,7 @@ func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLRef } clearLegacySSLPaths(cfg) sslType := cfg.EffectiveSSLType() + previousSSL := cfg.SSL management := cfg.SSL.Management if reference.Mode == config.ManagedSSLModeDefault { management = config.SSLManagementProject @@ -186,6 +192,10 @@ func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLRef DefaultDirectory: "ssl", CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.PrivateKeyRef, + DNSProvider: previousSSL.DNSProvider, + DNSZone: previousSSL.DNSZone, + DNSAccessKey: previousSSL.DNSAccessKey, + DNSSecretKey: previousSSL.DNSSecretKey, } if reference.Mode == config.ManagedSSLModeUpload { cfg.SSL.CustomDirectory = "ssl" @@ -403,16 +413,15 @@ func renewCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config) error { return errors.Wrap(err) } } else if cfg.EffectiveSSLType() == config.SSLTypeAutoDNS { - err = client.Challenge.SetDNS01Provider(GetDnsChallengeInstance(), dns01.AddDNSTimeout(60*time.Minute)) + provider, providerErr := automaticDNSProvider(cfg) + if providerErr != nil { + return providerErr + } + err = client.Challenge.SetDNS01Provider(provider, dns01.AddDNSTimeout(15*time.Second)) if err != nil { return errors.Wrap(err) } - log.Errorf("Please Set DNS Record/请将以下内容添加到DNS记录中:\n") - for _, item := range GetDnsChallengeInstance().GetDNSSettings(nil) { - log.Errorf("Type:%s\tHost:%s\tValue generated\n", item.Type, item.Host) - } - } var reg *registration.Resource @@ -466,7 +475,11 @@ func generateCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config, newAc return errors.Wrap(err) } } else if cfg.EffectiveSSLType() == config.SSLTypeAutoDNS { - err = client.Challenge.SetDNS01Provider(GetDnsChallengeInstance(), dns01.AddDNSTimeout(60*time.Minute)) + provider, providerErr := automaticDNSProvider(cfg) + if providerErr != nil { + return providerErr + } + err = client.Challenge.SetDNS01Provider(provider, dns01.AddDNSTimeout(15*time.Second)) if err != nil { return errors.Wrap(err) } diff --git a/deploy/systemd/smail-tls-sync.path b/deploy/systemd/smail-tls-sync.path new file mode 100644 index 0000000..bca4e6f --- /dev/null +++ b/deploy/systemd/smail-tls-sync.path @@ -0,0 +1,9 @@ +[Unit] +Description=Smail 项目证书入口同步监听 + +[Path] +PathChanged=/opt/smail/config/config.json +Unit=smail-tls-sync.service + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/smail-tls-sync.service b/deploy/systemd/smail-tls-sync.service new file mode 100644 index 0000000..149c541 --- /dev/null +++ b/deploy/systemd/smail-tls-sync.service @@ -0,0 +1,24 @@ +[Unit] +Description=Smail 项目证书同步至 Nginx +After=smail.service nginx.service +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +Type=oneshot +User=root +UMask=0077 +ExecStart=/opt/smail/smail-config --root /opt/smail --export-project-tls /etc/nginx/smail/tls.pem +ExecStart=/usr/sbin/nginx -t +ExecStart=/usr/bin/systemctl reload nginx.service +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/etc/nginx/smail /opt/smail/data /var/log/nginx +TimeoutStartSec=30 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 6a23537..4c8ebbd 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -72,6 +72,8 @@ const certificateManagement = config => { return ['managed_default', 'default'].includes(config.tls?.mode) ? 'project' : 'custom' } const projectManagedCertificate = config => certificateManagement(config) === 'project' +const projectDNSCertificate = config => projectManagedCertificate(config) && config.tls?.challenge === 'dns' +const automaticDNSCertificate = config => projectDNSCertificate(config) && config.tls?.dnsProvider === 'alidns' const externalManagedCertificate = config => !projectManagedCertificate(config) const absoluteCertificatePath = value => typeof value === 'string' && value === value.trim() @@ -180,6 +182,26 @@ const sslGroups = [ fallback: 'http', options: options([['http', 'HTTP 验证'], ['dns', 'DNS 验证']]) }), + field('tls.dnsProvider', 'DNS 记录管理', 'select', { + show: projectDNSCertificate, + fallback: '', + options: options([['', '手动设置 TXT'], ['alidns', '阿里云 DNS(自动续期)']]), + hint: '自动续期需配置 DNS 服务商;手动模式每次签发都需自行设置 TXT。' + }), + field('tls.dnsZone', 'DNS 托管域名', 'text', { + show: automaticDNSCertificate, + placeholder: '留空使用主邮件域名' + }), + field('tls.dnsAccessKeyInput', '访问密钥编号', 'password', { + show: automaticDNSCertificate, + placeholder: '留空保持原值', + dynamicHint: config => config.dnsAccessKeyStatus?.configured ? '已设置,输入新值后替换。' : '未设置,请填写阿里云 AccessKey ID。' + }), + field('tls.dnsSecretKeyInput', '访问密钥', 'password', { + show: automaticDNSCertificate, + placeholder: '留空保持原值', + dynamicHint: config => config.dnsSecretKeyStatus?.configured ? '已设置,输入新值后替换。' : '未设置,请填写阿里云 AccessKey Secret。' + }), field('tls.certificateRef', '证书文件', 'text', { wide: true, show: externalManagedCertificate, @@ -227,6 +249,7 @@ export function getFieldGroups(section, config) { .map(input => ({ ...input, label: input.dynamicLabel ? input.dynamicLabel(config) : input.label, + hint: input.dynamicHint ? input.dynamicHint(config) : input.hint, recommendedPort: getRecommendedPort(config, input.path) })) })) @@ -367,6 +390,17 @@ export function validateFormConfig(section, config) { } if (section === 'ssl') { const tls = config.tls || {} + if (projectDNSCertificate(config)) { + if (!['', 'alidns'].includes(tls.dnsProvider || '')) errors['tls.dnsProvider'] = '请选择支持的 DNS 服务商' + if (automaticDNSCertificate(config)) { + if (tls.dnsZone && (!validDomain(tls.dnsZone) || normalizedDomain(tls.dnsZone) !== tls.dnsZone)) errors['tls.dnsZone'] = '请填写小写域名,不包含协议、路径或末尾点' + for (const name of ['dnsAccessKey', 'dnsSecretKey']) { + const value = tls[`${name}Input`] + if (!value && !config[`${name}Status`]?.configured) errors[`tls.${name}Input`] = '请填写 DNS 服务商凭据' + if (value && (typeof value !== 'string' || value !== value.trim() || value.length > 4096)) errors[`tls.${name}Input`] = '凭据不能包含首尾空格或超过 4096 个字符' + } + } + } const management = certificateManagement(config) if (!['web_server', 'project', 'custom'].includes(management)) errors['tls.management'] = '请选择证书管理方式' if (management === 'project' && !['http', 'dns'].includes(tls.challenge || 'http')) errors['tls.challenge'] = '请选择证书验证方式' diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index 9b2bc24..0696b2a 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -485,6 +485,15 @@ export function usePage(pageKey = 'base') { if (selectedSection.value === 'base') { delete payload.databasePasswordStatus } + if (apiSection() === 'ssl') { + for (const name of ['dnsAccessKey', 'dnsSecretKey']) { + delete payload[`${name}Status`] + if (payload.tls) { + delete payload.tls[`${name}Input`] + delete payload.tls[name] + } + } + } if (apiSection() === 'mail') { delete payload.relayPasswordStatus if (payload.relay && 'password' in payload.relay) { @@ -521,6 +530,12 @@ export function usePage(pageKey = 'base') { if (relayPassword && selectedSection.value === 'mail') { secretChanges['relay.password'] = { operation: 'replace', value: { plaintext: relayPassword } } } + if (selectedSection.value === 'ssl' && formConfig.value?.tls?.dnsProvider === 'alidns') { + for (const name of ['dnsAccessKey', 'dnsSecretKey']) { + const input = formConfig.value.tls[`${name}Input`] + if (input) secretChanges[`tls.${name}`] = { operation: 'replace', value: { plaintext: input } } + } + } return { expectedGeneration: Number(status.activeGeneration), payload: parsePayload(), diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index f5dbacb..30f7797 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -13,6 +13,21 @@ const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } = '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }' )() +test('项目 DNS 自动续期需要凭据,已设置的凭据可留空且外部托管隐藏 DNS 字段', () => { + const config = { tls: { management: 'project', challenge: 'dns', mode: 'managed_default', defaultDirectory: 'ssl', dnsProvider: 'alidns' } } + assert.ok(validateFormConfig('ssl', config)['tls.dnsAccessKeyInput']) + assert.ok(validateFormConfig('ssl', config)['tls.dnsSecretKeyInput']) + config.dnsAccessKeyStatus = { configured: true } + config.tls.dnsSecretKeyInput = 'test-secret' + assert.deepEqual(validateFormConfig('ssl', config), {}) + config.tls.dnsZone = 'https://example.com' + assert.ok(validateFormConfig('ssl', config)['tls.dnsZone']) + for (const management of ['custom', 'web_server']) { + config.tls.management = management + assert.ok(getFieldGroups('ssl', config).flatMap(group => group.fields).every(field => !field.path.join('.').includes('dns'))) + } +}) + test('公网 HTTPS 端口独立编辑且保留内部监听与模式切换', () => { const config = { database: { type: 'sqlite' }, diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index 6a5a1a5..8fbae91 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -66,6 +66,29 @@ function deferred() { return { promise, resolve, reject } } +test('DNS 凭据只通过专用密钥变更提交,留空保持原值', async () => { + const sent = [] + const current = { + tls: { management: 'project', mode: 'managed_default', defaultDirectory: 'ssl', challenge: 'dns', dnsProvider: 'alidns', dnsAccessKey: {}, dnsSecretKey: {} }, + dnsAccessKeyStatus: { configured: true }, dnsSecretKeyStatus: { configured: true } + } + const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { available: true, activeGeneration: 1 } : current }), 'ssl', { + http: { post: async (path, body) => { sent.push(body); return { errorNo: 0, data: { valid: true } } } } + }) + await page.loadAll() + page.updateField(['tls', 'dnsSecretKeyInput'], 'new-test-secret') + await page.validateConfig() + assert.equal(sent.length, 1) + assert.deepEqual(sent[0].secretChanges, { 'tls.dnsSecretKey': { operation: 'replace', value: { plaintext: 'new-test-secret' } } }) + assert.equal(sent[0].payload.dnsAccessKeyStatus, undefined) + assert.equal(sent[0].payload.dnsSecretKeyStatus, undefined) + assert.equal(sent[0].payload.tls.dnsSecretKeyInput, undefined) + assert.equal(sent[0].payload.tls.dnsSecretKey, undefined) + page.updateField(['tls', 'dnsSecretKeyInput'], '') + await page.validateConfig() + assert.deepEqual(sent[1].secretChanges, {}) +}) + for (const cancelled of [false, true]) { test(`共享身份确认保留单次重试,移除审计不影响其他操作:取消=${cancelled}`, async () => { const requests = []