检查点:保存待复审的Nginx本地激活服务
This commit is contained in:
parent
89e443c768
commit
cc94a6181b
69
backend/cmd/scert-nginx/main_linux.go
Normal file
69
backend/cmd/scert-nginx/main_linux.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
144
backend/internal/nginxactivation/client_linux.go
Normal file
144
backend/internal/nginxactivation/client_linux.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
153
backend/internal/nginxactivation/ipc_linux.go
Normal file
153
backend/internal/nginxactivation/ipc_linux.go
Normal file
@ -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
|
||||
}
|
||||
167
backend/internal/nginxactivation/ipc_linux_test.go
Normal file
167
backend/internal/nginxactivation/ipc_linux_test.go
Normal file
@ -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") }
|
||||
}
|
||||
131
backend/internal/nginxactivation/policy_linux.go
Normal file
131
backend/internal/nginxactivation/policy_linux.go
Normal file
@ -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
|
||||
}
|
||||
84
backend/internal/nginxactivation/protocol.go
Normal file
84
backend/internal/nginxactivation/protocol.go
Normal file
@ -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
|
||||
}
|
||||
71
backend/internal/nginxactivation/protocol_test.go
Normal file
71
backend/internal/nginxactivation/protocol_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
130
backend/internal/nginxactivation/reconcile_linux.go
Normal file
130
backend/internal/nginxactivation/reconcile_linux.go
Normal file
@ -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
|
||||
}
|
||||
81
backend/internal/nginxactivation/reconcile_linux_test.go
Normal file
81
backend/internal/nginxactivation/reconcile_linux_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
16
backend/internal/nginxactivation/status.go
Normal file
16
backend/internal/nginxactivation/status.go
Normal file
@ -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"`
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user