diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go index 70c4d58..d15bcf9 100644 --- a/backend/config/manager_v2.go +++ b/backend/config/manager_v2.go @@ -279,6 +279,15 @@ func (m *ConfigManager) Validate(ctx context.Context, req ValidateRequest) (*Val if err != nil { return nil, err } + if req.Section == SectionSSL { + if _, err := prepareCustomSSLPaths(m.loader.root, &candidate); err != nil { + issue, ok := SSLValidationIssue(err) + if !ok { + issue = PublicIssue{Code: "SSL_INVALID", Message: "证书校验失败,请检查证书文件"} + } + return &ValidationResult{Valid: false, Generation: current.Generation, Issues: []PublicIssue{issue}}, nil + } + } setCandidateGeneration(&candidate, current.Generation+1) if err := ValidateRuntimeConfig(candidate, m.options); err != nil { return &ValidationResult{Valid: false, Generation: current.Generation, Issues: []PublicIssue{{Code: "CONFIG_INVALID", Message: err.Error()}}}, nil @@ -312,6 +321,13 @@ func (m *ConfigManager) Update(ctx context.Context, req UpdateRequest) (*UpdateR if err != nil { return nil, err } + var sslMaterial *customSSLMaterial + if req.Section == SectionSSL { + sslMaterial, err = prepareCustomSSLPaths(m.loader.root, &candidate) + if err != nil { + return nil, err + } + } if err := contextError(ctx); err != nil { return nil, err } @@ -332,6 +348,10 @@ func (m *ConfigManager) Update(ctx context.Context, req UpdateRequest) (*UpdateR if err != nil { return nil, err } + if err := sslMaterial.stage(m.loader.root, &candidate); err != nil { + return nil, err + } + setCandidateGeneration(&candidate, current.Generation+1) m.mu.Lock() m.pending = &pendingConfig{ID: id, Candidate: candidate, Section: req.Section, Actor: req.Actor, Confirmation: req.Confirmation, PreviousRestart: append([]ServiceName(nil), m.status.PendingRestart...)} m.status.PendingRestart = mergeServices(m.status.PendingRestart, servicesForSection(req.Section)) @@ -397,6 +417,11 @@ func (m *ConfigManager) apply(ctx context.Context, generation uint64, actor Audi if err := ValidateRuntimeConfig(candidate, m.options); err != nil { return nil, err } + if pending.Section == SectionSSL { + if err := validateCustomSSLReferences(m.loader.root, candidate); err != nil { + return nil, err + } + } if err := ValidateRuntimeSecrets(m.vault, current); err != nil { return nil, err } diff --git a/backend/config/ssl_custom.go b/backend/config/ssl_custom.go new file mode 100644 index 0000000..f8abb38 --- /dev/null +++ b/backend/config/ssl_custom.go @@ -0,0 +1,210 @@ +package config + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" +) + +var ErrSSLFileUnavailable = errors.New("SSL certificate file is missing or unreadable") + +// customSSLMaterial 在只读准备阶段保留已校验的材料,确认后再暂存,避免重新读取外部文件。 +type customSSLMaterial struct { + privateKey []byte + certificate []byte + domains []string +} + +func prepareCustomSSLPaths(root string, candidate *RuntimeConfig) (*customSSLMaterial, error) { + ssl := &candidate.SSL.TLS + if EffectiveSSLManagement(*ssl) != SSLManagementCustom { + return nil, nil + } + certificatePath := strings.TrimSpace(ssl.CertificateRef) + privateKeyPath := strings.TrimSpace(ssl.PrivateKeyRef) + if certificatePath == "" && privateKeyPath == "" { + return nil, nil + } + if certificatePath == "" || privateKeyPath == "" { + return nil, ErrInvalidSSLReference + } + // 已保存的受管引用只复验,不重复导入;普通相对路径以项目运行根目录为基准。 + if strings.HasPrefix(certificatePath, managedSSLRelativeRoot+"/") && strings.HasPrefix(privateKeyPath, managedSSLRelativeRoot+"/") { + return nil, validateCustomSSLReferences(root, *candidate) + } + var err error + certificatePath, err = resolveCustomSSLInput(root, certificatePath) + if err != nil { + return nil, err + } + privateKeyPath, err = resolveCustomSSLInput(root, privateKeyPath) + if err != nil { + return nil, err + } + material := &customSSLMaterial{domains: runtimeCertificateDomains(candidate)} + material.privateKey, err = readExternalSSLFile(privateKeyPath, true) + if err != nil { + return nil, err + } + material.certificate, err = readExternalSSLFile(certificatePath, false) + if err != nil { + return nil, err + } + if err := validateSSLMaterial(material.privateKey, material.certificate, nil, material.domains); err != nil { + return nil, err + } + ssl.Mode = ManagedSSLModeUpload + ssl.DefaultDirectory = "config/ssl" + ssl.CustomDirectory = "config/ssl" + // 仅在内存中使用合法占位引用完成跨分区校验,最终候选必须换成独立版本。 + ssl.CertificateRef = managedSSLReference(ManagedSSLModeUpload, "public.crt") + ssl.PrivateKeyRef = managedSSLReference(ManagedSSLModeUpload, "private.key") + return material, nil +} + +func (material *customSSLMaterial) stage(root string, candidate *RuntimeConfig) error { + if material == nil { + return nil + } + reference, err := stageManagedSSLAtRoot(root, ManagedSSLModeUpload, material.privateKey, material.certificate, nil, material.domains) + if err != nil { + return err + } + candidate.SSL.TLS.CertificateRef = reference.CertificateRef + candidate.SSL.TLS.PrivateKeyRef = reference.PrivateKeyRef + return nil +} + +func resolveCustomSSLInput(root, value string) (string, error) { + for _, part := range strings.FieldsFunc(value, func(r rune) bool { return r == '/' || r == '\\' }) { + if part == ".." { + return "", ErrInvalidSSLReference + } + } + if !filepath.IsAbs(value) { + if isWindowsAbsolute(value) || filepath.VolumeName(value) != "" || strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`) { + return "", ErrInvalidSSLReference + } + value = filepath.Join(root, value) + } + path, err := filepath.Abs(value) + if err != nil { + return "", ErrInvalidSSLReference + } + return path, nil +} + +func readExternalSSLFile(path string, privateKey bool) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, ErrSSLFileUnavailable + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxManagedSSLFileSize || hasMultipleFileLinks(path) { + return nil, ErrUnsafeSSLFile + } + resolvedDirectory, err := filepath.EvalSymlinks(filepath.Dir(path)) + if err != nil || filepath.Clean(resolvedDirectory) != filepath.Clean(filepath.Dir(path)) { + return nil, ErrUnsafeSSLFile + } + if privateKey && info.Mode().Perm()&0077 != 0 && runtimeSupportsPermissionCheck() { + return nil, ErrSSLPrivateKeyPermission + } + file, err := os.Open(path) + if err != nil { + return nil, ErrSSLFileUnavailable + } + 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 { + return nil, ErrSSLFileUnavailable + } + after, err := os.Lstat(path) + if err != nil || !os.SameFile(info, after) || info.Size() != after.Size() || !info.ModTime().Equal(after.ModTime()) || len(data) != int(info.Size()) || len(data) > maxManagedSSLFileSize { + return nil, ErrUnsafeSSLFile + } + return data, nil +} + +func validateCustomSSLReferences(root string, candidate RuntimeConfig) error { + ssl := candidate.SSL.TLS + if EffectiveSSLManagement(ssl) != SSLManagementCustom || ssl.CertificateRef == "" && ssl.PrivateKeyRef == "" { + return nil + } + reference, err := resolveManagedSSLReferencesAtRoot(root, ssl) + if err != nil { + return err + } + privateKey, err := readExternalSSLFile(reference.PrivateKeyPath, true) + if err != nil { + return err + } + certificate, err := readExternalSSLFile(reference.CertificatePath, false) + if err != nil { + return err + } + return validateSSLMaterial(privateKey, certificate, nil, runtimeCertificateDomains(&candidate)) +} + +func runtimeCertificateDomains(candidate *RuntimeConfig) []string { + return SSLCertificateDomains(candidate.Root.Domains.Primary, candidate.Root.Domains.Web, candidate.Root.Domains.Accepted) +} + +// SSLCertificateDomains 统一初始化和配置管理所需的 Web、主域及邮件协议域名。 +func SSLCertificateDomains(primary, web string, accepted []string) []string { + seen := make(map[string]bool) + var domains []string + appendDomain := func(value string) { + value = strings.ToLower(strings.TrimSpace(value)) + if value != "" && !seen[value] { + seen[value] = true + domains = append(domains, value) + } + } + appendDomain(web) + appendDomain(primary) + for _, domain := range accepted { + domain = strings.TrimSpace(domain) + if domain == "" { + continue + } + appendDomain(domain) + appendDomain("smtp." + domain) + appendDomain("pop." + domain) + appendDomain("imap." + domain) + } + return domains +} + +// SSLValidationIssue 将已知证书错误转换成安全的中文提示,不回显磁盘路径或私钥材料。 +func SSLValidationIssue(err error) (PublicIssue, bool) { + issue := PublicIssue{Code: "SSL_INVALID"} + switch { + case errors.Is(err, ErrSSLFileUnavailable): + issue.Message = "证书或私钥文件不存在或无法读取,请检查服务器路径和运行用户权限" + case errors.Is(err, ErrSSLCertificateMismatch): + issue.Message = "证书与私钥不匹配,请选择同一套证书文件" + case errors.Is(err, ErrSSLCertificateExpired): + issue.Message = "证书尚未生效或已经过期,请更换有效证书" + case errors.Is(err, ErrSSLCertificateDomain): + issue.Message = "证书未覆盖已配置的主域名、Web 域名或 SMTP、IMAP、POP3 域名" + case errors.Is(err, ErrSSLPrivateKeyPermission): + issue.Message = "私钥权限过于宽松,请限制为仅运行用户可读取" + case errors.Is(err, ErrUnsafeSSLFile): + issue.Message = "证书文件不安全或大小无效,请使用不超过 1 MB 的普通文件,避免链接文件" + case errors.Is(err, ErrInvalidSSLMaterial): + issue.Message = "证书内容、证书链或密钥强度无效,请使用有效的 PEM 证书和私钥" + case errors.Is(err, ErrInvalidSSLReference): + issue.Message = "请填写完整的证书和私钥路径,路径不能包含上级目录跳转" + case errors.Is(err, ErrInvalidSSLMode), errors.Is(err, ErrInvalidSSL): + issue.Message = "证书管理方式或存储配置无效,请检查 SSL 配置" + default: + return PublicIssue{}, false + } + return issue, true +} diff --git a/backend/config/ssl_storage.go b/backend/config/ssl_storage.go index 0353f6d..dfa23bb 100644 --- a/backend/config/ssl_storage.go +++ b/backend/config/ssl_storage.go @@ -99,6 +99,10 @@ func LoadRuntimeTLSCertificate(cfg *Config) (tls.Certificate, error) { // ImportManagedSSL 校验证书材料后写入候选目录,并原子切换 current 目录。 func ImportManagedSSL(mode string, privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) (ManagedSSLReference, error) { + return importManagedSSLAtRoot(ROOT_PATH, mode, privateKeyPEM, certificatePEM, issuerPEM, domains) +} + +func importManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) (ManagedSSLReference, error) { if !validManagedSSLMode(mode) { return ManagedSSLReference{}, ErrInvalidSSLMode } @@ -106,11 +110,11 @@ func ImportManagedSSL(mode string, privateKeyPEM, certificatePEM, issuerPEM []by return ManagedSSLReference{}, err } - rootPath, err := managedSSLRootPath(mode) + rootPath, err := managedSSLRootPathAtRoot(root, mode) if err != nil { return ManagedSSLReference{}, err } - if err := ensureManagedDirectory(rootPath); err != nil { + if err := ensureManagedDirectoryAtRoot(root, rootPath); err != nil { return ManagedSSLReference{}, err } @@ -199,21 +203,25 @@ func ImportManagedSSL(mode string, privateKeyPEM, certificatePEM, issuerPEM []by // ResolveManagedSSLReferences 将内部引用解析为受控文件路径,拒绝任意路径输入。 func ResolveManagedSSLReferences(ssl SSLConfig) (ManagedSSLReference, error) { + return resolveManagedSSLReferencesAtRoot(ROOT_PATH, ssl) +} + +func resolveManagedSSLReferencesAtRoot(root string, ssl SSLConfig) (ManagedSSLReference, error) { if !validManagedSSLMode(ssl.Mode) { return ManagedSSLReference{}, ErrInvalidSSLMode } if ssl.CertificateRef == "" || ssl.PrivateKeyRef == "" { return ManagedSSLReference{}, ErrInvalidSSLReference } - rootPath, err := managedSSLRootPath(ssl.Mode) + rootPath, err := managedSSLRootPathAtRoot(root, ssl.Mode) if err != nil { return ManagedSSLReference{}, err } - certificatePath, err := resolveManagedReference(rootPath, ssl.CertificateRef, "public.crt") + certificatePath, err := resolveManagedReferenceAtRoot(root, rootPath, ssl.CertificateRef, "public.crt") if err != nil { return ManagedSSLReference{}, err } - privateKeyPath, err := resolveManagedReference(rootPath, ssl.PrivateKeyRef, "private.key") + privateKeyPath, err := resolveManagedReferenceAtRoot(root, rootPath, ssl.PrivateKeyRef, "private.key") if err != nil { return ManagedSSLReference{}, err } @@ -363,10 +371,14 @@ func validManagedSSLMode(mode string) bool { } func managedSSLRootPath(mode string) (string, error) { + return managedSSLRootPathAtRoot(ROOT_PATH, mode) +} + +func managedSSLRootPathAtRoot(root string, mode string) (string, error) { if !validManagedSSLMode(mode) { return "", ErrInvalidSSLMode } - rootPath, err := filepath.Abs(filepath.Clean(ROOT_PATH)) + rootPath, err := filepath.Abs(filepath.Clean(root)) if err != nil { return "", err } @@ -386,6 +398,10 @@ func managedSSLReference(mode, name string) string { } func resolveManagedReference(rootPath, reference, expectedName string) (string, error) { + return resolveManagedReferenceAtRoot(ROOT_PATH, rootPath, reference, expectedName) +} + +func resolveManagedReferenceAtRoot(root, rootPath, reference, expectedName string) (string, error) { rawReference := strings.TrimSpace(reference) if rawReference == "" || strings.Contains(rawReference, `\`) || filepath.IsAbs(rawReference) || isWindowsAbsolute(rawReference) { return "", ErrInvalidSSLReference @@ -394,7 +410,7 @@ func resolveManagedReference(rootPath, reference, expectedName string) (string, if filepath.ToSlash(filepath.Clean(reference)) != rawReference { return "", ErrInvalidSSLReference } - workspaceRoot, err := filepath.Abs(filepath.Clean(ROOT_PATH)) + workspaceRoot, err := filepath.Abs(filepath.Clean(root)) if err != nil { return "", ErrInvalidSSLReference } @@ -424,21 +440,25 @@ func validSSLVersion(version string) bool { // StageManagedSSL 只写入独立版本,不替换现用文件;配置事务成功后引用才生效。 func StageManagedSSL(mode string, privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) (ManagedSSLReference, error) { + return stageManagedSSLAtRoot(ROOT_PATH, mode, privateKeyPEM, certificatePEM, issuerPEM, domains) +} + +func stageManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) (ManagedSSLReference, error) { if err := validateSSLMaterial(privateKeyPEM, certificatePEM, issuerPEM, domains); err != nil { return ManagedSSLReference{}, err } - root, err := managedSSLRootPath(mode) + managedRoot, err := managedSSLRootPathAtRoot(root, mode) if err != nil { return ManagedSSLReference{}, err } - if err := ensureManagedDirectory(root); err != nil { + if err := ensureManagedDirectoryAtRoot(root, managedRoot); err != nil { return ManagedSSLReference{}, err } token, err := randomStorageToken() if err != nil { return ManagedSSLReference{}, err } - directory := filepath.Join(root, "version-"+token) + directory := filepath.Join(managedRoot, "version-"+token) if err := os.Mkdir(directory, 0700); err != nil { return ManagedSSLReference{}, err } @@ -461,7 +481,7 @@ func StageManagedSSL(mode string, privateKeyPEM, certificatePEM, issuerPEM []byt if err := validateManagedSSLFiles(publicPath, privatePath); err != nil { return ManagedSSLReference{}, err } - rootPath, err := filepath.Abs(ROOT_PATH) + rootPath, err := filepath.Abs(root) if err != nil { return ManagedSSLReference{}, err } @@ -580,7 +600,11 @@ func removeManagedSSLDirectory(directory string) error { } func ensureManagedDirectory(directory string) error { - rootPath, err := filepath.Abs(filepath.Clean(ROOT_PATH)) + return ensureManagedDirectoryAtRoot(ROOT_PATH, directory) +} + +func ensureManagedDirectoryAtRoot(root, directory string) error { + rootPath, err := filepath.Abs(filepath.Clean(root)) if err != nil { return err } diff --git a/backend/config/ssl_storage_test.go b/backend/config/ssl_storage_test.go index 23d5fb8..925dacd 100644 --- a/backend/config/ssl_storage_test.go +++ b/backend/config/ssl_storage_test.go @@ -203,6 +203,58 @@ func TestValidateSSLMaterialRejectsDomainExpiryAndWeakKeys(t *testing.T) { } } +func TestPrepareCustomSSLPathsImportsExternalFilesOnlyWhenSaving(t *testing.T) { + root := t.TempDir() + previousRoot := ROOT_PATH + ROOT_PATH = root + t.Cleanup(func() { ROOT_PATH = previousRoot }) + external := t.TempDir() + privateKeyPEM, certificatePEM := testManagedCertificate(t, "mail.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour), 2048) + privateKeyPath := filepath.Join(external, "privkey.pem") + certificatePath := filepath.Join(external, "fullchain.pem") + if err := os.WriteFile(privateKeyPath, privateKeyPEM, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(certificatePath, certificatePEM, 0600); err != nil { + t.Fatal(err) + } + runtime := validRuntimeConfig() + runtime.Root.Domains = DomainConfig{Primary: "mail.example.com", Web: "mail.example.com"} + runtime.SSL.TLS = SSLConfig{ + Management: SSLManagementCustom, + Mode: ManagedSSLModeUpload, + DefaultDirectory: "config/ssl", + CustomDirectory: "config/ssl", + CertificateRef: certificatePath, + PrivateKeyRef: privateKeyPath, + } + + material, err := prepareCustomSSLPaths(root, &runtime) + if err != nil { + t.Fatalf("校验外部证书失败: %v", err) + } + if runtime.SSL.TLS.CertificateRef == certificatePath || runtime.SSL.TLS.PrivateKeyRef == privateKeyPath { + t.Fatal("仅校验阶段不应保留外部证书路径") + } + managedRoot := filepath.Join(root, "config", "ssl", "managed", "upload") + if _, err := os.Stat(managedRoot); !os.IsNotExist(err) { + t.Fatalf("仅校验阶段不应创建受管目录,error = %v", err) + } + + if err := material.stage(root, &runtime); err != nil { + t.Fatalf("保存外部证书失败: %v", err) + } + if runtime.SSL.TLS.CertificateRef == certificatePath || runtime.SSL.TLS.PrivateKeyRef == privateKeyPath { + t.Fatal("保存后不应继续使用外部证书路径") + } + if !strings.HasPrefix(runtime.SSL.TLS.CertificateRef, "config/ssl/managed/upload/version-") { + t.Fatalf("证书引用未切换到受管版本: %q", runtime.SSL.TLS.CertificateRef) + } + if _, err := ResolveManagedSSLReferences(runtime.SSL.TLS); err != nil { + t.Fatalf("受管证书引用无法解析: %v", err) + } +} + func TestValidateSSLMaterialAcceptsBundledIssuerWithoutDuplicateChainEntry(t *testing.T) { privateKeyPEM, certificatePEM, issuerPEM := testChainedCertificate(t) certificateBundle := append(append([]byte{}, certificatePEM...), issuerPEM...) diff --git a/backend/controllers/config_error.go b/backend/controllers/config_error.go index 4f8f1c0..dc99149 100644 --- a/backend/controllers/config_error.go +++ b/backend/controllers/config_error.go @@ -42,6 +42,9 @@ func configErrorDetails(err error) (string, int, string, map[string]string) { status := http.StatusBadRequest message := "配置请求未完成" fields := map[string]string(nil) + if issue, ok := config.SSLValidationIssue(err); ok { + return issue.Code, http.StatusUnprocessableEntity, issue.Message, nil + } var denial *access.Denial switch { diff --git a/backend/controllers/config_error_test.go b/backend/controllers/config_error_test.go index e9f01bf..1d5fc43 100644 --- a/backend/controllers/config_error_test.go +++ b/backend/controllers/config_error_test.go @@ -2,6 +2,7 @@ package controllers import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -14,6 +15,15 @@ import ( "git.pcux.cn/xuans/Smail/services/dbmigration" ) +func TestConfigSSLErrorUsesSafeSpecificMessage(t *testing.T) { + for _, failure := range []error{config.ErrSSLFileUnavailable, config.ErrSSLCertificateMismatch, config.ErrSSLCertificateExpired, config.ErrSSLCertificateDomain, config.ErrInvalidSSLReference} { + code, status, message, _ := configErrorDetails(fmt.Errorf("%w: /private/internal-path", failure)) + if code != "SSL_INVALID" || status != http.StatusUnprocessableEntity || message == "" || strings.Contains(message, "/private/") || strings.Contains(message, "配置校验未通过") { + t.Fatalf("证书错误未正确脱敏或区分: %s %d %s", code, status, message) + } + } +} + func TestWriteConfigErrorKeepsStableAuthorizationCode(t *testing.T) { recorder := httptest.NewRecorder() writeConfigError(recorder, &access.Denial{Code: access.CodeRecoveryCodeRequired}) diff --git a/backend/services/setup/ssl/ssl.go b/backend/services/setup/ssl/ssl.go index 7c72cc0..b569618 100644 --- a/backend/services/setup/ssl/ssl.go +++ b/backend/services/setup/ssl/ssl.go @@ -313,28 +313,7 @@ func sslDomains(cfg *config.Config) []string { if cfg == nil { return nil } - seen := make(map[string]struct{}) - domains := make([]string, 0, len(cfg.Domains)*3+1) - appendDomain := func(domain string) { - domain = strings.TrimSpace(domain) - if domain == "" { - return - } - if _, exists := seen[domain]; exists { - return - } - seen[domain] = struct{}{} - domains = append(domains, domain) - } - appendDomain(cfg.WebDomain) - appendDomain(cfg.Domain) - for _, domain := range cfg.Domains { - appendDomain(domain) - appendDomain("smtp." + domain) - appendDomain("pop." + domain) - appendDomain("imap." + domain) - } - return domains + return config.SSLCertificateDomains(cfg.Domain, cfg.WebDomain, cfg.Domains) } func certificateMatchesConfiguredDomains(cert *x509.Certificate, cfg *config.Config) bool { diff --git a/test/backend/ssl_custom_test.go b/test/backend/ssl_custom_test.go new file mode 100644 index 0000000..dcca85a --- /dev/null +++ b/test/backend/ssl_custom_test.go @@ -0,0 +1,248 @@ +package config + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func customSSLFixture(t *testing.T) (string, *ConfigManager, UpdateRequest, []byte) { + t.Helper() + root := t.TempDir() + runtime := validRuntimeConfig() + runtime.Root.Domains = DomainConfig{Primary: "example.com", Web: "example.com", Accepted: []string{"example.com"}} + writeRuntimeFixture(t, root, runtime) + manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}) + if err != nil { + t.Fatal(err) + } + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + certificate := &x509.Certificate{ + SerialNumber: big.NewInt(1), DNSNames: []string{"example.com", "*.example.com"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + external := filepath.Join(root, "external") + if err := os.Mkdir(external, 0700); err != nil { + t.Fatal(err) + } + for name, content := range map[string][]byte{"public.crt": certPEM, "private.key": keyPEM} { + if err := os.WriteFile(filepath.Join(external, name), content, 0600); err != nil { + t.Fatal(err) + } + } + section := runtime.SSL + section.TLS = SSLConfig{ + Management: SSLManagementCustom, Mode: ManagedSSLModeUpload, + DefaultDirectory: "config/ssl", CustomDirectory: "config/ssl", + CertificateRef: filepath.Join(external, "public.crt"), PrivateKeyRef: filepath.Join(external, "private.key"), + } + payload, err := json.Marshal(section) + if err != nil { + t.Fatal(err) + } + return root, manager, UpdateRequest{Section: SectionSSL, ExpectedGeneration: runtime.Generation, Payload: payload, Actor: authorizedConfigActor()}, certPEM +} + +func assertNoCustomSSLVersions(t *testing.T, root string) { + t.Helper() + if _, err := os.Stat(filepath.Join(root, "config", "ssl", "managed", "upload")); !os.IsNotExist(err) { + t.Fatalf("失败或只读操作产生了证书存储副作用: %v", err) + } +} + +func TestCustomSSLManagerValidateSaveApplyReload(t *testing.T) { + for _, relative := range []bool{false, true} { + t.Run(map[bool]string{false: "absolute", true: "relative"}[relative], func(t *testing.T) { + root, manager, request, certificate := customSSLFixture(t) + if relative { + var section SSLSectionConfig + if err := json.Unmarshal(request.Payload, §ion); err != nil { + t.Fatal(err) + } + section.TLS.CertificateRef = "external/public.crt" + section.TLS.PrivateKeyRef = "external/private.key" + request.Payload, _ = json.Marshal(section) + } + result, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionSSL, ExpectedGeneration: request.ExpectedGeneration, Payload: request.Payload}) + if err != nil || !result.Valid { + t.Fatalf("只读校验失败: %+v, %v", result, err) + } + assertNoCustomSSLVersions(t, root) + oldKey, oldCertificate := testManagedCertificate(t, "old.example.org", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048) + oldReference, err := importManagedSSLAtRoot(root, ManagedSSLModeUpload, oldKey, oldCertificate, nil, nil) + if err != nil { + t.Fatal(err) + } + updated, err := manager.Update(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if manager.Snapshot().Generation != request.ExpectedGeneration { + t.Fatal("暂存提前切换了当前配置") + } + if !strings.HasPrefix(updated.Candidate.SSL.TLS.CertificateRef, "config/ssl/managed/upload/version-") { + t.Fatal("候选未使用独立受管引用") + } + currentBytes, err := os.ReadFile(oldReference.CertificatePath) + if err != nil || string(currentBytes) != string(oldCertificate) { + t.Fatal("暂存不应覆盖原有 current 证书") + } + if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionSSL, request.Actor); err != nil { + t.Fatal(err) + } + if err := manager.Load(context.Background()); err != nil { + t.Fatal(err) + } + reference, err := resolveManagedSSLReferencesAtRoot(root, manager.Snapshot().SSL.TLS) + if err != nil { + t.Fatal(err) + } + pair, err := tls.LoadX509KeyPair(reference.CertificatePath, reference.PrivateKeyPath) + if err != nil || len(pair.Certificate) == 0 { + t.Fatalf("重新加载 TLS 证书失败: %v", err) + } + stored, err := os.ReadFile(reference.CertificatePath) + if err != nil || string(stored) != string(certificate) { + t.Fatal("导入改变了证书内容") + } + // 再次保存受管引用不应重复导入。 + payload, _ := json.Marshal(manager.Snapshot().SSL) + again, err := manager.Update(context.Background(), UpdateRequest{Section: SectionSSL, ExpectedGeneration: updated.Generation, Payload: payload, Actor: request.Actor}) + if err != nil || again.Candidate.SSL.TLS.CertificateRef != reference.CertificateRef { + t.Fatalf("受管引用回填无法再次保存: %v", err) + } + }) + } +} + +func TestCustomSSLManagerRejectsBeforeWriting(t *testing.T) { + for _, name := range []string{"unauthorized", "confirmation", "invalid_config", "cancelled"} { + t.Run(name, func(t *testing.T) { + root, manager, request, _ := customSSLFixture(t) + ctx := context.Background() + switch name { + case "unauthorized": + request.Actor = AuditActor{} + case "confirmation": + manager.security.Confirm = func(context.Context, AuditActor, StepUpConfirmation, ConfigOperation, Section) error { + return ErrStepUpRequired + } + case "invalid_config": + manager.options.HasSuperAdmin = false + case "cancelled": + cancelled, cancel := context.WithCancel(ctx) + cancel() + ctx = cancelled + } + if _, err := manager.Update(ctx, request); err == nil { + t.Fatal("无效操作意外成功") + } + assertNoCustomSSLVersions(t, root) + if manager.Pending(request.Actor) != nil { + t.Fatal("无效操作留下候选") + } + }) + } +} + +func TestCustomSSLApplyRechecksFilesAndCancelPreservesActive(t *testing.T) { + root, manager, request, _ := customSSLFixture(t) + updated, err := manager.Update(context.Background(), request) + if err != nil { + t.Fatal(err) + } + reference, err := resolveManagedSSLReferencesAtRoot(root, updated.Candidate.SSL.TLS) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(reference.CertificatePath, []byte("invalid"), 0600); err != nil { + t.Fatal(err) + } + if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionSSL, request.Actor); err == nil { + t.Fatal("损坏候选证书不应应用成功") + } + if manager.Snapshot().Generation != request.ExpectedGeneration { + t.Fatal("失败应用改变了当前代次") + } + if err := manager.ManagePending(context.Background(), updated.Generation, updated.CandidateID, request.Actor, StepUpConfirmation{}, true); err != nil { + t.Fatal(err) + } + if manager.Pending(request.Actor) != nil || manager.Snapshot().Generation != request.ExpectedGeneration { + t.Fatal("取消候选影响了现用配置") + } +} + +func TestCustomSSLRejectsInvalidMaterialsAndPaths(t *testing.T) { + root, manager, request, _ := customSSLFixture(t) + var original SSLSectionConfig + if err := json.Unmarshal(request.Payload, &original); err != nil { + t.Fatal(err) + } + for _, name := range []string{"missing", "traversal", "mismatch", "domain", "expired", "weak"} { + t.Run(name, func(t *testing.T) { + section := original + want := ErrSSLFileUnavailable + switch name { + case "missing": + section.TLS.CertificateRef = filepath.Join(root, "missing.crt") + case "traversal": + section.TLS.CertificateRef = "external/../external/public.crt" + want = ErrInvalidSSLReference + default: + bits, expires := 2048, time.Now().Add(time.Hour) + want = ErrSSLCertificateDomain + if name == "expired" { + expires = time.Now().Add(-time.Minute) + want = ErrSSLCertificateExpired + } + if name == "weak" { + bits = 1024 + want = ErrInvalidSSLMaterial + } + key, cert := testManagedCertificate(t, "other.example.org", time.Now().Add(-time.Hour), expires, bits) + section.TLS.CertificateRef = filepath.Join(root, name+".crt") + if err := os.WriteFile(section.TLS.CertificateRef, cert, 0600); err != nil { + t.Fatal(err) + } + if name == "mismatch" { + want = ErrSSLCertificateMismatch + } else { + section.TLS.PrivateKeyRef = filepath.Join(root, name+".key") + if err := os.WriteFile(section.TLS.PrivateKeyRef, key, 0600); err != nil { + t.Fatal(err) + } + } + } + request.Payload, _ = json.Marshal(section) + if _, err := manager.Update(context.Background(), request); !errors.Is(err, want) { + t.Fatalf("证书错误类型不符: %v, want %v", err, want) + } + result, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionSSL, Payload: request.Payload}) + if err != nil || result.Valid || len(result.Issues) != 1 || strings.Contains(result.Issues[0].Message, root) { + t.Fatalf("校验接口应返回安全的具体错误: %+v, %v", result, err) + } + assertNoCustomSSLVersions(t, root) + }) + } +} diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index a338947..7350350 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -695,6 +695,46 @@ test('邮件页保持验证、保存、应用端点和代次协议,中继密 assert.equal(page.savedGeneration.value, 0) }) +test('自定义证书导入后使用规范化的候选引用继续应用,不误判表单已变化', async () => { + const requests = [] + let pending = null + let active = { + tls: { + management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl', + certificateRef: 'D:/certs/public.crt', privateKeyRef: 'D:/certs/private.key' + } + } + const normalized = { + tls: { + ...active.tls, + certificateRef: 'config/ssl/managed/upload/version-0123456789abcdef0123456789abcdef/public.crt', + privateKeyRef: 'config/ssl/managed/upload/version-0123456789abcdef0123456789abcdef/private.key' + } + } + const { page } = fixture(async () => ({ errorNo: 0, data: active }), 'ssl', { + http: { + get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 8 } : active }), + put: async (path, body) => { + requests.push({ path, body }) + pending = { generation: 9, candidateId: 'ssl-candidate', section: 'ssl', sameSession: true, payload: normalized } + return { errorNo: 0, data: pending } + }, + post: async (path, body) => { + requests.push({ path, body }) + active = normalized + pending = null + return { errorNo: 0, data: { generation: 9 } } + } + } + }) + await page.loadAll() + await page.saveAndApply() + assert.deepEqual(requests.map(item => item.path), ['/api/v1/config/ssl', '/api/v1/config/ssl/apply']) + assert.deepEqual(requests[1].body, { generation: 9, candidateId: 'ssl-candidate' }) + assert.equal(JSON.parse(page.editorText.value).tls.certificateRef, normalized.tls.certificateRef) + assert.equal(page.savedGeneration.value, 0) +}) + test('可视化编辑按权限和工作状态保护,编辑会清除过期确认及候选代次', async () => { const config = { listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, diff --git a/test/run.mjs b/test/run.mjs index a484ed2..9318be9 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -42,6 +42,7 @@ if (suite !== 'frontend' && result === 0) { const args = extra[0] === '--' ? extra.slice(1) : extra // 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。 const replacements = { + 'backend/config/ssl_custom_regression_test.go': 'test/backend/ssl_custom_test.go', 'backend/config/webauthn_defaults_test.go': 'test/backend/webauthn_config_test.go', 'backend/res_init/webauthn_runtime_test.go': 'test/backend/webauthn_runtime_test.go', 'backend/services/credentials/client_regression_test.go': 'test/backend/protocol_credentials_test.go', diff --git a/test/ssl-runtime-check.py b/test/ssl-runtime-check.py new file mode 100644 index 0000000..d5414c6 --- /dev/null +++ b/test/ssl-runtime-check.py @@ -0,0 +1,34 @@ +"""核对本地邮件 TLS 监听实际使用的证书,仅用于开发环境验收。""" + +import argparse +import hashlib +from pathlib import Path +import socket +import ssl + + +def main(): + parser = argparse.ArgumentParser(description="核对邮件 TLS 端口的证书指纹") + parser.add_argument("certificate", type=Path) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--server-name", required=True) + parser.add_argument("--ports", type=int, nargs="+", default=[465, 993, 995]) + args = parser.parse_args() + pem = args.certificate.read_text(encoding="utf-8") + leaf = pem.split("-----END CERTIFICATE-----", 1)[0] + "-----END CERTIFICATE-----\n" + expected = hashlib.sha256(ssl.PEM_cert_to_DER_cert(leaf)).hexdigest() + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # 本地自签名证书用精确指纹验证,不改变应用或系统的信任配置。 + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + for port in args.ports: + with socket.create_connection((args.host, port), timeout=5) as connection: + with context.wrap_socket(connection, server_hostname=args.server_name) as stream: + actual = hashlib.sha256(stream.getpeercert(binary_form=True)).hexdigest() + if actual != expected: + raise RuntimeError(f"端口 {port} 未加载预期证书") + print(f"端口 {port}:TLS 握手成功,证书指纹一致,协议 {stream.version()}") + + +if __name__ == "__main__": + main()