Scert/backend/internal/nginxactivation/rotation_linux_test.go

412 lines
13 KiB
Go

package nginxactivation
import (
"bytes"
"context"
"crypto/x509"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/processlock"
)
type testReloader struct {
checkCount int
reloadCount int
check func(int) error
reload func(int) error
}
func (r *testReloader) Check(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
r.checkCount++
if r.check != nil {
return r.check(r.checkCount)
}
return nil
}
func (r *testReloader) Reload(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
r.reloadCount++
if r.reload != nil {
return r.reload(r.reloadCount)
}
return nil
}
type rotationFixture struct {
rotation Rotation
authority testAuthority
old testMaterial
candidate testMaterial
proxy *testEndpoint
upstream *testEndpoint
reloader *testReloader
initial []byte
}
func writeTestFile(t *testing.T, name string, data []byte) {
t.Helper()
if err := os.WriteFile(name, data, 0o600); err != nil {
t.Fatal(err)
}
}
func newRotationFixture(t *testing.T) *rotationFixture {
t.Helper()
a := newAuthority(t)
now := time.Now()
f := &rotationFixture{authority: a}
f.old = a.issue(t, testHostname, now.Add(-time.Hour), now.Add(24*time.Hour))
f.candidate = a.issue(t, testHostname, now.Add(-time.Hour), now.Add(48*time.Hour))
f.proxy = newTestEndpoint(t, a, f.old, nil)
f.upstream = newTestEndpoint(t, a, f.old, nil)
directory := t.TempDir()
if err := os.Chmod(directory, 0o700); err != nil {
t.Fatal(err)
}
id := "00000000000000000000000000000001"
if err := os.Mkdir(filepath.Join(directory, id), 0o700); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(directory, id, "fullchain.pem"), f.old.chain)
writeTestFile(t, filepath.Join(directory, id, "key.pem"), f.old.key)
f.initial = renderInclude(directory, id)
writeTestFile(t, filepath.Join(directory, includeName), f.initial)
f.reloader = &testReloader{}
f.rotation = Rotation{Directory: directory, Proxy: f.proxy.Endpoint, Upstream: f.upstream.Endpoint, Reloader: f.reloader}
f.reloader.reload = func(int) error {
active, err := os.ReadFile(filepath.Join(directory, includeName))
if err != nil {
return err
}
if bytes.Equal(active, f.initial) {
f.proxy.active.Store(&f.old.pair)
} else {
f.proxy.active.Store(&f.candidate.pair)
}
return nil
}
return f
}
func (f *rotationFixture) assertStatus(t *testing.T, status string) transaction {
t.Helper()
root, err := os.OpenRoot(f.rotation.Directory)
if err != nil {
t.Fatal(err)
}
defer root.Close()
current, err := readTransaction(root)
if err != nil || current.Status != status {
t.Fatalf("status=%s, want=%s, error=%v", current.Status, status, err)
}
return current
}
func TestRotationStageCommitAndRecover(t *testing.T) {
f := newRotationFixture(t)
ctx := context.Background()
id, err := f.rotation.Stage(ctx, f.candidate.chain, f.candidate.key)
if err != nil {
t.Fatal(err)
}
f.assertStatus(t, "staged")
if _, err := f.upstream.Check(ctx, f.old.pair.Certificate[0]); err != nil {
t.Fatal("暂存不应改变上游", err)
}
if _, err := f.rotation.Stage(ctx, f.candidate.chain, f.candidate.key); err == nil {
t.Fatal("存在未决事务时不应再次暂存")
}
if err := f.rotation.Commit(ctx, "wrong"); err == nil {
t.Fatal("错误事务编号不应提交")
}
deadline, cancel := context.WithTimeout(ctx, 150*time.Millisecond)
defer cancel()
if err := f.rotation.Commit(deadline, id); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("双层不一致不应提交:%v", err)
}
f.assertStatus(t, "staged")
f.upstream.active.Store(&f.candidate.pair)
if err := f.rotation.Commit(ctx, id); err != nil {
t.Fatal(err)
}
f.assertStatus(t, "committed")
reloads := f.reloader.reloadCount
if err := f.rotation.Recover(ctx); err != nil || f.reloader.reloadCount != reloads {
t.Fatalf("恢复不应回退已提交事务:%v", err)
}
if err := f.rotation.Rollback(ctx, id); err == nil {
t.Fatal("不应回退已提交事务")
}
}
func TestRotationFailuresRestorePreviousCertificate(t *testing.T) {
for _, scenario := range []string{"check", "reload", "probe", "cancel"} {
t.Run(scenario, func(t *testing.T) {
f := newRotationFixture(t)
ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond)
defer cancel()
originalReload := f.reloader.reload
f.reloader.check = func(number int) error {
if scenario == "check" && number == 2 {
return errors.New("注入候选配置检查失败")
}
return nil
}
f.reloader.reload = func(number int) error {
if number == 1 {
switch scenario {
case "reload":
return errors.New("注入重载失败")
case "probe":
return nil
case "cancel":
cancel()
}
}
return originalReload(number)
}
if _, err := f.rotation.Stage(ctx, f.candidate.chain, f.candidate.key); err == nil {
t.Fatal("故障应导致暂存失败")
}
f.assertStatus(t, "rolled_back")
if _, err := f.proxy.Check(context.Background(), f.old.pair.Certificate[0]); err != nil {
t.Fatal("旧入口未恢复", err)
}
})
}
}
func TestRotationRestartRecoveryAndExternalChanges(t *testing.T) {
for _, scenario := range []string{"staged", "pending-before-switch", "pending-after-switch", "external-include", "external-material", "rollback-check"} {
t.Run(scenario, func(t *testing.T) {
f := newRotationFixture(t)
ctx := context.Background()
id, err := f.rotation.Stage(ctx, f.candidate.chain, f.candidate.key)
if err != nil {
t.Fatal(err)
}
current := f.assertStatus(t, "staged")
root, err := os.OpenRoot(f.rotation.Directory)
if err != nil {
t.Fatal(err)
}
defer root.Close()
switch scenario {
case "pending-before-switch", "pending-after-switch":
current.Status = "pending"
if err := saveTransaction(root, current); err != nil {
t.Fatal(err)
}
if scenario == "pending-before-switch" {
writeTestFile(t, filepath.Join(f.rotation.Directory, includeName), f.initial)
f.proxy.active.Store(&f.old.pair)
}
case "external-include":
writeTestFile(t, filepath.Join(f.rotation.Directory, includeName), []byte("# 外部变更\n"))
case "external-material":
oldID, err := includeVersion(f.rotation.Directory, f.initial)
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(f.rotation.Directory, oldID, "key.pem"), f.candidate.key)
case "rollback-check":
f.reloader.check = func(int) error { return errors.New("注入回退检查失败") }
}
restarted := f.rotation
err = restarted.Recover(ctx)
if scenario == "external-include" || scenario == "external-material" || scenario == "rollback-check" {
if err == nil {
t.Fatal("未完成回退不应记录成功")
}
f.assertStatus(t, "staged")
if scenario == "external-include" {
active, err := os.ReadFile(filepath.Join(f.rotation.Directory, includeName))
if err != nil || string(active) != "# 外部变更\n" {
t.Fatal("不应覆盖外部配置", err)
}
}
return
}
if err != nil {
t.Fatal(err)
}
f.assertStatus(t, "rolled_back")
if err := restarted.Rollback(ctx, id); err != nil {
t.Fatal("重复回退应安全", err)
}
})
}
}
func TestRotationRejectsInvalidCandidatesBeforeReload(t *testing.T) {
for _, scenario := range []string{"expired", "future", "hostname", "untrusted", "upstream-trust", "key-mismatch", "unchanged-expiry", "large", "initial-mismatch", "disk-mismatch"} {
t.Run(scenario, func(t *testing.T) {
f := newRotationFixture(t)
now := time.Now()
material := f.candidate
switch scenario {
case "expired":
material = f.authority.issue(t, testHostname, now.Add(-48*time.Hour), now.Add(-time.Hour))
case "future":
material = f.authority.issue(t, testHostname, now.Add(time.Hour), now.Add(72*time.Hour))
case "hostname":
material = f.authority.issue(t, "other.example.test", now.Add(-time.Hour), now.Add(72*time.Hour))
case "untrusted":
material = newAuthority(t).issue(t, testHostname, now.Add(-time.Hour), now.Add(72*time.Hour))
case "upstream-trust":
f.rotation.Upstream.Roots = x509.NewCertPool()
case "key-mismatch":
material.key = f.old.key
case "unchanged-expiry":
material = f.old
case "large":
material.chain = make([]byte, (1<<20)+1)
case "initial-mismatch":
f.upstream.active.Store(&f.candidate.pair)
case "disk-mismatch":
id, err := includeVersion(f.rotation.Directory, f.initial)
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(f.rotation.Directory, id, "fullchain.pem"), f.candidate.chain)
}
if _, err := f.rotation.Stage(context.Background(), material.chain, material.key); err == nil {
t.Fatal("不应接受无效候选或不一致基线")
}
if f.reloader.checkCount != 0 || f.reloader.reloadCount != 0 {
t.Fatal("预检失败不应调用重载器")
}
if _, err := os.Stat(filepath.Join(f.rotation.Directory, journalName)); !errors.Is(err, os.ErrNotExist) {
t.Fatal("预检失败不应开始事务", err)
}
})
}
}
func TestRotationLockAndCorruptJournal(t *testing.T) {
f := newRotationFixture(t)
lock, err := processlock.Acquire(filepath.Join(f.rotation.Directory, ".activation.lock"))
if err != nil {
t.Fatal(err)
}
if _, err := f.rotation.Stage(context.Background(), f.candidate.chain, f.candidate.key); !errors.Is(err, processlock.ErrLocked) {
t.Fatalf("并发事务未被拒绝:%v", err)
}
if err := lock.Close(); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(f.rotation.Directory, journalName), []byte(`{"status":"staged"}`))
if err := f.rotation.Recover(context.Background()); err == nil {
t.Fatal("损坏事务不应触发恢复")
}
if f.reloader.reloadCount != 0 {
t.Fatal("损坏事务不应调用重载器")
}
}
func TestRotationPreservesConcurrentIncludeChange(t *testing.T) {
for _, checkNumber := range []int{1, 2} {
t.Run(fmt.Sprint(checkNumber), func(t *testing.T) {
f := newRotationFixture(t)
external := []byte("# 管理员更新\n")
f.reloader.check = func(number int) error {
if number == checkNumber {
writeTestFile(t, filepath.Join(f.rotation.Directory, includeName), external)
}
return nil
}
if _, err := f.rotation.Stage(context.Background(), f.candidate.chain, f.candidate.key); err == nil {
t.Fatal("外部变更应阻止切换")
}
actual, err := os.ReadFile(filepath.Join(f.rotation.Directory, includeName))
if err != nil || !bytes.Equal(actual, external) || f.reloader.reloadCount != 0 {
t.Fatal("不应覆盖或重载外部变更", err)
}
})
}
}
func TestRotationJournalFailureRemainsRecoverable(t *testing.T) {
f := newRotationFixture(t)
originalReload := f.reloader.reload
f.reloader.reload = func(number int) error {
if number == 1 {
if err := os.Chmod(filepath.Join(f.rotation.Directory, journalName), 0o666); err != nil {
return err
}
}
return originalReload(number)
}
if _, err := f.rotation.Stage(context.Background(), f.candidate.chain, f.candidate.key); err == nil {
t.Fatal("日志持久化失败不应返回成功")
}
if _, err := f.proxy.Check(context.Background(), f.old.pair.Certificate[0]); err != nil {
t.Fatal("日志失败后仍应恢复旧入口", err)
}
if err := os.Chmod(filepath.Join(f.rotation.Directory, journalName), 0o600); err != nil {
t.Fatal(err)
}
f.assertStatus(t, "pending")
if err := f.rotation.Recover(context.Background()); err != nil {
t.Fatal(err)
}
f.assertStatus(t, "rolled_back")
}
func TestRotationCrossProcessLock(t *testing.T) {
if path := os.Getenv("SCERT_TEST_ACTIVATION_LOCK"); path != "" {
lock, err := processlock.Acquire(path)
if os.Getenv("SCERT_TEST_EXPECT_LOCKED") == "1" {
if lock != nil {
_ = lock.Close()
}
if !errors.Is(err, processlock.ErrLocked) {
t.Fatalf("其他进程持有的锁未生效:%v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if err := lock.Close(); err != nil {
t.Fatal(err)
}
return
}
path := filepath.Join(t.TempDir(), ".activation.lock")
lock, err := processlock.Acquire(path)
if err != nil {
t.Fatal(err)
}
defer lock.Close()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"1", "0"} {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
command := exec.CommandContext(ctx, executable, "-test.run=^TestRotationCrossProcessLock$")
command.Env = append(os.Environ(), "SCERT_TEST_ACTIVATION_LOCK="+path, "SCERT_TEST_EXPECT_LOCKED="+expected)
output, err := command.CombinedOutput()
cancel()
if err != nil {
t.Fatalf("跨进程锁验证失败:%v\n%s", err, output)
}
if err := lock.Close(); err != nil {
t.Fatal(err)
}
}
}