补齐管理证书TLS热更新并保留原版本用于回退
This commit is contained in:
parent
230cbaa017
commit
9074d83dc8
@ -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)
|
||||
}
|
||||
|
||||
72
backend/internal/certificate/management_storage_test.go
Normal file
72
backend/internal/certificate/management_storage_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
102
backend/internal/httpapi/tls_certificate.go
Normal file
102
backend/internal/httpapi/tls_certificate.go
Normal file
@ -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
|
||||
}
|
||||
335
backend/internal/httpapi/tls_certificate_test.go
Normal file
335
backend/internal/httpapi/tls_certificate_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user