406 lines
14 KiB
Go
406 lines
14 KiB
Go
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)
|
|
}
|
|
}
|