From 9074d83dc8aaa628d09f1103c2f43e23682e317e Mon Sep 17 00:00:00 2001 From: xuans Date: Fri, 18 Sep 2026 00:32:11 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=AE=A1=E7=90=86=E8=AF=81?= =?UTF-8?q?=E4=B9=A6TLS=E7=83=AD=E6=9B=B4=E6=96=B0=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=8E=9F=E7=89=88=E6=9C=AC=E7=94=A8=E4=BA=8E=E5=9B=9E?= =?UTF-8?q?=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/certificate/lifecycle.go | 4 +- .../certificate/management_storage_test.go | 72 ++++ backend/internal/certificate/service.go | 2 +- backend/internal/httpapi/server.go | 46 ++- backend/internal/httpapi/tls_certificate.go | 102 ++++++ .../internal/httpapi/tls_certificate_test.go | 335 ++++++++++++++++++ 6 files changed, 551 insertions(+), 10 deletions(-) create mode 100644 backend/internal/certificate/management_storage_test.go create mode 100644 backend/internal/httpapi/tls_certificate.go create mode 100644 backend/internal/httpapi/tls_certificate_test.go diff --git a/backend/internal/certificate/lifecycle.go b/backend/internal/certificate/lifecycle.go index 7dc2ad0..3862999 100644 --- a/backend/internal/certificate/lifecycle.go +++ b/backend/internal/certificate/lifecycle.go @@ -62,6 +62,8 @@ type FileStore struct { Root string BackupRoot string Now func() time.Time + // 管理入口仍可能引用旧版本,完成双层 TLS 切换前不得清理该目录。 + RetainPrevious bool } func NormalizeDomains(domains []string) ([]string, error) { @@ -268,7 +270,7 @@ func (s FileStore) save(ctx context.Context, domain, certificateType string, res } return SavedResource{}, fmt.Errorf("写入证书元数据失败:%w", err) } - if certificateType != "" && activeDirectory != "" && activeDirectory != currentDirectory { + if !s.RetainPrevious && certificateType != "" && activeDirectory != "" && activeDirectory != currentDirectory { if err := removeCertificateDirectory(activeDirectory, typeDirectory); err != nil { return SavedResource{}, fmt.Errorf("清理旧当前证书目录失败:%w", err) } diff --git a/backend/internal/certificate/management_storage_test.go b/backend/internal/certificate/management_storage_test.go new file mode 100644 index 0000000..1113607 --- /dev/null +++ b/backend/internal/certificate/management_storage_test.go @@ -0,0 +1,72 @@ +package certificate + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/go-acme/lego/v5/challenge" +) + +func TestManagementCertificatePreservesReferencedVersions(t *testing.T) { + cfg := certificateTestConfig(t.TempDir()) + issuer := &serviceIssuer{resource: testResource(t, "management.example.com")} + service := NewService(cfg, nil, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) + service.acmeRoots = testResourceRoots(t, issuer.resource) + service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { return issuer } + request := SetupCertificateRequest{ + Domains: []string{"management.example.com"}, CertificateType: "single", + ProviderID: "cloudflare", CredentialRef: "secret://dns/cloudflare/test", + AccountEmail: "admin@example.com", Environment: "staging", + CertificateRoot: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup, + } + first, err := service.IssueForSetup(t.Context(), request) + if err != nil { + t.Fatal(err) + } + original := make(map[string][]byte) + for _, name := range []string{"cert.pem", "fullchain.pem", "key.pem", "metadata.json"} { + data, err := os.ReadFile(filepath.Join(first.Directory, name)) + if err != nil { + t.Fatal(err) + } + original[name] = data + } + // 使用不同证书证明后续申请及失败回退不会覆盖旧版本内容。 + issuer.resource = testResource(t, "management.example.com") + service.acmeRoots = testResourceRoots(t, issuer.resource) + second, err := service.IssueForSetup(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if second.Directory == first.Directory || second.BackupDirectory == "" { + t.Fatal("管理证书候选必须保存到新版本并备份旧版本") + } + assertUnchanged := func() { + t.Helper() + for name, expected := range original { + actual, err := os.ReadFile(filepath.Join(first.Directory, name)) + if err != nil || !bytes.Equal(actual, expected) { + t.Fatalf("仍被引用的管理证书文件被修改:%s %v", name, err) + } + } + } + assertUnchanged() + if err := RollbackSavedResource(context.Background(), second); err != nil { + t.Fatal(err) + } + assertUnchanged() + third, err := service.IssueForSetup(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if third.Directory == second.Directory || third.Directory == first.Directory { + t.Fatal("后续申请不得复用既有管理证书版本") + } + assertUnchanged() + if _, err := os.Stat(second.FullChainPath); err != nil { + t.Fatalf("失败候选应保留用于审查:%v", err) + } +} diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index a3460ff..52d2bb6 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -271,7 +271,7 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq if err := s.validateACMEResource(ctx, resource, domains, environment); err != nil { return SavedResource{}, err } - store := FileStore{Root: request.CertificateRoot, BackupRoot: request.BackupRoot} + store := FileStore{Root: request.CertificateRoot, BackupRoot: request.BackupRoot, RetainPrevious: true} saved, err := store.SaveForType(ctx, domains[0], certificateType, resource) if err != nil { return SavedResource{}, fmt.Errorf("保存初始化证书失败:%w", err) diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index d18a606..2fa4056 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -3,6 +3,7 @@ package httpapi import ( "context" "crypto/tls" + "crypto/x509" "database/sql" "errors" "fmt" @@ -13,6 +14,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/gin-gonic/gin" @@ -115,6 +117,9 @@ type Server struct { httpMu sync.Mutex httpStopped bool httpServer *http.Server + tlsMu sync.Mutex + tlsCertificate atomic.Pointer[tls.Certificate] + tlsRoots *x509.CertPool router *gin.Engine } @@ -220,43 +225,68 @@ func (s *Server) Handler() http.Handler { } func (s *Server) ListenAndServe() error { + server, err := s.prepareHTTPServer() + if err != nil { + return err + } + return server.ListenAndServeTLS("", "") +} + +func (s *Server) prepareHTTPServer() (*http.Server, error) { + s.tlsMu.Lock() + defer s.tlsMu.Unlock() + s.httpMu.Lock() + stopped, started := s.httpStopped, s.httpServer != nil + s.httpMu.Unlock() + if stopped { + return nil, http.ErrServerClosed + } + if started { + return nil, errors.New("HTTPS 服务已经启动") + } s.configMu.RLock() cfg := s.config s.configMu.RUnlock() if !cfg.SetupReady { - return errors.New("初始化未完成,请使用独立 HTTPS 初始化服务") + return nil, errors.New("初始化未完成,请使用独立 HTTPS 初始化服务") } address, err := listenAddress(cfg) if err != nil { - return err + return nil, err } certPath := strings.TrimSpace(cfg.Server.TLSCert) keyPath := strings.TrimSpace(cfg.Server.TLSKey) if certPath == "" || keyPath == "" { - return fmt.Errorf("初始化已完成,但 HTTPS 证书或私钥路径未配置") + return nil, fmt.Errorf("初始化已完成,但 HTTPS 证书或私钥路径未配置") } if _, err := os.Stat(filepath.Clean(certPath)); err != nil { - return fmt.Errorf("读取 HTTPS 证书失败:%w", err) + return nil, fmt.Errorf("读取 HTTPS 证书失败:%w", err) } if _, err := os.Stat(filepath.Clean(keyPath)); err != nil { - return fmt.Errorf("读取 HTTPS 私钥失败:%w", err) + return nil, fmt.Errorf("读取 HTTPS 私钥失败:%w", err) + } + pair, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf("加载 HTTPS 证书与私钥失败:%w", err) } server := newHTTPServer(address, s.router) + server.TLSConfig.GetCertificate = s.getTLSCertificate s.httpMu.Lock() if s.httpStopped { s.httpMu.Unlock() - return http.ErrServerClosed + return nil, http.ErrServerClosed } if s.httpServer != nil { s.httpMu.Unlock() - return errors.New("HTTPS 服务已经启动") + return nil, errors.New("HTTPS 服务已经启动") } + s.tlsCertificate.Store(&pair) s.httpServer = server s.httpMu.Unlock() if s.logger != nil { s.logger.Info("HTTPS 服务启动", "address", server.Addr, "https_mode", cfg.Server.HTTPSMode) } - return server.ListenAndServeTLS(certPath, keyPath) + return server, nil } func newHTTPServer(address string, handler http.Handler) *http.Server { diff --git a/backend/internal/httpapi/tls_certificate.go b/backend/internal/httpapi/tls_certificate.go new file mode 100644 index 0000000..5b2916d --- /dev/null +++ b/backend/internal/httpapi/tls_certificate.go @@ -0,0 +1,102 @@ +package httpapi + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "strings" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/certificate" +) + +type ManagementCertificateState struct { + Hostname string + CertificatePath string + PrivateKeyPath string + NotAfter time.Time +} + +// ManagementCertificate 返回实际配置的管理证书,不依赖业务域名仓储。 +func (s *Server) ManagementCertificate() (ManagementCertificateState, error) { + s.configMu.RLock() + defer s.configMu.RUnlock() + state := ManagementCertificateState{ + Hostname: strings.TrimSpace(s.config.Web.Hostname), + CertificatePath: s.config.Server.TLSCert, + PrivateKeyPath: s.config.Server.TLSKey, + } + pair, err := tls.LoadX509KeyPair(state.CertificatePath, state.PrivateKeyPath) + if err != nil { + return state, fmt.Errorf("读取管理证书失败:%w", err) + } + leaf, err := x509.ParseCertificate(pair.Certificate[0]) + if err != nil { + return state, fmt.Errorf("解析管理证书失败:%w", err) + } + state.NotAfter = leaf.NotAfter + return state, nil +} + +// ActivateTLSCertificate 先持久化完整证书路径,再切换不可变快照;失败不影响旧连接。 +// 此方法仅供进程内部续期流程调用,不提供上传或任意路径的 HTTP 接口。 +func (s *Server) ActivateTLSCertificate(certificatePath, privateKeyPath string) error { + s.tlsMu.Lock() + defer s.tlsMu.Unlock() + s.configMu.Lock() + defer s.configMu.Unlock() + if !s.config.SetupReady || strings.TrimSpace(s.configPath) == "" { + return errors.New("管理证书热更新需要已初始化的持久化配置") + } + certificatePath = strings.TrimSpace(certificatePath) + privateKeyPath = strings.TrimSpace(privateKeyPath) + pair, err := tls.LoadX509KeyPair(certificatePath, privateKeyPath) + if err != nil { + return fmt.Errorf("加载新的管理证书失败:%w", err) + } + leaf, err := x509.ParseCertificate(pair.Certificate[0]) + if err != nil { + return fmt.Errorf("解析新的管理证书失败:%w", err) + } + now := time.Now() + if now.Before(leaf.NotBefore) || !now.Before(leaf.NotAfter) { + return errors.New("新的管理证书不在有效期内") + } + host := strings.TrimSpace(s.config.Web.Hostname) + if host == "" { + host = strings.TrimSpace(s.config.Web.PublicIP) + } + if host == "" { + return errors.New("管理域名或公网 IP 未配置") + } + // 校验与切换使用同一份内存数据,避免重复读文件造成校验对象变化。 + var chain []byte + for _, der := range pair.Certificate { + chain = append(chain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...) + } + validator := certificate.LocalValidator{VerifyChain: true, Roots: s.tlsRoots} + if _, err := validator.ValidatePEM(context.Background(), chain, []string{host}); err != nil { + return fmt.Errorf("新的管理证书校验失败:%w", err) + } + pair.Leaf = leaf + candidate := s.config + candidate.Server.TLSCert = certificatePath + candidate.Server.TLSKey = privateKeyPath + if err := candidate.Save(s.configPath); err != nil { + return fmt.Errorf("保存管理证书配置失败:%w", err) + } + s.config = candidate + s.tlsCertificate.Store(&pair) + return nil +} + +func (s *Server) getTLSCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { + pair := s.tlsCertificate.Load() + if pair == nil { + return nil, errors.New("管理 HTTPS 证书尚未加载") + } + return pair, nil +} diff --git a/backend/internal/httpapi/tls_certificate_test.go b/backend/internal/httpapi/tls_certificate_test.go new file mode 100644 index 0000000..84a1f2b --- /dev/null +++ b/backend/internal/httpapi/tls_certificate_test.go @@ -0,0 +1,335 @@ +package httpapi + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +type rotationFixture struct { + server *Server + address string + root *x509.Certificate + key *ecdsa.PrivateKey + pool *x509.CertPool + directory string + firstCertificate string + firstKey string +} + +func newRotationFixture(t *testing.T) *rotationFixture { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + root := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Scert test CA"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(48 * time.Hour), + IsCA: true, BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, root, root, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + root, err = x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + fixture := &rotationFixture{root: root, key: key, directory: t.TempDir(), pool: x509.NewCertPool()} + fixture.pool.AddCert(root) + fixture.firstCertificate, fixture.firstKey = fixture.issue(t, 2, "management.example.test", time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour)) + cfg := readyTestConfig(fixture.directory) + cfg.Admin = config.AdminConfig{Username: "admin", Email: "admin@example.test", PasswordHash: "test-only-hash"} + cfg.DNS.VerificationState = "verified" + cfg.Web.Hostname = "management.example.test" + cfg.Server.Host = "127.0.0.1" + cfg.Server.TLSCert, cfg.Server.TLSKey = fixture.firstCertificate, fixture.firstKey + configPath := filepath.Join(fixture.directory, "scert.json") + if err := cfg.Save(configPath); err != nil { + t.Fatal(err) + } + fixture.server = New(Dependencies{Config: cfg, ConfigPath: configPath}) + fixture.server.tlsRoots = fixture.pool + server, err := fixture.server.prepareHTTPServer() + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + fixture.address = listener.Addr().String() + done := make(chan error, 1) + go func() { done <- server.ServeTLS(listener, "", "") }() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := fixture.server.Shutdown(ctx); err != nil { + t.Error(err) + } + if err := <-done; !errors.Is(err, http.ErrServerClosed) { + t.Errorf("HTTPS 服务退出异常:%v", err) + } + }) + return fixture +} + +func (f *rotationFixture) issue(t *testing.T, serial int64, host string, before, after time.Time) (string, string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + leaf := &x509.Certificate{ + SerialNumber: big.NewInt(serial), DNSNames: []string{host}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + NotBefore: before, NotAfter: after, + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, leaf, f.root, &key.PublicKey, f.key) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + certificatePath := filepath.Join(f.directory, fmt.Sprintf("%d.pem", serial)) + keyPath := filepath.Join(f.directory, fmt.Sprintf("%d.key", serial)) + chain := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + chain = append(chain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: f.root.Raw})...) + if err := os.WriteFile(certificatePath, chain, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil { + t.Fatal(err) + } + return certificatePath, keyPath +} + +func (f *rotationFixture) serial() (int64, error) { + connection, err := tls.DialWithDialer(&net.Dialer{Timeout: 3 * time.Second}, "tcp", f.address, &tls.Config{ + RootCAs: f.pool, ServerName: "management.example.test", MinVersion: tls.VersionTLS12, + }) + if err != nil { + return 0, err + } + defer connection.Close() + return connection.ConnectionState().PeerCertificates[0].SerialNumber.Int64(), nil +} + +func TestTLSCertificateRotationPersistsAndServesWithoutRestart(t *testing.T) { + f := newRotationFixture(t) + if serial, err := f.serial(); err != nil || serial != 2 { + t.Fatalf("初始证书异常:%d %v", serial, err) + } + certificatePath, keyPath := f.issue(t, 3, "management.example.test", time.Now().Add(-time.Hour), time.Now().Add(30*time.Hour)) + before := f.server.httpServer + if err := f.server.ActivateTLSCertificate(certificatePath, keyPath); err != nil { + t.Fatal(err) + } + if serial, err := f.serial(); err != nil || serial != 3 { + t.Fatalf("新握手未使用新证书:%d %v", serial, err) + } + if f.server.httpServer != before { + t.Fatal("证书更新不应替换 HTTP 服务") + } + stored, err := config.Load(f.server.configPath) + if err != nil { + t.Fatal(err) + } + if stored.Server.TLSCert != certificatePath || stored.Server.TLSKey != keyPath { + t.Fatal("重启所需证书路径未持久化") + } + state, err := f.server.ManagementCertificate() + if err != nil || state.CertificatePath != certificatePath || state.NotAfter.IsZero() { + t.Fatalf("续期读取的证书状态异常:%+v %v", state, err) + } + if err := f.server.ActivateTLSCertificate(f.firstCertificate, f.firstKey); err != nil { + t.Fatal(err) + } + if serial, err := f.serial(); err != nil || serial != 2 { + t.Fatalf("旧证书回退失败:%d %v", serial, err) + } + stored, err = config.Load(f.server.configPath) + if err != nil || stored.Server.TLSCert != f.firstCertificate { + t.Fatalf("回退未持久化:%v", err) + } +} + +func TestTLSCertificateRotationRejectsInvalidCandidates(t *testing.T) { + f := newRotationFixture(t) + now := time.Now() + tests := []struct { + name, host string + before, after time.Time + }{ + {"wrong-host", "other.example.test", now.Add(-time.Hour), now.Add(time.Hour)}, + {"expired", "management.example.test", now.Add(-2 * time.Hour), now.Add(-time.Hour)}, + {"future", "management.example.test", now.Add(time.Hour), now.Add(2 * time.Hour)}, + } + for i, test := range tests { + t.Run(test.name, func(t *testing.T) { + certPath, keyPath := f.issue(t, int64(i+10), test.host, test.before, test.after) + if err := f.server.ActivateTLSCertificate(certPath, keyPath); err == nil { + t.Fatal("无效证书不应生效") + } + }) + } + _, wrongKey := f.issue(t, 20, "management.example.test", now.Add(-time.Hour), now.Add(time.Hour)) + if err := f.server.ActivateTLSCertificate(f.firstCertificate, wrongKey); err == nil { + t.Fatal("不匹配的私钥不应生效") + } + if err := f.server.ActivateTLSCertificate(filepath.Join(f.directory, "missing.pem"), wrongKey); err == nil { + t.Fatal("缺失的证书不应生效") + } + if serial, err := f.serial(); err != nil || serial != 2 { + t.Fatalf("失败后旧证书不可用:%d %v", serial, err) + } + stored, err := config.Load(f.server.configPath) + if err != nil || stored.Server.TLSCert != f.firstCertificate { + t.Fatalf("失败不应修改配置:%v", err) + } +} + +func TestTLSCertificateRotationPreservesStateWhenPersistenceFails(t *testing.T) { + f := newRotationFixture(t) + certificatePath, keyPath := f.issue(t, 3, "management.example.test", time.Now().Add(-time.Hour), time.Now().Add(30*time.Hour)) + // 用目录占据目标文件,跨平台触发确定的持久化失败。 + f.server.configPath = t.TempDir() + if err := f.server.ActivateTLSCertificate(certificatePath, keyPath); err == nil { + t.Fatal("配置写入失败不应报告成功") + } + if serial, err := f.serial(); err != nil || serial != 2 { + t.Fatalf("配置写入失败后旧证书不可用:%d %v", serial, err) + } + state, err := f.server.ManagementCertificate() + if err != nil || state.CertificatePath != f.firstCertificate { + t.Fatalf("配置写入失败不应修改内存:%+v %v", state, err) + } +} + +func TestTLSCertificateRotationConcurrentHandshakes(t *testing.T) { + f := newRotationFixture(t) + certificatePath, keyPath := f.issue(t, 3, "management.example.test", time.Now().Add(-time.Hour), time.Now().Add(30*time.Hour)) + var workers sync.WaitGroup + for range 4 { + workers.Add(1) + go func() { + defer workers.Done() + for range 15 { + serial, err := f.serial() + if err != nil || (serial != 2 && serial != 3) { + t.Errorf("并发握手失败:%d %v", serial, err) + return + } + } + }() + } + for range 6 { + if err := f.server.ActivateTLSCertificate(certificatePath, keyPath); err != nil { + t.Error(err) + } + if err := f.server.ActivateTLSCertificate(f.firstCertificate, f.firstKey); err != nil { + t.Error(err) + } + } + workers.Wait() +} + +func TestTLSCertificateRotationRejectsUntrustedChain(t *testing.T) { + f := newRotationFixture(t) + certificatePath, keyPath := f.issue(t, 3, "management.example.test", time.Now().Add(-time.Hour), time.Now().Add(30*time.Hour)) + f.server.tlsRoots = x509.NewCertPool() + if err := f.server.ActivateTLSCertificate(certificatePath, keyPath); err == nil { + t.Fatal("不可信证书链不应生效") + } + if serial, err := f.serial(); err != nil || serial != 2 { + t.Fatalf("不可信更新不应影响旧证书:%d %v", serial, err) + } +} + +func TestTLSCertificateRotationWithoutSNI(t *testing.T) { + f := newRotationFixture(t) + certificatePath, keyPath := f.issue(t, 3, "management.example.test", time.Now().Add(-time.Hour), time.Now().Add(30*time.Hour)) + if err := f.server.ActivateTLSCertificate(certificatePath, keyPath); err != nil { + t.Fatal(err) + } + connection, err := tls.DialWithDialer(&net.Dialer{Timeout: 3 * time.Second}, "tcp", f.address, &tls.Config{ + RootCAs: f.pool, ServerName: "127.0.0.1", MinVersion: tls.VersionTLS12, + }) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + if serial := connection.ConnectionState().PeerCertificates[0].SerialNumber.Int64(); serial != 3 { + t.Fatalf("无 SNI 的握手未使用新证书:%d", serial) + } +} + +func TestTLSCertificateRotationSurvivesServerRestart(t *testing.T) { + f := newRotationFixture(t) + certificatePath, keyPath := f.issue(t, 3, "management.example.test", time.Now().Add(-time.Hour), time.Now().Add(30*time.Hour)) + if err := f.server.ActivateTLSCertificate(certificatePath, keyPath); err != nil { + t.Fatal(err) + } + stored, err := config.Load(f.server.configPath) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if err := f.server.Shutdown(ctx); err != nil { + t.Fatal(err) + } + restarted := New(Dependencies{Config: stored, ConfigPath: f.server.configPath}) + server, err := restarted.prepareHTTPServer() + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- server.ServeTLS(listener, "", "") }() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := restarted.Shutdown(ctx); err != nil { + t.Error(err) + } + if err := <-done; !errors.Is(err, http.ErrServerClosed) { + t.Error(err) + } + }) + connection, err := tls.DialWithDialer(&net.Dialer{Timeout: 3 * time.Second}, "tcp", listener.Addr().String(), &tls.Config{ + RootCAs: f.pool, ServerName: "management.example.test", MinVersion: tls.VersionTLS12, + }) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + if serial := connection.ConnectionState().PeerCertificates[0].SerialNumber.Int64(); serial != 3 { + t.Fatalf("重新启动未使用已持久化的新证书:%d", serial) + } +}