diff --git a/backend/internal/nginxactivation/files.go b/backend/internal/nginxactivation/files.go index 52c430e..75331c6 100644 --- a/backend/internal/nginxactivation/files.go +++ b/backend/internal/nginxactivation/files.go @@ -10,6 +10,9 @@ import ( "io" "os" "path/filepath" + "syscall" + + "golang.org/x/sys/unix" ) func readRegular(root *os.Root, name string, limit int64) ([]byte, error) { @@ -17,14 +20,24 @@ func readRegular(root *os.Root, name string, limit int64) ([]byte, error) { if err != nil { return nil, err } - if !info.Mode().IsRegular() || info.Size() > limit { - return nil, errors.New("激活状态必须是大小受限的普通文件") + if err := validateStateFile(info, limit); err != nil { + return nil, err } - file, err := root.Open(name) + file, err := root.OpenFile(name, os.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) if err != nil { return nil, err } defer file.Close() + opened, err := file.Stat() + if err != nil { + return nil, err + } + if err := validateStateFile(opened, limit); err != nil { + return nil, err + } + if !os.SameFile(info, opened) { + return nil, errors.New("激活状态文件在读取期间发生变化") + } data, err := io.ReadAll(io.LimitReader(file, limit+1)) if err != nil || int64(len(data)) > limit { return nil, errors.Join(errors.New("读取激活状态失败"), err) @@ -32,6 +45,17 @@ func readRegular(root *os.Root, name string, limit int64) ([]byte, error) { return data, nil } +func validateStateFile(info os.FileInfo, limit int64) error { + if !info.Mode().IsRegular() || info.Size() > limit { + return errors.New("激活状态必须是大小受限的普通文件") + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != uint32(os.Geteuid()) || stat.Nlink != 1 || info.Mode().Perm()&0o022 != 0 { + return errors.New("激活状态文件所有者、链接数或写权限不可信") + } + return nil +} + func writeExclusive(root *os.Root, name string, data []byte) error { file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { @@ -44,8 +68,8 @@ func writeExclusive(root *os.Root, name string, data []byte) error { func writeAtomic(root *os.Root, name string, data []byte) error { if info, err := root.Lstat(name); err == nil { - if !info.Mode().IsRegular() { - return errors.New("拒绝覆盖非常规激活状态文件") + if err := validateStateFile(info, 64<<10); err != nil { + return err } } else if !errors.Is(err, os.ErrNotExist) { return err @@ -89,12 +113,23 @@ func validateDirectory(directory string) error { } for path := directory; ; path = filepath.Dir(path) { info, err := os.Lstat(path) - if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + if err != nil { return fmt.Errorf("激活目录及其父路径必须是真实目录:%w", err) } - if path == directory && info.Mode().Perm()&0o077 != 0 { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("激活目录及其父路径必须是真实目录") + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || (stat.Uid != 0 && stat.Uid != uint32(os.Geteuid())) { + return errors.New("激活目录及其父路径的所有者不可信") + } + if path == directory && (info.Mode().Perm() != 0o700 || stat.Uid != uint32(os.Geteuid())) { return errors.New("激活目录只能由激活器所属用户访问") } + // 仅允许 root 所有且带粘滞位的共享父目录;其他用户不能替换受信子目录。 + if info.Mode().Perm()&0o022 != 0 && !(stat.Uid == 0 && info.Mode()&os.ModeSticky != 0) { + return errors.New("激活目录的父路径允许非受信用户写入") + } if filepath.Dir(path) == path { break } diff --git a/backend/internal/nginxactivation/files_linux_test.go b/backend/internal/nginxactivation/files_linux_test.go new file mode 100644 index 0000000..121749c --- /dev/null +++ b/backend/internal/nginxactivation/files_linux_test.go @@ -0,0 +1,124 @@ +package nginxactivation + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" +) + +func TestActivationDirectoryTrust(t *testing.T) { + base := t.TempDir() + if err := os.Chmod(base, 0o700); err != nil { + t.Fatal(err) + } + if err := validateDirectory(base); err != nil { + t.Fatal(err) + } + for _, mode := range []os.FileMode{0o755, 0o770, 0o777, 0o500} { + if err := os.Chmod(base, mode); err != nil { + t.Fatal(err) + } + if err := validateDirectory(base); err == nil { + t.Fatalf("不应接受目录权限 %o", mode) + } + } + if err := os.Chmod(base, 0o700); err != nil { + t.Fatal(err) + } + parent := filepath.Join(base, "writable") + child := filepath.Join(parent, "activation") + if err := os.MkdirAll(child, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(parent, 0o777); err != nil { + t.Fatal(err) + } + if err := validateDirectory(child); err == nil { + t.Fatal("不应接受可由其他用户写入的父目录") + } + link := filepath.Join(base, "link") + if err := os.Symlink(child, link); err != nil { + t.Fatal(err) + } + for _, path := range []string{link, "relative", "/", base + "/../escape", base + ";injection"} { + if err := validateDirectory(path); err == nil { + t.Fatalf("不应接受目录 %q", path) + } + } +} + +func TestActivationFilesRejectLinksAndSpecialFiles(t *testing.T) { + base := t.TempDir() + root, err := os.OpenRoot(base) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err := writeExclusive(root, "state", []byte("original")); err != nil { + t.Fatal(err) + } + if err := writeExclusive(root, "state", []byte("overwrite")); !errors.Is(err, os.ErrExist) { + t.Fatal("独占写入不应覆盖文件", err) + } + if _, err := readRegular(root, "state", 2); err == nil { + t.Fatal("不应读取超限文件") + } + if err := root.Symlink("state", "symlink"); err != nil { + t.Fatal(err) + } + if err := root.Link("state", "hardlink"); err != nil { + t.Fatal(err) + } + if err := unix.Mkfifo(filepath.Join(base, "fifo"), 0o600); err != nil { + t.Fatal(err) + } + if err := root.Mkdir("directory", 0o700); err != nil { + t.Fatal(err) + } + for _, path := range []string{"symlink", "hardlink", "state", "fifo", "directory"} { + if _, err := readRegular(root, path, 4096); err == nil { + t.Fatalf("不应读取不可信文件 %s", path) + } + if err := writeAtomic(root, path, []byte("replaced")); err == nil { + t.Fatalf("不应替换不可信文件 %s", path) + } + } + content, err := os.ReadFile(filepath.Join(base, "state")) + if err != nil || string(content) != "original" { + t.Fatal("被拒绝的操作改变了原文件", err) + } + if err := writeExclusive(root, "writable", []byte("untrusted")); err != nil { + t.Fatal(err) + } + if err := root.Chmod("writable", 0o666); err != nil { + t.Fatal(err) + } + if _, err := readRegular(root, "writable", 4096); err == nil { + t.Fatal("不应读取可由其他用户修改的状态") + } +} + +func TestActivationAtomicWritePersistsPrivateState(t *testing.T) { + root, err := os.OpenRoot(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer root.Close() + for _, content := range [][]byte{[]byte("first"), []byte("second")} { + if err := writeAtomic(root, "state", content); err != nil { + t.Fatal(err) + } + actual, err := readRegular(root, "state", 4096) + if err != nil || !bytes.Equal(actual, content) { + t.Fatal("原子写入内容不一致", err) + } + info, err := root.Stat("state") + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatal("状态文件权限不正确", err) + } + } +} diff --git a/backend/internal/nginxactivation/nginx_linux_test.go b/backend/internal/nginxactivation/nginx_linux_test.go new file mode 100644 index 0000000..46a926b --- /dev/null +++ b/backend/internal/nginxactivation/nginx_linux_test.go @@ -0,0 +1,152 @@ +package nginxactivation + +import ( + "context" + "encoding/pem" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" +) + +// 实机验收显式指定 Nginx 路径;仅启动独立进程,不使用系统站点、PID 或服务单元。 +func TestRotationWithRealNginx(t *testing.T) { + binary := os.Getenv("SCERT_TEST_NGINX_BINARY") + if binary == "" { + t.Skip("未指定隔离验收使用的 Nginx 可执行文件") + } + if !filepath.IsAbs(binary) { + t.Fatal("测试 Nginx 路径必须是绝对路径") + } + f := newRotationFixture(t) + prefix := t.TempDir() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + rootPath := filepath.Join(prefix, "ca.pem") + writeTestFile(t, rootPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: f.authority.certificate.Raw})) + configPath := filepath.Join(prefix, "nginx.conf") + configuration := fmt.Sprintf(`worker_processes 1; +pid %s/nginx.pid; +error_log stderr warn; +events { worker_connections 64; } +http { + access_log off; + client_body_temp_path %s/client; + proxy_temp_path %s/proxy; + fastcgi_temp_path %s/fastcgi; + uwsgi_temp_path %s/uwsgi; + scgi_temp_path %s/scgi; + server { + listen %s ssl; + server_name %s; + include %s/active.conf; + ssl_protocols TLSv1.2 TLSv1.3; + location / { + proxy_pass https://%s; + proxy_set_header Host %s; + proxy_ssl_server_name on; + proxy_ssl_name %s; + proxy_ssl_verify on; + proxy_ssl_trusted_certificate %s; + } + } +} +`, prefix, prefix, prefix, prefix, prefix, prefix, address, testHostname, + f.rotation.Directory, f.upstream.Address, net.JoinHostPort(testHostname, endpointPort(t, f.upstream.Address)), testHostname, rootPath) + writeTestFile(t, configPath, []byte(configuration)) + reloader := CommandReloader{Binary: binary, Config: configPath, Prefix: prefix + "/"} + if err := reloader.Check(context.Background()); err != nil { + t.Fatal(err) + } + logFile, err := os.OpenFile(filepath.Join(prefix, "process.log"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + command := exec.Command(binary, "-p", prefix+"/", "-c", configPath, "-g", "daemon off;") + command.Stdout, command.Stderr = logFile, logFile + command.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "LANG=C"} + if err := command.Start(); err != nil { + _ = logFile.Close() + t.Fatal(err) + } + finished := make(chan error, 1) + go func() { finished <- command.Wait() }() + t.Cleanup(func() { + _ = command.Process.Signal(syscall.SIGQUIT) + select { + case err := <-finished: + if err != nil { + t.Errorf("隔离 Nginx 退出失败:%v", err) + } + case <-time.After(5 * time.Second): + _ = command.Process.Kill() + <-finished + t.Error("隔离 Nginx 未在时限内退出") + } + _ = logFile.Close() + if t.Failed() { + content, _ := os.ReadFile(filepath.Join(prefix, "process.log")) + t.Logf("隔离 Nginx 日志:\n%s", content) + } + }) + f.rotation.Proxy = Endpoint{Address: address, Hostname: testHostname, Roots: f.authority.roots} + f.rotation.Reloader = reloader + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := f.rotation.Proxy.Wait(ctx, f.old.pair.Certificate[0]); err != nil { + t.Fatal(err) + } + id, err := f.rotation.Stage(ctx, f.candidate.chain, f.candidate.key) + if err != nil { + t.Fatal(err) + } + f.upstream.active.Store(&f.candidate.pair) + if err := f.rotation.Commit(ctx, id); err != nil { + t.Fatal(err) + } + f.assertStatus(t, "committed") + next := f.authority.issue(t, testHostname, time.Now().Add(-time.Hour), time.Now().Add(72*time.Hour)) + if _, err := f.rotation.Stage(ctx, next.chain, next.key); err != nil { + t.Fatal(err) + } + // 模拟调用方退出后由新组件恢复,真实入口必须重新提供上一张已提交证书。 + restarted := f.rotation + if err := restarted.Recover(ctx); err != nil { + t.Fatal(err) + } + f.assertStatus(t, "rolled_back") + if _, err := restarted.Proxy.Check(ctx, f.candidate.pair.Certificate[0]); err != nil { + t.Fatal(err) + } +} + +func endpointPort(t *testing.T, address string) string { + t.Helper() + _, port, err := net.SplitHostPort(address) + if err != nil { + t.Fatal(err) + } + return port +} + +func TestCommandReloaderRejectsRelativePolicy(t *testing.T) { + for _, policy := range []CommandReloader{ + {Binary: "nginx", Config: "/etc/nginx/nginx.conf", Prefix: "/etc/nginx"}, + {Binary: "/usr/sbin/nginx", Config: "nginx.conf", Prefix: "/etc/nginx"}, + {Binary: "/usr/sbin/nginx", Config: "/etc/nginx/nginx.conf", Prefix: "."}, + } { + if err := policy.Check(context.Background()); err == nil { + t.Fatal("不应执行相对路径策略") + } + } +} diff --git a/backend/internal/nginxactivation/probe.go b/backend/internal/nginxactivation/probe.go index 7f01116..607ffb2 100644 --- a/backend/internal/nginxactivation/probe.go +++ b/backend/internal/nginxactivation/probe.go @@ -13,6 +13,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" "git.pcux.cn/xuans/Scert/backend/internal/domain" @@ -29,10 +30,10 @@ func (e Endpoint) validate() error { host, port, err := net.SplitHostPort(e.Address) number, portErr := strconv.Atoi(port) ip := net.ParseIP(host) - if err != nil || portErr != nil || number < 1 || number > 65535 || ip == nil || !ip.IsLoopback() { + if err != nil || portErr != nil || number < 1 || number > 65535 || ip == nil || !ip.IsLoopback() || port != strconv.Itoa(number) || host != ip.String() { return errors.New("管理证书探测地址必须是本机回环 IP 和有效端口") } - if !domain.IsValidName(e.Hostname) || net.ParseIP(e.Hostname) != nil { + if !domain.IsValidName(e.Hostname) || net.ParseIP(e.Hostname) != nil || strings.Contains(e.Hostname, "*") || strings.ToLower(strings.TrimSpace(e.Hostname)) != e.Hostname { return errors.New("管理证书探测域名无效") } return nil diff --git a/backend/internal/nginxactivation/probe_test.go b/backend/internal/nginxactivation/probe_test.go new file mode 100644 index 0000000..248f8da --- /dev/null +++ b/backend/internal/nginxactivation/probe_test.go @@ -0,0 +1,226 @@ +package nginxactivation + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "io" + "log" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +const testHostname = "management.example.test" + +type testAuthority struct { + certificate *x509.Certificate + key *ecdsa.PrivateKey + roots *x509.CertPool +} + +type testMaterial struct { + chain []byte + key []byte + pair tls.Certificate +} + +func newAuthority(t *testing.T) testAuthority { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Scert test CA"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(365 * 24 * time.Hour), + IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + certificate, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + roots.AddCert(certificate) + return testAuthority{certificate: certificate, key: key, roots: roots} +} + +func (a testAuthority) issue(t *testing.T, hostname string, before, after time.Time) testMaterial { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120)) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: serial, DNSNames: []string{hostname}, NotBefore: before, NotAfter: after, + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, a.certificate, &key.PublicKey, a.key) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + material := testMaterial{ + chain: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + key: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), + } + material.chain = append(material.chain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: a.certificate.Raw})...) + material.pair, err = tls.X509KeyPair(material.chain, material.key) + if err != nil { + t.Fatal(err) + } + return material +} + +type testEndpoint struct { + Endpoint + active atomic.Pointer[tls.Certificate] + server *httptest.Server +} + +func newTestEndpoint(t *testing.T, a testAuthority, material testMaterial, handler http.Handler) *testEndpoint { + t.Helper() + if handler == nil { + handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/readyz" || r.Method != http.MethodGet || r.TLS.ServerName != testHostname || !strings.HasPrefix(r.Host, testHostname+":") { + http.Error(w, "请求无效", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"service":"scert","status":"ready"}`) + }) + } + e := &testEndpoint{} + e.active.Store(&material.pair) + e.server = httptest.NewUnstartedServer(handler) + e.server.Config.ErrorLog = log.New(io.Discard, "", 0) + e.server.TLS = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return e.active.Load(), nil + }} + e.server.StartTLS() + t.Cleanup(e.server.Close) + e.Endpoint = Endpoint{Address: e.server.Listener.Addr().String(), Hostname: testHostname, Roots: a.roots} + return e +} + +func TestEndpointStrictTLSAndReadiness(t *testing.T) { + a := newAuthority(t) + now := time.Now() + material := a.issue(t, testHostname, now.Add(-time.Hour), now.Add(24*time.Hour)) + other := a.issue(t, testHostname, now.Add(-time.Hour), now.Add(48*time.Hour)) + for _, test := range []struct { + name string + body string + status int + accepted bool + }{ + {"ready", `{"service":"scert","status":"ready"}`, 200, true}, + {"wrong-service", `{"service":"other","status":"ready"}`, 200, false}, + {"not-ready", `{"service":"scert","status":"starting"}`, 200, false}, + {"redirect", `{"service":"scert","status":"ready"}`, 308, false}, + {"unavailable", `{"service":"scert","status":"ready"}`, 503, false}, + {"trailing-data", `{"service":"scert","status":"ready"}{}`, 200, false}, + {"large-body", strings.Repeat(" ", 4097), 200, false}, + } { + t.Run(test.name, func(t *testing.T) { + e := newTestEndpoint(t, a, material, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "/redirected") + w.WriteHeader(test.status) + _, _ = io.WriteString(w, test.body) + })) + leaf, err := e.Check(context.Background(), material.pair.Certificate[0]) + if (err == nil) != test.accepted { + t.Fatalf("accepted=%v, error=%v", test.accepted, err) + } + if test.accepted && !bytes.Equal(leaf, material.pair.Certificate[0]) { + t.Fatal("叶证书不匹配") + } + }) + } + e := newTestEndpoint(t, a, material, nil) + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1") + if _, err := e.Check(context.Background(), nil); err != nil { + t.Fatal(err) + } + if _, err := e.Check(context.Background(), other.pair.Certificate[0]); err == nil { + t.Fatal("不应接受其他叶证书") + } + wrongHost := e.Endpoint + wrongHost.Hostname = "other.example.test" + if _, err := wrongHost.Check(context.Background(), nil); err == nil { + t.Fatal("不应接受错误主机") + } + wrongRoot := e.Endpoint + wrongRoot.Roots = x509.NewCertPool() + if _, err := wrongRoot.Check(context.Background(), nil); err == nil { + t.Fatal("不应接受不可信证书") + } +} + +func TestEndpointWaitAndCancellation(t *testing.T) { + a := newAuthority(t) + now := time.Now() + old := a.issue(t, testHostname, now.Add(-time.Hour), now.Add(24*time.Hour)) + newMaterial := a.issue(t, testHostname, now.Add(-time.Hour), now.Add(48*time.Hour)) + e := newTestEndpoint(t, a, old, nil) + switched := make(chan struct{}) + go func() { + time.Sleep(150 * time.Millisecond) + e.active.Store(&newMaterial.pair) + close(switched) + }() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := e.Wait(ctx, newMaterial.pair.Certificate[0]); err != nil { + t.Fatal(err) + } + <-switched + cancelled, stop := context.WithCancel(context.Background()) + stop() + if err := e.Wait(cancelled, old.pair.Certificate[0]); !errors.Is(err, context.Canceled) { + t.Fatalf("取消未传播:%v", err) + } + if err := e.Wait(ctx, nil); err == nil { + t.Fatal("缺少预期证书应被拒绝") + } +} + +func TestEndpointRejectsUnsafePolicies(t *testing.T) { + for _, address := range []string{"example.test:443", "192.0.2.1:443", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:+443", "127.0.0.1:0443", "[::ffff:127.0.0.1]:443", "127.0.0.1"} { + if err := (Endpoint{Address: address, Hostname: testHostname}).validate(); err == nil { + t.Fatalf("不应接受地址 %q", address) + } + } + for _, hostname := range []string{"*.example.test", "127.0.0.1", " management.example.test", "MANAGEMENT.example.test", "example.test/path"} { + if err := (Endpoint{Address: "127.0.0.1:443", Hostname: hostname}).validate(); err == nil { + t.Fatalf("不应接受域名 %q", hostname) + } + } + for _, address := range []string{"127.0.0.1:443", "[::1]:443"} { + if err := (Endpoint{Address: address, Hostname: testHostname}).validate(); err != nil { + t.Fatal(err) + } + } +} diff --git a/backend/internal/nginxactivation/rotation_linux.go b/backend/internal/nginxactivation/rotation_linux.go index 4fc40d5..c05070d 100644 --- a/backend/internal/nginxactivation/rotation_linux.go +++ b/backend/internal/nginxactivation/rotation_linux.go @@ -5,11 +5,13 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" + "strings" "time" "git.pcux.cn/xuans/Scert/backend/internal/certificate" @@ -72,13 +74,16 @@ func (r Rotation) Stage(ctx context.Context, chain, key []byte) (id string, resu if err != nil || !leaf.NotAfter.After(oldCertificate.NotAfter) { return "", errors.New("候选管理证书没有延长入口实际有效期") } - if _, err := r.Upstream.Check(ctx, nil); err != nil { + if _, err := r.Upstream.Check(ctx, oldLeaf); err != nil { return "", err } oldInclude, err := readRegular(root, includeName, 4096) if err != nil { return "", err } + if err := verifyIncludeMaterial(root, oldInclude, oldLeaf); err != nil { + return "", err + } if err := r.Reloader.Check(ctx); err != nil { return "", fmt.Errorf("Nginx 原配置检查失败:%w", err) } @@ -105,8 +110,8 @@ func (r Rotation) Stage(ctx context.Context, chain, key []byte) (id string, resu } current := transaction{ ID: id, Status: "pending", PreviousInclude: oldInclude, PreviousLeaf: oldLeaf, - CandidateLeaf: leaf.Raw, - CandidateInclude: []byte(fmt.Sprintf("ssl_certificate %s/%s/fullchain.pem;\nssl_certificate_key %s/%s/key.pem;\n", r.Directory, id, r.Directory, id)), + CandidateLeaf: leaf.Raw, + CandidateInclude: renderInclude(r.Directory, id), } if err := saveTransaction(root, current); err != nil { return "", err @@ -118,18 +123,27 @@ func (r Rotation) Stage(ctx context.Context, chain, key []byte) (id string, resu resultErr = errors.Join(resultErr, r.rollback(rollbackCtx, root, current)) } }() + if err := requireInclude(root, current.PreviousInclude); err != nil { + return id, err + } if err := writeAtomic(root, includeName, current.CandidateInclude); err != nil { return id, err } if err := r.Reloader.Check(ctx); err != nil { return id, fmt.Errorf("Nginx 候选配置检查失败:%w", err) } + if err := requireInclude(root, current.CandidateInclude); err != nil { + return id, err + } if err := r.Reloader.Reload(ctx); err != nil { return id, fmt.Errorf("Nginx 重载失败:%w", err) } if err := r.Proxy.Wait(ctx, current.CandidateLeaf); err != nil { return id, err } + if err := requireInclude(root, current.CandidateInclude); err != nil { + return id, err + } current.Status = "staged" return id, saveTransaction(root, current) } @@ -153,12 +167,18 @@ func (r Rotation) Commit(ctx context.Context, id string) error { if err != nil || !bytes.Equal(include, current.CandidateInclude) { return errors.New("管理证书专用引用已被修改,拒绝提交") } + if err := verifyIncludeMaterial(root, include, current.CandidateLeaf); err != nil { + return err + } if err := r.Upstream.Wait(ctx, current.CandidateLeaf); err != nil { return err } if err := r.Proxy.Wait(ctx, current.CandidateLeaf); err != nil { return err } + if err := requireInclude(root, current.CandidateInclude); err != nil { + return err + } current.Status = "committed" return saveTransaction(root, current) } @@ -206,18 +226,27 @@ func (r Rotation) rollback(ctx context.Context, root *os.Root, current transacti if !bytes.Equal(active, current.CandidateInclude) && !bytes.Equal(active, current.PreviousInclude) { return errors.New("Nginx 证书引用发生外部变化,拒绝回退覆盖") } + if err := verifyIncludeMaterial(root, current.PreviousInclude, current.PreviousLeaf); err != nil { + return err + } if err := writeAtomic(root, includeName, current.PreviousInclude); err != nil { return fmt.Errorf("恢复 Nginx 证书引用失败:%w", err) } if err := r.Reloader.Check(ctx); err != nil { return fmt.Errorf("Nginx 回退配置检查失败:%w", err) } + if err := requireInclude(root, current.PreviousInclude); err != nil { + return err + } if err := r.Reloader.Reload(ctx); err != nil { return fmt.Errorf("Nginx 回退重载失败:%w", err) } if err := r.Proxy.Wait(ctx, current.PreviousLeaf); err != nil { return fmt.Errorf("Nginx 回退就绪检查失败:%w", err) } + if err := requireInclude(root, current.PreviousInclude); err != nil { + return err + } current.Status = "rolled_back" return saveTransaction(root, current) } @@ -234,6 +263,10 @@ func (r Rotation) validateMaterial(ctx context.Context, chain, key []byte) (*x50 if _, err := validator.ValidatePEM(ctx, chain, []string{r.Proxy.Hostname}); err != nil { return nil, fmt.Errorf("候选证书未通过可信校验:%w", err) } + validator.Roots = r.Upstream.Roots + if _, err := validator.ValidatePEM(ctx, chain, []string{r.Upstream.Hostname}); err != nil { + return nil, fmt.Errorf("候选证书未通过上游可信校验:%w", err) + } if len(pair.Certificate[0]) > 16<<10 { return nil, errors.New("候选叶证书超过大小限制") } @@ -250,6 +283,13 @@ func (r Rotation) open(ctx context.Context) (*os.Root, *processlock.Lock, error) if err := errors.Join(r.Proxy.validate(), r.Upstream.validate(), validateDirectory(r.Directory)); err != nil { return nil, nil, err } + if info, err := os.Lstat(filepath.Join(r.Directory, ".activation.lock")); err == nil { + if err := validateStateFile(info, 4096); err != nil { + return nil, nil, err + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, nil, err + } lock, err := processlock.Acquire(filepath.Join(r.Directory, ".activation.lock")) if err != nil { return nil, nil, err @@ -271,9 +311,20 @@ func readTransaction(root *os.Root) (transaction, error) { if err := json.Unmarshal(data, ¤t); err != nil { return current, err } - if current.ID == "" || len(current.PreviousInclude) == 0 || len(current.CandidateInclude) == 0 || len(current.PreviousLeaf) == 0 || len(current.CandidateLeaf) == 0 { + if !validID(current.ID) || len(current.PreviousInclude) == 0 || len(current.PreviousInclude) > 4096 || len(current.PreviousLeaf) == 0 || len(current.PreviousLeaf) > 16<<10 || len(current.CandidateLeaf) == 0 || len(current.CandidateLeaf) > 16<<10 { return current, errors.New("管理证书激活记录不完整") } + if !bytes.Equal(current.CandidateInclude, renderInclude(root.Name(), current.ID)) { + return current, errors.New("管理证书激活记录的候选引用无效") + } + if _, err := includeVersion(root.Name(), current.PreviousInclude); err != nil { + return current, err + } + for _, leaf := range [][]byte{current.PreviousLeaf, current.CandidateLeaf} { + if _, err := x509.ParseCertificate(leaf); err != nil { + return current, errors.New("管理证书激活记录包含无效证书") + } + } switch current.Status { case "pending", "staged", "committed", "rolled_back": return current, nil @@ -289,3 +340,58 @@ func saveTransaction(root *os.Root, current transaction) error { } return writeAtomic(root, journalName, data) } + +func validID(id string) bool { + decoded, err := hex.DecodeString(id) + return err == nil && len(decoded) == 16 && hex.EncodeToString(decoded) == id +} + +func requireInclude(root *os.Root, expected []byte) error { + actual, err := readRegular(root, includeName, 4096) + if err != nil { + return err + } + if !bytes.Equal(actual, expected) { + return errors.New("管理证书专用引用发生外部变化") + } + return nil +} + +func renderInclude(directory, id string) []byte { + return []byte(fmt.Sprintf("ssl_certificate %s/%s/fullchain.pem;\nssl_certificate_key %s/%s/key.pem;\n", directory, id, directory, id)) +} + +func includeVersion(directory string, include []byte) (string, error) { + fields := strings.Fields(string(include)) + if len(fields) != 4 { + return "", errors.New("管理证书专用引用格式无效") + } + id := filepath.Base(filepath.Dir(fields[1])) + if !validID(id) || !bytes.Equal(include, renderInclude(directory, id)) { + return "", errors.New("管理证书必须引用激活器专用版本目录") + } + return id, nil +} + +func verifyIncludeMaterial(root *os.Root, include, expected []byte) error { + id, err := includeVersion(root.Name(), include) + if err != nil { + return err + } + if err := validateDirectory(filepath.Join(root.Name(), id)); err != nil { + return err + } + chain, err := readRegular(root, filepath.Join(id, "fullchain.pem"), 1<<20) + if err != nil { + return err + } + key, err := readRegular(root, filepath.Join(id, "key.pem"), 64<<10) + if err != nil { + return err + } + pair, err := tls.X509KeyPair(chain, key) + if err != nil || !bytes.Equal(pair.Certificate[0], expected) { + return errors.New("管理证书磁盘材料与已验证入口不一致") + } + return nil +} diff --git a/backend/internal/nginxactivation/rotation_linux_test.go b/backend/internal/nginxactivation/rotation_linux_test.go new file mode 100644 index 0000000..89fd0c5 --- /dev/null +++ b/backend/internal/nginxactivation/rotation_linux_test.go @@ -0,0 +1,411 @@ +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) + } + } +} diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 11126e8..e263858 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,39 @@ # Linux 生产流程测试记录 +## 当前进展:Nginx 证书激活事务组件实机验证 + +2026-09-18 01:45 至 02:03 本轮接续,修改前检查点 `10645ea` 已推送。本节为最新状态;本轮没有替换正式程序、重载正式 Nginx 或清空正式数据库,组件验证通过不代表完整生产验收完成。 + +### 已完成的修改与复审 + +- [✓] 探测仅允许规范化回环 IP、端口和具体管理域名;每次建立新 TLS 连接,严格验证可信链、域名和预期叶证书,并检查受限大小的 Scert 就绪响应。不使用环境代理,不跟随重定向。 +- [✓] 激活器专用目录及父路径检查所有者、权限和符号链接;专用目录要求当前激活器用户所有且权限为 0700。仅例外允许 root 所有、带粘滞位的共享父目录,不允许其他可写父目录。 +- [✓] 状态读取使用禁止跟随符号链接的文件打开方式,检查普通文件、文件身份、大小、所有者、写权限及硬链接数。新证书、私钥和事务快照以 0600 写入并同步。 +- [✓] 切换前验证代理、上游和专用目录内旧材料一致;候选证书必须符合两侧信任配置、覆盖管理域名、处于有效期内并延长实际有效期。 +- [✓] 专用 include 仅接受固定格式和随机版本目录,事务记录验证版本编号、引用和证书。切换、配置检查和就绪探测后重新核对 include,发现外部修改则拒绝覆盖或报告成功。 +- [✓] 暂存只切 Nginx;上游切换后双层证书一致才允许提交。取消、检查失败、重载失败和探测失败进行有界回退;未完成事务可恢复,已提交事务不自动回退。日志写入失败不会被误报为成功。 + +### 验证证据 + +本地 `go test -p=2 ./...` 通过,未变更包使用 Go 缓存;`go test -race ./internal/nginxactivation -count=1` 通过,Windows 只覆盖通用探测代码。使用 Linux 编译目标执行 `go vet ./internal/nginxactivation` 通过。Linux 测试覆盖目录、文件、事务和受限命令实现。 + +最终测试程序为 `.runtime/nginx-activation-20260918-0202.test`,远端 `/scert/acceptance-20260917-1044/nginx-activation-20260918-0202.test`;两端 SHA-256 一致:`26db07a3ed15f4324af4e742cd252e0e8700253b0543bee3757a13cc5845f796`。此前两版测试程序保留,没有覆盖上传。 + +最终单元 `scert-nginx-final-20260918-0202.service` 以 Scert 用户运行,私有网络、私有临时目录、只读系统、隐藏用户目录、禁止提权;正式数据、配置、日志、备份及证书目录禁止访问。限制内存 192 MiB、禁用 Swap、CPU 50%、64 个任务和 120 秒运行上限。通过 `SCERT_TEST_NGINX_BINARY=/usr/sbin/nginx` 显式启用真实 Nginx 用例,执行 `-test.v -test.timeout=90s -test.count=3`:16 项顶层测试连续三轮通过,无失败、无跳过;运行 4.492 秒,CPU 1.102 秒,峰值内存 13 MiB,Swap 为零。 + +真实 Nginx 用例使用独立配置、PID、临时目录与随机回环端口,启动独立非特权进程;验证暂存重载、上游切换后提交、下一轮未提交事务恢复及实际入口证书。退出时等待独立 Nginx 正常终止,不操作正式 Nginx 服务。测试还覆盖跨进程锁互斥及释放后重新获取、候选错误、双层不一致、日志失败、模拟重启阶段、外部 include 或材料变更、符号链接、硬链接、FIFO 和可写父目录。 + +最初单元 `scert-nginx-unit-20260918-0155.service` 在测试开始前因 `/ssl` 不存在而出现 systemd `226/NAMESPACE`;调整为 `InaccessiblePaths` 中 `-/ssl` 后,缺失路径可忽略,而现有正式目录仍强制隔离。后续单元 `scert-nginx-unit-20260918-0156.service`、`scert-nginx-real-20260918-0159.service` 及最终单元均退出零。 + +结束时正式 Scert PID `589832`、Nginx `578671`、MySQL `589785` 与本轮起点一致。正式二进制摘要仍为 `f5b08bf0fb88f7b19281e91f942aa2995866f96420e422c015890b841bae6366`;Nginx 主配置和 Scert 站点摘要未变。`nginx -t` 通过,按管理域名严格 TLS 验证的回环 `/readyz` 返回 200 和 `ready`。最终隔离单元为 inactive、PID 为零;本轮未进行浏览器或独立公网探测验收。 + +### 接线前必须完成 + +- [ ] 独立受限辅助进程、受保护本地 IPC、请求身份鉴别及固定本地策略;不能将 Nginx 重载权限赋予 Scert 主进程,也不能通过 HTTP 接收任意命令或配置路径。 +- [ ] 安装时将当前证书复制到激活器专用版本目录并生成 include;现有线上配置仍直接引用原证书目录,不满足组件专用目录契约,不能直接切换。 +- [ ] 应用协调者持久化双层激活状态,处理上游已持久化但代理未提交、提交应答丢失、调用者退出及超时恢复;组件 Recover 只回退 Nginx,不替代上游配置恢复。 +- [ ] 确认上述接线后再解除代理模式申请前保护,部署最新候选,继续真实目标机推送、通知送达、灾难恢复、容量及完整生产验收。 + ## 当前进展:Linux 测试隔离与跨平台路径复验 2026-09-18 本轮修改前检查点 `5a96623` 已推送。下方旧阶段的“当前进展”和待办按各自记录时间理解,以本节为最新状态;本轮没有替换正式程序,也不表示完整生产验收通过。