Scert/backend/internal/bootstrap/complete_test.go

410 lines
14 KiB
Go
Raw 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 bootstrap
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/dnsverification"
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
type completionMemorySecrets struct {
mu sync.Mutex
values map[string]map[string]string
deleted []string
}
func newCompletionMemorySecrets() *completionMemorySecrets {
return &completionMemorySecrets{values: make(map[string]map[string]string)}
}
func (store *completionMemorySecrets) Put(_ context.Context, reference string, value map[string]string) error {
store.mu.Lock()
defer store.mu.Unlock()
store.values[reference] = cloneCompletionSecrets(value)
return nil
}
func (store *completionMemorySecrets) Get(_ context.Context, reference string) (map[string]string, error) {
store.mu.Lock()
defer store.mu.Unlock()
value, ok := store.values[reference]
if !ok {
return nil, errors.New("secret not found")
}
return cloneCompletionSecrets(value), nil
}
func (store *completionMemorySecrets) Delete(_ context.Context, reference string) error {
store.mu.Lock()
defer store.mu.Unlock()
delete(store.values, reference)
store.deleted = append(store.deleted, reference)
return nil
}
func (store *completionMemorySecrets) has(reference string) bool {
store.mu.Lock()
defer store.mu.Unlock()
_, ok := store.values[reference]
return ok
}
func cloneCompletionSecrets(value map[string]string) map[string]string {
result := make(map[string]string, len(value))
for key, item := range value {
result[key] = item
}
return result
}
type completionDNSTester struct{}
func (completionDNSTester) Verify(_ context.Context, _, testDomain string, _ map[string]string) (setup.DNSVerificationReport, error) {
return dnsverification.Report{RecordName: "_scert." + testDomain}, nil
}
type completionCertificateIssuer struct {
root string
saved certificate.SavedResource
err error
calls int
}
func (issuer *completionCertificateIssuer) IssueForSetup(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
issuer.calls++
if issuer.err != nil {
return certificate.SavedResource{}, issuer.err
}
resource := completionDomainResource(request.Domains[0])
store := certificate.FileStore{Root: issuer.root, BackupRoot: filepath.Join(filepath.Dir(issuer.root), "backups")}
saved, err := store.SaveForType(ctx, request.Domains[0], "single", resource)
issuer.saved = saved
return saved, err
}
type completionFirewall struct {
mu sync.Mutex
result firewall.Result
openErr error
rollbackErr error
openCalls int
rollbackCalls int
block chan struct{}
}
func (manager *completionFirewall) OpenTCP(_ context.Context, port int) (firewall.Result, error) {
manager.mu.Lock()
manager.openCalls++
result := manager.result
openErr := manager.openErr
block := manager.block
manager.mu.Unlock()
if block != nil {
<-block
}
if result.Port == 0 {
result = firewall.Result{Backend: "test", Port: port, Created: true}
manager.mu.Lock()
manager.result = result
manager.mu.Unlock()
}
return result, openErr
}
func (manager *completionFirewall) Rollback(_ context.Context, _ firewall.Result) error {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.rollbackCalls++
return manager.rollbackErr
}
func (manager *completionFirewall) counts() (int, int) {
manager.mu.Lock()
defer manager.mu.Unlock()
return manager.openCalls, manager.rollbackCalls
}
func (manager *completionFirewall) setBlock(block chan struct{}) {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.block = block
}
type completionFixture struct {
base config.Config
configPath string
urlPath string
store *completionMemorySecrets
database *DatabaseVerificationManager
dns *setup.DNSVerificationManager
databaseToken string
dnsToken string
databaseRef string
dnsRef string
sessions *SessionManager
sessionCookie string
issuer *completionCertificateIssuer
firewall *completionFirewall
}
func newCompletionFixture(t *testing.T, domain string) completionFixture {
t.Helper()
baseDir := t.TempDir()
base := config.Default(baseDir)
configPath := filepath.Join(base.Paths.Config, "scert.json")
store := newCompletionMemorySecrets()
databaseManager := NewDatabaseVerificationManager(base, store)
databaseManager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
databaseResult, err := databaseManager.Verify(context.Background(), DatabaseInput{Driver: "sqlite"})
if err != nil {
t.Fatal(err)
}
dnsManager := setup.NewDNSVerificationManager(completionDNSTester{}, store)
dnsResult, err := dnsManager.Verify(context.Background(), setup.DNSVerificationInput{
ProviderID: "aliyun", TestDomain: domain,
Credentials: map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"},
})
if err != nil {
t.Fatal(err)
}
sessions, oneTimeToken, err := NewSessionManager()
if err != nil {
t.Fatal(err)
}
session, err := sessions.Exchange(oneTimeToken, "192.0.2.1:5000")
if err != nil {
t.Fatal(err)
}
return completionFixture{
base: base, configPath: configPath, urlPath: filepath.Join(base.Paths.Data, "bootstrap-url"),
store: store, database: databaseManager, dns: dnsManager,
databaseToken: databaseResult.VerificationToken, dnsToken: dnsResult.VerificationToken,
databaseRef: databaseResult.CredentialRef, dnsRef: dnsResult.CredentialRef,
sessions: sessions, sessionCookie: session.Token,
issuer: &completionCertificateIssuer{root: base.Paths.Certificate},
firewall: &completionFirewall{},
}
}
func (fixture completionFixture) completer(overrides func(*CompleteDependencies)) *Completer {
dependencies := CompleteDependencies{
BaseConfig: fixture.base, ConfigPath: fixture.configPath, BootstrapURLPath: fixture.urlPath,
DatabaseSessions: fixture.database, DNSSessions: fixture.dns, Credentials: fixture.store,
Sessions: fixture.sessions, Firewall: fixture.firewall, Certificate: fixture.issuer,
}
if overrides != nil {
overrides(&dependencies)
}
return NewCompleter(dependencies)
}
func (fixture completionFixture) request(domain string) CompleteRequest {
return CompleteRequest{
DatabaseVerificationToken: fixture.databaseToken,
DNSVerificationToken: fixture.dnsToken,
AdminUsername: "owner", AdminEmail: "owner@example.test", AdminPassword: "Admin2026!",
ServerPort: 8443, WebHostname: domain, WebEnabled: true,
HTTPSMode: "direct", ACMEEnvironment: "staging",
PropagationTimeoutSecs: 60, PropagationPollingIntervalSecs: 2,
}
}
func TestCompleterCommitsFullInitializationAndRevokesBootstrapSession(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
if err := os.MkdirAll(filepath.Dir(fixture.urlPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fixture.urlPath, []byte("temporary"), 0o600); err != nil {
t.Fatal(err)
}
result, err := fixture.completer(nil).Complete(context.Background(), fixture.request("admin.example.test"))
if err != nil {
t.Fatal(err)
}
if !result.Completed || result.ManagementURL != "https://admin.example.test:8443/" || len(result.Warnings) != 0 {
t.Fatalf("初始化结果不正确:%+v", result)
}
stored, err := config.Load(fixture.configPath)
if err != nil {
t.Fatal(err)
}
if !stored.SetupReady || !stored.Web.InitReady || stored.Database.Driver != "sqlite" ||
stored.DNS.CredentialRef != fixture.dnsRef || stored.Server.TLSCert != fixture.issuer.saved.FullChainPath {
t.Fatalf("正式配置未完整提交:%+v", stored)
}
if fixture.sessions.Authenticate(fixture.sessionCookie) {
t.Fatal("初始化成功后旧 Bootstrap 会话仍然有效")
}
if !fixture.store.has(fixture.dnsRef) {
t.Fatal("正式 DNS 凭据被批量清理误删")
}
if _, err := os.Stat(fixture.urlPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Bootstrap 地址文件未清理:%v", err)
}
_, rollbackCalls := fixture.firewall.counts()
if rollbackCalls != 0 {
t.Fatal("初始化成功后不应回滚防火墙")
}
}
func TestCompleterRejectsDNSDomainMismatchBeforeDatabaseSideEffects(t *testing.T) {
fixture := newCompletionFixture(t, "verified.example.test")
openCalls := 0
_, err := fixture.completer(func(dependencies *CompleteDependencies) {
dependencies.OpenDatabase = func(context.Context, config.DatabaseConfig) (*database.Database, error) {
openCalls++
return nil, errors.New("must not run")
}
}).Complete(context.Background(), fixture.request("other.example.test"))
firewallOpenCalls, _ := fixture.firewall.counts()
if err == nil || openCalls != 0 || fixture.issuer.calls != 0 || firewallOpenCalls != 0 {
t.Fatalf("域名不匹配未在副作用前拒绝:err=%v open=%d", err, openCalls)
}
if fixture.store.has(fixture.dnsRef) {
t.Fatal("失败初始化的 DNS 临时凭据未回收")
}
}
func TestCompleterRollsBackCertificateAndFirewallWhenConfigSaveFails(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
_, err := fixture.completer(func(dependencies *CompleteDependencies) {
dependencies.SaveConfig = func(config.Config, string) error { return errors.New("save failed") }
}).Complete(context.Background(), fixture.request("admin.example.test"))
if err == nil {
t.Fatal("配置保存失败未中止初始化")
}
_, rollbackCalls := fixture.firewall.counts()
if rollbackCalls != 1 {
t.Fatalf("新防火墙规则未回滚:%d", rollbackCalls)
}
for _, path := range []string{fixture.issuer.saved.CertificatePath, fixture.issuer.saved.FullChainPath, fixture.issuer.saved.PrivateKeyPath} {
if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("失败初始化的证书未回滚:%s err=%v", path, statErr)
}
}
if fixture.store.has(fixture.dnsRef) {
t.Fatal("配置保存失败后 DNS 临时凭据未回收")
}
if _, statErr := os.Stat(fixture.configPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("配置保存失败后仍留下正式配置:%v", statErr)
}
}
func TestCompleterAllowsOnlyOneConcurrentCompletion(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
block := make(chan struct{})
fixture.firewall.setBlock(block)
completer := fixture.completer(nil)
firstDone := make(chan error, 1)
go func() {
_, err := completer.Complete(context.Background(), fixture.request("admin.example.test"))
firstDone <- err
}()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
openCalls, _ := fixture.firewall.counts()
if openCalls != 0 {
break
}
time.Sleep(time.Millisecond)
}
_, secondErr := completer.Complete(context.Background(), fixture.request("admin.example.test"))
if !errors.Is(secondErr, ErrCompleteBusy) {
t.Fatalf("并发初始化未返回 Busy:%v", secondErr)
}
close(block)
if err := <-firstDone; err != nil {
t.Fatal(err)
}
if _, err := completer.Complete(context.Background(), fixture.request("admin.example.test")); !errors.Is(err, ErrCompleteDone) {
t.Fatalf("初始化完成后未返回 Done:%v", err)
}
}
func TestCompleterPostCommitCleanupFailureDoesNotRollbackFormalConfig(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
result, err := fixture.completer(func(dependencies *CompleteDependencies) {
dependencies.RemoveFile = func(string) error { return errors.New("remove failed") }
}).Complete(context.Background(), fixture.request("admin.example.test"))
if err != nil {
t.Fatal(err)
}
if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "Bootstrap") {
t.Fatalf("提交后清理失败没有返回安全警告:%+v", result)
}
stored, err := config.Load(fixture.configPath)
_, rollbackCalls := fixture.firewall.counts()
if err != nil || !stored.SetupReady || rollbackCalls != 0 {
t.Fatalf("提交后清理失败错误回滚了正式配置:cfg=%+v err=%v", stored, err)
}
}
func TestCompleterSupportsPublicIPManagementCertificate(t *testing.T) {
fixture := newCompletionFixture(t, "certificate-zone.example.test")
request := fixture.request("")
request.WebPublicIP = "203.0.113.70"
result, err := fixture.completer(nil).Complete(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if result.ManagementURL != "https://203.0.113.70:8443/" {
t.Fatalf("公网 IP 管理地址不正确:%s", result.ManagementURL)
}
stored, err := config.Load(fixture.configPath)
if err != nil {
t.Fatal(err)
}
if stored.Web.PublicIP != "203.0.113.70" || stored.Web.Hostname != "" || !strings.Contains(stored.Server.TLSCert, "management-ip") {
t.Fatalf("公网 IP 初始化配置不正确:%+v", stored)
}
}
func completionDomainResource(name string) certificate.Resource {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
panic(err)
}
now := time.Now().UTC()
template := &x509.Certificate{
SerialNumber: serial, Subject: pkix.Name{CommonName: name}, DNSNames: []string{name},
NotBefore: now.Add(-time.Minute), NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
panic(err)
}
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
panic(err)
}
return certificate.Resource{
Domains: []string{name}, Certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyDER}),
NotBefore: template.NotBefore, NotAfter: template.NotAfter,
}
}