完善tls配置

This commit is contained in:
2026-06-02 18:47:28 +08:00
parent 3dcc3f9a35
commit bd56c4dc5f
6 changed files with 215 additions and 42 deletions
+31 -1
View File
@@ -2,6 +2,7 @@ package pop3_server
import ( import (
"bufio" "bufio"
"crypto/tls"
"fmt" "fmt"
"log" "log"
"net" "net"
@@ -66,7 +67,36 @@ func (s *POP3Server) StartTLS() error {
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" { if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
return fmt.Errorf("POP3 TLS certificate or key not configured") return fmt.Errorf("POP3 TLS certificate or key not configured")
} }
return fmt.Errorf("POP3 TLS not yet implemented")
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
if err != nil {
return fmt.Errorf("load POP3 TLS certificate failed: %w", err)
}
listener, err := tls.Listen("tcp", s.cfg.TLSAddr, &tls.Config{Certificates: []tls.Certificate{cert}})
if err != nil {
return fmt.Errorf("POP3 TLS listen failed: %w", err)
}
log.Printf("POP3 TLS server listening on %s", s.cfg.TLSAddr)
s.wg.Add(1)
go func() {
defer s.wg.Done()
for {
conn, err := listener.Accept()
if err != nil {
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.handleConn(conn)
}()
}
}()
return nil
} }
// handleConn handles a single POP3 client connection. // handleConn handles a single POP3 client connection.
+10
View File
@@ -11,6 +11,7 @@ type DomainStore interface {
Create(domain *db.Domain) error Create(domain *db.Domain) error
GetByID(id uint) (*db.Domain, error) GetByID(id uint) (*db.Domain, error)
GetByName(name string) (*db.Domain, error) GetByName(name string) (*db.Domain, error)
GetFirstTLSEnabledWithCert() (*db.Domain, error)
Update(domain *db.Domain) error Update(domain *db.Domain) error
Delete(id uint) error Delete(id uint) error
List(page, size int) ([]db.Domain, int64, error) List(page, size int) ([]db.Domain, int64, error)
@@ -49,6 +50,15 @@ func (s *domainStoreGorm) GetByName(name string) (*db.Domain, error) {
return &domain, nil return &domain, nil
} }
// GetFirstTLSEnabledWithCert retrieves the first TLS-enabled domain with certificate paths.
func (s *domainStoreGorm) GetFirstTLSEnabledWithCert() (*db.Domain, error) {
var domain db.Domain
if err := s.db.Where("tls_enabled = ? AND tls_cert_path <> ? AND tls_key_path <> ?", true, "", "").Order("id ASC").First(&domain).Error; err != nil {
return nil, err
}
return &domain, nil
}
// Update saves changes to an existing domain record. // Update saves changes to an existing domain record.
func (s *domainStoreGorm) Update(domain *db.Domain) error { func (s *domainStoreGorm) Update(domain *db.Domain) error {
return s.db.Save(domain).Error return s.db.Save(domain).Error
+99 -21
View File
@@ -1,10 +1,14 @@
package handlers package handlers
import ( import (
"crypto/tls"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings"
"time" "time"
"mail_go/internal/db" "mail_go/internal/db"
@@ -20,11 +24,12 @@ import (
type AdminHandler struct { type AdminHandler struct {
stores *store.Stores stores *store.Stores
storage *storage.AttachmentStorage storage *storage.AttachmentStorage
tlsDir string
} }
// NewAdminHandler creates a new AdminHandler with the given stores and attachment storage. // NewAdminHandler creates a new AdminHandler with the given stores and attachment storage.
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage) *AdminHandler { func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string) *AdminHandler {
return &AdminHandler{stores: stores, storage: attStorage} return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir}
} }
// Dashboard renders the admin dashboard with summary statistics. // Dashboard renders the admin dashboard with summary statistics.
@@ -64,17 +69,17 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_dashboard", gin.H{ c.HTML(200, "admin_dashboard", gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"domainCount": domainCount, "domainCount": domainCount,
"userCount": userCount, "userCount": userCount,
"totalMails": totalMails, "totalMails": totalMails,
"inboxCount": inboxCount, "inboxCount": inboxCount,
"sentCount": sentCount, "sentCount": sentCount,
"draftsCount": draftsCount, "draftsCount": draftsCount,
"trashCount": trashCount, "trashCount": trashCount,
"totalSize": totalSize, "totalSize": totalSize,
"inboxSize": inboxSize, "inboxSize": inboxSize,
"sentSize": sentSize, "sentSize": sentSize,
"todayReceived": todayReceived, "todayReceived": todayReceived,
"todaySent": todaySent, "todaySent": todaySent,
"weekReceived": weekReceived, "weekReceived": weekReceived,
@@ -202,11 +207,13 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_domain_form", gin.H{ c.HTML(200, "admin_domain_form", gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "domains", "activeFolder": "domains",
"error": "", "error": "",
"isEdit": true, "isEdit": true,
"domain": domain, "domain": domain,
"tlsPublicCert": readTLSCert(domain.TlsCertPath),
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
}) })
} }
@@ -229,6 +236,13 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
domain.Pop3Port = formIntOrDefault(c, "pop3_port", domain.Pop3Port) domain.Pop3Port = formIntOrDefault(c, "pop3_port", domain.Pop3Port)
domain.TlsEnabled = c.PostForm("tls_enabled") == "on" domain.TlsEnabled = c.PostForm("tls_enabled") == "on"
tlsPrivateKey := strings.TrimSpace(c.PostForm("tls_private_key"))
tlsPublicCert := strings.TrimSpace(c.PostForm("tls_public_cert"))
if err := h.handleDomainTLSUpdate(domain, tlsPublicCert, tlsPrivateKey); err != nil {
h.renderDomainFormError(c, domain, err.Error(), tlsPublicCert)
return
}
// 重新生成DKIM // 重新生成DKIM
if c.PostForm("regenerate_dkim") == "on" { if c.PostForm("regenerate_dkim") == "on" {
privKey, pubKey, err := dkim.GenerateKeyPair() privKey, pubKey, err := dkim.GenerateKeyPair()
@@ -240,20 +254,84 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
} }
if err := h.stores.Domains.Update(domain); err != nil { if err := h.stores.Domains.Update(domain); err != nil {
currentUser, _ := c.Get("currentUser") h.renderDomainFormError(c, domain, fmt.Sprintf("更新域名失败: %v", err), tlsPublicCert)
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
"error": fmt.Sprintf("更新域名失败: %v", err),
"isEdit": true,
"domain": domain,
})
return return
} }
c.Redirect(http.StatusFound, "/admin/domains") c.Redirect(http.StatusFound, "/admin/domains")
} }
func readTLSCert(path string) string {
if path == "" {
return ""
}
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(data)
}
func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, privateKey string) error {
if !domain.TlsEnabled {
return nil
}
hasExistingCert := domain.TlsCertPath != "" && domain.TlsKeyPath != ""
if publicCert == "" && privateKey == "" {
if hasExistingCert {
return nil
}
return fmt.Errorf("启用 TLS 时必须填写 TLS 私钥和公钥证书")
}
if hasExistingCert && privateKey == "" && strings.TrimSpace(readTLSCert(domain.TlsCertPath)) == publicCert {
return nil
}
if publicCert == "" || privateKey == "" {
return fmt.Errorf("TLS 私钥和公钥证书必须同时填写")
}
if _, err := tls.X509KeyPair([]byte(publicCert), []byte(privateKey)); err != nil {
return fmt.Errorf("TLS 证书或私钥无效: %v", err)
}
domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10))
if err := os.MkdirAll(domainTLSDir, 0700); err != nil {
return fmt.Errorf("创建 TLS 证书目录失败: %v", err)
}
certPath := filepath.Join(domainTLSDir, "cert.pem")
keyPath := filepath.Join(domainTLSDir, "key.pem")
if err := os.WriteFile(certPath, []byte(publicCert+"\n"), 0644); err != nil {
return fmt.Errorf("保存 TLS 公钥证书失败: %v", err)
}
if err := os.WriteFile(keyPath, []byte(privateKey+"\n"), 0600); err != nil {
return fmt.Errorf("保存 TLS 私钥失败: %v", err)
}
domain.TlsCertPath = certPath
domain.TlsKeyPath = keyPath
return nil
}
func (h *AdminHandler) renderDomainFormError(c *gin.Context, domain *db.Domain, message, tlsPublicCert string) {
currentUser, _ := c.Get("currentUser")
if tlsPublicCert == "" {
tlsPublicCert = readTLSCert(domain.TlsCertPath)
}
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
"error": message,
"isEdit": true,
"domain": domain,
"tlsPublicCert": tlsPublicCert,
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
})
}
// DeleteDomain removes a domain by ID. // DeleteDomain removes a domain by ID.
func (h *AdminHandler) DeleteDomain(c *gin.Context) { func (h *AdminHandler) DeleteDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+22 -19
View File
@@ -6,6 +6,7 @@ import (
"math" "math"
"net" "net"
"os" "os"
"path/filepath"
"strings" "strings"
"mail_go/config" "mail_go/config"
@@ -35,22 +36,23 @@ func formatBytes(b int64) string {
// WebServer wraps the Gin engine and its dependencies. // WebServer wraps the Gin engine and its dependencies.
type WebServer struct { type WebServer struct {
engine *gin.Engine engine *gin.Engine
stores *store.Stores stores *store.Stores
storage *storage.AttachmentStorage storage *storage.AttachmentStorage
cfg config.WebConfig cfg config.WebConfig
authCfg config.AuthConfig storageCfg config.StorageConfig
banCfg config.BanConfig authCfg config.AuthConfig
banCfg config.BanConfig
} }
// templateFuncs returns custom template functions for rendering. // templateFuncs returns custom template functions for rendering.
func templateFuncs() template.FuncMap { func templateFuncs() template.FuncMap {
return template.FuncMap{ return template.FuncMap{
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b }, "sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b }, "mul": func(a, b int) int { return a * b },
"div": func(a, b int) int { return a / b }, "div": func(a, b int) int { return a / b },
"mod": func(a, b int) int { return a % b }, "mod": func(a, b int) int { return a % b },
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) }, "ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
"seq": func(n int) []int { "seq": func(n int) []int {
result := make([]int, n) result := make([]int, n)
@@ -76,7 +78,7 @@ func templateFuncs() template.FuncMap {
// NewWebServer creates a new WebServer, initializes the Gin engine, // NewWebServer creates a new WebServer, initializes the Gin engine,
// configures sessions, middleware, and registers all routes. // configures sessions, middleware, and registers all routes.
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, authCfg config.AuthConfig, banCfg config.BanConfig) *WebServer { func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig) *WebServer {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
engine := gin.New() engine := gin.New()
engine.Use(gin.Logger()) engine.Use(gin.Logger())
@@ -99,12 +101,13 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
engine.SetHTMLTemplate(tmpl) engine.SetHTMLTemplate(tmpl)
ws := &WebServer{ ws := &WebServer{
engine: engine, engine: engine,
stores: stores, stores: stores,
storage: attStorage, storage: attStorage,
cfg: cfg, cfg: cfg,
authCfg: authCfg, storageCfg: storageCfg,
banCfg: banCfg, authCfg: authCfg,
banCfg: banCfg,
} }
ws.registerRoutes() ws.registerRoutes()
@@ -115,7 +118,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
func (ws *WebServer) registerRoutes() { func (ws *WebServer) registerRoutes() {
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg) authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage) mailHandler := handlers.NewMailHandler(ws.stores, ws.storage)
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage) adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"))
// Apply BanMiddleware globally before public routes // Apply BanMiddleware globally before public routes
ws.engine.Use(middleware.BanMiddleware(ws.stores)) ws.engine.Use(middleware.BanMiddleware(ws.stores))
@@ -51,13 +51,36 @@
</label> </label>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">勾选后端口将自动切换为 SSL/TLS 标准端口</p> <p style="color:#7f8c8d;font-size:12px;margin-top:4px;">勾选后端口将自动切换为 SSL/TLS 标准端口</p>
</div> </div>
{{if .isEdit}}
<div id="tls_cert_fields" style="display:none;">
<div class="form-group">
<label>TLS 私钥 PEM</label>
<textarea name="tls_private_key" rows="8" placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----" style="font-family:monospace;"></textarea>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">私钥不会回显;留空则保留现有私钥。</p>
</div>
<div class="form-group">
<label>TLS 公钥证书 PEM</label>
<textarea name="tls_public_cert" rows="8" placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----" style="font-family:monospace;">{{.tlsPublicCert}}</textarea>
{{if .tlsCertConfigured}}
<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>
{{end}}
<script> <script>
function togglePorts() { function togglePorts() {
var tls = document.getElementById('tls_enabled').checked; var tls = document.getElementById('tls_enabled').checked;
document.querySelector('input[name="smtp_port"]').value = tls ? 465 : 25; document.querySelector('input[name="smtp_port"]').value = tls ? 465 : 25;
document.querySelector('input[name="imap_port"]').value = tls ? 993 : 143; document.querySelector('input[name="imap_port"]').value = tls ? 993 : 143;
document.querySelector('input[name="pop3_port"]').value = tls ? 995 : 110; document.querySelector('input[name="pop3_port"]').value = tls ? 995 : 110;
var certFields = document.getElementById('tls_cert_fields');
if (certFields) {
certFields.style.display = tls ? 'block' : 'none';
}
} }
document.addEventListener('DOMContentLoaded', togglePorts);
</script> </script>
{{if .isEdit}} {{if .isEdit}}
<div class="form-group"> <div class="form-group">
+30 -1
View File
@@ -16,6 +16,34 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func applyDomainTLSConfig(stores *store.Stores, cfg *config.Config) {
domain, err := stores.Domains.GetFirstTLSEnabledWithCert()
if err != nil {
return
}
applied := false
if cfg.SMTP.TLSCert == "" && cfg.SMTP.TLSKey == "" {
cfg.SMTP.TLSCert = domain.TlsCertPath
cfg.SMTP.TLSKey = domain.TlsKeyPath
applied = true
}
if cfg.IMAP.TLSCert == "" && cfg.IMAP.TLSKey == "" {
cfg.IMAP.TLSCert = domain.TlsCertPath
cfg.IMAP.TLSKey = domain.TlsKeyPath
applied = true
}
if cfg.POP3.TLSCert == "" && cfg.POP3.TLSKey == "" {
cfg.POP3.TLSCert = domain.TlsCertPath
cfg.POP3.TLSKey = domain.TlsKeyPath
applied = true
}
if applied {
log.Printf("使用域名 %s 的 TLS 证书;更新证书后需重启服务生效", domain.Name)
}
}
func main() { func main() {
// 1. Load configuration // 1. Load configuration
cfg, err := config.LoadConfig() cfg, err := config.LoadConfig()
@@ -39,6 +67,7 @@ func main() {
// 5. Initialize attachment storage // 5. Initialize attachment storage
attStorage := storage.NewAttachmentStorage(cfg.Storage.AttachDir) attStorage := storage.NewAttachmentStorage(cfg.Storage.AttachDir)
applyDomainTLSConfig(stores, cfg)
// 6. Start SMTP server // 6. Start SMTP server
smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage) smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage)
@@ -89,7 +118,7 @@ func main() {
} }
// 9. Start Web server // 9. Start Web server
webServer := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Auth, cfg.Ban) webServer := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban)
fmt.Printf("Web 服务启动在 %s\n", cfg.Web.Addr) fmt.Printf("Web 服务启动在 %s\n", cfg.Web.Addr)
go func() { go func() {
if err := webServer.Start(); err != nil { if err := webServer.Start(); err != nil {