diff --git a/backend/cmd/scert-nginx/main_linux.go b/backend/cmd/scert-nginx/main_linux.go new file mode 100644 index 0000000..a394a47 --- /dev/null +++ b/backend/cmd/scert-nginx/main_linux.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "net" + "os" + "os/signal" + "strconv" + "syscall" + + "git.pcux.cn/xuans/Scert/backend/internal/nginxactivation" +) + +func inheritedListener(expectedPath string) (*net.UnixListener, error) { + if os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) || os.Getenv("LISTEN_FDS") != "1" { + return nil, errors.New("必须由单个 systemd Unix socket 启动激活服务") + } + file := os.NewFile(3, "activation-socket") + if file == nil { + return nil, errors.New("激活 socket 描述符不存在") + } + defer file.Close() + listener, err := net.FileListener(file) + if err != nil { + return nil, err + } + unixListener, ok := listener.(*net.UnixListener) + if !ok { + _ = listener.Close() + return nil, errors.New("激活器拒绝非 Unix 监听器") + } + unixListener.SetUnlinkOnClose(false) + if unixListener.Addr().Network() != "unix" || unixListener.Addr().String() != expectedPath { + _ = unixListener.Close() + return nil, errors.New("激活 socket 与固定策略不匹配") + } + return unixListener, nil +} + +func run(ctx context.Context, path string) error { + if os.Geteuid() != 0 { + return errors.New("独立 Nginx 激活服务必须由 root 启动") + } + policy, err := nginxactivation.LoadPolicy(path) + if err != nil { + return fmt.Errorf("读取受信激活策略失败:%w", err) + } + listener, err := inheritedListener(policy.SocketPath) + if err != nil { + return err + } + defer listener.Close() + service := nginxactivation.LocalService{Rotation: policy.Rotation(), AllowedUID: *policy.AllowedUID} + return service.Serve(ctx, listener) +} + +func main() { + path := flag.String("policy", "/etc/scert-nginx/policy.json", "root 管理的独立激活策略文件") + flag.Parse() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx, *path); err != nil { + fmt.Fprintln(os.Stderr, "Nginx 本地激活服务启动或运行失败:", err) + os.Exit(1) + } +} diff --git a/backend/internal/nginxactivation/client_linux.go b/backend/internal/nginxactivation/client_linux.go new file mode 100644 index 0000000..bf65097 --- /dev/null +++ b/backend/internal/nginxactivation/client_linux.go @@ -0,0 +1,144 @@ +package nginxactivation + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "syscall" + "time" +) + +var ErrLocalOperation = errors.New("本地 Nginx 激活操作未完成,请核对事务状态") + +// Client 只能连接受保护的本地 Unix socket;生产调用方必须指定 root 的 UID 0。 +type Client struct { + SocketPath string + ServiceUID uint32 +} + +func (c Client) Ready(ctx context.Context) (ReadyState, error) { + result, err := c.call(ctx, request{Action: "ready"}) + if err != nil { + return ReadyState{}, err + } + if result.Ready == nil { + return ReadyState{}, errors.New("本地激活服务没有返回就绪状态") + } + return *result.Ready, nil +} + +func (c Client) State(ctx context.Context) (State, error) { + result, err := c.call(ctx, request{Action: "state"}) + if err != nil { + return State{}, err + } + if result.State == nil { + return State{}, errors.New("本地激活服务没有返回事务状态") + } + return *result.State, nil +} + +func (c Client) Stage(ctx context.Context, chain, key []byte) (string, error) { + result, err := c.call(ctx, request{Action: "stage", Chain: chain, Key: key}) + if err == nil && !validID(result.ID) { + return "", errors.New("本地激活服务返回无效事务编号") + } + return result.ID, err +} + +func (c Client) Commit(ctx context.Context, id string) error { + _, err := c.call(ctx, request{Action: "commit", ID: id}) + return err +} + +func (c Client) Reconcile(ctx context.Context, id string) (State, error) { + result, err := c.call(ctx, request{Action: "reconcile", ID: id}) + if result.State != nil { + return *result.State, err + } + if err != nil { + return State{}, err + } + return State{}, errors.New("本地激活服务没有返回恢复结果") +} + +func (c Client) call(ctx context.Context, message request) (response, error) { + message.Version = protocolVersion + if !validRequest(message) { + return response{}, errors.New("本地激活请求无效") + } + if err := validateSocket(c.SocketPath, c.ServiceUID); err != nil { + return response{}, err + } + ctx, cancel := context.WithTimeout(ctx, 95*time.Second) + defer cancel() + connection, err := (&net.Dialer{}).DialContext(ctx, "unix", c.SocketPath) + if err != nil { + return response{}, err + } + defer connection.Close() + unixConnection, ok := connection.(*net.UnixConn) + if !ok { + return response{}, errors.New("本地激活连接类型无效") + } + uid, err := peerUID(unixConnection) + if err != nil || uid != c.ServiceUID { + return response{}, errors.New("本地激活服务身份不可信") + } + stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) + defer stop() + deadline, _ := ctx.Deadline() + if err := connection.SetDeadline(deadline); err != nil { + return response{}, err + } + if err := writeFrame(connection, message); err != nil { + return response{}, errors.Join(err, ctx.Err()) + } + var result response + if err := readFrame(connection, &result); err != nil { + return response{}, errors.Join(err, ctx.Err()) + } + if result.Version != protocolVersion { + return response{}, errors.New("本地激活协议版本不匹配") + } + if result.Code != "ok" { + return result, ErrLocalOperation + } + return result, nil +} + +func validateSocket(path string, uid uint32) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" { + return errors.New("激活 socket 必须使用规范绝对路径") + } + info, err := os.Lstat(path) + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || info.Mode()&os.ModeSocket == 0 || stat.Uid != uid || info.Mode().Perm()&0o117 != 0 { + return errors.New("激活 socket 类型、所有者或权限不可信") + } + return validateTrustedParents(filepath.Dir(path), uid) +} + +func validateTrustedParents(path string, uid uint32) error { + for ; ; path = filepath.Dir(path) { + info, err := os.Lstat(path) + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.IsDir() || (stat.Uid != uid && stat.Uid != 0) { + return errors.New("激活策略父目录的类型或所有者不可信") + } + if info.Mode().Perm()&0o022 != 0 && !(stat.Uid == 0 && info.Mode()&os.ModeSticky != 0) { + return errors.New("激活策略父目录允许非受信用户写入") + } + if filepath.Dir(path) == path { + return nil + } + } +} diff --git a/backend/internal/nginxactivation/ipc_linux.go b/backend/internal/nginxactivation/ipc_linux.go new file mode 100644 index 0000000..eef5cc5 --- /dev/null +++ b/backend/internal/nginxactivation/ipc_linux.go @@ -0,0 +1,153 @@ +package nginxactivation + +import ( + "context" + "errors" + "net" + "sync" + "time" + + "golang.org/x/sys/unix" +) + +const operationTimeout = 60 * time.Second + +// LocalService 只接受内核认证的指定 UID,不依赖请求提供的身份。 +// 监听器由受信启动器提供;本服务不创建 TCP 入口或删除 socket 文件。 +type LocalService struct { + Rotation Rotation + AllowedUID uint32 +} + +func peerUID(connection *net.UnixConn) (uint32, error) { + raw, err := connection.SyscallConn() + if err != nil { + return 0, err + } + var credentials *unix.Ucred + var socketErr error + if err := raw.Control(func(fd uintptr) { + credentials, socketErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }); err != nil { + return 0, err + } + if socketErr != nil { + return 0, socketErr + } + if credentials == nil || credentials.Pid <= 0 { + return 0, errors.New("本地激活连接缺少内核身份") + } + return credentials.Uid, nil +} + +func (s LocalService) Serve(ctx context.Context, listener *net.UnixListener) error { + if listener == nil { + return errors.New("本地激活服务缺少 Unix 监听器") + } + if s.Rotation.Reloader == nil { + return errors.New("本地激活策略未初始化") + } + listener.SetUnlinkOnClose(false) + defer listener.Close() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stop := context.AfterFunc(ctx, func() { _ = listener.Close() }) + defer stop() + var workers sync.WaitGroup + defer workers.Wait() + capacity := make(chan struct{}, 4) + for { + connection, err := listener.AcceptUnix() + if err != nil { + cancel() + if errors.Is(err, net.ErrClosed) && ctx.Err() != nil { + return nil + } + return err + } + select { + case capacity <- struct{}{}: + workers.Add(1) + go func() { + defer workers.Done() + defer func() { <-capacity }() + s.handle(ctx, connection) + }() + default: + _ = connection.Close() + } + } +} + +func (s LocalService) handle(ctx context.Context, connection *net.UnixConn) { + defer connection.Close() + uid, err := peerUID(connection) + if err != nil || uid != s.AllowedUID { + return + } + _ = connection.SetReadDeadline(time.Now().Add(3 * time.Second)) + stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) + defer stop() + var message request + if err := readFrame(connection, &message); err != nil || !validRequest(message) { + _ = connection.SetWriteDeadline(time.Now().Add(time.Second)) + _ = writeFrame(connection, response{Version: protocolVersion, Code: "invalid_request"}) + return + } + 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) +} + +func validRequest(message request) bool { + if message.Version != protocolVersion { + return false + } + switch message.Action { + case "ready", "state": + return message.ID == "" && len(message.Chain) == 0 && len(message.Key) == 0 + case "stage": + return message.ID == "" && len(message.Chain) > 0 && len(message.Chain) <= 1<<20 && len(message.Key) > 0 && len(message.Key) <= 64<<10 + case "commit", "reconcile": + return validID(message.ID) && len(message.Chain) == 0 && len(message.Key) == 0 + default: + return false + } +} + +func (s LocalService) execute(ctx context.Context, message request) response { + result := response{Version: protocolVersion, Code: "ok"} + var err error + switch message.Action { + case "ready": + value, readyErr := s.Rotation.Ready(ctx) + err = readyErr + if err == nil { + result.Ready = &value + } + case "state": + value, stateErr := s.Rotation.State(ctx) + err = stateErr + if err == nil { + result.State = &value + } + case "stage": + result.ID, err = s.Rotation.Stage(ctx, message.Chain, message.Key) + case "commit": + err = s.Rotation.Commit(ctx, message.ID) + case "reconcile": + value, reconcileErr := s.Rotation.Reconcile(ctx, message.ID) + err = reconcileErr + if value.ID != "" { + result.State = &value + } + } + if err != nil { + result.Code = "operation_failed" + } + return result +} diff --git a/backend/internal/nginxactivation/ipc_linux_test.go b/backend/internal/nginxactivation/ipc_linux_test.go new file mode 100644 index 0000000..c238ead --- /dev/null +++ b/backend/internal/nginxactivation/ipc_linux_test.go @@ -0,0 +1,167 @@ +package nginxactivation + +import ( + "context" + "encoding/binary" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +func startLocalService(t *testing.T, rotation Rotation, allowed uint32) (Client, context.CancelFunc, <-chan error) { + 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) } + listener.SetUnlinkOnClose(false) + 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} + go func() { done <- service.Serve(ctx, listener) }() + t.Cleanup(cancel) + return Client{SocketPath: path, ServiceUID: uint32(os.Geteuid())}, cancel, done +} + +func stopLocalService(t *testing.T, cancel context.CancelFunc, done <-chan error) { + t.Helper() + cancel() + select { + case err := <-done: + if err != nil { t.Fatal(err) } + case <-time.After(5 * time.Second): + t.Fatal("本地激活服务未及时退出") + } +} + +func TestLocalServiceRoundTrip(t *testing.T) { + f := newRotationFixture(t) + client, cancel, done := startLocalService(t, f.rotation, uint32(os.Geteuid())) + defer stopLocalService(t, cancel, done) + ctx := context.Background() + if ready, err := client.Ready(ctx); err != nil || ready.Hostname != testHostname { + t.Fatal("就绪查询失败", ready, err) + } + id, err := client.Stage(ctx, f.candidate.chain, f.candidate.key) + 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) } + state, err = client.State(ctx) + 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) + } + if err := client.Commit(ctx, id); !errors.Is(err, ErrLocalOperation) { + t.Fatal("重复提交应要求查询已知终态", err) + } +} + +func TestLocalServiceRejectsUnauthorizedPeer(t *testing.T) { + f := newRotationFixture(t) + client, cancel, done := startLocalService(t, f.rotation, uint32(os.Geteuid())+1) + defer stopLocalService(t, cancel, done) + ctx, stop := context.WithTimeout(context.Background(), time.Second) + defer stop() + if _, err := client.Stage(ctx, f.candidate.chain, f.candidate.key); err == nil { + t.Fatal("未授权 UID 不应调用激活器") + } + if state, err := f.rotation.State(context.Background()); err != nil || state.Status != "idle" { + t.Fatal("未授权调用改变了事务", state, err) + } +} + +func TestLocalServiceRejectsUnsafeMessages(t *testing.T) { + f := newRotationFixture(t) + client, cancel, done := startLocalService(t, f.rotation, uint32(os.Geteuid())) + defer stopLocalService(t, cancel, done) + for _, message := range []request{ + {Version: 1, Action: "exec"}, + {Version: 2, Action: "state"}, + {Version: 1, Action: "commit", ID: "../../config"}, + {Version: 1, Action: "state", Key: []byte("secret")}, + {Version: 1, Action: "stage", Chain: []byte("pem")}, + } { + connection, err := net.Dial("unix", client.SocketPath) + if err != nil { t.Fatal(err) } + _ = connection.SetDeadline(time.Now().Add(time.Second)) + if err := writeFrame(connection, message); err != nil { t.Fatal(err) } + var result response + err = readFrame(connection, &result) + _ = connection.Close() + if err != nil || result.Code != "invalid_request" { + t.Fatal("无效请求没有被拒绝", result, err) + } + } + connection, err := net.Dial("unix", client.SocketPath) + 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) } + 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) } +} + +func TestLocalServiceLostStageResponseCanReconcile(t *testing.T) { + f := newRotationFixture(t) + 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 := writeFrame(connection, request{Version: 1, Action: "stage", Chain: f.candidate.chain, Key: f.candidate.key}); err != nil { + t.Fatal(err) + } + _ = 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) + } + state, err = client.Reconcile(ctx, state.ID) + 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) } + defer connection.Close() + 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) + } +} + +func TestLocalClientRejectsUntrustedSocket(t *testing.T) { + f := newRotationFixture(t) + client, cancel, done := startLocalService(t, f.rotation, uint32(os.Geteuid())) + defer stopLocalService(t, cancel, done) + wrong := client + wrong.ServiceUID++ + 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) } + 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") } +} diff --git a/backend/internal/nginxactivation/policy_linux.go b/backend/internal/nginxactivation/policy_linux.go new file mode 100644 index 0000000..069cfd4 --- /dev/null +++ b/backend/internal/nginxactivation/policy_linux.go @@ -0,0 +1,131 @@ +package nginxactivation + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "syscall" + + "golang.org/x/sys/unix" +) + +// 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"` +} + +func LoadPolicy(path string) (Policy, error) { + return loadPolicy(path, 0) +} + +func loadPolicy(path string, owner uint32) (Policy, error) { + var policy Policy + data, err := readTrustedPolicy(path, owner) + if err != nil { + return policy, err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&policy); err != nil { + return policy, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return policy, errors.New("激活策略包含多余内容") + } + if policy.Version != 1 || policy.AllowedUID == nil || *policy.AllowedUID == 0 { + return policy, errors.New("激活策略版本或非特权调用者 UID 无效") + } + if !filepath.IsAbs(policy.SocketPath) || filepath.Clean(policy.SocketPath) != policy.SocketPath || policy.SocketPath == "/" || len(policy.SocketPath) > 107 { + return policy, errors.New("激活 socket 路径无效") + } + if err := validateDirectory(policy.Directory); err != nil { + return policy, err + } + rotation := policy.Rotation() + if err := errors.Join(rotation.Proxy.validate(), rotation.Upstream.validate()); err != nil { + return policy, err + } + if policy.ProxyAddress == policy.UpstreamAddress { + return policy, errors.New("代理和上游不能使用同一地址") + } + if err := validateOwnedFile(policy.NginxBinary, owner, true); err != nil { + return policy, err + } + if err := validateOwnedFile(policy.NginxConfig, owner, false); err != nil { + return policy, err + } + if !filepath.IsAbs(policy.NginxPrefix) || filepath.Clean(policy.NginxPrefix) != policy.NginxPrefix { + return policy, errors.New("Nginx 前缀必须是规范绝对目录") + } + if err := validateTrustedParents(policy.NginxPrefix, owner); err != nil { + return policy, err + } + return policy, nil +} + +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}, + } +} + +func validateOwnedFile(path string, owner uint32, executable bool) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return errors.New("激活策略文件必须使用规范绝对路径") + } + if err := validateTrustedParents(filepath.Dir(path), owner); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() || stat.Uid != owner || stat.Nlink != 1 || info.Mode().Perm()&0o022 != 0 { + return errors.New("激活策略文件类型、所有者或写权限不可信") + } + if executable && (info.Mode().Perm()&0o100 == 0 || info.Mode()&(os.ModeSetuid|os.ModeSetgid) != 0) { + return errors.New("Nginx 可执行文件权限无效") + } + return nil +} + +func readTrustedPolicy(path string, owner uint32) ([]byte, error) { + if err := validateOwnedFile(path, owner, false); err != nil { + return nil, err + } + file, err := os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() || stat.Uid != owner || stat.Nlink != 1 || info.Mode().Perm()&0o022 != 0 || info.Size() > 16<<10 { + return nil, errors.New("激活策略文件不可信或超过限制") + } + data, err := io.ReadAll(io.LimitReader(file, (16<<10)+1)) + if err != nil || len(data) > 16<<10 { + return nil, errors.New("读取激活策略失败或超过限制") + } + return data, nil +} diff --git a/backend/internal/nginxactivation/protocol.go b/backend/internal/nginxactivation/protocol.go new file mode 100644 index 0000000..1d05135 --- /dev/null +++ b/backend/internal/nginxactivation/protocol.go @@ -0,0 +1,84 @@ +package nginxactivation + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "io" +) + +const protocolVersion = 1 +const maxFrameSize = 2 << 20 + +type request struct { + Version int `json:"version"` + Action string `json:"action"` + ID string `json:"id,omitempty"` + Chain []byte `json:"chain,omitempty"` + Key []byte `json:"key,omitempty"` +} + +type response struct { + Version int `json:"version"` + Code string `json:"code"` + ID string `json:"id,omitempty"` + State *State `json:"state,omitempty"` + Ready *ReadyState `json:"ready,omitempty"` +} + +// 固定长度前缀避免依赖连接 EOF;先校验上限再分配请求缓冲。 +func readFrame(reader io.Reader, value any) error { + var header [4]byte + if _, err := io.ReadFull(reader, header[:]); err != nil { + return err + } + length := binary.BigEndian.Uint32(header[:]) + if length == 0 || length > maxFrameSize { + return errors.New("本地激活消息大小无效") + } + data := make([]byte, int(length)) + if _, err := io.ReadFull(reader, data); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return errors.New("本地激活消息包含多余内容") + } + return nil +} + +func writeFrame(writer io.Writer, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + if len(data) == 0 || len(data) > maxFrameSize { + return errors.New("本地激活消息超过限制") + } + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(data))) + if err := writeAll(writer, header[:]); err != nil { + return err + } + return writeAll(writer, data) +} + +func writeAll(writer io.Writer, data []byte) error { + for len(data) > 0 { + n, err := writer.Write(data) + if err != nil { + return err + } + if n <= 0 || n > len(data) { + return io.ErrShortWrite + } + data = data[n:] + } + return nil +} diff --git a/backend/internal/nginxactivation/protocol_test.go b/backend/internal/nginxactivation/protocol_test.go new file mode 100644 index 0000000..e2cc9eb --- /dev/null +++ b/backend/internal/nginxactivation/protocol_test.go @@ -0,0 +1,71 @@ +package nginxactivation + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "testing" +) + +func framedBytes(data []byte) []byte { + framed := make([]byte, 4, len(data)+4) + binary.BigEndian.PutUint32(framed, uint32(len(data))) + return append(framed, data...) +} + +func TestLocalProtocolBoundsAndSchema(t *testing.T) { + oversized := make([]byte, 4) + binary.BigEndian.PutUint32(oversized, maxFrameSize+1) + for _, data := range [][]byte{ + {}, {0, 0}, {0, 0, 0, 0}, oversized, + {0, 0, 0, 10, '{'}, + framedBytes([]byte(`{"version":1,"action":"state","command":"nginx"}`)), + framedBytes([]byte(`{"version":1,"action":"state"}{}`)), + framedBytes([]byte(`[]`)), + } { + var message request + if err := readFrame(bytes.NewReader(data), &message); err == nil { + t.Fatalf("不应接受消息 %q", data) + } + } + want := request{Version: 1, Action: "stage", Chain: []byte("certificate"), Key: []byte("private")} + var stream bytes.Buffer + if err := writeFrame(&stream, want); err != nil { + t.Fatal(err) + } + var got request + if err := readFrame(&stream, &got); err != nil || got.Action != want.Action || !bytes.Equal(got.Key, want.Key) || !bytes.Equal(got.Chain, want.Chain) { + t.Fatalf("消息往返不一致:%+v, %v", got, err) + } + if err := writeFrame(io.Discard, request{Chain: make([]byte, maxFrameSize)}); err == nil { + t.Fatal("不应编码超过上限的帧") + } +} + +type partialWriter struct { bytes.Buffer } + +func (w *partialWriter) Write(data []byte) (int, error) { + if len(data) > 2 { + data = data[:2] + } + return w.Buffer.Write(data) +} + +type stalledWriter struct{} + +func (stalledWriter) Write([]byte) (int, error) { return 0, nil } + +func TestLocalProtocolHandlesShortWrites(t *testing.T) { + writer := &partialWriter{} + if err := writeFrame(writer, response{Version: 1, Code: "ok"}); err != nil { + t.Fatal(err) + } + var reply response + if err := readFrame(&writer.Buffer, &reply); err != nil || reply.Code != "ok" { + t.Fatal("短写导致响应丢失", err) + } + if err := writeFrame(stalledWriter{}, reply); !errors.Is(err, io.ErrShortWrite) { + t.Fatal("无进展写入应退出", err) + } +} diff --git a/backend/internal/nginxactivation/reconcile_linux.go b/backend/internal/nginxactivation/reconcile_linux.go new file mode 100644 index 0000000..b8c9a9a --- /dev/null +++ b/backend/internal/nginxactivation/reconcile_linux.go @@ -0,0 +1,130 @@ +package nginxactivation + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" +) + +func leafHash(der []byte) string { + hash := sha256.Sum256(der) + return hex.EncodeToString(hash[:]) +} + +func transactionState(current transaction) State { + return State{ID: current.ID, Status: current.Status, PreviousHash: leafHash(current.PreviousLeaf), CandidateHash: leafHash(current.CandidateLeaf)} +} + +func (r Rotation) State(ctx context.Context) (State, error) { + root, lock, err := r.open(ctx) + if err != nil { + return State{}, err + } + defer root.Close() + defer lock.Close() + current, err := readTransaction(root) + if errors.Is(err, os.ErrNotExist) { + return State{Status: "idle"}, nil + } + if err != nil { + return State{}, err + } + return transactionState(current), nil +} + +// Ready 在申请前核对固定策略、双层入口、专用材料和 Nginx 配置。 +func (r Rotation) Ready(ctx context.Context) (ReadyState, error) { + root, lock, err := r.open(ctx) + if err != nil { + return ReadyState{}, err + } + defer root.Close() + defer lock.Close() + current, err := readTransaction(root) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return ReadyState{}, err + } + if err == nil && (current.Status == "pending" || current.Status == "staged") { + return ReadyState{}, errors.New("管理证书仍有未完成事务") + } + leaf, err := r.Proxy.Check(ctx, nil) + if err != nil { + return ReadyState{}, err + } + if _, err := r.Upstream.Check(ctx, leaf); err != nil { + return ReadyState{}, err + } + include, err := readRegular(root, includeName, 4096) + if err != nil { + return ReadyState{}, err + } + if err := verifyIncludeMaterial(root, include, leaf); err != nil { + return ReadyState{}, err + } + if err := r.Reloader.Check(ctx); err != nil { + return ReadyState{}, err + } + if err := requireInclude(root, include); err != nil { + return ReadyState{}, err + } + return ReadyState{Hostname: r.Proxy.Hostname, ProxyAddress: r.Proxy.Address, UpstreamAddress: r.Upstream.Address, LeafHash: leafHash(leaf)}, nil +} + +// Reconcile 仅根据严格验证的实际上游完成未决事务,不推断配置保存是否成功。 +// 上游不可用、外部改动或证书不属于事务时保留记录,交由协调者处理后重试。 +func (r Rotation) Reconcile(ctx context.Context, id string) (State, error) { + root, lock, err := r.open(ctx) + if err != nil { + return State{}, err + } + defer root.Close() + defer lock.Close() + current, err := readTransaction(root) + if err != nil { + return State{}, err + } + if id == "" || id != current.ID { + return State{}, errors.New("恢复事务编号不匹配") + } + state := transactionState(current) + if current.Status == "committed" || current.Status == "rolled_back" { + return state, nil + } + upstream, err := r.Upstream.Check(ctx, nil) + if err != nil { + return state, err + } + if bytes.Equal(upstream, current.PreviousLeaf) { + if err := r.rollback(ctx, root, current); err != nil { + return state, err + } + current.Status = "rolled_back" + return transactionState(current), nil + } + if !bytes.Equal(upstream, current.CandidateLeaf) { + return state, errors.New("上游证书不属于未决事务,拒绝自动恢复") + } + if err := requireInclude(root, current.CandidateInclude); err != nil { + return state, err + } + if err := verifyIncludeMaterial(root, current.CandidateInclude, current.CandidateLeaf); err != nil { + return state, err + } + if err := r.Proxy.Wait(ctx, current.CandidateLeaf); err != nil { + return state, err + } + if _, err := r.Upstream.Check(ctx, current.CandidateLeaf); err != nil { + return state, err + } + if err := requireInclude(root, current.CandidateInclude); err != nil { + return state, err + } + current.Status = "committed" + if err := saveTransaction(root, current); err != nil { + return state, err + } + return transactionState(current), nil +} diff --git a/backend/internal/nginxactivation/reconcile_linux_test.go b/backend/internal/nginxactivation/reconcile_linux_test.go new file mode 100644 index 0000000..25c9995 --- /dev/null +++ b/backend/internal/nginxactivation/reconcile_linux_test.go @@ -0,0 +1,81 @@ +package nginxactivation + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestRotationReadyAndState(t *testing.T) { + f := newRotationFixture(t) + ctx := context.Background() + state, err := f.rotation.State(ctx) + if err != nil || state.Status != "idle" { + t.Fatalf("空事务状态错误:%+v, %v", state, err) + } + ready, err := f.rotation.Ready(ctx) + if err != nil || ready.LeafHash != leafHash(f.old.pair.Certificate[0]) || ready.Hostname != testHostname || ready.UpstreamAddress != f.upstream.Address { + t.Fatalf("就绪状态错误:%+v, %v", ready, err) + } + if _, err := f.rotation.Stage(ctx, f.candidate.chain, f.candidate.key); err != nil { + t.Fatal(err) + } + if _, err := f.rotation.Ready(ctx); err == nil { + t.Fatal("未决事务不能通过申请前检查") + } + state, err = f.rotation.State(ctx) + if err != nil || state.Status != "staged" || state.CandidateHash != leafHash(f.candidate.pair.Certificate[0]) || state.PreviousHash != ready.LeafHash { + t.Fatalf("候选事务状态错误:%+v, %v", state, err) + } +} + +func TestRotationReconcileUsesActualUpstream(t *testing.T) { + for _, scenario := range []string{"old", "candidate", "pending-candidate", "unrelated", "unavailable", "external-include", "wrong-id"} { + 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) + } + expected := "committed" + f.upstream.active.Store(&f.candidate.pair) + switch scenario { + case "old": + f.upstream.active.Store(&f.old.pair) + expected = "rolled_back" + case "pending-candidate": + current := f.assertStatus(t, "staged") + current.Status = "pending" + root, err := os.OpenRoot(f.rotation.Directory) + if err != nil { t.Fatal(err) } + defer root.Close() + 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) + case "unavailable": + f.upstream.server.Close() + case "external-include": + writeTestFile(t, filepath.Join(f.rotation.Directory, includeName), []byte("# 外部修改\n")) + case "wrong-id": + id = "00000000000000000000000000000000" + } + state, err := f.rotation.Reconcile(ctx, id) + if scenario == "unrelated" || scenario == "unavailable" || scenario == "external-include" || scenario == "wrong-id" { + if err == nil { t.Fatal("无法确认的状态不应完成恢复") } + f.assertStatus(t, "staged") + return + } + if err != nil || state.Status != expected { + t.Fatalf("恢复结果错误:%+v, %v", state, err) + } + f.assertStatus(t, expected) + if _, err := f.rotation.Reconcile(ctx, id); err != nil { + t.Fatal("重复恢复应返回终态", err) + } + }) + } +} diff --git a/backend/internal/nginxactivation/status.go b/backend/internal/nginxactivation/status.go new file mode 100644 index 0000000..a1c504c --- /dev/null +++ b/backend/internal/nginxactivation/status.go @@ -0,0 +1,16 @@ +package nginxactivation + +// State 不包含文件路径、私钥或完整配置,可通过受保护的本地协议返回。 +type State struct { + ID string `json:"id,omitempty"` + Status string `json:"status"` + PreviousHash string `json:"previous_hash,omitempty"` + CandidateHash string `json:"candidate_hash,omitempty"` +} + +type ReadyState struct { + Hostname string `json:"hostname"` + ProxyAddress string `json:"proxy_address"` + UpstreamAddress string `json:"upstream_address"` + LeafHash string `json:"leaf_hash"` +}