Smail/backend/config/ssl_storage_test.go
smail 606861822b
Some checks are pending
Security Checks / security (push) Waiting to run
保存统一Web域名恢复入口接手基线
2026-09-16 19:17:55 +08:00

336 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package config
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
func TestImportManagedSSLUsesImmutableRootFiles(t *testing.T) {
oldRoot := ROOT_PATH
ROOT_PATH = t.TempDir()
t.Cleanup(func() { ROOT_PATH = oldRoot })
privateKeyPEM, certificatePEM := testManagedCertificate(t, "mail.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour), 2048)
first, err := ImportManagedSSL(ManagedSSLModeDefault, privateKeyPEM, certificatePEM, nil, []string{"mail.example.com"})
if err != nil {
t.Fatal(err)
}
defer ReleaseStagedSSL(first)
if filepath.IsAbs(first.CertificateRef) || filepath.IsAbs(first.PrivateKeyRef) {
t.Fatal("SSL 内部引用不应为绝对路径")
}
if !flatSSLPair(ROOT_PATH, first.CertificatePath, first.PrivateKeyPath) {
t.Fatalf("内部引用不符合预期: %+v", first)
}
resolved, err := ResolveManagedSSLReferences(SSLConfig{Mode: ManagedSSLModeDefault, DefaultDirectory: "config/ssl", CertificateRef: first.CertificateRef, PrivateKeyRef: first.PrivateKeyRef})
if err != nil {
t.Fatal(err)
}
if resolved.CertificatePath != first.CertificatePath || resolved.PrivateKeyPath != first.PrivateKeyPath {
t.Fatal("解析后的受控路径与导入结果不一致")
}
secondKey, secondCertificate := testManagedCertificate(t, "mail.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour), 2048)
second, err := ImportManagedSSL(ManagedSSLModeDefault, secondKey, secondCertificate, nil, []string{"mail.example.com"})
if err != nil {
t.Fatal(err)
}
defer ReleaseStagedSSL(second)
if second.CertificatePath == first.CertificatePath {
t.Fatal("新证书必须使用独立文件,不能覆盖正在使用的材料")
}
currentData, err := os.ReadFile(second.CertificatePath)
if err != nil {
t.Fatal(err)
}
if string(currentData) == string(certificatePEM) {
t.Fatal("current 目录没有切换到新证书")
}
previousData, err := os.ReadFile(first.CertificatePath)
if err != nil || string(previousData) != string(certificatePEM) {
t.Fatal("暂存新证书改变了原证书")
}
}
func TestResolveRuntimeSSLPathsUsesManagedReferences(t *testing.T) {
oldRoot := ROOT_PATH
ROOT_PATH = t.TempDir()
t.Cleanup(func() { ROOT_PATH = oldRoot })
privateKeyPEM, certificatePEM := testManagedCertificate(t, "mail.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour), 2048)
reference, err := ImportManagedSSL(ManagedSSLModeDefault, privateKeyPEM, certificatePEM, nil, []string{"mail.example.com"})
if err != nil {
t.Fatal(err)
}
cfg := &Config{SSL: SSLConfig{Mode: ManagedSSLModeDefault, CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.PrivateKeyRef}}
certificatePath, privateKeyPath, err := ResolveRuntimeSSLPaths(cfg)
if err != nil {
t.Fatal(err)
}
if certificatePath != reference.CertificatePath || privateKeyPath != reference.PrivateKeyPath {
t.Fatalf("resolved paths = %q, %q", certificatePath, privateKeyPath)
}
if _, err := LoadRuntimeTLSCertificate(cfg); err != nil {
t.Fatal(err)
}
}
func TestResolveRuntimeSSLPathsRejectsPartialManagedConfiguration(t *testing.T) {
_, _, err := ResolveRuntimeSSLPaths(&Config{SSL: SSLConfig{Mode: ManagedSSLModeDefault, CertificateRef: "config/ssl/managed/default/current/public.crt"}})
if !errors.Is(err, ErrInvalidSSLReference) {
t.Fatalf("error = %v, want %v", err, ErrInvalidSSLReference)
}
}
func TestResolveRuntimeSSLPathsValidatesLegacyFiles(t *testing.T) {
oldRoot := ROOT_PATH
ROOT_PATH = t.TempDir()
t.Cleanup(func() { ROOT_PATH = oldRoot })
privateKeyPEM, certificatePEM := testManagedCertificate(t, "mail.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour), 2048)
directory := filepath.Join(t.TempDir(), "legacy-ssl")
if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatal(err)
}
privateKeyPath := filepath.Join(directory, "private.key")
certificatePath := filepath.Join(directory, "public.crt")
if err := os.WriteFile(privateKeyPath, privateKeyPEM, 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(certificatePath, certificatePEM, 0600); err != nil {
t.Fatal(err)
}
resolvedCertificate, resolvedPrivate, err := ResolveRuntimeSSLPaths(&Config{SSLPublicKeyPath: certificatePath, SSLPrivateKeyPath: privateKeyPath})
if err != nil {
t.Fatal(err)
}
if resolvedCertificate != certificatePath || resolvedPrivate != privateKeyPath {
t.Fatalf("resolved paths = %q, %q", resolvedCertificate, resolvedPrivate)
}
}
func TestResolveManagedSSLReferencesRejectsUnsafeReferences(t *testing.T) {
oldRoot := ROOT_PATH
ROOT_PATH = t.TempDir()
t.Cleanup(func() { ROOT_PATH = oldRoot })
privateKeyPEM, certificatePEM := testManagedCertificate(t, "mail.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour), 2048)
reference, err := ImportManagedSSL(ManagedSSLModeUpload, privateKeyPEM, certificatePEM, nil, nil)
if err != nil {
t.Fatal(err)
}
unsafe := []SSLConfig{
{Mode: ManagedSSLModeUpload, CertificateRef: filepath.Join(filepath.Dir(reference.CertificateRef), "..", "public.crt"), PrivateKeyRef: reference.PrivateKeyRef},
{Mode: ManagedSSLModeUpload, CertificateRef: filepath.Join(t.TempDir(), "public.crt"), PrivateKeyRef: reference.PrivateKeyRef},
{Mode: ManagedSSLModeUpload, CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.CertificateRef},
{Mode: ManagedSSLModeUpload, CertificateRef: `C:\outside\public.crt`, PrivateKeyRef: reference.PrivateKeyRef},
{Mode: ManagedSSLModeUpload, CertificateRef: `\\server\share\public.crt`, PrivateKeyRef: reference.PrivateKeyRef},
}
for index, candidate := range unsafe {
if _, err := ResolveManagedSSLReferences(candidate); err == nil {
t.Fatalf("用例 %d 不应接受缺失、不配对或不安全的文件", index)
}
}
}
func TestResolveManagedSSLReferencesRejectsSymlinkAndBroadPrivatePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("当前测试依赖 Unix 文件权限和符号链接")
}
oldRoot := ROOT_PATH
ROOT_PATH = t.TempDir()
t.Cleanup(func() { ROOT_PATH = oldRoot })
privateKeyPEM, certificatePEM := testManagedCertificate(t, "mail.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour), 2048)
reference, err := ImportManagedSSL(ManagedSSLModeDefault, privateKeyPEM, certificatePEM, nil, nil)
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(reference.PrivateKeyPath, 0640); err != nil {
t.Fatal(err)
}
if _, err := ResolveManagedSSLReferences(SSLConfig{Mode: ManagedSSLModeDefault, CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.PrivateKeyRef}); !errors.Is(err, ErrSSLPrivateKeyPermission) {
t.Fatalf("私钥权限 error = %v, want %v", err, ErrSSLPrivateKeyPermission)
}
if err := os.Chmod(reference.PrivateKeyPath, 0600); err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "outside.crt")
if err := os.WriteFile(outside, certificatePEM, 0600); err != nil {
t.Fatal(err)
}
if err := os.Remove(reference.CertificatePath); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, reference.CertificatePath); err != nil {
t.Skipf("当前环境不支持符号链接测试: %v", err)
}
if _, err := ResolveManagedSSLReferences(SSLConfig{Mode: ManagedSSLModeDefault, CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.PrivateKeyRef}); !errors.Is(err, ErrUnsafeSSLFile) {
t.Fatalf("符号链接 error = %v, want %v", err, ErrUnsafeSSLFile)
}
}
func TestValidateSSLMaterialRejectsDomainExpiryAndWeakKeys(t *testing.T) {
privateKeyPEM, certificatePEM := testManagedCertificate(t, "mail.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour), 2048)
if err := ValidateSSLMaterial(privateKeyPEM, certificatePEM, nil, []string{"other.example.com"}); !errors.Is(err, ErrSSLCertificateDomain) {
t.Fatalf("域名不匹配 error = %v, want %v", err, ErrSSLCertificateDomain)
}
expiredKey, expiredCertificate := testManagedCertificate(t, "mail.example.com", time.Now().Add(-48*time.Hour), time.Now().Add(-time.Hour), 2048)
if err := ValidateSSLMaterial(expiredKey, expiredCertificate, nil, nil); !errors.Is(err, ErrSSLCertificateExpired) {
t.Fatalf("过期证书 error = %v, want %v", err, ErrSSLCertificateExpired)
}
weakKey, weakCertificate := testManagedCertificate(t, "mail.example.com", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour), 1024)
if err := ValidateSSLMaterial(weakKey, weakCertificate, nil, nil); !errors.Is(err, ErrInvalidSSLMaterial) {
t.Fatalf("弱密钥 error = %v, want %v", err, ErrInvalidSSLMaterial)
}
}
func TestSSLCertificateDomainsIncludesWebAndMailServiceDomains(t *testing.T) {
domains := SSLCertificateDomains("example.com", "mail.example.com", []string{"example.com"})
for _, want := range []string{"example.com", "mail.example.com", "smtp.example.com", "imap.example.com", "pop.example.com"} {
found := false
for _, got := range domains {
if got == want {
found = true
break
}
}
if !found {
t.Fatalf("certificate domains %v do not include %q", domains, want)
}
}
}
func TestPrepareCustomSSLPathsKeepsDirectFilesWithoutCopying(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 != filepath.ToSlash(certificatePath) || runtime.SSL.TLS.PrivateKeyRef != filepath.ToSlash(privateKeyPath) {
t.Fatal("仅校验阶段应保留规范化的直接路径")
}
managedRoot := filepath.Join(root, "data", "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 != filepath.ToSlash(certificatePath) || runtime.SSL.TLS.PrivateKeyRef != filepath.ToSlash(privateKeyPath) {
t.Fatal("保存后应继续引用原始文件")
}
if _, err := os.Stat(managedRoot); !os.IsNotExist(err) {
t.Fatal("保存自定义证书不应创建副本")
}
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...)
if err := ValidateSSLMaterial(privateKeyPEM, certificateBundle, issuerPEM, []string{"mail.example.com"}); err != nil {
t.Fatalf("bundled issuer was rejected as a duplicate chain entry: %v", err)
}
}
func testManagedCertificate(t *testing.T, domain string, notBefore, notAfter time.Time, bits int, additionalDomains ...string) ([]byte, []byte) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: domain},
DNSNames: append([]string{domain}, additionalDomains...),
NotBefore: notBefore,
NotAfter: notAfter,
BasicConstraintsValid: true,
IsCA: true,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
func testChainedCertificate(t *testing.T) ([]byte, []byte, []byte) {
t.Helper()
issuerKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
now := time.Now()
issuerTemplate := &x509.Certificate{
SerialNumber: big.NewInt(100),
Subject: pkix.Name{CommonName: "Smail Test Issuer"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
BasicConstraintsValid: true,
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
issuerDER, err := x509.CreateCertificate(rand.Reader, issuerTemplate, issuerTemplate, &issuerKey.PublicKey, issuerKey)
if err != nil {
t.Fatal(err)
}
leafKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
leafTemplate := &x509.Certificate{
SerialNumber: big.NewInt(101),
Subject: pkix.Name{CommonName: "mail.example.com"},
DNSNames: []string{"mail.example.com"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, issuerTemplate, &leafKey.PublicKey, issuerKey)
if err != nil {
t.Fatal(err)
}
return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(leafKey)}),
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}),
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuerDER})
}