diff --git a/backend/cmd/scert-nginx/main_linux_test.go b/backend/cmd/scert-nginx/main_linux_test.go new file mode 100644 index 0000000..a5c707b --- /dev/null +++ b/backend/cmd/scert-nginx/main_linux_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/nginxactivation/README.md b/backend/internal/nginxactivation/README.md new file mode 100644 index 0000000..13906dc --- /dev/null +++ b/backend/internal/nginxactivation/README.md @@ -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`。 diff --git a/backend/internal/nginxactivation/client_linux_test.go b/backend/internal/nginxactivation/client_linux_test.go new file mode 100644 index 0000000..b007f41 --- /dev/null +++ b/backend/internal/nginxactivation/client_linux_test.go @@ -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) + } +} diff --git a/backend/internal/nginxactivation/identity_linux_test.go b/backend/internal/nginxactivation/identity_linux_test.go new file mode 100644 index 0000000..f6fa975 --- /dev/null +++ b/backend/internal/nginxactivation/identity_linux_test.go @@ -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()) + } + }) +} diff --git a/backend/internal/nginxactivation/ipc_linux.go b/backend/internal/nginxactivation/ipc_linux.go index eef5cc5..efea1f5 100644 --- a/backend/internal/nginxactivation/ipc_linux.go +++ b/backend/internal/nginxactivation/ipc_linux.go @@ -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) } diff --git a/backend/internal/nginxactivation/ipc_linux_test.go b/backend/internal/nginxactivation/ipc_linux_test.go index c238ead..ada55be 100644 --- a/backend/internal/nginxactivation/ipc_linux_test.go +++ b/backend/internal/nginxactivation/ipc_linux_test.go @@ -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") + } } diff --git a/backend/internal/nginxactivation/policy_linux.go b/backend/internal/nginxactivation/policy_linux.go index 069cfd4..2605264 100644 --- a/backend/internal/nginxactivation/policy_linux.go +++ b/backend/internal/nginxactivation/policy_linux.go @@ -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}, } } diff --git a/backend/internal/nginxactivation/policy_linux_test.go b/backend/internal/nginxactivation/policy_linux_test.go new file mode 100644 index 0000000..88e8ee7 --- /dev/null +++ b/backend/internal/nginxactivation/policy_linux_test.go @@ -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("不可信策略文件被接受") + } + }) + } +} diff --git a/backend/internal/nginxactivation/probe.go b/backend/internal/nginxactivation/probe.go index 607ffb2..c4c305d 100644 --- a/backend/internal/nginxactivation/probe.go +++ b/backend/internal/nginxactivation/probe.go @@ -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 { diff --git a/backend/internal/nginxactivation/probe_test.go b/backend/internal/nginxactivation/probe_test.go index 248f8da..27ec1e2 100644 --- a/backend/internal/nginxactivation/probe_test.go +++ b/backend/internal/nginxactivation/probe_test.go @@ -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()) + } +} diff --git a/backend/internal/nginxactivation/protocol.go b/backend/internal/nginxactivation/protocol.go index 1d05135..d03b8c1 100644 --- a/backend/internal/nginxactivation/protocol.go +++ b/backend/internal/nginxactivation/protocol.go @@ -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("本地激活消息超过限制") } diff --git a/backend/internal/nginxactivation/protocol_test.go b/backend/internal/nginxactivation/protocol_test.go index e2cc9eb..64e7149 100644 --- a/backend/internal/nginxactivation/protocol_test.go +++ b/backend/internal/nginxactivation/protocol_test.go @@ -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 { diff --git a/backend/internal/nginxactivation/reconcile_linux_test.go b/backend/internal/nginxactivation/reconcile_linux_test.go index 25c9995..b2a1498 100644 --- a/backend/internal/nginxactivation/reconcile_linux_test.go +++ b/backend/internal/nginxactivation/reconcile_linux_test.go @@ -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 } diff --git a/backend/internal/nginxactivation/status.go b/backend/internal/nginxactivation/status.go index a1c504c..32c7ba8 100644 --- a/backend/internal/nginxactivation/status.go +++ b/backend/internal/nginxactivation/status.go @@ -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"` } diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index e263858..d868c4b 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -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 或清空正式数据库,组件验证通过不代表完整生产验收完成。