Smail/test/backend/ssl_retention_test.go
smail 9dc861d560
Some checks are pending
Security Checks / security (push) Waiting to run
角色权限收紧前保存证书直接引用与续期热切换改造
2026-09-09 17:42:19 +08:00

292 lines
9.6 KiB
Go
Raw Permalink 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 (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func sslConfigForReference(ref ManagedSSLReference) SSLConfig {
return SSLConfig{Mode: ref.Mode, CertificateRef: ref.CertificateRef, PrivateKeyRef: ref.PrivateKeyRef}
}
func countFlatVersions(t *testing.T, root string) int {
t.Helper()
entries, err := os.ReadDir(filepath.Join(root, "ssl"))
if err != nil {
t.Fatal(err)
}
count := 0
for _, entry := range entries {
publicPath := filepath.Join(root, "ssl", entry.Name())
privatePath := strings.TrimSuffix(publicPath, ".crt") + ".key"
if flatSSLPair(root, publicPath, privatePath) {
if _, err := os.Stat(privatePath); err != nil {
t.Fatal("证书没有对应私钥", err)
}
count++
}
}
return count
}
func countManagedVersions(t *testing.T, root string) int {
t.Helper()
count := 0
for _, mode := range []string{ManagedSSLModeDefault, ManagedSSLModeUpload} {
path, err := managedSSLRootPathAtRoot(root, mode)
if err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(path)
if err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
for _, entry := range entries {
if validSSLVersion(entry.Name()) {
count++
}
}
}
return count
}
func TestSSLRetentionHandlesExpiredPreviousAndLegacyCurrent(t *testing.T) {
root := t.TempDir()
if err := InitializeDeployment(root); err != nil {
t.Fatal(err)
}
key, cert := testManagedCertificate(t, "example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048)
legacy, err := importManagedSSLAtRoot(root, ManagedSSLModeDefault, key, cert, nil, nil)
if err != nil {
t.Fatal(err)
}
var refs []ManagedSSLReference
for index := 0; index < 4; index++ {
ref, err := stageManagedSSLAtRoot(root, ManagedSSLModeUpload, key, cert, nil, nil)
if err != nil {
t.Fatal(err)
}
releaseManagedSSL(root, sslConfigForReference(ref))
refs = append(refs, ref)
}
expiredKey, expiredCert := testManagedCertificate(t, "example.com", time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), 2048)
if err := os.WriteFile(refs[2].PrivateKeyPath, expiredKey, 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(refs[2].CertificatePath, expiredCert, 0600); err != nil {
t.Fatal(err)
}
maintainManagedSSL(root, sslConfigForReference(refs[3]), sslConfigForReference(refs[2]))
if countManagedVersions(t, root) != 2 {
t.Fatal("上一份证书过期阻止了清理")
}
if _, err := os.Stat(legacy.CertificatePath); !os.IsNotExist(err) {
t.Fatal("未被引用的旧 current 未清理")
}
if _, err := os.Stat(refs[2].CertificatePath); err != nil {
t.Fatal("清理删除了受保护的上一份证书")
}
old := validRuntimeConfig()
old.SSL.TLS = sslConfigForReference(legacy)
if err := validateRollbackSSL(root, old); !errors.Is(err, ErrSSLHistoryUnavailable) {
t.Fatal("历史 current 丢失后仍允许回滚")
}
}
func TestSSLRetentionProtectsIndependentLease(t *testing.T) {
root := t.TempDir()
if err := InitializeDeployment(root); err != nil {
t.Fatal(err)
}
key, cert := testManagedCertificate(t, "example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048)
var refs []ManagedSSLReference
for index := 0; index < 4; index++ {
ref, err := stageManagedSSLAtRoot(root, ManagedSSLModeUpload, key, cert, nil, nil)
if err != nil {
t.Fatal(err)
}
releaseManagedSSL(root, sslConfigForReference(ref))
refs = append(refs, ref)
}
lease, err := openSSLLease(refs[0].PrivateKeyPath)
if err != nil {
t.Fatal(err)
}
defer lease.Close()
unlock, err := lockStateFile(lease)
if err != nil {
t.Fatal(err)
}
defer unlock()
maintainManagedSSL(root, sslConfigForReference(refs[3]))
if _, err := os.Stat(refs[0].CertificatePath); err != nil {
t.Fatal("清理忽略了其他句柄持有的暂存租约")
}
if countManagedVersions(t, root) != 2 {
t.Fatal("暂存和生效证书以外的历史未清理")
}
}
func TestSSLRetentionKeepsTwoAcrossModesAndProtectsStaged(t *testing.T) {
root := t.TempDir()
if err := InitializeDeployment(root); err != nil {
t.Fatal(err)
}
key, cert := testManagedCertificate(t, "example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048)
var refs []ManagedSSLReference
for index := 0; index < 4; index++ {
mode := ManagedSSLModeDefault
if index%2 == 1 {
mode = ManagedSSLModeUpload
}
ref, err := stageManagedSSLAtRoot(root, mode, key, cert, nil, nil)
if err != nil {
t.Fatal(err)
}
refs = append(refs, ref)
if index > 0 {
maintainManagedSSL(root, sslConfigForReference(refs[index-1]))
if _, err := os.Stat(ref.PrivateKeyPath); err != nil {
t.Fatal("清理误删尚未提交的续期版本")
}
maintainManagedSSL(root, sslConfigForReference(ref), sslConfigForReference(refs[index-1]))
} else {
maintainManagedSSL(root, sslConfigForReference(ref))
}
want := index + 1
if want > 2 {
want = 2
}
if actual := countManagedVersions(t, root); actual != want {
t.Fatalf("跨模式保留总量错误:%d,期望 %d", actual, want)
}
}
old := validRuntimeConfig()
old.SSL.TLS = sslConfigForReference(refs[0])
if err := validateRollbackSSL(root, old); !errors.Is(err, ErrSSLHistoryUnavailable) {
t.Fatalf("旧证书已清理却允许回滚:%v", err)
}
// 即使当前证书的目录时间最旧,也必须保留当前证书。
if err := os.Chtimes(filepath.Dir(refs[2].CertificatePath), time.Unix(1, 0), time.Unix(1, 0)); err != nil {
t.Fatal(err)
}
maintainManagedSSL(root, sslConfigForReference(refs[2]))
if _, err := resolveManagedSSLReferencesAtRoot(root, sslConfigForReference(refs[2])); err != nil {
t.Fatal("清理删除了旧时间的生效证书")
}
}
func TestSSLRetentionDoesNotPartiallyDeleteUnknownFiles(t *testing.T) {
directory := filepath.Join(t.TempDir(), "version-00000000000000000000000000000000")
if err := os.Mkdir(directory, 0700); err != nil {
t.Fatal(err)
}
for _, name := range []string{"private.key", "public.crt", "zzz-user-note.txt"} {
if err := os.WriteFile(filepath.Join(directory, name), []byte("preserved"), 0600); err != nil {
t.Fatal(err)
}
}
if err := removeManagedSSLDirectory(directory); !errors.Is(err, ErrUnsafeSSLFile) {
t.Fatal("未知文件没有阻止删除")
}
for _, name := range []string{"private.key", "public.crt", "zzz-user-note.txt"} {
if _, err := os.Stat(filepath.Join(directory, name)); err != nil {
t.Fatal("预检期间已经删除了部分文件")
}
}
}
func TestSSLRetentionRunsOnApplyCancelAndReload(t *testing.T) {
root, manager, request, _ := customSSLFixture(t)
var first RuntimeConfig
for index := 0; index < 4; index++ {
// 自定义保存不再产生副本;这里显式模拟自动续期的新版本。
certificate, err := os.ReadFile(filepath.Join(root, "external", "public.crt"))
if err != nil {
t.Fatal(err)
}
key, err := os.ReadFile(filepath.Join(root, "external", "private.key"))
if err != nil {
t.Fatal(err)
}
ref, err := stageFlatSSLAtRoot(root, ManagedSSLModeUpload, key, certificate, nil, nil)
if err != nil {
t.Fatal(err)
}
var section SSLSectionConfig
if err := json.Unmarshal(request.Payload, &section); err != nil {
t.Fatal(err)
}
section.TLS.CertificateRef, section.TLS.PrivateKeyRef = ref.CertificateRef, ref.PrivateKeyRef
request.Payload, _ = json.Marshal(section)
request.ExpectedGeneration = manager.Snapshot().Generation
updated, err := manager.Update(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionSSL, request.Actor); err != nil {
t.Fatal(err)
}
if index == 0 {
first = manager.Snapshot()
if _, err := manager.transactions.CreateBackup(first); err != nil {
t.Fatal(err)
}
}
maintainManagedSSL(root, manager.Snapshot().SSL.TLS)
if index > 0 && countFlatVersions(t, root) != 2 {
t.Fatal("配置应用完成后未自动保留两份")
}
}
if _, err := manager.PreviewRollback(context.Background(), first.Generation, request.Actor); !errors.Is(err, ErrSSLHistoryUnavailable) {
t.Fatalf("预览允许回滚已经清理的证书:%v", err)
}
if _, _, err := manager.rollbackWithState(context.Background(), first.Generation, request.Actor, StepUpConfirmation{Confirmed: true, Method: "ticket"}); !errors.Is(err, ErrSSLHistoryUnavailable) {
t.Fatalf("实际回滚没有拒绝已清理证书:%v", err)
}
request.ExpectedGeneration = manager.Snapshot().Generation
updated, err := manager.Update(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if err := manager.ManagePending(context.Background(), updated.Generation, updated.CandidateID, request.Actor, StepUpConfirmation{}, true); err != nil {
t.Fatal(err)
}
if countFlatVersions(t, root) != 2 {
t.Fatal("取消候选未收敛到两份")
}
if err := manager.Load(context.Background()); err != nil {
t.Fatal(err)
}
if countFlatVersions(t, root) != 2 {
t.Fatal("重新加载后保留数量错误")
}
// 确认五分区仍可完整读取,证书删除不能导致摘要被直接修改。
data, _ := json.Marshal(manager.Snapshot().SSL)
if len(data) == 0 {
t.Fatal("配置快照不可读")
}
request.ExpectedGeneration = manager.Snapshot().Generation
updated, err = manager.Update(context.Background(), request)
if err != nil {
t.Fatal(err)
}
manager.releasePendingSSL()
path, err := resolveManagedSSLPathsAtRoot(root, updated.Candidate.SSL.TLS)
if err != nil {
t.Fatal(err)
}
managedSSLStorage.Lock()
stillLeased := managedSSLStorage.staged[path.CertificatePath] != nil
managedSSLStorage.Unlock()
if stillLeased {
t.Fatal("管理器停止后仍持有暂存租约")
}
}