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

622 lines
18 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 ssl
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
stderrors "errors"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/services/setup"
"git.pcux.cn/xuans/Smail/signal"
"git.pcux.cn/xuans/Smail/utils/async"
"git.pcux.cn/xuans/Smail/utils/errors"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/dns01"
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
)
type MyUser struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
type userCertificateError struct {
message string
}
func (e *userCertificateError) Error() string {
return e.message
}
func newUserCertificateError(message string) error {
return &userCertificateError{message: message}
}
// UserCertificateErrorMessage 只返回可以安全展示给初始化页面的证书错误。
func UserCertificateErrorMessage(err error) string {
var certificateError *userCertificateError
if stderrors.As(err, &certificateError) {
return certificateError.message
}
return ""
}
func normalizeUserCertificateImportError(err error) error {
switch {
case stderrors.Is(err, config.ErrSSLCertificateMismatch):
return newUserCertificateError("SSL 证书与私钥不匹配")
case stderrors.Is(err, config.ErrSSLCertificateDomain):
return newUserCertificateError("SSL 证书与已配置域名不匹配,请检查主域名、Web 域名和附加域名")
case stderrors.Is(err, config.ErrSSLCertificateExpired):
return newUserCertificateError("SSL 证书无效、尚未生效或已经过期")
case stderrors.Is(err, config.ErrSSLPrivateKeyPermission):
return newUserCertificateError("SSL 私钥权限过于宽松,请限制为仅 Smail 运行用户可读取")
case stderrors.Is(err, config.ErrInvalidSSLMaterial), stderrors.Is(err, config.ErrUnsafeSSLFile):
return newUserCertificateError("SSL 证书或私钥文件无效")
default:
return err
}
}
func (u *MyUser) GetEmail() string {
return u.Email
}
func (u MyUser) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *MyUser) GetPrivateKey() crypto.PrivateKey {
return u.key
}
func GetSSL() (string, error) {
cfg, err := config.ReadConfig()
if err != nil {
return "", errors.Wrap(err)
}
if cfg.UsesProxyHTTPS() {
return config.SSLTypeUser, nil
}
if cfg.SSLType == "" {
return config.SSLTypeAutoHTTP, nil
}
return cfg.SSLType, nil
}
func SetSSL(sslType, priKey, crtKey string) error {
cfg, err := config.ReadConfig()
if err != nil {
return errors.Wrap(err)
}
expected := *cfg
if sslType == config.SSLTypeAutoHTTP || sslType == config.SSLTypeUser || sslType == config.SSLTypeAutoDNS {
cfg.SSLType = sslType
} else {
return errors.New("SSL 类型错误")
}
if cfg.UsesProxyHTTPS() && cfg.SSLType != config.SSLTypeUser {
return newUserCertificateError("HTTPS 反代模式由反向代理管理证书,Smail 不自动申请或续期;邮件服务请导入外部维护的证书")
}
if cfg.SSLType != config.SSLTypeUser {
clearLegacySSLPaths(cfg)
cfg.SSL = config.SSLConfig{
Management: config.SSLManagementProject,
Mode: config.ManagedSSLModeDefault,
DefaultDirectory: runtimepath.SSLReference(config.ROOT_PATH, "data/ssl"),
}
if cfg.SSLType == config.SSLTypeAutoDNS {
cfg.SSL.Challenge = config.SSLChallengeDNS
if err := validateWildcardConfiguration(cfg); err != nil {
return err
}
} else {
cfg.SSL.Challenge = config.SSLChallengeHTTP
}
}
if cfg.SSLType == config.SSLTypeUser {
if cfg.UsesProxyHTTPS() {
cfg.SSL.Management = config.SSLManagementWebServer
} else {
cfg.SSL.Management = config.SSLManagementCustom
}
privatePath, publicPath, err := validateUserCertificatePaths(priKey, crtKey)
if err != nil {
return err
}
privatePEM, err := os.ReadFile(privatePath)
if err != nil {
return newUserCertificateError("SSL 私钥无法读取,请确认文件路径和 smail 用户权限")
}
publicPEM, err := os.ReadFile(publicPath)
if err != nil {
return newUserCertificateError("SSL 证书无法读取,请确认文件路径和 smail 用户权限")
}
reference, err := config.StageManagedSSL(config.ManagedSSLModeUpload, privatePEM, publicPEM, nil, sslDomains(cfg))
if err != nil {
return normalizeUserCertificateImportError(err)
}
applyManagedSSLReference(cfg, reference)
// 导入邮件服务证书不能重新启用反代模式下的内置 HTTPS。
if cfg.UsesProxyHTTPS() {
cfg.HttpsEnabled = 2
} else {
cfg.HttpsEnabled = 1
}
}
err = config.WriteSSLConfig(&expected, cfg)
if err != nil {
return errors.Wrap(err)
}
return nil
}
// clearLegacySSLPaths 只清理新受控模式不再使用的旧聚合路径字段。
func clearLegacySSLPaths(cfg *config.Config) {
if cfg == nil {
return
}
cfg.SSLPrivateKeyPath = ""
cfg.SSLPublicKeyPath = ""
}
// applyManagedSSLReference 将证书切换结果写入内部引用,不持久化服务器路径。
func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLReference) {
if cfg == nil {
return
}
clearLegacySSLPaths(cfg)
management := cfg.SSL.Management
if reference.Mode == config.ManagedSSLModeDefault {
management = config.SSLManagementProject
} else if !config.ValidSSLManagement(management) || management == config.SSLManagementProject {
management = config.SSLManagementCustom
}
cfg.SSL = config.SSLConfig{
Management: management,
Mode: reference.Mode,
DefaultDirectory: runtimepath.SSLReference(config.ROOT_PATH, "data/ssl"),
CertificateRef: reference.CertificateRef,
PrivateKeyRef: reference.PrivateKeyRef,
}
if reference.Mode == config.ManagedSSLModeUpload {
cfg.SSL.CustomDirectory = runtimepath.SSLReference(config.ROOT_PATH, "data/ssl/managed/upload")
} else if cfg.SSLType == config.SSLTypeAutoDNS {
cfg.SSL.Challenge = config.SSLChallengeDNS
} else {
cfg.SSL.Challenge = config.SSLChallengeHTTP
}
}
func isAbsoluteUserCertificatePath(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
if filepath.IsAbs(value) {
return true
}
volume := filepath.VolumeName(value)
return volume != "" || strings.HasPrefix(value, `\\`) || strings.HasPrefix(value, "//")
}
func validateUserCertificatePaths(privatePath, publicPath string) (string, string, error) {
rootPath, err := filepath.Abs(filepath.Clean(config.ROOT_PATH))
if err != nil {
return "", "", err
}
resolve := func(value string) (string, []byte, error) {
if strings.TrimSpace(value) == "" {
return "", nil, newUserCertificateError("请填写 SSL 证书和私钥路径")
}
path := strings.TrimSpace(value)
if isAbsoluteUserCertificatePath(path) {
if filepath.VolumeName(path) != "" && !filepath.IsAbs(path) {
return "", nil, newUserCertificateError("SSL 路径必须是完整的绝对路径")
}
} else {
path = filepath.Join(rootPath, path)
}
path, err = filepath.Abs(filepath.Clean(path))
if err != nil {
return "", nil, err
}
info, err := os.Stat(path)
if err != nil {
return "", nil, newUserCertificateError("SSL 文件不存在或无权读取")
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > 1<<20 {
return "", nil, newUserCertificateError("SSL 文件无效或大小超过限制")
}
content, err := os.ReadFile(path)
if err != nil || len(content) == 0 || len(content) > 1<<20 {
return "", nil, newUserCertificateError("SSL 文件读取失败或内容无效")
}
return path, content, nil
}
privatePath, privatePEM, err := resolve(privatePath)
if err != nil {
return "", "", err
}
publicPath, publicPEM, err := resolve(publicPath)
if err != nil {
return "", "", err
}
keyPair, err := tls.X509KeyPair(publicPEM, privatePEM)
if err != nil || len(keyPair.Certificate) == 0 {
return "", "", newUserCertificateError("SSL 证书与私钥不匹配")
}
certificate, err := x509.ParseCertificate(keyPair.Certificate[0])
if err != nil || time.Now().Before(certificate.NotBefore) || !time.Now().Before(certificate.NotAfter) {
return "", "", newUserCertificateError("SSL 证书无效、尚未生效或已经过期")
}
switch publicKey := certificate.PublicKey.(type) {
case *rsa.PublicKey:
if publicKey.N.BitLen() < 2048 {
return "", "", newUserCertificateError("SSL 证书密钥强度不能低于 2048 位")
}
case *ecdsa.PublicKey:
if publicKey.Curve == nil || publicKey.Curve.Params().BitSize < 256 {
return "", "", newUserCertificateError("SSL 证书密钥强度不能低于 256 位 ECDSA")
}
default:
return "", "", newUserCertificateError("不支持当前 SSL 证书密钥类型")
}
privateInfo, err := os.Stat(privatePath)
if err != nil {
return "", "", newUserCertificateError("SSL 私钥不存在或无权读取")
}
if runtime.GOOS != "windows" && privateInfo.Mode().Perm()&0007 != 0 {
return "", "", newUserCertificateError("SSL 私钥不能允许其他用户读取或写入")
}
return privatePath, publicPath, nil
}
func writeCertificateFiles(cfg *config.Config, privateKey, certificate, issuer []byte) error {
if cfg == nil {
return errors.New("SSL 配置不能为空")
}
if err := validateWildcardConfiguration(cfg); err != nil {
return err
}
next := *cfg
reference, err := config.StageManagedSSL(config.ManagedSSLModeDefault, privateKey, certificate, issuer, certificateDomains(cfg))
if err != nil {
return err
}
applyManagedSSLReference(&next, reference)
if err := config.WriteSSLConfig(cfg, &next); err != nil {
return errors.Wrap(err)
}
*cfg = next
return nil
}
func sslDomains(cfg *config.Config) []string {
if cfg == nil {
return nil
}
return config.SSLCertificateDomains(cfg.Domain, cfg.WebDomain, cfg.Domains)
}
func certificateMatchesConfiguredDomains(cert *x509.Certificate, cfg *config.Config) bool {
if cert == nil || cfg == nil {
return false
}
if validateWildcardConfiguration(cfg) != nil {
return false
}
for _, domain := range certificateDomains(cfg) {
if err := cert.VerifyHostname(domain); err != nil {
return false
}
}
return true
}
// 自动 DNS 只申请主域泛域名;不覆盖的服务地址是配置错误,不能靠重复续期修复。
func validateWildcardConfiguration(cfg *config.Config) error {
if cfg == nil || cfg.SSLType != config.SSLTypeAutoDNS {
return nil
}
domains := wildcardCertificateDomains(cfg)
if len(domains) != 1 {
return newUserCertificateError("自动 DNS 证书需要有效的主域名")
}
base := strings.TrimPrefix(domains[0], "*.")
covered := func(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
suffix := "." + strings.ToLower(base)
label := strings.TrimSuffix(host, suffix)
return strings.HasSuffix(host, suffix) && label != "" && !strings.Contains(label, ".")
}
if cfg.WebDomain != "" && !covered(cfg.WebDomain) {
return newUserCertificateError("Web 域名必须是主域名的一级子域名,泛域名证书不覆盖根域名或多级子域名")
}
for _, domain := range cfg.Domains {
if !covered("smtp."+domain) || !covered("imap."+domain) || !covered("pop."+domain) {
return newUserCertificateError("附加邮箱域名的服务地址不在主域泛域名证书覆盖范围内")
}
}
return nil
}
func wildcardCertificateDomains(cfg *config.Config) []string {
if cfg == nil {
return nil
}
domain := strings.TrimSpace(cfg.Domain)
domain = strings.TrimSuffix(domain, ".")
domain = strings.TrimPrefix(domain, "*.")
if domain == "" {
return nil
}
return []string{"*." + domain}
}
func certificateDomains(cfg *config.Config) []string {
if cfg != nil && cfg.SSLType == config.SSLTypeAutoDNS {
return wildcardCertificateDomains(cfg)
}
return sslDomains(cfg)
}
func renewCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config) error {
myUser := MyUser{
Email: "SSLMaster@" + cfg.Domain,
key: privateKey,
}
conf := lego.NewConfig(&myUser)
conf.UserAgent = "Smail"
conf.Certificate.KeyType = certcrypto.RSA2048
// A client facilitates communication with the CA server.
client, err := lego.NewClient(conf)
if err != nil {
return errors.Wrap(err)
}
if cfg.SSLType == config.SSLTypeAutoHTTP {
err = client.Challenge.SetHTTP01Provider(GetHttpChallengeInstance())
if err != nil {
return errors.Wrap(err)
}
} else if cfg.SSLType == config.SSLTypeAutoDNS {
err = client.Challenge.SetDNS01Provider(GetDnsChallengeInstance(), dns01.AddDNSTimeout(60*time.Minute))
if err != nil {
return errors.Wrap(err)
}
log.Errorf("Please Set DNS Record/请将以下内容添加到DNS记录中:\n")
for _, item := range GetDnsChallengeInstance().GetDNSSettings(nil) {
log.Errorf("Type:%s\tHost:%s\tValue generated\n", item.Type, item.Host)
}
}
var reg *registration.Resource
reg, err = client.Registration.ResolveAccountByKey()
if err != nil {
return errors.Wrap(err)
}
myUser.Registration = reg
domains := certificateDomains(cfg)
request := certificate.ObtainRequest{
Domains: domains,
Bundle: true,
}
log.Infof("wait ssl renew")
certificates, err := client.Certificate.Obtain(request)
if err != nil {
return errors.Wrap(err)
}
if err = writeCertificateFiles(cfg, certificates.PrivateKey, certificates.Certificate, certificates.IssuerCertificate); err != nil {
return err
}
return nil
}
func generateCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config, newAccount bool) error {
myUser := MyUser{
Email: "i@" + cfg.Domain,
key: privateKey,
}
conf := lego.NewConfig(&myUser)
conf.UserAgent = "Smail"
conf.Certificate.KeyType = certcrypto.RSA2048
// A client facilitates communication with the CA server.
client, err := lego.NewClient(conf)
if err != nil {
return errors.Wrap(err)
}
if cfg.SSLType == config.SSLTypeAutoHTTP {
err = client.Challenge.SetHTTP01Provider(GetHttpChallengeInstance())
if err != nil {
return errors.Wrap(err)
}
} else if cfg.SSLType == config.SSLTypeAutoDNS {
err = client.Challenge.SetDNS01Provider(GetDnsChallengeInstance(), dns01.AddDNSTimeout(60*time.Minute))
if err != nil {
return errors.Wrap(err)
}
}
var reg *registration.Resource
if newAccount {
reg, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return errors.Wrap(err)
}
} else {
reg, err = client.Registration.ResolveAccountByKey()
if err != nil {
return errors.Wrap(err)
}
}
myUser.Registration = reg
domains := certificateDomains(cfg)
request := certificate.ObtainRequest{
Domains: domains,
Bundle: true,
}
as := async.New(nil)
as.Process(func(params any) {
log.Infof("wait ssl")
certificates, err := client.Certificate.Obtain(request)
if err != nil {
log.WithError(err).Error("certificate issuance failed")
return
}
log.Infof("证书校验通过!")
if err := writeCertificateFiles(cfg, certificates.PrivateKey, certificates.Certificate, certificates.IssuerCertificate); err != nil {
log.WithError(err).Error("certificate storage failed")
return
}
if err := setup.Finish(); err != nil {
log.WithError(err).Error("finish setup after certificate issuance failed")
return
}
}, nil)
return nil
}
func GenSSL(update bool) error {
cfg, err := config.ReadConfig()
if err != nil {
return errors.Wrap(err)
}
if cfg.UsesProxyHTTPS() {
return newUserCertificateError("HTTPS 反代模式由反向代理管理证书,Smail 不自动申请或续期;邮件服务请导入外部维护的证书")
}
if !cfg.AutomaticSSLEnabled() {
return newUserCertificateError("当前证书由外部维护,不能自动申请或续期")
}
if err := validateWildcardConfiguration(cfg); err != nil {
return err
}
if !update {
privatePath, publicPath, pathErr := config.ResolveRuntimeSSLPaths(cfg)
privateFile, errpi := os.ReadFile(privatePath)
public, errpu := os.ReadFile(publicPath)
if pathErr != nil {
errpi = pathErr
errpu = pathErr
}
// 当前存在证书数据,就不生成了
if errpi == nil && errpu == nil && len(privateFile) > 0 && len(public) > 0 {
return nil
}
}
privateKey, newAccount, err := config.ReadPrivateKey()
if err != nil {
return errors.Wrap(err)
}
if !update {
return generateCertificate(privateKey, cfg, newAccount)
}
return renewCertificate(privateKey, cfg)
}
// CheckSSLCrtInfo 返回证书过期剩余天数
func CheckSSLCrtInfo() (int, time.Time, bool, error) {
cfg, err := config.ReadConfig()
if err != nil {
return -1, time.Now(), true, errors.Wrap(err)
}
// load cert and key by tls.LoadX509KeyPair
tlsCert, err := config.LoadRuntimeTLSCertificate(cfg)
if err != nil {
return -1, time.Now(), true, errors.Wrap(err)
}
cert, err := x509.ParseCertificate(tlsCert.Certificate[0])
if err != nil {
return -1, time.Now(), true, errors.Wrap(err)
}
nameMatchFail := !certificateMatchesConfiguredDomains(cert, cfg)
// 检查过期时间
hours := cert.NotAfter.Sub(time.Now()).Hours()
if hours <= 0 {
return -1, time.Now(), nameMatchFail, errors.New("Certificate has expired")
}
return cast.ToInt(hours / 24), cert.NotAfter, nameMatchFail, nil
}
func Update(needRestart bool) {
cfg := config.CurrentConfig()
if cfg.AutomaticSSLEnabled() && cfg.IsSetupReady() {
days, _, nameMatchFail, err := CheckSSLCrtInfo()
if days < 30 || err != nil || nameMatchFail {
if err != nil {
log.Errorf("SSL Check Error, Update SSL Certificate. Error Info :%+v", err)
} else {
log.Infof("SSL certificate remaining time is only %d days, renew SSL certificate.", days)
}
err = GenSSL(true)
if err != nil {
log.Errorf("SSL Update Error! %+v", err)
return
}
if needRestart {
// 更新完证书,重启服务
signal.RestartChan <- true
}
} else {
log.Debugf("SSL Check.")
}
}
}