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:
14 files changed
+1253
-83
No files matched your search
@@ -5,12 +5,14 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/caddycert"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/dkim"
|
||||
"mail_go/internal/outbound"
|
||||
@@ -23,16 +25,17 @@ import (
|
||||
|
||||
// AdminHandler handles admin-related routes (dashboard, domain/user management).
|
||||
type AdminHandler struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
tlsDir string
|
||||
outbound *outbound.Manager
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
tlsDir string
|
||||
caddyDataDir string
|
||||
outbound *outbound.Manager
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new AdminHandler with the given stores, attachment
|
||||
// storage, TLS directory and outbound delivery manager.
|
||||
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, ob *outbound.Manager) *AdminHandler {
|
||||
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, outbound: ob}
|
||||
// storage, TLS directory, Caddy data directory and outbound delivery manager.
|
||||
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager) *AdminHandler {
|
||||
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob}
|
||||
}
|
||||
|
||||
// Dashboard renders the admin dashboard with summary statistics.
|
||||
@@ -209,6 +212,16 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
caddyMsg, caddyMsgType := "", ""
|
||||
if c.Query("caddy_err") != "" {
|
||||
caddyMsg = c.Query("caddy_err")
|
||||
caddyMsgType = "error"
|
||||
} else if c.Query("caddy_ok") == "1" {
|
||||
caddyMsg = "✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。"
|
||||
caddyMsgType = "success"
|
||||
}
|
||||
|
||||
c.HTML(200, "admin_domain_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "domains",
|
||||
@@ -217,6 +230,8 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
|
||||
"domain": domain,
|
||||
"tlsPublicCert": readTLSCert(domain.TlsCertPath),
|
||||
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
|
||||
"caddyMsg": caddyMsg,
|
||||
"caddyMsgType": caddyMsgType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -264,6 +279,78 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/admin/domains")
|
||||
}
|
||||
|
||||
// FetchCaddyCert 尝试从本机 Caddy 的证书存储中获取该域名的证书与私钥,
|
||||
// 保存到 MailGo 的域名 TLS 目录并更新数据库记录。结果通过查询参数回显到
|
||||
// 编辑页面(caddy_ok=1 成功 / caddy_err=<消息> 失败)。
|
||||
func (h *AdminHandler) FetchCaddyCert(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的域名ID")
|
||||
return
|
||||
}
|
||||
|
||||
domain, err := h.stores.Domains.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "域名不存在")
|
||||
return
|
||||
}
|
||||
|
||||
editURL := fmt.Sprintf("/admin/domains/%d/edit", domain.ID)
|
||||
fail := func(msg string) {
|
||||
log.Printf("从 Caddy 获取证书失败 domain=%s: %s", domain.Name, msg)
|
||||
c.Redirect(http.StatusFound, editURL+"?caddy_err="+url.QueryEscape(msg))
|
||||
}
|
||||
|
||||
cert, err := caddycert.Fetch(domain.Name, h.caddyCertRoots())
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("从 Caddy 获取证书失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 保存到域名 TLS 目录(与手动上传证书的位置一致)
|
||||
domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10))
|
||||
if err := os.MkdirAll(domainTLSDir, 0700); err != nil {
|
||||
fail(fmt.Sprintf("创建 TLS 证书目录失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
certPath := filepath.Join(domainTLSDir, "cert.pem")
|
||||
keyPath := filepath.Join(domainTLSDir, "key.pem")
|
||||
if err := os.WriteFile(certPath, cert.CertPEM, 0644); err != nil {
|
||||
fail(fmt.Sprintf("保存 TLS 公钥证书失败: %v", err))
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(keyPath, cert.KeyPEM, 0600); err != nil {
|
||||
fail(fmt.Sprintf("保存 TLS 私钥失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
domain.TlsCertPath = certPath
|
||||
domain.TlsKeyPath = keyPath
|
||||
domain.TlsEnabled = true
|
||||
if err := h.stores.Domains.Update(domain); err != nil {
|
||||
fail(fmt.Sprintf("更新域名记录失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("已从 Caddy 导入域名 %s 的证书 (%s),热加载生效", domain.Name, cert.Source)
|
||||
c.Redirect(http.StatusFound, editURL+"?caddy_ok=1")
|
||||
}
|
||||
|
||||
// caddyCertRoots 返回按优先级排列的证书来源目录:
|
||||
// 1. MailGo 的同步镜像目录 <storage>/tls/caddy —— 由 install.sh 安装的
|
||||
// systemd path 同步任务以 root 权限从 Caddy 证书存储镜像而来,
|
||||
// mail_go 始终可读,证书续期后自动更新;
|
||||
// 2. 配置文件 caddy.data_dir 指定的目录(可选);
|
||||
//
|
||||
// 其余默认位置由 caddycert.Fetch 自行探测。
|
||||
func (h *AdminHandler) caddyCertRoots() []string {
|
||||
return []string{
|
||||
filepath.Join(filepath.Dir(h.tlsDir), "caddy"),
|
||||
h.caddyDataDir,
|
||||
}
|
||||
}
|
||||
|
||||
func readTLSCert(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
|
||||
+21
-18
@@ -38,14 +38,15 @@ func formatBytes(b int64) string {
|
||||
|
||||
// WebServer wraps the Gin engine and its dependencies.
|
||||
type WebServer struct {
|
||||
engine *gin.Engine
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
cfg config.WebConfig
|
||||
storageCfg config.StorageConfig
|
||||
authCfg config.AuthConfig
|
||||
banCfg config.BanConfig
|
||||
outbound *outbound.Manager
|
||||
engine *gin.Engine
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
cfg config.WebConfig
|
||||
storageCfg config.StorageConfig
|
||||
authCfg config.AuthConfig
|
||||
banCfg config.BanConfig
|
||||
caddyDataDir string
|
||||
outbound *outbound.Manager
|
||||
}
|
||||
|
||||
// templateFuncs returns custom template functions for rendering.
|
||||
@@ -84,7 +85,7 @@ func templateFuncs() template.FuncMap {
|
||||
|
||||
// NewWebServer creates a new WebServer, initializes the Gin engine,
|
||||
// configures sessions, middleware, and registers all routes.
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, ob *outbound.Manager) *WebServer {
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, caddyCfg config.CaddyConfig, ob *outbound.Manager) *WebServer {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Logger())
|
||||
@@ -107,14 +108,15 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
engine.SetHTMLTemplate(tmpl)
|
||||
|
||||
ws := &WebServer{
|
||||
engine: engine,
|
||||
stores: stores,
|
||||
storage: attStorage,
|
||||
cfg: cfg,
|
||||
storageCfg: storageCfg,
|
||||
authCfg: authCfg,
|
||||
banCfg: banCfg,
|
||||
outbound: ob,
|
||||
engine: engine,
|
||||
stores: stores,
|
||||
storage: attStorage,
|
||||
cfg: cfg,
|
||||
storageCfg: storageCfg,
|
||||
authCfg: authCfg,
|
||||
banCfg: banCfg,
|
||||
caddyDataDir: caddyCfg.DataDir,
|
||||
outbound: ob,
|
||||
}
|
||||
|
||||
ws.registerRoutes()
|
||||
@@ -125,7 +127,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
func (ws *WebServer) registerRoutes() {
|
||||
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.outbound)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound)
|
||||
|
||||
// Apply BanMiddleware globally before public routes
|
||||
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
||||
@@ -175,6 +177,7 @@ func (ws *WebServer) registerRoutes() {
|
||||
admin.GET("/domains/:id/edit", adminHandler.EditDomain)
|
||||
admin.POST("/domains/:id", adminHandler.UpdateDomain)
|
||||
admin.POST("/domains/:id/delete", adminHandler.DeleteDomain)
|
||||
admin.POST("/domains/:id/fetch-caddy-cert", adminHandler.FetchCaddyCert)
|
||||
admin.GET("/domains/:id/dns", adminHandler.DNSHint)
|
||||
admin.GET("/users", adminHandler.ListUsers)
|
||||
admin.GET("/users/new", adminHandler.NewUser)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑域名{{else}}新增域名{{end}}</h2>
|
||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||
{{if .caddyMsg}}<div class="alert {{if eq .caddyMsgType "success"}}alert-success{{else}}alert-error{{end}}">{{.caddyMsg}}</div>{{end}}
|
||||
<form method="POST" action="{{if .isEdit}}/admin/domains/{{.domain.ID}}{{else}}/admin/domains{{end}}">
|
||||
<div class="form-group">
|
||||
<label>域名</label>
|
||||
@@ -63,12 +64,40 @@
|
||||
<label>TLS 公钥证书 PEM</label>
|
||||
<textarea name="tls_public_cert" rows="8" placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----" style="font-family:monospace;">{{.tlsPublicCert}}</textarea>
|
||||
{{if .tlsCertConfigured}}
|
||||
<p style="color:#27ae60;font-size:12px;margin-top:4px;">✅ TLS 证书已配置;上传新证书后需重启服务生效。</p>
|
||||
<p style="color:#27ae60;font-size:12px;margin-top:4px;">✅ TLS 证书已配置;上传新证书后自动热加载生效。</p>
|
||||
{{else}}
|
||||
<p style="color:#e67e22;font-size:12px;margin-top:4px;">⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:4px;">
|
||||
<label>从 Caddy 获取证书</label>
|
||||
<p style="color:#7f8c8d;font-size:12px;margin-top:2px;margin-bottom:8px;">若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。</p>
|
||||
<button type="button" class="btn" id="btn_fetch_caddy" onclick="fetchCaddyCert()" style="background:#2e86de;color:#fff;">🔒 从 Caddy 获取证书</button>
|
||||
<span id="caddy_fetch_msg" style="margin-left:10px;font-size:12px;color:#7f8c8d;"></span>
|
||||
<script>
|
||||
async function fetchCaddyCert() {
|
||||
var btn = document.getElementById('btn_fetch_caddy');
|
||||
var msg = document.getElementById('caddy_fetch_msg');
|
||||
btn.disabled = true;
|
||||
var oldText = btn.textContent;
|
||||
btn.textContent = '获取中…';
|
||||
msg.textContent = '';
|
||||
try {
|
||||
var resp = await fetch('/admin/domains/{{.domain.ID}}/fetch-caddy-cert', { method: 'POST' });
|
||||
if (resp.redirected) {
|
||||
window.location.href = resp.url;
|
||||
return;
|
||||
}
|
||||
msg.textContent = '获取失败: ' + await resp.text();
|
||||
} catch (e) {
|
||||
msg.textContent = '请求失败: ' + e;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = oldText;
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
{{end}}
|
||||
<script>
|
||||
function togglePorts() {
|
||||
|
||||
Reference in New Issue
Block a user