加固Nginx证书激活事务并通过Linux隔离实机验证

This commit is contained in:
xuans 2026-09-18 02:04:29 +08:00
parent 10645ea1e4
commit e7069d5584
8 changed files with 1102 additions and 13 deletions

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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("不应执行相对路径策略")
}
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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, &current); 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
}

View File

@ -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)
}
}
}

View File

@ -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` 已推送。下方旧阶段的“当前进展”和待办按各自记录时间理解,以本节为最新状态;本轮没有替换正式程序,也不表示完整生产验收通过。