264 lines
9.6 KiB
Go
264 lines
9.6 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"git.pcux.cn/xuans/Smail/utils/runtimepath"
|
|
)
|
|
|
|
var ErrSSLFileUnavailable = errors.New("SSL certificate file is missing or unreadable")
|
|
|
|
// customSSLMaterial 保留只读准备结果;自定义文件只验证和引用,不复制。
|
|
type customSSLMaterial struct {
|
|
privateKey []byte
|
|
certificate []byte
|
|
domains []string
|
|
}
|
|
|
|
func prepareCustomSSLPaths(root string, candidate *RuntimeConfig) (*customSSLMaterial, error) {
|
|
candidate.SSL.TLS = normalizeSSLDataReferences(candidate.SSL.TLS)
|
|
ssl := &candidate.SSL.TLS
|
|
if EffectiveSSLManagement(*ssl) == SSLManagementProject {
|
|
return nil, nil
|
|
}
|
|
certificatePath := strings.TrimSpace(ssl.CertificateRef)
|
|
privateKeyPath := strings.TrimSpace(ssl.PrivateKeyRef)
|
|
if certificatePath == "" && privateKeyPath == "" {
|
|
return nil, nil
|
|
}
|
|
if certificatePath == "" || privateKeyPath == "" {
|
|
return nil, ErrInvalidSSLReference
|
|
}
|
|
// 已保存的受管引用只复验,不重复导入;普通相对路径以项目运行根目录为基准。
|
|
managedRoot := runtimepath.SSLReference(root, managedSSLRelativeRoot)
|
|
if strings.HasPrefix(certificatePath, managedRoot+"/") && strings.HasPrefix(privateKeyPath, managedRoot+"/") {
|
|
return nil, validateCustomSSLReferences(root, *candidate)
|
|
}
|
|
var err error
|
|
certificatePath, err = resolveCustomSSLInput(root, certificatePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
privateKeyPath, err = resolveCustomSSLInput(root, privateKeyPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
material := &customSSLMaterial{domains: runtimeCertificateDomains(candidate)}
|
|
material.privateKey, err = readExternalSSLFile(privateKeyPath, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
material.certificate, err = readExternalSSLFile(certificatePath, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateSSLMaterial(material.privateKey, material.certificate, nil, material.domains); err != nil {
|
|
return nil, err
|
|
}
|
|
ssl.Mode = ManagedSSLModeUpload
|
|
ssl.DefaultDirectory = runtimepath.SSLReference(root, "data/ssl")
|
|
ssl.CustomDirectory = runtimepath.SSLReference(root, "data/ssl")
|
|
ssl.CertificateRef = directSSLReference(root, certificatePath)
|
|
ssl.PrivateKeyRef = directSSLReference(root, privateKeyPath)
|
|
return material, nil
|
|
}
|
|
|
|
func (material *customSSLMaterial) stage(root string, candidate *RuntimeConfig) error {
|
|
if material == nil {
|
|
return nil
|
|
}
|
|
return validateCustomSSLReferences(root, *candidate)
|
|
}
|
|
|
|
// 部署目录内使用清晰的相对路径,目录外保留完整绝对路径。
|
|
func directSSLReference(root, path string) string {
|
|
absoluteRoot, err := filepath.Abs(root)
|
|
if err == nil && pathWithin(absoluteRoot, path) {
|
|
if relative, err := filepath.Rel(absoluteRoot, path); err == nil {
|
|
return filepath.ToSlash(relative)
|
|
}
|
|
}
|
|
return filepath.ToSlash(path)
|
|
}
|
|
|
|
// ValidateDirectSSLPaths 供初始化使用,与配置管理共用相同的文件安全校验。
|
|
func ValidateDirectSSLPaths(certificatePath, privateKeyPath string, domains []string) (ManagedSSLReference, error) {
|
|
publicPath, err := resolveCustomSSLInput(ROOT_PATH, certificatePath)
|
|
if err != nil {
|
|
return ManagedSSLReference{}, err
|
|
}
|
|
privatePath, err := resolveCustomSSLInput(ROOT_PATH, privateKeyPath)
|
|
if err != nil {
|
|
return ManagedSSLReference{}, err
|
|
}
|
|
publicKey, err := readExternalSSLFile(publicPath, false)
|
|
if err != nil {
|
|
return ManagedSSLReference{}, err
|
|
}
|
|
privateKey, err := readExternalSSLFile(privatePath, true)
|
|
if err != nil {
|
|
return ManagedSSLReference{}, err
|
|
}
|
|
if err := validateSSLMaterial(privateKey, publicKey, nil, domains); err != nil {
|
|
return ManagedSSLReference{}, err
|
|
}
|
|
return ManagedSSLReference{Mode: ManagedSSLModeUpload, CertificateRef: directSSLReference(ROOT_PATH, publicPath), PrivateKeyRef: directSSLReference(ROOT_PATH, privatePath), CertificatePath: publicPath, PrivateKeyPath: privatePath}, nil
|
|
}
|
|
|
|
func resolveCustomSSLInput(root, value string) (string, error) {
|
|
if strings.TrimSpace(value) == "" || strings.ContainsRune(value, 0) {
|
|
return "", ErrInvalidSSLReference
|
|
}
|
|
for _, part := range strings.FieldsFunc(value, func(r rune) bool { return r == '/' || r == '\\' }) {
|
|
if part == ".." {
|
|
return "", ErrInvalidSSLReference
|
|
}
|
|
}
|
|
if !filepath.IsAbs(value) {
|
|
if isWindowsAbsolute(value) || filepath.VolumeName(value) != "" || strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`) {
|
|
return "", ErrInvalidSSLReference
|
|
}
|
|
value = filepath.Join(root, value)
|
|
}
|
|
path, err := filepath.Abs(value)
|
|
if err != nil {
|
|
return "", ErrInvalidSSLReference
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func readExternalSSLFile(path string, privateKey bool) ([]byte, error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return nil, ErrSSLFileUnavailable
|
|
}
|
|
if 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
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, ErrSSLFileUnavailable
|
|
}
|
|
defer file.Close()
|
|
opened, err := file.Stat()
|
|
if err != nil || !os.SameFile(info, opened) {
|
|
return nil, ErrUnsafeSSLFile
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(file, maxManagedSSLFileSize+1))
|
|
if err != nil {
|
|
return nil, ErrSSLFileUnavailable
|
|
}
|
|
after, err := os.Lstat(path)
|
|
if err != nil || !os.SameFile(info, after) || info.Size() != after.Size() || !info.ModTime().Equal(after.ModTime()) || len(data) != int(info.Size()) || len(data) > maxManagedSSLFileSize {
|
|
return nil, ErrUnsafeSSLFile
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func validateCustomSSLReferences(root string, candidate RuntimeConfig) error {
|
|
ssl := candidate.SSL.TLS
|
|
if ssl.CertificateRef == "" && ssl.PrivateKeyRef == "" {
|
|
return nil
|
|
}
|
|
reference, err := resolveManagedSSLReferencesAtRoot(root, ssl)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
privateKey, err := readExternalSSLFile(reference.PrivateKeyPath, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
certificate, err := readExternalSSLFile(reference.CertificatePath, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return validateSSLMaterial(privateKey, certificate, nil, validationCertificateDomains(&candidate))
|
|
}
|
|
|
|
func validationCertificateDomains(candidate *RuntimeConfig) []string {
|
|
if EffectiveSSLManagement(candidate.SSL.TLS) == SSLManagementProject && candidate.SSL.TLS.Challenge == SSLChallengeDNS {
|
|
primary := strings.TrimPrefix(candidate.Root.Domains.Primary, "*.")
|
|
// 加载、导出和配置复验与自动申请一致,同时要求根域名和泛域名。
|
|
domains := []string{primary, "*." + primary}
|
|
if candidate.Root.Domains.Web != "" {
|
|
domains = append(domains, candidate.Root.Domains.Web)
|
|
}
|
|
for _, domain := range candidate.Root.Domains.Accepted {
|
|
domains = append(domains, "smtp."+domain, "imap."+domain, "pop."+domain)
|
|
}
|
|
return domains
|
|
}
|
|
return runtimeCertificateDomains(candidate)
|
|
}
|
|
|
|
func runtimeCertificateDomains(candidate *RuntimeConfig) []string {
|
|
return SSLCertificateDomains(candidate.Root.Domains.Primary, candidate.Root.Domains.Web, candidate.Root.Domains.Accepted)
|
|
}
|
|
|
|
// SSLCertificateDomains 统一初始化和配置管理所需的 Web、主域及邮件协议域名。
|
|
func SSLCertificateDomains(primary, web string, accepted []string) []string {
|
|
seen := make(map[string]bool)
|
|
var domains []string
|
|
appendDomain := func(value string) {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value != "" && !seen[value] {
|
|
seen[value] = true
|
|
domains = append(domains, value)
|
|
}
|
|
}
|
|
appendDomain(web)
|
|
appendDomain(primary)
|
|
for _, domain := range accepted {
|
|
domain = strings.TrimSpace(domain)
|
|
if domain == "" {
|
|
continue
|
|
}
|
|
appendDomain(domain)
|
|
appendDomain("smtp." + domain)
|
|
appendDomain("pop." + domain)
|
|
appendDomain("imap." + domain)
|
|
}
|
|
return domains
|
|
}
|
|
|
|
// SSLValidationIssue 将已知证书错误转换成安全的中文提示,不回显磁盘路径或私钥材料。
|
|
func SSLValidationIssue(err error) (PublicIssue, bool) {
|
|
issue := PublicIssue{Code: "SSL_INVALID"}
|
|
switch {
|
|
case errors.Is(err, ErrSSLHistoryUnavailable):
|
|
issue.Message = "历史证书已清理或不可用,请重新导入有效证书,不能回滚此配置"
|
|
case errors.Is(err, ErrSSLFileUnavailable):
|
|
issue.Message = "证书或私钥文件不存在或无法读取,请检查服务器路径和运行用户权限"
|
|
case errors.Is(err, ErrSSLCertificateMismatch):
|
|
issue.Message = "证书与私钥不匹配,请选择同一套证书文件"
|
|
case errors.Is(err, ErrSSLCertificateExpired):
|
|
issue.Message = "证书尚未生效或已经过期,请更换有效证书"
|
|
case errors.Is(err, ErrSSLCertificateDomain):
|
|
issue.Message = "证书未覆盖已配置的主域名、Web 域名或 SMTP、IMAP、POP3 域名"
|
|
case errors.Is(err, ErrSSLPrivateKeyPermission):
|
|
issue.Message = "私钥权限过于宽松,请限制为仅运行用户可读取"
|
|
case errors.Is(err, ErrUnsafeSSLFile):
|
|
issue.Message = "证书文件不安全或大小无效,请使用不超过 1 MB 的普通文件,避免链接文件"
|
|
case errors.Is(err, ErrInvalidSSLMaterial):
|
|
issue.Message = "证书内容、证书链或密钥强度无效,请使用有效的 PEM 证书和私钥"
|
|
case errors.Is(err, ErrInvalidSSLReference):
|
|
issue.Message = "请填写完整的证书和私钥路径,路径不能包含上级目录跳转"
|
|
case errors.Is(err, ErrInvalidSSLMode), errors.Is(err, ErrInvalidSSL):
|
|
issue.Message = "证书管理方式或存储配置无效,请检查 SSL 配置"
|
|
default:
|
|
return PublicIssue{}, false
|
|
}
|
|
return issue, true
|
|
}
|