Smail/backend/config/ssl_export.go
smail 1790e1f4f6
Some checks are pending
Security Checks / security (push) Waiting to run
修复只读证书同步并拒绝硬链接私钥
2026-09-20 04:53:18 +08:00

118 lines
4.0 KiB
Go

package config
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"runtime"
"git.pcux.cn/xuans/Smail/utils/atomicfile"
)
// ExportProjectTLS 为本机 TLS 入口导出一个原子更新的证书私钥合并文件。
// 仅接受项目生成的平铺证书;不允许高权限调用者按配置读取任意服务器路径。
func ExportProjectTLS(root, destination string) error {
if runtime.GOOS != "linux" {
return errors.New("本机 Nginx 证书同步仅支持 Linux")
}
if !filepath.IsAbs(root) || !filepath.IsAbs(destination) {
return ErrUnsafeSSLFile
}
root, destination = filepath.Clean(root), filepath.Clean(destination)
runtime, err := NewConfigLoader(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}).Load()
if err != nil {
return ErrInvalidSSL
}
if EffectiveSSLManagement(runtime.SSL.TLS) != SSLManagementProject || runtime.Root.HTTP.TLSMode == HTTPSTLSModeProxy {
return nil
}
publicPath := filepath.Join(root, filepath.FromSlash(runtime.SSL.TLS.CertificateRef))
privatePath := filepath.Join(root, filepath.FromSlash(runtime.SSL.TLS.PrivateKeyRef))
if !flatSSLPair(root, publicPath, privatePath) {
return ErrInvalidSSLReference
}
sourceDirectory := filepath.Join(root, "ssl")
resolvedSource, err := filepath.EvalSymlinks(sourceDirectory)
if err != nil || resolvedSource != sourceDirectory {
return ErrUnsafeSSLFile
}
sourceInfo, err := os.Stat(sourceDirectory)
if err != nil || !sourceInfo.IsDir() || sourceInfo.Mode().Perm()&0022 != 0 {
return ErrUnsafeSSLFile
}
storage, err := os.OpenRoot(sourceDirectory)
if err != nil {
return ErrUnsafeSSLFile
}
defer storage.Close()
read := func(name string) ([]byte, error) {
info, err := storage.Lstat(name)
if err != nil || !info.Mode().IsRegular() || hasMultipleInfoLinks(info) || info.Size() > maxManagedSSLFileSize || info.Mode().Perm()&0077 != 0 {
return nil, ErrUnsafeSSLFile
}
file, err := storage.Open(name)
if err != nil {
return nil, ErrUnsafeSSLFile
}
defer file.Close()
opened, err := file.Stat()
if err != nil || !os.SameFile(info, opened) || !opened.Mode().IsRegular() || hasMultipleInfoLinks(opened) || opened.Mode().Perm()&0077 != 0 {
return nil, ErrUnsafeSSLFile
}
data, err := io.ReadAll(io.LimitReader(file, maxManagedSSLFileSize+1))
if err != nil || len(data) > maxManagedSSLFileSize {
return nil, ErrUnsafeSSLFile
}
after, err := file.Stat()
if err != nil || !os.SameFile(opened, after) || hasMultipleInfoLinks(after) || after.Size() != int64(len(data)) || !opened.ModTime().Equal(after.ModTime()) || after.Mode().Perm()&0077 != 0 {
return nil, ErrUnsafeSSLFile
}
return data, nil
}
certificate, err := read(filepath.Base(publicPath))
if err != nil {
return err
}
privateKey, err := read(filepath.Base(privatePath))
if err != nil {
return err
}
if err := validateSSLMaterial(privateKey, certificate, nil, validationCertificateDomains(&runtime)); err != nil {
return err
}
combined := append(append(append([]byte{}, certificate...), '\n'), privateKey...)
// 输出目录由部署管理员创建,拒绝符号链接和其他用户可写目录。
directory := filepath.Dir(destination)
resolved, err := filepath.EvalSymlinks(directory)
if err != nil || resolved != directory {
return ErrUnsafeSSLFile
}
info, err := os.Stat(directory)
if err != nil || !info.IsDir() || info.Mode().Perm()&0022 != 0 {
return ErrUnsafeSSLFile
}
output, err := os.OpenRoot(directory)
if err != nil {
return ErrUnsafeSSLFile
}
defer output.Close()
name := filepath.Base(destination)
if info, err := output.Lstat(name); err == nil {
if !info.Mode().IsRegular() || hasMultipleInfoLinks(info) || info.Mode().Perm()&0077 != 0 {
return ErrUnsafeSSLFile
}
previous, err := output.ReadFile(name)
if err != nil {
return ErrUnsafeSSLFile
}
if bytes.Equal(previous, combined) {
return nil
}
} else if !os.IsNotExist(err) {
return ErrUnsafeSSLFile
}
return atomicfile.WriteRoot(output, name, combined, 0600)
}