完善Nginx本地激活身份校验并通过Linux重复验证
This commit is contained in:
parent
cc94a6181b
commit
87589612cb
81
backend/cmd/scert-nginx/main_linux_test.go
Normal file
81
backend/cmd/scert-nginx/main_linux_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInheritedListener(t *testing.T) {
|
||||
if mode := os.Getenv("SCERT_LISTENER_TEST_CHILD"); mode != "" {
|
||||
os.Setenv("LISTEN_PID", strconv.Itoa(os.Getpid()))
|
||||
os.Setenv("LISTEN_FDS", "1")
|
||||
switch mode {
|
||||
case "pid":
|
||||
os.Setenv("LISTEN_PID", "0")
|
||||
case "fds":
|
||||
os.Setenv("LISTEN_FDS", "2")
|
||||
}
|
||||
listener, err := inheritedListener(os.Getenv("SCERT_LISTENER_TEST_PATH"))
|
||||
if mode == "valid" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := listener.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(os.Getenv("SCERT_LISTENER_TEST_PATH")); err != nil {
|
||||
t.Fatal("继承监听器关闭不应删除 systemd socket", err)
|
||||
}
|
||||
} else if err == nil {
|
||||
listener.Close()
|
||||
t.Fatal("无效继承监听器被接受")
|
||||
}
|
||||
fmt.Println("INHERITED_LISTENER_VERIFIED")
|
||||
return
|
||||
}
|
||||
for _, mode := range []string{"valid", "pid", "fds", "path", "tcp"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.sock")
|
||||
var listener net.Listener
|
||||
var file *os.File
|
||||
var err error
|
||||
if mode == "tcp" {
|
||||
tcp, listenErr := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if listenErr != nil {
|
||||
t.Fatal(listenErr)
|
||||
}
|
||||
listener = tcp
|
||||
file, err = tcp.File()
|
||||
} else {
|
||||
unix, listenErr := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
|
||||
if listenErr != nil {
|
||||
t.Fatal(listenErr)
|
||||
}
|
||||
unix.SetUnlinkOnClose(false)
|
||||
listener = unix
|
||||
file, err = unix.File()
|
||||
}
|
||||
defer listener.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
if mode == "path" {
|
||||
path += ".wrong"
|
||||
}
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestInheritedListener$")
|
||||
command.Env = append(os.Environ(), "SCERT_LISTENER_TEST_CHILD="+mode, "SCERT_LISTENER_TEST_PATH="+path)
|
||||
command.ExtraFiles = []*os.File{file}
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(output), "INHERITED_LISTENER_VERIFIED") {
|
||||
t.Fatalf("继承监听器子进程失败:%s, %v", output, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
43
backend/internal/nginxactivation/README.md
Normal file
43
backend/internal/nginxactivation/README.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Nginx 本地激活组件
|
||||
|
||||
本组件负责受限的 Nginx 管理证书事务,不是完整的管理证书自动续期协调器。当前不能仅凭组件测试通过就解除应用内代理续期保护。
|
||||
|
||||
## 权限边界
|
||||
|
||||
- `scert-nginx` 独立命令只在 Linux 上运行,以 root 身份加载 root 所有且不可由其他用户写入的独立策略文件。
|
||||
- 仅接受 systemd 提供的单个 Unix 流监听器,验证进程编号、描述符数量和固定 socket 路径;不自行监听 TCP、不删除 socket。
|
||||
- 服务端通过 `SO_PEERCRED` 核对固定非 root UID;客户端同时验证 socket 路径及父目录和服务端内核 UID。
|
||||
- 请求不能提供命令、配置路径、证书磁盘路径、上游地址或域名;这些值只能来自独立策略。策略引用的 Nginx 配置及其 include 依赖仍须由部署者维护为可信配置。
|
||||
- `Directory` 及其版本目录为激活器所有的 0700 目录,私钥、证书、事务文件使用 0600。应用主进程不获得 Nginx 重载权限。
|
||||
|
||||
## 协议与事务
|
||||
|
||||
协议为四字节大端长度前缀和 JSON,版本为 1,帧上限为 2 MiB,拒绝未知字段和尾随内容。并发连接上限为四个;事务冲突直接返回失败,由协调者查询状态,不自动重放变更操作。
|
||||
|
||||
| 动作 | 行为 |
|
||||
| --- | --- |
|
||||
| `ready` | 检查不存在未决事务、双层 TLS 和专用证书材料一致、Nginx 配置合法 |
|
||||
| `state` | 返回事务编号、状态和两张叶证书的 SHA-256,不返回私钥或磁盘路径 |
|
||||
| `stage` | 验证候选,持久化事务并仅切换 Nginx,成功后返回随机事务编号 |
|
||||
| `commit` | 确认双层已使用候选证书,再记录提交 |
|
||||
| `reconcile` | 依据实际上游证书回退或提交;无关证书、上游不可用或外部变更时保留未决事务 |
|
||||
|
||||
`stage` 的应答丢失不等于没有执行。调用方必须用 `state` 查询,并结合持久化的候选摘要核对事务,不能直接重新申请证书或重试暂存。重复 `commit` 不返回幂等成功,调用方须确认查询到的是同一事务的提交终态。`reconcile` 对已完成的同一事务返回原终态,不代表重新验证了当下入口状态。
|
||||
|
||||
激活等待要求三次间隔 100 毫秒的新连接探测连续通过,每次严格核验证书链、域名、叶证书及 Scert 就绪响应。该检查降低平滑重载过程中偶然命中新工作进程导致的误判,不构成对未来所有连接的保证。
|
||||
|
||||
停止服务会取消活动请求并等待有界回退;监听器异常关闭报告错误。请求持有的私钥切片及帧缓冲在返回路径清理,但不声明能够擦除 Go 运行时或 JSON 解码器产生的所有内部副本。
|
||||
|
||||
## 接入前要求
|
||||
|
||||
1. 安装独立 socket/service,固定调用 UID,初始化专用版本目录并让目标站点引用 `active.conf`。
|
||||
2. 应用须持久化旧状态、候选摘要、激活阶段和关联事务;完整生命周期必须串行化,不能在恢复检查上游之后并发切换上游。
|
||||
3. 覆盖代理暂存、上游配置持久化与内存切换、代理提交之间的中断及应答丢失,并在启动时先恢复再允许申请下一张证书。
|
||||
4. 不可将本组件的 `Recover` 视作双层恢复。它只回退代理;`Reconcile` 也必须由了解应用持久化状态的协调者调用。
|
||||
5. 当前没有自动恢复循环、请求编号幂等机制或租约超时策略,不能宣称已具备无人值守续期闭环。
|
||||
|
||||
## 验证范围
|
||||
|
||||
Windows 测试只能覆盖通用协议和 TLS 探测。Linux 测试覆盖目录、文件、Unix 身份、策略、事务及继承监听器;真实 Nginx 用例需显式指定 `SCERT_TEST_NGINX_BINARY`。不同 UID 测试需在隔离 root 单元中设置 `SCERT_TEST_ROOT_IPC=1`,只在临时目录中复制测试程序并降权子进程,不修改真实用户或正式 socket。
|
||||
|
||||
实机执行命令、程序摘要和失败修复记录见仓库 `doc/Linux生产流程测试记录.md`。
|
||||
70
backend/internal/nginxactivation/client_linux_test.go
Normal file
70
backend/internal/nginxactivation/client_linux_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
package nginxactivation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientCancellation(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.sock")
|
||||
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener.SetUnlinkOnClose(false)
|
||||
defer listener.Close()
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
clientDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := (Client{SocketPath: path, ServiceUID: uint32(os.Geteuid())}).State(ctx)
|
||||
clientDone <- err
|
||||
}()
|
||||
if err := listener.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connection, err := listener.AcceptUnix()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
_ = connection.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
var message request
|
||||
if err := readFrame(connection, &message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-clientDone:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("取消原因丢失", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("客户端取消后没有及时关闭连接")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUnexpectedClose(t *testing.T) {
|
||||
f := newRotationFixture(t)
|
||||
path := filepath.Join(t.TempDir(), "a.sock")
|
||||
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener.SetUnlinkOnClose(false)
|
||||
if err := listener.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := LocalService{Rotation: f.rotation, AllowedUID: uint32(os.Geteuid())}
|
||||
if err := service.Serve(context.Background(), listener); !errors.Is(err, net.ErrClosed) {
|
||||
t.Fatal("监听器异常关闭不应报告正常退出", err)
|
||||
}
|
||||
}
|
||||
187
backend/internal/nginxactivation/identity_linux_test.go
Normal file
187
backend/internal/nginxactivation/identity_linux_test.go
Normal file
@ -0,0 +1,187 @@
|
||||
package nginxactivation
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func identityChild(t *testing.T, mode string) {
|
||||
t.Helper()
|
||||
path := os.Getenv("SCERT_IDENTITY_TEST_SOCKET")
|
||||
if mode == "server" {
|
||||
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener.SetUnlinkOnClose(false)
|
||||
defer listener.Close()
|
||||
_ = listener.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
fmt.Println("LISTENING")
|
||||
connection, err := listener.AcceptUnix()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
_ = connection.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
var data [1]byte
|
||||
n, err := connection.Read(data[:])
|
||||
if n != 0 || err != io.EOF {
|
||||
t.Fatal("客户端向不可信服务发送了数据", n, err)
|
||||
}
|
||||
fmt.Println("IDENTITY_VERIFIED")
|
||||
return
|
||||
}
|
||||
connection, err := net.DialTimeout("unix", path, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal("未到达内核身份检查边界", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
_ = connection.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
err = writeFrame(connection, request{Version: protocolVersion, Action: "state"})
|
||||
var result response
|
||||
if err == nil {
|
||||
err = readFrame(connection, &result)
|
||||
}
|
||||
if mode == "allowed" {
|
||||
if err != nil || result.Code != "ok" || result.State == nil || result.State.Status != "idle" {
|
||||
t.Fatal("合法非特权进程未通过身份校验", err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("非授权进程获得了响应")
|
||||
}
|
||||
fmt.Println("IDENTITY_VERIFIED")
|
||||
}
|
||||
|
||||
func TestRootPeerIdentity(t *testing.T) {
|
||||
if mode := os.Getenv("SCERT_IDENTITY_TEST_CHILD"); mode != "" {
|
||||
identityChild(t, mode)
|
||||
return
|
||||
}
|
||||
if os.Getenv("SCERT_TEST_ROOT_IPC") != "1" {
|
||||
t.Skip("仅在显式启用的 root 隔离测试单元内执行不同 UID 测试")
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
t.Fatal("不同 UID 测试要求隔离单元以 root 运行")
|
||||
}
|
||||
base := t.TempDir()
|
||||
for _, path := range []string{base, filepath.Dir(base)} {
|
||||
if err := os.Chmod(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 为降权子进程复制独立测试程序,避免放宽正式目录的访问权限。
|
||||
source, err := os.Open(os.Args[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer source.Close()
|
||||
executable := filepath.Join(base, "identity.test")
|
||||
copy, err := os.OpenFile(executable, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, copyErr := io.Copy(copy, source)
|
||||
closeErr := copy.Close()
|
||||
if copyErr != nil || closeErr != nil {
|
||||
t.Fatal(copyErr, closeErr)
|
||||
}
|
||||
command := func(mode, path string, uid uint32) (*exec.Cmd, context.CancelFunc) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
cmd := exec.CommandContext(ctx, executable, "-test.run=^TestRootPeerIdentity$")
|
||||
cmd.Env = append(os.Environ(), "SCERT_IDENTITY_TEST_CHILD="+mode, "SCERT_IDENTITY_TEST_SOCKET="+path)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uid, Gid: uid}}
|
||||
return cmd, cancel
|
||||
}
|
||||
t.Run("caller", func(t *testing.T) {
|
||||
f := newRotationFixture(t)
|
||||
path := filepath.Join(base, "service.sock")
|
||||
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener.SetUnlinkOnClose(false)
|
||||
// 此用例故意放宽 inode 权限,只验证内核 UID 拒绝,而非文件权限拒绝。
|
||||
if err := os.Chmod(path, 0o666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
service := LocalService{Rotation: f.rotation, AllowedUID: 65534}
|
||||
go func() { done <- service.Serve(ctx, listener) }()
|
||||
defer stopLocalService(t, cancel, done)
|
||||
for _, peer := range []struct {
|
||||
mode string
|
||||
uid uint32
|
||||
}{{"allowed", 65534}, {"denied", 65533}} {
|
||||
cmd, stop := command(peer.mode, path, peer.uid)
|
||||
output, err := cmd.CombinedOutput()
|
||||
stop()
|
||||
if err != nil || !strings.Contains(string(output), "IDENTITY_VERIFIED") {
|
||||
t.Fatalf("UID %d 身份验证失败:%s, %v", peer.uid, output, err)
|
||||
}
|
||||
}
|
||||
if state, err := f.rotation.State(context.Background()); err != nil || state.Status != "idle" {
|
||||
t.Fatal("身份验证改变了事务状态", state, err)
|
||||
}
|
||||
})
|
||||
t.Run("server", func(t *testing.T) {
|
||||
parent := filepath.Join(base, "server")
|
||||
if err := os.Mkdir(parent, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(parent, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(parent, "service.sock")
|
||||
cmd, cancel := command("server", path, 65534)
|
||||
defer cancel()
|
||||
output, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { cancel(); _ = cmd.Wait() }()
|
||||
scanner := bufio.NewScanner(output)
|
||||
if !scanner.Scan() || scanner.Text() != "LISTENING" {
|
||||
t.Fatal("身份模拟服务未启动")
|
||||
}
|
||||
// inode 看起来由 root 管理,但真正的监听创建者仍是非特权进程。
|
||||
if err := os.Chown(path, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(parent, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateSocket(path, 0); err != nil {
|
||||
t.Fatal("未到达服务内核身份检查", err)
|
||||
}
|
||||
_, err = (Client{SocketPath: path}).State(context.Background())
|
||||
if err == nil || err.Error() != "本地激活服务身份不可信" {
|
||||
t.Fatal("未拒绝服务身份伪装", err)
|
||||
}
|
||||
verified := false
|
||||
for scanner.Scan() {
|
||||
verified = verified || scanner.Text() == "IDENTITY_VERIFIED"
|
||||
}
|
||||
if err := cmd.Wait(); err != nil || !verified {
|
||||
t.Fatal("身份模拟服务验证失败", err, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -59,8 +59,9 @@ func (s LocalService) Serve(ctx context.Context, listener *net.UnixListener) err
|
||||
for {
|
||||
connection, err := listener.AcceptUnix()
|
||||
if err != nil {
|
||||
stopping := ctx.Err() != nil
|
||||
cancel()
|
||||
if errors.Is(err, net.ErrClosed) && ctx.Err() != nil {
|
||||
if errors.Is(err, net.ErrClosed) && stopping {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
@ -89,6 +90,7 @@ func (s LocalService) handle(ctx context.Context, connection *net.UnixConn) {
|
||||
stop := context.AfterFunc(ctx, func() { _ = connection.Close() })
|
||||
defer stop()
|
||||
var message request
|
||||
defer func() { clear(message.Key) }()
|
||||
if err := readFrame(connection, &message); err != nil || !validRequest(message) {
|
||||
_ = connection.SetWriteDeadline(time.Now().Add(time.Second))
|
||||
_ = writeFrame(connection, response{Version: protocolVersion, Code: "invalid_request"})
|
||||
@ -97,8 +99,6 @@ func (s LocalService) handle(ctx context.Context, connection *net.UnixConn) {
|
||||
operationCtx, cancel := context.WithTimeout(ctx, operationTimeout)
|
||||
defer cancel()
|
||||
result := s.execute(operationCtx, message)
|
||||
// 私钥不写入日志、错误或响应,处理结束后清理请求缓冲。
|
||||
clear(message.Key)
|
||||
_ = connection.SetWriteDeadline(time.Now().Add(3 * time.Second))
|
||||
_ = writeFrame(connection, result)
|
||||
}
|
||||
|
||||
@ -15,9 +15,13 @@ func startLocalService(t *testing.T, rotation Rotation, allowed uint32) (Client,
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "activation.sock")
|
||||
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener.SetUnlinkOnClose(false)
|
||||
if err := os.Chmod(path, 0o600); err != nil { t.Fatal(err) }
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
service := LocalService{Rotation: rotation, AllowedUID: allowed}
|
||||
@ -31,7 +35,9 @@ func stopLocalService(t *testing.T, cancel context.CancelFunc, done <-chan error
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("本地激活服务未及时退出")
|
||||
}
|
||||
@ -46,15 +52,21 @@ func TestLocalServiceRoundTrip(t *testing.T) {
|
||||
t.Fatal("就绪查询失败", ready, err)
|
||||
}
|
||||
id, err := client.Stage(ctx, f.candidate.chain, f.candidate.key)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := client.State(ctx)
|
||||
if err != nil || state.ID != id || state.Status != "staged" {
|
||||
t.Fatalf("事务查询失败:%+v, %v", state, err)
|
||||
}
|
||||
f.upstream.active.Store(&f.candidate.pair)
|
||||
if err := client.Commit(ctx, id); err != nil { t.Fatal(err) }
|
||||
if err := client.Commit(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err = client.State(ctx)
|
||||
if err != nil || state.Status != "committed" { t.Fatal(state, err) }
|
||||
if err != nil || state.Status != "committed" {
|
||||
t.Fatal(state, err)
|
||||
}
|
||||
if state, err := client.Reconcile(ctx, id); err != nil || state.Status != "committed" {
|
||||
t.Fatal(state, err)
|
||||
}
|
||||
@ -89,9 +101,13 @@ func TestLocalServiceRejectsUnsafeMessages(t *testing.T) {
|
||||
{Version: 1, Action: "stage", Chain: []byte("pem")},
|
||||
} {
|
||||
connection, err := net.Dial("unix", client.SocketPath)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = connection.SetDeadline(time.Now().Add(time.Second))
|
||||
if err := writeFrame(connection, message); err != nil { t.Fatal(err) }
|
||||
if err := writeFrame(connection, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result response
|
||||
err = readFrame(connection, &result)
|
||||
_ = connection.Close()
|
||||
@ -100,50 +116,82 @@ func TestLocalServiceRejectsUnsafeMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
connection, err := net.Dial("unix", client.SocketPath)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
_ = connection.SetDeadline(time.Now().Add(time.Second))
|
||||
var header [4]byte
|
||||
binary.BigEndian.PutUint32(header[:], maxFrameSize+1)
|
||||
if _, err := connection.Write(header[:]); err != nil { t.Fatal(err) }
|
||||
if _, err := connection.Write(header[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result response
|
||||
if err := readFrame(connection, &result); err != nil || result.Code != "invalid_request" {
|
||||
t.Fatal("超限帧没有立即拒绝", result, err)
|
||||
}
|
||||
state, err := f.rotation.State(context.Background())
|
||||
if err != nil || state.Status != "idle" { t.Fatal(state, err) }
|
||||
if err != nil || state.Status != "idle" {
|
||||
t.Fatal(state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalServiceLostStageResponseCanReconcile(t *testing.T) {
|
||||
f := newRotationFixture(t)
|
||||
started := make(chan struct{})
|
||||
f.reloader.check = func(number int) error {
|
||||
if number == 1 {
|
||||
close(started)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
client, cancel, done := startLocalService(t, f.rotation, uint32(os.Geteuid()))
|
||||
defer stopLocalService(t, cancel, done)
|
||||
connection, err := net.Dial("unix", client.SocketPath)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeFrame(connection, request{Version: 1, Action: "stage", Chain: f.candidate.chain, Key: f.candidate.key}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 先确认暂存取得事务锁,避免状态查询抢锁使请求在开始前就失败。
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = connection.Close()
|
||||
t.Fatal("暂存请求未开始执行")
|
||||
}
|
||||
_ = connection.Close()
|
||||
ctx, stop := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer stop()
|
||||
var state State
|
||||
for {
|
||||
state, err = client.State(ctx)
|
||||
if err == nil && state.Status == "staged" { break }
|
||||
if ctx.Err() != nil { t.Fatal("丢失响应后未找到暂存事务", state, err) }
|
||||
time.Sleep(10*time.Millisecond)
|
||||
if err == nil && state.Status == "staged" {
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal("丢失响应后未找到暂存事务", state, err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
state, err = client.Reconcile(ctx, state.ID)
|
||||
if err != nil || state.Status != "rolled_back" { t.Fatal(state, err) }
|
||||
if err != nil || state.Status != "rolled_back" {
|
||||
t.Fatal(state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalServiceShutdownClosesStalledClients(t *testing.T) {
|
||||
f := newRotationFixture(t)
|
||||
client, cancel, done := startLocalService(t, f.rotation, uint32(os.Geteuid()))
|
||||
connection, err := net.Dial("unix", client.SocketPath)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
if _, err := connection.Write([]byte{0}); err != nil { t.Fatal(err) }
|
||||
if _, err := connection.Write([]byte{0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stopLocalService(t, cancel, done)
|
||||
if _, err := os.Lstat(client.SocketPath); err != nil {
|
||||
t.Fatal("服务停止不应删除托管 socket", err)
|
||||
@ -156,12 +204,22 @@ func TestLocalClientRejectsUntrustedSocket(t *testing.T) {
|
||||
defer stopLocalService(t, cancel, done)
|
||||
wrong := client
|
||||
wrong.ServiceUID++
|
||||
if _, err := wrong.State(context.Background()); err == nil { t.Fatal("不应信任错误所有者") }
|
||||
if _, err := wrong.State(context.Background()); err == nil {
|
||||
t.Fatal("不应信任错误所有者")
|
||||
}
|
||||
link := filepath.Join(filepath.Dir(client.SocketPath), "link.sock")
|
||||
if err := os.Symlink(client.SocketPath, link); err != nil { t.Fatal(err) }
|
||||
if err := os.Symlink(client.SocketPath, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrong = client
|
||||
wrong.SocketPath = link
|
||||
if _, err := wrong.State(context.Background()); err == nil { t.Fatal("不应连接符号链接") }
|
||||
if err := os.Chmod(client.SocketPath, 0o666); err != nil { t.Fatal(err) }
|
||||
if _, err := client.State(context.Background()); err == nil { t.Fatal("不应连接公开可写 socket") }
|
||||
if _, err := wrong.State(context.Background()); err == nil {
|
||||
t.Fatal("不应连接符号链接")
|
||||
}
|
||||
if err := os.Chmod(client.SocketPath, 0o666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.State(context.Background()); err == nil {
|
||||
t.Fatal("不应连接公开可写 socket")
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,16 +14,16 @@ import (
|
||||
|
||||
// Policy 必须由 root 独立维护,不能复用 Scert 可写的应用配置。
|
||||
type Policy struct {
|
||||
Version int `json:"version"`
|
||||
AllowedUID *uint32 `json:"allowed_uid"`
|
||||
SocketPath string `json:"socket_path"`
|
||||
Directory string `json:"directory"`
|
||||
Hostname string `json:"hostname"`
|
||||
ProxyAddress string `json:"proxy_address"`
|
||||
UpstreamAddress string `json:"upstream_address"`
|
||||
NginxBinary string `json:"nginx_binary"`
|
||||
NginxConfig string `json:"nginx_config"`
|
||||
NginxPrefix string `json:"nginx_prefix"`
|
||||
Version int `json:"version"`
|
||||
AllowedUID *uint32 `json:"allowed_uid"`
|
||||
SocketPath string `json:"socket_path"`
|
||||
Directory string `json:"directory"`
|
||||
Hostname string `json:"hostname"`
|
||||
ProxyAddress string `json:"proxy_address"`
|
||||
UpstreamAddress string `json:"upstream_address"`
|
||||
NginxBinary string `json:"nginx_binary"`
|
||||
NginxConfig string `json:"nginx_config"`
|
||||
NginxPrefix string `json:"nginx_prefix"`
|
||||
}
|
||||
|
||||
func LoadPolicy(path string) (Policy, error) {
|
||||
@ -51,6 +51,9 @@ func loadPolicy(path string, owner uint32) (Policy, error) {
|
||||
if !filepath.IsAbs(policy.SocketPath) || filepath.Clean(policy.SocketPath) != policy.SocketPath || policy.SocketPath == "/" || len(policy.SocketPath) > 107 {
|
||||
return policy, errors.New("激活 socket 路径无效")
|
||||
}
|
||||
if err := validateTrustedParents(filepath.Dir(policy.SocketPath), owner); err != nil {
|
||||
return policy, err
|
||||
}
|
||||
if err := validateDirectory(policy.Directory); err != nil {
|
||||
return policy, err
|
||||
}
|
||||
@ -79,9 +82,9 @@ func loadPolicy(path string, owner uint32) (Policy, error) {
|
||||
func (p Policy) Rotation() Rotation {
|
||||
return Rotation{
|
||||
Directory: p.Directory,
|
||||
Proxy: Endpoint{Address: p.ProxyAddress, Hostname: p.Hostname},
|
||||
Upstream: Endpoint{Address: p.UpstreamAddress, Hostname: p.Hostname},
|
||||
Reloader: CommandReloader{Binary: p.NginxBinary, Config: p.NginxConfig, Prefix: p.NginxPrefix},
|
||||
Proxy: Endpoint{Address: p.ProxyAddress, Hostname: p.Hostname},
|
||||
Upstream: Endpoint{Address: p.UpstreamAddress, Hostname: p.Hostname},
|
||||
Reloader: CommandReloader{Binary: p.NginxBinary, Config: p.NginxConfig, Prefix: p.NginxPrefix},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
165
backend/internal/nginxactivation/policy_linux_test.go
Normal file
165
backend/internal/nginxactivation/policy_linux_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package nginxactivation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func newPolicyFixture(t *testing.T) (Policy, string) {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
if err := os.Chmod(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uid := uint32(os.Geteuid()) + 1
|
||||
policy := Policy{
|
||||
Version: 1, AllowedUID: &uid,
|
||||
SocketPath: filepath.Join(directory, "a.sock"), Directory: directory,
|
||||
Hostname: testHostname, ProxyAddress: "127.0.0.1:443", UpstreamAddress: "127.0.0.1:9004",
|
||||
NginxBinary: filepath.Join(directory, "nginx"), NginxConfig: filepath.Join(directory, "nginx.conf"),
|
||||
NginxPrefix: directory,
|
||||
}
|
||||
writeTestFile(t, policy.NginxBinary, []byte("test executable"))
|
||||
if err := os.Chmod(policy.NginxBinary, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, policy.NginxConfig, []byte("events {}\n"))
|
||||
return policy, filepath.Join(directory, "policy.json")
|
||||
}
|
||||
|
||||
func writePolicy(t *testing.T, path string, policy Policy) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, path, data)
|
||||
}
|
||||
|
||||
func TestPolicyValidation(t *testing.T) {
|
||||
for _, scenario := range []string{
|
||||
"valid", "version", "missing-uid", "root-uid", "socket-relative", "socket-long", "socket-parent",
|
||||
"directory-mode", "hostname", "proxy-public", "upstream-public", "same-address", "binary-mode",
|
||||
"binary-no-exec", "binary-setuid", "config-mode", "prefix-relative", "prefix-mode",
|
||||
} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
policy, path := newPolicyFixture(t)
|
||||
chmod := func(path string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
switch scenario {
|
||||
case "version":
|
||||
policy.Version = 2
|
||||
case "missing-uid":
|
||||
policy.AllowedUID = nil
|
||||
case "root-uid":
|
||||
*policy.AllowedUID = 0
|
||||
case "socket-relative":
|
||||
policy.SocketPath = "a.sock"
|
||||
case "socket-long":
|
||||
policy.SocketPath = "/" + strings.Repeat("a", 108)
|
||||
case "socket-parent":
|
||||
parent := filepath.Join(policy.Directory, "socket")
|
||||
if err := os.Mkdir(parent, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chmod(parent, 0o777)
|
||||
policy.SocketPath = filepath.Join(parent, "a.sock")
|
||||
case "directory-mode":
|
||||
chmod(policy.Directory, 0o755)
|
||||
case "hostname":
|
||||
policy.Hostname = "*.example.com"
|
||||
case "proxy-public":
|
||||
policy.ProxyAddress = "192.0.2.1:443"
|
||||
case "upstream-public":
|
||||
policy.UpstreamAddress = "0.0.0.0:9004"
|
||||
case "same-address":
|
||||
policy.UpstreamAddress = policy.ProxyAddress
|
||||
case "binary-mode":
|
||||
chmod(policy.NginxBinary, 0o777)
|
||||
case "binary-no-exec":
|
||||
chmod(policy.NginxBinary, 0o600)
|
||||
case "binary-setuid":
|
||||
chmod(policy.NginxBinary, 0o700|os.ModeSetuid)
|
||||
case "config-mode":
|
||||
chmod(policy.NginxConfig, 0o666)
|
||||
case "prefix-relative":
|
||||
policy.NginxPrefix = "."
|
||||
case "prefix-mode":
|
||||
policy.NginxPrefix = filepath.Join(policy.Directory, "prefix")
|
||||
if err := os.Mkdir(policy.NginxPrefix, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chmod(policy.NginxPrefix, 0o777)
|
||||
}
|
||||
writePolicy(t, path, policy)
|
||||
got, err := loadPolicy(path, uint32(os.Geteuid()))
|
||||
if scenario == "valid" {
|
||||
if err != nil || got.Hostname != policy.Hostname || got.Rotation().Directory != policy.Directory {
|
||||
t.Fatal("合法策略未正确加载", err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("不可信策略被接受")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyFileSafety(t *testing.T) {
|
||||
for _, scenario := range []string{"symlink", "hardlink", "fifo", "writable", "owner", "oversize", "unknown", "trailing", "null"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
policy, path := newPolicyFixture(t)
|
||||
writePolicy(t, path, policy)
|
||||
owner := uint32(os.Geteuid())
|
||||
switch scenario {
|
||||
case "symlink":
|
||||
link := path + ".link"
|
||||
if err := os.Symlink(path, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path = link
|
||||
case "hardlink":
|
||||
if err := os.Link(path, path+".link"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "fifo":
|
||||
path += ".fifo"
|
||||
if err := unix.Mkfifo(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "writable":
|
||||
if err := os.Chmod(path, 0o666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "owner":
|
||||
owner++
|
||||
case "oversize":
|
||||
writeTestFile(t, path, []byte(strings.Repeat(" ", (16<<10)+1)))
|
||||
case "unknown", "trailing":
|
||||
data, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scenario == "unknown" {
|
||||
data = append(data[:len(data)-1], []byte(`,"command":"id"}`)...)
|
||||
} else {
|
||||
data = append(data, []byte(`{}`)...)
|
||||
}
|
||||
writeTestFile(t, path, data)
|
||||
case "null":
|
||||
writeTestFile(t, path, []byte("null"))
|
||||
}
|
||||
if _, err := loadPolicy(path, owner); err == nil {
|
||||
t.Fatal("不可信策略文件被接受")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -99,10 +99,17 @@ func (e Endpoint) Wait(ctx context.Context, expected []byte) error {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
matched := 0
|
||||
for {
|
||||
_, err := e.Check(ctx, expected)
|
||||
if err == nil {
|
||||
return nil
|
||||
matched++
|
||||
// 平滑重载期间新旧工作进程可能短暂并存,单次命中不足以确认切换。
|
||||
if matched == 3 {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
matched = 0
|
||||
}
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
select {
|
||||
|
||||
@ -224,3 +224,28 @@ func TestEndpointRejectsUnsafePolicies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointWaitRequiresConsecutiveMatches(t *testing.T) {
|
||||
a := newAuthority(t)
|
||||
now := time.Now()
|
||||
old := a.issue(t, testHostname, now.Add(-time.Hour), now.Add(24*time.Hour))
|
||||
candidate := a.issue(t, testHostname, now.Add(-time.Hour), now.Add(48*time.Hour))
|
||||
var requests atomic.Int32
|
||||
var endpoint *testEndpoint
|
||||
endpoint = newTestEndpoint(t, a, candidate, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if requests.Add(1) == 1 {
|
||||
endpoint.active.Store(&old.pair)
|
||||
} else {
|
||||
endpoint.active.Store(&candidate.pair)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"service":"scert","status":"ready"}`)
|
||||
}))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := endpoint.Wait(ctx, candidate.pair.Certificate[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requests.Load() < 5 {
|
||||
t.Fatal("出现旧证书后未重新累计连续成功探测", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ func readFrame(reader io.Reader, value any) error {
|
||||
return errors.New("本地激活消息大小无效")
|
||||
}
|
||||
data := make([]byte, int(length))
|
||||
defer clear(data)
|
||||
if _, err := io.ReadFull(reader, data); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -58,6 +59,7 @@ func writeFrame(writer io.Writer, value any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clear(data)
|
||||
if len(data) == 0 || len(data) > maxFrameSize {
|
||||
return errors.New("本地激活消息超过限制")
|
||||
}
|
||||
|
||||
@ -43,7 +43,7 @@ func TestLocalProtocolBoundsAndSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type partialWriter struct { bytes.Buffer }
|
||||
type partialWriter struct{ bytes.Buffer }
|
||||
|
||||
func (w *partialWriter) Write(data []byte) (int, error) {
|
||||
if len(data) > 2 {
|
||||
|
||||
@ -50,9 +50,13 @@ func TestRotationReconcileUsesActualUpstream(t *testing.T) {
|
||||
current := f.assertStatus(t, "staged")
|
||||
current.Status = "pending"
|
||||
root, err := os.OpenRoot(f.rotation.Directory)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
if err := saveTransaction(root, current); err != nil { t.Fatal(err) }
|
||||
if err := saveTransaction(root, current); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "unrelated":
|
||||
other := f.authority.issue(t, testHostname, time.Now().Add(-time.Hour), time.Now().Add(72*time.Hour))
|
||||
f.upstream.active.Store(&other.pair)
|
||||
@ -65,7 +69,9 @@ func TestRotationReconcileUsesActualUpstream(t *testing.T) {
|
||||
}
|
||||
state, err := f.rotation.Reconcile(ctx, id)
|
||||
if scenario == "unrelated" || scenario == "unavailable" || scenario == "external-include" || scenario == "wrong-id" {
|
||||
if err == nil { t.Fatal("无法确认的状态不应完成恢复") }
|
||||
if err == nil {
|
||||
t.Fatal("无法确认的状态不应完成恢复")
|
||||
}
|
||||
f.assertStatus(t, "staged")
|
||||
return
|
||||
}
|
||||
|
||||
@ -9,8 +9,8 @@ type State struct {
|
||||
}
|
||||
|
||||
type ReadyState struct {
|
||||
Hostname string `json:"hostname"`
|
||||
ProxyAddress string `json:"proxy_address"`
|
||||
Hostname string `json:"hostname"`
|
||||
ProxyAddress string `json:"proxy_address"`
|
||||
UpstreamAddress string `json:"upstream_address"`
|
||||
LeafHash string `json:"leaf_hash"`
|
||||
LeafHash string `json:"leaf_hash"`
|
||||
}
|
||||
|
||||
@ -1,6 +1,52 @@
|
||||
# Linux 生产流程测试记录
|
||||
|
||||
## 当前进展:Nginx 证书激活事务组件实机验证
|
||||
## 当前进展:受限本地激活服务与身份边界验证
|
||||
|
||||
2026-09-18 02:20 起接续,修改前检查点 `cc94a61` 已推送。本轮继续验证独立辅助服务,没有替换正式二进制、安装正式 socket、重载正式 Nginx 或清空共享数据库。代理模式续期仍在申请前拒绝,完整生产验收尚未完成。
|
||||
|
||||
### 已完成的修改
|
||||
|
||||
- [✓] 补齐受限 Unix socket 服务、客户端及独立 root 策略的测试;仅开放 `ready/state/stage/commit/reconcile`,请求不能指定命令或路径。
|
||||
- [✓] 修复监听器异常关闭被误报为正常退出;请求校验失败也清理已解析的私钥切片,并清理编码、读取持有的帧缓冲。
|
||||
- [✓] 策略加载增加 socket 父目录信任校验,覆盖非法 UID、地址、域名、权限、符号链接、硬链接、FIFO、超限及错误 JSON。
|
||||
- [✓] 激活等待改为连续三次新连接的严格 TLS/就绪探测;补测新旧证书交替时需重新累计成功次数。
|
||||
- [✓] 实际不同 UID 子进程验证授权与拒绝;伪装服务端即使将 socket inode 改为 root 所有,客户端仍通过内核身份拒绝,不发送请求。
|
||||
- [✓] 继承 FD3 的子进程验证合法 Unix socket、错误 PID、错误 FD 数量、错误路径及 TCP 监听器拒绝,关闭不删除托管 socket。
|
||||
|
||||
### 验证证据
|
||||
|
||||
本地 `go test -p=2 ./...`、`go test -race ./internal/nginxactivation -count=1` 通过;竞态测试仅覆盖 Windows 通用代码。Linux 目标 `go vet ./internal/nginxactivation ./cmd/scert-nginx`、辅助命令构建及两类测试程序交叉编译通过。
|
||||
|
||||
远端测试程序位于 `/scert/acceptance-20260917-1044/`,同名本地文件位于 `.runtime/`;上传前后 SHA-256 一致:
|
||||
|
||||
| 文件 | SHA-256 |
|
||||
| --- | --- |
|
||||
| `nginx-ipc-20260918-0240.test` | `0c41d5a0f0fd7990bb33377280af651054198529eef076adeedf7d0a3bd5d497` |
|
||||
| `nginx-listener-20260918-0230.test` | `c99dc7ef748ec8c6b3219859d7c070935c77312449b4338ce9e8539e28e3ea1e` |
|
||||
|
||||
`scert-nginx-ipc-20260918-0240.service` 以 Scert 用户运行,私有网络与临时目录、只读系统、隐藏用户目录、禁止提权,限制 192 MiB 内存、禁用 Swap、CPU 50%、64 个任务。通过 `InaccessiblePaths` 隐藏 `/scert/conf /scert/data /scert/log /scert/backups /ssl-scert-acceptance -/ssl`。显式使用 `/usr/sbin/nginx`,以 `-test.timeout=150s -test.count=10` 连续十轮通过,退出零,运行 111.123 秒、CPU 7.418 秒、峰值内存 21.9 MiB。不同 UID 用例在非 root 单元中按设计跳过,在下列独立 root 单元中执行。
|
||||
|
||||
`scert-nginx-listener-20260918-0230.service` 使用同类非特权隔离,继承监听器五个场景连续三轮通过,退出零,运行 170 毫秒、峰值内存 6.1 MiB。
|
||||
|
||||
`scert-nginx-identity-20260918-0242.service` 是仅执行身份边界用例的临时 root 隔离单元,保持私有网络、私有临时目录、只读系统和正式数据不可访问,能力限制为 `CAP_CHOWN CAP_SETUID CAP_SETGID CAP_DAC_OVERRIDE` 并显式设为环境能力。设置 `SCERT_TEST_ROOT_IPC=1`,以 `-test.run=^TestRootPeerIdentity$ -test.count=3` 验证实际 UID 65534 的授权请求、UID 65533 的拒绝及非 root 服务身份伪装,全部通过,运行 310 毫秒、峰值内存 32.6 MiB。没有向正式 Scert 服务增加能力。
|
||||
|
||||
最终留档单元 `scert-nginx-ipc-final-20260918-0244.service` 使用相同最终测试程序再运行一轮,31 项顶层测试通过,仅 root 身份用例按设计跳过;该用例已在上面的独立单元通过。最终单元运行 11.094 秒、峰值内存 20.3 MiB,详细日志保存在 systemd journal,可使用 `journalctl -u scert-nginx-ipc-final-20260918-0244.service` 复核。
|
||||
|
||||
2026-09-18 02:42 结束检查:正式 Scert PID `589832`、Nginx PID `578671`、MySQL PID `589785` 均未变化;正式程序 SHA-256 仍为 `f5b08bf0fb88f7b19281e91f942aa2995866f96420e422c015890b841bae6366`,Nginx 主配置及 Scert 站点摘要与本轮开始一致。最终测试与身份测试单元均已退出,PID 为零。`nginx -t` 通过,使用管理域名严格验证回环 TLS 后 `/readyz` 返回 200 和 `ready`。本轮不包含浏览器或新增公网验收。
|
||||
|
||||
### 失败与修复记录
|
||||
|
||||
初始测试单元因误写不存在的 `/scert/logs` 在执行测试前出现 `226/NAMESPACE`;核实正式日志目录为 `/scert/log` 后修正隔离参数。旧测试程序 `nginx-ipc-20260918-0230.test` 重复运行暴露应答丢失用例的抢锁竞争,以及真实 Nginx 单次探测成功后仍遇到旧工作进程。前者改为先确认暂存已获得锁再模拟应答丢失;后者加强连续探测并增加回归测试,而非简单增加测试超时。旧程序和失败记录均保留。
|
||||
|
||||
不同 UID 单元最初只设置能力边界,子进程在执行前报 `operation not permitted`;独立诊断确认有效能力中缺少 `CAP_SETUID`,`setpriv` 同样失败。显式设置所需环境能力后,同一测试程序通过,不将首次未进入校验逻辑的失败计为安全验证。
|
||||
|
||||
### 仍需完成
|
||||
|
||||
- [ ] 安装 socket/service、专用版本目录和目标站点 include;当前正式站点仍直接引用原证书文件。
|
||||
- [ ] 应用持久化双层协调与启动恢复、完整生命周期串行化、应答丢失及中断恢复;目前不能自动处理任意应用崩溃窗口。
|
||||
- [ ] 最新完整候选构建部署、管理证书续期闭环、目标机真实推送、通知送达、完整灾难恢复和容量验收。
|
||||
|
||||
## 前序进展:Nginx 证书激活事务组件实机验证
|
||||
|
||||
2026-09-18 01:45 至 02:03 本轮接续,修改前检查点 `10645ea` 已推送。本节为最新状态;本轮没有替换正式程序、重载正式 Nginx 或清空正式数据库,组件验证通过不代表完整生产验收完成。
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user