This commit is contained in:
parent
fd2e703b2c
commit
2e4813ec92
@ -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
|
||||
}
|
||||
|
||||
210
backend/config/ssl_custom.go
Normal file
210
backend/config/ssl_custom.go
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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...)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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 {
|
||||
|
||||
248
test/backend/ssl_custom_test.go
Normal file
248
test/backend/ssl_custom_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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',
|
||||
|
||||
34
test/ssl-runtime-check.py
Normal file
34
test/ssl-runtime-check.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user