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/certificate" "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 TestManagementActivationRejectsChangedConfiguration(t *testing.T) { for _, field := range []string{"hostname", "dns", "mode", "certificate", "root"} { t.Run(field, func(t *testing.T) { f := newRotationFixture(t) state, err := f.server.ManagementCertificate() if err != nil { t.Fatal(err) } certificatePath, keyPath := f.issue(t, 3, state.Hostname, time.Now().Add(-time.Hour), time.Now().Add(30*time.Hour)) f.server.configMu.Lock() switch field { case "hostname": f.server.config.Web.Hostname = "changed.example.test" case "dns": f.server.config.DNS.CredentialRef = "secret://dns/changed" case "mode": f.server.config.Server.HTTPSMode = "proxy" case "certificate": f.server.config.Server.TLSCert = "changed.pem" case "root": f.server.config.Paths.Certificate = "changed-root" } f.server.configMu.Unlock() if err := f.server.ActivateManagementCertificate(state, certificate.SavedResource{FullChainPath: certificatePath, PrivateKeyPath: keyPath}); err == nil { t.Fatal("签发期间配置变化未阻止覆盖") } if serial, err := f.serial(); err != nil || serial != 2 { t.Fatalf("拒绝更新后旧证书不可用:%d %v", serial, err) } }) } } func TestManagementActivationUsesCurrentSnapshot(t *testing.T) { f := newRotationFixture(t) state, err := f.server.ManagementCertificate() if err != nil { t.Fatal(err) } if !state.DNSVerified || len(state.Request.Domains) != 1 || state.Request.Domains[0] != state.Hostname { t.Fatalf("管理续期状态不完整:%+v", state) } certificatePath, keyPath := f.issue(t, 3, state.Hostname, time.Now().Add(-time.Hour), time.Now().Add(30*time.Hour)) if err := f.server.ActivateManagementCertificate(state, certificate.SavedResource{FullChainPath: certificatePath, PrivateKeyPath: keyPath}); err != nil { t.Fatal(err) } if serial, err := f.serial(); err != nil || serial != 3 { t.Fatalf("管理证书未激活:%d %v", serial, err) } } func TestManagementActivationChecksActualExpiryInsteadOfSavedMetadata(t *testing.T) { f := newRotationFixture(t) state, err := f.server.ManagementCertificate() if err != nil { t.Fatal(err) } certificatePath, keyPath := f.issue(t, 3, state.Hostname, time.Now().Add(-time.Hour), state.NotAfter.Add(-time.Hour)) err = f.server.ActivateManagementCertificate(state, certificate.SavedResource{ FullChainPath: certificatePath, PrivateKeyPath: keyPath, NotAfter: state.NotAfter.Add(time.Hour), }) if err == nil { t.Fatal("候选元数据不能替代证书实际有效期检查") } if serial, err := f.serial(); err != nil || serial != 2 { t.Fatalf("拒绝短有效期候选后旧证书不可用:%d %v", serial, 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) } }