234 lines
6.6 KiB
Go
234 lines
6.6 KiB
Go
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})
|
||
}
|