Scert/backend/internal/certstorage/service_linux.go

234 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package certstorage
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"golang.org/x/sys/unix"
)
var safePath = regexp.MustCompile(`^/[A-Za-z0-9._/-]+$`)
// Service 的策略仅来自 root 所有的 systemd unit,不读取应用可改写的配置。
type Service struct {
UID uint32
GID uint32
Roots []string
Log func(error)
}
func within(parent, child string) bool {
return parent == child || strings.HasPrefix(child, parent+"/")
}
func validatePath(path string, roots []string) error {
if !safePath.MatchString(path) || len(path) > 1024 || filepath.Clean(path) != path || path == "/" {
return errors.New("证书目录必须是规范绝对路径")
}
for _, protected := range []string{"/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib64", "/proc", "/root", "/run", "/sbin", "/scert", "/sys", "/tmp", "/usr"} {
if within(protected, path) || within(path, protected) {
return errors.New("证书目录不能覆盖系统或应用文件")
}
}
if within("/var", path) && !within("/var/lib/scert", path) {
return errors.New("证书目录不能覆盖其他服务的数据")
}
for _, root := range roots {
if root != "/" && filepath.Clean(root) == root && within(root, path) {
return nil
}
}
return errors.New("证书目录不在安装时批准的范围内")
}
// prepareDirectory 逐层使用不跟随链接的目录描述符。特权操作要求父目录仅 root 可写,
// 防止运行账户在校验后替换路径;已存在的目录不被夺取所有权或递归修改权限。
func prepareDirectory(path string, uid, gid uint32) error {
if uid == 0 || gid == 0 {
return errors.New("目录所有者不能是 root")
}
fd, err := unix.Open("/", unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return err
}
defer func() { _ = unix.Close(fd) }()
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
for index, part := range parts {
var parent unix.Stat_t
if err := unix.Fstat(fd, &parent); err != nil {
return err
}
if parent.Uid != 0 || parent.Mode&0o022 != 0 {
return errors.New("特权创建要求父目录仅由 root 写入")
}
created := false
next, err := unix.Openat(fd, part, unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if errors.Is(err, unix.ENOENT) {
if err = unix.Mkdirat(fd, part, 0o750); err != nil {
return err
}
created = true
next, err = unix.Openat(fd, part, unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
}
if err != nil {
return err
}
_ = unix.Close(fd)
fd = next
last := index == len(parts)-1
if created {
if last {
if err := unix.Fchown(fd, int(uid), int(gid)); err != nil {
return err
}
if err := unix.Fchmod(fd, 0o700); err != nil {
return err
}
} else {
if err := unix.Fchmod(fd, 0o755); err != nil {
return err
}
}
if err := unix.Fsync(fd); err != nil {
return err
}
}
if last {
var info unix.Stat_t
if err := unix.Fstat(fd, &info); err != nil {
return err
}
if info.Uid != uid || info.Gid != gid || info.Mode&0o027 != 0 {
return errors.New("现有目录不属于 Scert 或权限过宽,拒绝接管")
}
}
}
return nil
}
func systemctl(ctx context.Context, args ...string) error {
// 参数为固定动作、固定服务及校验后的路径,不经过 shell。
output, err := exec.CommandContext(ctx, "/usr/bin/systemctl", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("目录服务调用 systemd 失败:%w:%s", err, output)
}
return nil
}
func (s Service) prepare(ctx context.Context, path string) error {
if err := validatePath(path, s.Roots); err != nil {
return err
}
if err := prepareDirectory(path, s.UID, s.GID); err != nil {
return err
}
// 不重启正在初始化的进程,保留数据库、DNS 和浏览器的一次性验证会话。
if err := systemctl(ctx, "bind", "--mkdir", "Scert.service", path, path); err != nil {
return err
}
return persistPermission(ctx, path)
}
func persistPermission(ctx context.Context, path string) error {
const directory = "/etc/systemd/system/Scert.service.d"
// 写入位置的全部父目录必须受 root 保护,不能被应用替换成链接。
for current := directory; ; current = filepath.Dir(current) {
var stat unix.Stat_t
if err := unix.Lstat(current, &stat); err != nil {
return err
}
if stat.Mode&unix.S_IFMT != unix.S_IFDIR || stat.Uid != 0 || stat.Mode&0o022 != 0 {
return errors.New("服务权限配置目录不可信")
}
if current == "/" {
break
}
}
name := fmt.Sprintf("60-storage-%x.conf", sha256.Sum256([]byte(path)))
content := []byte("[Service]\nReadWritePaths=" + path + "\n")
target := filepath.Join(directory, name)
if existing, err := os.ReadFile(target); err == nil {
if !bytes.Equal(existing, content) {
return errors.New("已有目录授权配置不匹配,拒绝覆盖")
}
return systemctl(ctx, "daemon-reload")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return err
}
_, writeErr := file.Write(content)
syncErr := file.Sync()
closeErr := file.Close()
if err := errors.Join(writeErr, syncErr, closeErr); err != nil {
return err
}
return systemctl(ctx, "daemon-reload")
}
func (s Service) Serve(ctx context.Context, listener *net.UnixListener) error {
if os.Geteuid() != 0 || s.UID == 0 || s.GID == 0 {
return errors.New("目录服务身份配置无效")
}
listener.SetUnlinkOnClose(false)
defer listener.Close()
stop := context.AfterFunc(ctx, func() { _ = listener.Close() })
defer stop()
// 顺序处理,避免同时修改挂载点和持久授权。
for {
connection, err := listener.AcceptUnix()
if err != nil {
if ctx.Err() != nil {
return nil
}
return err
}
s.handle(ctx, connection)
}
}
func (s Service) handle(ctx context.Context, connection *net.UnixConn) {
defer connection.Close()
uid, err := peerUID(connection)
if err != nil || uid != s.UID {
return
}
_ = connection.SetDeadline(time.Now().Add(25 * time.Second))
line, err := bufio.NewReaderSize(connection, 2048).ReadSlice('\n')
if err != nil {
return
}
var message request
decoder := json.NewDecoder(bytes.NewReader(line))
decoder.DisallowUnknownFields()
if decoder.Decode(&message) != nil {
return
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return
}
operation, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
err = s.prepare(operation, message.Path)
if err != nil && s.Log != nil {
s.Log(err)
}
_ = json.NewEncoder(connection).Encode(response{Ready: err == nil})
}