完善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 (
"bufio"
"crypto/tls"
"fmt"
"log"
"net"
@@ -66,7 +67,36 @@ func (s *POP3Server) StartTLS() error {
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
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.
+10
View File
@@ -11,6 +11,7 @@ type DomainStore interface {
Create(domain *db.Domain) error
GetByID(id uint) (*db.Domain, error)
GetByName(name string) (*db.Domain, error)
GetFirstTLSEnabledWithCert() (*db.Domain, error)
Update(domain *db.Domain) error
Delete(id uint) 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
}
// 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.
func (s *domainStoreGorm) Update(domain *db.Domain) error {
return s.db.Save(domain).Error
+88 -10
View File
@@ -1,10 +1,14 @@
package handlers
import (
"crypto/tls"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"mail_go/internal/db"
@@ -20,11 +24,12 @@ import (
type AdminHandler struct {
stores *store.Stores
storage *storage.AttachmentStorage
tlsDir string
}
// NewAdminHandler creates a new AdminHandler with the given stores and attachment storage.
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage) *AdminHandler {
return &AdminHandler{stores: stores, storage: attStorage}
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string) *AdminHandler {
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir}
}
// Dashboard renders the admin dashboard with summary statistics.
@@ -207,6 +212,8 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
"error": "",
"isEdit": true,
"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.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
if c.PostForm("regenerate_dkim") == "on" {
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 {
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
"error": fmt.Sprintf("更新域名失败: %v", err),
"isEdit": true,
"domain": domain,
})
h.renderDomainFormError(c, domain, fmt.Sprintf("更新域名失败: %v", err), tlsPublicCert)
return
}
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.
func (h *AdminHandler) DeleteDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+5 -2
View File
@@ -6,6 +6,7 @@ import (
"math"
"net"
"os"
"path/filepath"
"strings"
"mail_go/config"
@@ -39,6 +40,7 @@ type WebServer struct {
stores *store.Stores
storage *storage.AttachmentStorage
cfg config.WebConfig
storageCfg config.StorageConfig
authCfg config.AuthConfig
banCfg config.BanConfig
}
@@ -76,7 +78,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, 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)
engine := gin.New()
engine.Use(gin.Logger())
@@ -103,6 +105,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
stores: stores,
storage: attStorage,
cfg: cfg,
storageCfg: storageCfg,
authCfg: authCfg,
banCfg: banCfg,
}
@@ -115,7 +118,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)
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
ws.engine.Use(middleware.BanMiddleware(ws.stores))
@@ -51,13 +51,36 @@
</label>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">勾选后端口将自动切换为 SSL/TLS 标准端口</p>
</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>
function togglePorts() {
var tls = document.getElementById('tls_enabled').checked;
document.querySelector('input[name="smtp_port"]').value = tls ? 465 : 25;
document.querySelector('input[name="imap_port"]').value = tls ? 993 : 143;
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>
{{if .isEdit}}
<div class="form-group">
+30 -1
View File
@@ -16,6 +16,34 @@ import (
"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() {
// 1. Load configuration
cfg, err := config.LoadConfig()
@@ -39,6 +67,7 @@ func main() {
// 5. Initialize attachment storage
attStorage := storage.NewAttachmentStorage(cfg.Storage.AttachDir)
applyDomainTLSConfig(stores, cfg)
// 6. Start SMTP server
smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage)
@@ -89,7 +118,7 @@ func main() {
}
// 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)
go func() {
if err := webServer.Start(); err != nil {