feat: 管理后台一键从 Caddy 获取证书 + TLS 证书热加载
- 域名编辑页新增“从 Caddy 获取证书”按钮:一键把本机 Caddy 已签发的 证书与私钥导入该域名的 TLS 目录并自动启用 TLS,支持通配符证书 (如 *.example.com 可匹配 mail.example.com),成功/失败均回显横幅 - 新增 internal/caddycert:搜索 Caddy 证书存储(同步镜像目录优先、 caddy.data_dir 与常见位置兜底),校验密钥对/有效期/SAN,并给出 可操作的中文错误提示(未找到/证书无效/权限不足) - install.sh 新增 setup-caddy-cert:安装 root 权限的 systemd path+timer 同步任务(mailgo-caddy-sync),把 Caddy 证书树镜像到 /srv/mail_go/tls/caddy(证书续期后自动更新、每日兜底),另授予 ACL 作为直接读取兜底;install 时自动检测并配置 - 新增 [caddy] data_dir 配置节,支持自定义 Caddy 数据目录 - 新增 internal/tlsutil:TLS 证书热加载器,每次握手按需重载证书 文件(mtime 检测),重载失败继续使用旧证书兜底并节流重试; 应用于 SMTPS 465/IMAPS 993/POP3S 995 与 STARTTLS,导入或上传 新证书后无需重启服务即生效 - 证书来源动态切换:协议显式配置优先,否则取首个启用 TLS 且有证书 的域名(10 秒缓存),新域名一键导入证书后自动切换 - 更新 README 与界面文案(去掉“重启服务生效”提示)
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// Package tlsutil 提供 TLS 证书热加载:每次 TLS 握手时按需检查
|
||||
// 证书路径与文件内容是否变化,变化则自动重载,证书更新后无需重启
|
||||
// 服务即可生效。
|
||||
package tlsutil
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// retryInterval 是重载失败后的最小重试间隔,避免证书文件损坏时
|
||||
// 每个握手都重复做无意义的磁盘读取。
|
||||
const retryInterval = 5 * time.Second
|
||||
|
||||
// Source 返回当前应使用的证书路径。路径可能随时间变化(例如管理后台
|
||||
// 一键导入证书后切换到新的域名证书);返回空路径表示暂无可用证书。
|
||||
type Source func() (certPath, keyPath string)
|
||||
|
||||
// Loader 管理一对可热加载的证书。所有方法均并发安全。
|
||||
type Loader struct {
|
||||
mu sync.Mutex
|
||||
source Source
|
||||
certPath string
|
||||
keyPath string
|
||||
cert *tls.Certificate
|
||||
certMod time.Time
|
||||
keyMod time.Time
|
||||
lastTry time.Time
|
||||
logf func(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// NewLoader 立即加载并校验证书,失败返回错误(保持启动时 fail-fast)。
|
||||
// source 为 nil 时证书路径固定不变,仅检测文件内容变化。
|
||||
func NewLoader(certPath, keyPath string, source Source, logf func(string, ...interface{})) (*Loader, error) {
|
||||
if certPath == "" || keyPath == "" {
|
||||
return nil, fmt.Errorf("TLS 证书路径为空")
|
||||
}
|
||||
l := &Loader{
|
||||
source: source,
|
||||
certPath: certPath,
|
||||
keyPath: keyPath,
|
||||
logf: logf,
|
||||
}
|
||||
if err := l.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// GetCertificate 实现 tls.Config.GetCertificate:
|
||||
// 每次 TLS 握手时检查证书路径与文件是否有变化,有则自动重载;
|
||||
// 重载失败时继续使用上一次成功加载的证书,避免中断现有服务。
|
||||
func (l *Loader) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
certPath, keyPath := l.certPath, l.keyPath
|
||||
if l.source != nil {
|
||||
certPath, keyPath = l.source()
|
||||
}
|
||||
if certPath == "" || keyPath == "" {
|
||||
// 暂无证书可用:继续使用旧证书(若有)
|
||||
return l.current()
|
||||
}
|
||||
|
||||
changed := certPath != l.certPath || keyPath != l.keyPath
|
||||
if !changed {
|
||||
changed = l.filesChanged(certPath, keyPath)
|
||||
}
|
||||
|
||||
// 仅在重载失败后节流(避免证书文件损坏时每个握手都重复读盘);
|
||||
// 成功后清零节流,保证正常的连续更新立即生效。
|
||||
if changed && time.Since(l.lastTry) >= retryInterval {
|
||||
l.lastTry = time.Now()
|
||||
l.certPath, l.keyPath = certPath, keyPath
|
||||
if err := l.load(); err != nil {
|
||||
if l.logf != nil {
|
||||
l.logf("TLS 证书重载失败 (%s, %s): %v,继续使用旧证书", certPath, keyPath, err)
|
||||
}
|
||||
} else {
|
||||
l.lastTry = time.Time{}
|
||||
if l.logf != nil {
|
||||
l.logf("TLS 证书已热加载: %s", certPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return l.current()
|
||||
}
|
||||
|
||||
// current 返回当前已加载的证书。
|
||||
func (l *Loader) current() (*tls.Certificate, error) {
|
||||
if l.cert == nil {
|
||||
return nil, fmt.Errorf("TLS 证书不可用")
|
||||
}
|
||||
return l.cert, nil
|
||||
}
|
||||
|
||||
// filesChanged 判断证书/私钥文件自上次加载后是否被修改。
|
||||
// 文件暂时不可读(如正在原子替换)时视为已变化,触发重载尝试。
|
||||
func (l *Loader) filesChanged(certPath, keyPath string) bool {
|
||||
stC, errC := os.Stat(certPath)
|
||||
stK, errK := os.Stat(keyPath)
|
||||
if errC != nil || errK != nil {
|
||||
return true
|
||||
}
|
||||
return !stC.ModTime().Equal(l.certMod) || !stK.ModTime().Equal(l.keyMod)
|
||||
}
|
||||
|
||||
// load 从当前路径加载证书对并记录文件修改时间。
|
||||
func (l *Loader) load() error {
|
||||
cert, err := tls.LoadX509KeyPair(l.certPath, l.keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stC, err := os.Stat(l.certPath); err == nil {
|
||||
l.certMod = stC.ModTime()
|
||||
}
|
||||
if stK, err := os.Stat(l.keyPath); err == nil {
|
||||
l.keyMod = stK.ModTime()
|
||||
}
|
||||
l.cert = &cert
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user