Smail/backend/config/ssl_storage.go
smail d7837de561
Some checks are pending
Security Checks / security (push) Waiting to run
保存模拟部署迁移与最新构建校验阶段成果
2026-09-08 18:33:21 +08:00

663 lines
21 KiB
Go

package config
import (
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.pcux.cn/xuans/Smail/utils/atomicfile"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
)
const (
ManagedSSLModeDefault = "managed_default"
ManagedSSLModeUpload = "managed_upload"
SSLManagementWebServer = "web_server"
SSLManagementProject = "project"
SSLManagementCustom = "custom"
managedSSLRelativeRoot = "data/ssl/managed"
maxManagedSSLFileSize = 1 << 20
)
// EffectiveSSLManagement 返回面向用户的证书管理方式,并兼容旧配置中的底层存储模式。
func EffectiveSSLManagement(ssl SSLConfig) string {
switch ssl.Management {
case SSLManagementWebServer, SSLManagementProject, SSLManagementCustom:
return ssl.Management
}
if ssl.Mode == ManagedSSLModeDefault || ssl.Mode == "default" {
return SSLManagementProject
}
return SSLManagementCustom
}
// ValidSSLManagement 判断证书管理责任是否为当前支持的三种业务模式。
func ValidSSLManagement(management string) bool {
return management == SSLManagementWebServer || management == SSLManagementProject || management == SSLManagementCustom
}
var (
ErrInvalidSSLMode = errors.New("invalid SSL managed mode")
ErrInvalidSSLMaterial = errors.New("invalid SSL certificate material")
ErrInvalidSSLReference = errors.New("invalid SSL certificate reference")
ErrUnsafeSSLFile = errors.New("unsafe SSL certificate file")
ErrSSLCertificateMismatch = errors.New("SSL certificate and private key do not match")
ErrSSLCertificateDomain = errors.New("SSL certificate domain does not match")
ErrSSLCertificateExpired = errors.New("SSL certificate is expired or not yet valid")
ErrSSLPrivateKeyPermission = errors.New("SSL private key permissions are too broad")
)
type ManagedSSLReference struct {
Mode string
CertificateRef string
PrivateKeyRef string
CertificatePath string
PrivateKeyPath string
}
// ResolveRuntimeSSLPaths 返回服务启动时使用的受控证书路径。
// 新配置优先使用内部引用,旧配置只通过只读兼容适配层加载。
func ResolveRuntimeSSLPaths(cfg *Config) (string, string, error) {
if cfg == nil {
return "", "", ErrInvalidSSLReference
}
if cfg.SSL.Mode != "" || cfg.SSL.CertificateRef != "" || cfg.SSL.PrivateKeyRef != "" {
if !validManagedSSLMode(cfg.SSL.Mode) || cfg.SSL.CertificateRef == "" || cfg.SSL.PrivateKeyRef == "" {
return "", "", ErrInvalidSSLReference
}
resolved, err := ResolveManagedSSLReferences(cfg.SSL)
if err != nil {
return "", "", err
}
return resolved.CertificatePath, resolved.PrivateKeyPath, nil
}
return resolveLegacySSLPaths(cfg.SSLPublicKeyPath, cfg.SSLPrivateKeyPath)
}
// LoadRuntimeTLSCertificate 在所有 TLS 服务中统一解析并加载证书。
func LoadRuntimeTLSCertificate(cfg *Config) (tls.Certificate, error) {
certificatePath, privateKeyPath, err := ResolveRuntimeSSLPaths(cfg)
if err != nil {
return tls.Certificate{}, err
}
certificate, err := tls.LoadX509KeyPair(certificatePath, privateKeyPath)
if err != nil {
return tls.Certificate{}, err
}
return certificate, nil
}
// ImportManagedSSL 校验证书材料后写入候选目录,并原子切换 current 目录。
func ImportManagedSSL(mode string, privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) (ManagedSSLReference, error) {
return importManagedSSLAtRoot(ROOT_PATH, mode, privateKeyPEM, certificatePEM, issuerPEM, domains)
}
func importManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) (ManagedSSLReference, error) {
if !validManagedSSLMode(mode) {
return ManagedSSLReference{}, ErrInvalidSSLMode
}
if err := validateSSLMaterial(privateKeyPEM, certificatePEM, issuerPEM, domains); err != nil {
return ManagedSSLReference{}, err
}
rootPath, err := managedSSLRootPathAtRoot(root, mode)
if err != nil {
return ManagedSSLReference{}, err
}
if err := ensureManagedDirectoryAtRoot(root, rootPath); err != nil {
return ManagedSSLReference{}, err
}
token, err := randomStorageToken()
if err != nil {
return ManagedSSLReference{}, err
}
candidatePath := filepath.Join(rootPath, ".candidate-"+token)
if err := os.Mkdir(candidatePath, 0700); err != nil {
return ManagedSSLReference{}, err
}
defer func() {
if _, statErr := os.Stat(candidatePath); statErr == nil {
_ = os.RemoveAll(candidatePath)
}
}()
candidateRoot, err := os.OpenRoot(candidatePath)
if err != nil {
return ManagedSSLReference{}, err
}
write := func(name string, data []byte, permission os.FileMode) error {
if len(data) == 0 {
return nil
}
return atomicfile.WriteRoot(candidateRoot, name, data, permission)
}
if err := write("private.key", privateKeyPEM, 0600); err != nil {
candidateRoot.Close()
return ManagedSSLReference{}, err
}
if err := write("public.crt", certificatePEM, 0600); err != nil {
candidateRoot.Close()
return ManagedSSLReference{}, err
}
if err := write("issuer.crt", issuerPEM, 0600); err != nil {
candidateRoot.Close()
return ManagedSSLReference{}, err
}
if err := candidateRoot.Close(); err != nil {
return ManagedSSLReference{}, err
}
activePath := filepath.Join(rootPath, "current")
backupPath := filepath.Join(rootPath, ".previous-"+token)
previousMoved := false
activeInstalled := false
if info, statErr := os.Lstat(activePath); statErr == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return ManagedSSLReference{}, ErrUnsafeSSLFile
}
if err := os.Rename(activePath, backupPath); err != nil {
return ManagedSSLReference{}, err
}
previousMoved = true
if err := os.Rename(candidatePath, activePath); err != nil {
_ = os.Rename(backupPath, activePath)
return ManagedSSLReference{}, err
}
activeInstalled = true
} else if !os.IsNotExist(statErr) {
return ManagedSSLReference{}, statErr
} else if err := os.Rename(candidatePath, activePath); err != nil {
return ManagedSSLReference{}, err
} else {
activeInstalled = true
}
certificatePath := filepath.Join(activePath, "public.crt")
privateKeyPath := filepath.Join(activePath, "private.key")
if err := validateManagedSSLFiles(certificatePath, privateKeyPath); err != nil {
rollbackErr := rollbackManagedSSLActivation(activePath, backupPath, previousMoved, activeInstalled)
if rollbackErr != nil {
return ManagedSSLReference{}, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr)
}
return ManagedSSLReference{}, err
}
return ManagedSSLReference{
Mode: mode,
CertificateRef: managedSSLReferenceAtRoot(root, mode, "public.crt"),
PrivateKeyRef: managedSSLReferenceAtRoot(root, mode, "private.key"),
CertificatePath: certificatePath,
PrivateKeyPath: privateKeyPath,
}, nil
}
// ResolveManagedSSLReferences 将内部引用解析为受控文件路径,拒绝任意路径输入。
func ResolveManagedSSLReferences(ssl SSLConfig) (ManagedSSLReference, error) {
return resolveManagedSSLReferencesAtRoot(ROOT_PATH, ssl)
}
func resolveManagedSSLReferencesAtRoot(root string, ssl SSLConfig) (ManagedSSLReference, error) {
ssl = normalizeSSLDataReferences(ssl)
if !validManagedSSLMode(ssl.Mode) {
return ManagedSSLReference{}, ErrInvalidSSLMode
}
if ssl.CertificateRef == "" || ssl.PrivateKeyRef == "" {
return ManagedSSLReference{}, ErrInvalidSSLReference
}
rootPath, err := managedSSLRootPathAtRoot(root, ssl.Mode)
if err != nil {
return ManagedSSLReference{}, err
}
certificatePath, err := resolveManagedReferenceAtRoot(root, rootPath, ssl.CertificateRef, "public.crt")
if err != nil {
return ManagedSSLReference{}, err
}
privateKeyPath, err := resolveManagedReferenceAtRoot(root, rootPath, ssl.PrivateKeyRef, "private.key")
if err != nil {
return ManagedSSLReference{}, err
}
if filepath.Dir(certificatePath) != filepath.Dir(privateKeyPath) {
return ManagedSSLReference{}, ErrInvalidSSLReference
}
if err := validateManagedSSLFiles(certificatePath, privateKeyPath); err != nil {
return ManagedSSLReference{}, err
}
return ManagedSSLReference{
Mode: ssl.Mode,
CertificateRef: ssl.CertificateRef,
PrivateKeyRef: ssl.PrivateKeyRef,
CertificatePath: certificatePath,
PrivateKeyPath: privateKeyPath,
}, nil
}
// ValidateSSLMaterial 校验证书链、私钥匹配、有效期和可选域名。
func ValidateSSLMaterial(privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) error {
return validateSSLMaterial(privateKeyPEM, certificatePEM, issuerPEM, domains)
}
func validateSSLMaterial(privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) error {
if len(privateKeyPEM) == 0 || len(certificatePEM) == 0 || len(privateKeyPEM) > maxManagedSSLFileSize || len(certificatePEM) > maxManagedSSLFileSize || len(issuerPEM) > maxManagedSSLFileSize {
return ErrInvalidSSLMaterial
}
certificates, err := parseCertificatePEM(certificatePEM)
if err != nil || len(certificates) == 0 {
return ErrInvalidSSLMaterial
}
if len(issuerPEM) > 0 {
issuerCertificates, issuerErr := parseCertificatePEM(issuerPEM)
if issuerErr != nil {
return ErrInvalidSSLMaterial
}
certificates = appendUniqueCertificates(certificates, issuerCertificates...)
}
if _, err := tls.X509KeyPair(certificatePEM, privateKeyPEM); err != nil {
return ErrSSLCertificateMismatch
}
leaf := certificates[0]
now := time.Now()
if now.Before(leaf.NotBefore) || !now.Before(leaf.NotAfter) {
return ErrSSLCertificateExpired
}
if err := validateCertificateChain(certificates); err != nil {
return err
}
if err := validateCertificateKeyStrength(leaf); err != nil {
return err
}
for _, domain := range domains {
domain = strings.TrimSpace(domain)
if domain == "" {
continue
}
if err := leaf.VerifyHostname(domain); err != nil {
return fmt.Errorf("%w: %s", ErrSSLCertificateDomain, domain)
}
}
return nil
}
func appendUniqueCertificates(certificates []*x509.Certificate, additions ...*x509.Certificate) []*x509.Certificate {
seen := make(map[string]struct{}, len(certificates)+len(additions))
for _, certificate := range certificates {
if certificate == nil {
continue
}
seen[string(certificate.Raw)] = struct{}{}
}
for _, certificate := range additions {
if certificate == nil {
continue
}
key := string(certificate.Raw)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
certificates = append(certificates, certificate)
}
return certificates
}
func parseCertificatePEM(data []byte) ([]*x509.Certificate, error) {
var certificates []*x509.Certificate
remaining := data
for {
block, rest := pem.Decode(remaining)
if block == nil {
if len(strings.TrimSpace(string(remaining))) != 0 {
return nil, ErrInvalidSSLMaterial
}
break
}
if block.Type != "CERTIFICATE" || len(block.Bytes) == 0 {
return nil, ErrInvalidSSLMaterial
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, ErrInvalidSSLMaterial
}
certificates = append(certificates, certificate)
remaining = rest
}
return certificates, nil
}
func validateCertificateChain(certificates []*x509.Certificate) error {
for index := 0; index+1 < len(certificates); index++ {
issuer := certificates[index+1]
if !issuer.IsCA || issuer.BasicConstraintsValid && !issuer.IsCA {
return ErrInvalidSSLMaterial
}
if err := certificates[index].CheckSignatureFrom(issuer); err != nil {
return ErrInvalidSSLMaterial
}
}
return nil
}
func validateCertificateKeyStrength(certificate *x509.Certificate) error {
switch publicKey := certificate.PublicKey.(type) {
case *rsa.PublicKey:
return validateRSAKeyStrength(publicKey)
case *ecdsa.PublicKey:
if publicKey.Curve == nil || publicKey.Curve.Params().BitSize < 256 {
return ErrInvalidSSLMaterial
}
return nil
default:
return ErrInvalidSSLMaterial
}
}
func validateRSAKeyStrength(publicKey *rsa.PublicKey) error {
if publicKey == nil || publicKey.N == nil || publicKey.N.BitLen() < 2048 {
return ErrInvalidSSLMaterial
}
return nil
}
func validManagedSSLMode(mode string) bool {
return mode == ManagedSSLModeDefault || mode == ManagedSSLModeUpload
}
func managedSSLRootPath(mode string) (string, error) {
return managedSSLRootPathAtRoot(ROOT_PATH, mode)
}
func managedSSLRootPathAtRoot(root string, mode string) (string, error) {
if !validManagedSSLMode(mode) {
return "", ErrInvalidSSLMode
}
rootPath, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return "", err
}
folder := "default"
if mode == ManagedSSLModeUpload {
folder = "upload"
}
return filepath.Join(rootPath, runtimepath.SSLReference(root, managedSSLRelativeRoot), folder), nil
}
func managedSSLReference(mode, name string) string {
return managedSSLReferenceAtRoot(ROOT_PATH, mode, name)
}
func managedSSLReferenceAtRoot(root, mode, name string) string {
folder := "default"
if mode == ManagedSSLModeUpload {
folder = "upload"
}
return filepath.ToSlash(filepath.Join(runtimepath.SSLReference(root, managedSSLRelativeRoot), folder, "current", name))
}
func resolveManagedReference(rootPath, reference, expectedName string) (string, error) {
return resolveManagedReferenceAtRoot(ROOT_PATH, rootPath, reference, expectedName)
}
func resolveManagedReferenceAtRoot(root, rootPath, reference, expectedName string) (string, error) {
rawReference := runtimepath.SSLReference(root, strings.TrimSpace(reference))
if rawReference == "" || strings.Contains(rawReference, `\`) || filepath.IsAbs(rawReference) || isWindowsAbsolute(rawReference) {
return "", ErrInvalidSSLReference
}
reference = filepath.FromSlash(rawReference)
if filepath.ToSlash(filepath.Clean(reference)) != rawReference {
return "", ErrInvalidSSLReference
}
workspaceRoot, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return "", ErrInvalidSSLReference
}
target, err := filepath.Abs(filepath.Join(workspaceRoot, reference))
if err != nil || filepath.Base(target) != expectedName || !pathWithin(rootPath, target) {
return "", ErrInvalidSSLReference
}
directory := filepath.Dir(target)
version := filepath.Base(directory)
if filepath.Dir(directory) != rootPath || (version != "current" && !validSSLVersion(version)) {
return "", ErrInvalidSSLReference
}
resolved, err := filepath.EvalSymlinks(directory)
if err != nil || filepath.Clean(resolved) != filepath.Clean(directory) {
return "", ErrUnsafeSSLFile
}
return target, nil
}
func validSSLVersion(version string) bool {
if !strings.HasPrefix(version, "version-") || len(version) != len("version-")+32 {
return false
}
_, err := hex.DecodeString(strings.TrimPrefix(version, "version-"))
return err == nil
}
// StageManagedSSL 只写入独立版本,不替换现用文件;配置事务成功后引用才生效。
func StageManagedSSL(mode string, privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) (ManagedSSLReference, error) {
return stageManagedSSLAtRoot(ROOT_PATH, mode, privateKeyPEM, certificatePEM, issuerPEM, domains)
}
func stageManagedSSLAtRoot(root string, mode string, privateKeyPEM, certificatePEM, issuerPEM []byte, domains []string) (ManagedSSLReference, error) {
if err := validateSSLMaterial(privateKeyPEM, certificatePEM, issuerPEM, domains); err != nil {
return ManagedSSLReference{}, err
}
managedRoot, err := managedSSLRootPathAtRoot(root, mode)
if err != nil {
return ManagedSSLReference{}, err
}
if err := ensureManagedDirectoryAtRoot(root, managedRoot); err != nil {
return ManagedSSLReference{}, err
}
token, err := randomStorageToken()
if err != nil {
return ManagedSSLReference{}, err
}
directory := filepath.Join(managedRoot, "version-"+token)
if err := os.Mkdir(directory, 0700); err != nil {
return ManagedSSLReference{}, err
}
storage, err := os.OpenRoot(directory)
if err != nil {
return ManagedSSLReference{}, err
}
defer storage.Close()
for _, file := range []struct {
name string
data []byte
}{{"private.key", privateKeyPEM}, {"public.crt", certificatePEM}, {"issuer.crt", issuerPEM}} {
if len(file.data) > 0 {
if err := atomicfile.WriteRoot(storage, file.name, file.data, 0600); err != nil {
return ManagedSSLReference{}, err
}
}
}
publicPath, privatePath := filepath.Join(directory, "public.crt"), filepath.Join(directory, "private.key")
if err := validateManagedSSLFiles(publicPath, privatePath); err != nil {
return ManagedSSLReference{}, err
}
rootPath, err := filepath.Abs(root)
if err != nil {
return ManagedSSLReference{}, err
}
publicRef, err := filepath.Rel(rootPath, publicPath)
if err != nil {
return ManagedSSLReference{}, err
}
privateRef, err := filepath.Rel(rootPath, privatePath)
if err != nil {
return ManagedSSLReference{}, err
}
return ManagedSSLReference{Mode: mode, CertificateRef: filepath.ToSlash(publicRef), PrivateKeyRef: filepath.ToSlash(privateRef), CertificatePath: publicPath, PrivateKeyPath: privatePath}, nil
}
func validateManagedSSLFiles(certificatePath, privateKeyPath string) error {
for _, item := range []struct {
path string
isPrivate bool
}{{certificatePath, false}, {privateKeyPath, true}} {
info, err := os.Lstat(item.path)
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxManagedSSLFileSize || hasMultipleFileLinks(item.path) {
return ErrUnsafeSSLFile
}
if item.isPrivate && info.Mode().Perm()&0077 != 0 && runtimeSupportsPermissionCheck() {
return ErrSSLPrivateKeyPermission
}
}
privateKeyPEM, err := os.ReadFile(privateKeyPath)
if err != nil {
return ErrUnsafeSSLFile
}
certificatePEM, err := os.ReadFile(certificatePath)
if err != nil {
return ErrUnsafeSSLFile
}
return validateSSLMaterial(privateKeyPEM, certificatePEM, nil, nil)
}
func resolveLegacySSLPaths(certificatePath, privateKeyPath string) (string, string, error) {
certificatePath, certificatePEM, err := readLegacySSLFile(certificatePath, false)
if err != nil {
return "", "", err
}
privateKeyPath, privateKeyPEM, err := readLegacySSLFile(privateKeyPath, true)
if err != nil {
return "", "", err
}
if err := validateSSLMaterial(privateKeyPEM, certificatePEM, nil, nil); err != nil {
return "", "", err
}
return certificatePath, privateKeyPath, nil
}
func readLegacySSLFile(value string, privateKey bool) (string, []byte, error) {
value = strings.TrimSpace(value)
if value == "" || !filepath.IsAbs(value) {
return "", nil, ErrInvalidSSLReference
}
path := filepath.Clean(value)
info, err := os.Lstat(path)
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxManagedSSLFileSize || hasMultipleFileLinks(path) {
return "", nil, ErrUnsafeSSLFile
}
resolvedDirectory, err := filepath.EvalSymlinks(filepath.Dir(path))
if err != nil || filepath.Clean(resolvedDirectory) != filepath.Clean(filepath.Dir(path)) {
return "", nil, ErrUnsafeSSLFile
}
if privateKey && info.Mode().Perm()&0077 != 0 && runtimeSupportsPermissionCheck() {
return "", nil, ErrSSLPrivateKeyPermission
}
data, err := os.ReadFile(path)
if err != nil {
return "", nil, ErrUnsafeSSLFile
}
return path, data, nil
}
func rollbackManagedSSLActivation(activePath, backupPath string, previousMoved, activeInstalled bool) error {
if activeInstalled {
if err := removeManagedSSLDirectory(activePath); err != nil {
return err
}
}
if previousMoved {
return os.Rename(backupPath, activePath)
}
return nil
}
func removeManagedSSLDirectory(directory string) error {
info, err := os.Lstat(directory)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return ErrUnsafeSSLFile
}
entries, err := os.ReadDir(directory)
if err != nil {
return err
}
for _, entry := range entries {
if entry.Name() != "private.key" && entry.Name() != "public.crt" && entry.Name() != "issuer.crt" {
return ErrUnsafeSSLFile
}
entryPath := filepath.Join(directory, entry.Name())
entryInfo, statErr := os.Lstat(entryPath)
if statErr != nil || entryInfo.Mode()&os.ModeSymlink != 0 || !entryInfo.Mode().IsRegular() {
return ErrUnsafeSSLFile
}
if err := os.Remove(entryPath); err != nil {
return err
}
}
return os.Remove(directory)
}
func ensureManagedDirectory(directory string) error {
return ensureManagedDirectoryAtRoot(ROOT_PATH, directory)
}
func ensureManagedDirectoryAtRoot(root, directory string) error {
rootPath, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return err
}
if !pathWithin(rootPath, directory) {
return ErrUnsafeSSLFile
}
relative, err := filepath.Rel(rootPath, directory)
if err != nil || filepath.IsAbs(relative) {
return ErrUnsafeSSLFile
}
current := rootPath
for _, part := range strings.Split(relative, string(os.PathSeparator)) {
if part == "" || part == "." || part == ".." {
return ErrUnsafeSSLFile
}
current = filepath.Join(current, part)
info, statErr := os.Lstat(current)
if os.IsNotExist(statErr) {
if err := os.Mkdir(current, 0700); err != nil {
return err
}
info, statErr = os.Lstat(current)
}
if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return ErrUnsafeSSLFile
}
if err := os.Chmod(current, 0700); err != nil {
return err
}
}
return nil
}
func pathWithin(root, target string) bool {
relative, err := filepath.Rel(root, target)
return err == nil && !filepath.IsAbs(relative) && relative != ".." && !strings.HasPrefix(relative, ".."+string(os.PathSeparator))
}
func randomStorageToken() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return hex.EncodeToString(buffer), nil
}
func runtimeSupportsPermissionCheck() bool {
return os.PathSeparator == '/'
}