From bd56c4dc5ff823ad0131ae61c0900654574214b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E5=B3=B0?= Date: Tue, 2 Jun 2026 18:47:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84tls=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/pop3_server/server.go | 32 ++++- internal/store/domain_store.go | 10 ++ internal/web/handlers/admin.go | 120 +++++++++++++++--- internal/web/server.go | 41 +++--- internal/web/templates/admin/domain_form.html | 23 ++++ main.go | 31 ++++- 6 files changed, 215 insertions(+), 42 deletions(-) diff --git a/internal/pop3_server/server.go b/internal/pop3_server/server.go index 12d5971..f9d5fd1 100644 --- a/internal/pop3_server/server.go +++ b/internal/pop3_server/server.go @@ -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. diff --git a/internal/store/domain_store.go b/internal/store/domain_store.go index 2aae424..89df933 100644 --- a/internal/store/domain_store.go +++ b/internal/store/domain_store.go @@ -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 diff --git a/internal/web/handlers/admin.go b/internal/web/handlers/admin.go index 842c5a0..2148c2e 100644 --- a/internal/web/handlers/admin.go +++ b/internal/web/handlers/admin.go @@ -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. @@ -64,17 +69,17 @@ func (h *AdminHandler) Dashboard(c *gin.Context) { currentUser, _ := c.Get("currentUser") c.HTML(200, "admin_dashboard", gin.H{ - "currentUser": currentUser, + "currentUser": currentUser, "domainCount": domainCount, "userCount": userCount, - "totalMails": totalMails, + "totalMails": totalMails, "inboxCount": inboxCount, "sentCount": sentCount, - "draftsCount": draftsCount, - "trashCount": trashCount, + "draftsCount": draftsCount, + "trashCount": trashCount, "totalSize": totalSize, - "inboxSize": inboxSize, - "sentSize": sentSize, + "inboxSize": inboxSize, + "sentSize": sentSize, "todayReceived": todayReceived, "todaySent": todaySent, "weekReceived": weekReceived, @@ -202,11 +207,13 @@ func (h *AdminHandler) EditDomain(c *gin.Context) { currentUser, _ := c.Get("currentUser") c.HTML(200, "admin_domain_form", gin.H{ - "currentUser": currentUser, - "activeFolder": "domains", - "error": "", - "isEdit": true, - "domain": domain, + "currentUser": currentUser, + "activeFolder": "domains", + "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) diff --git a/internal/web/server.go b/internal/web/server.go index e189676..37f98c3 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -6,6 +6,7 @@ import ( "math" "net" "os" + "path/filepath" "strings" "mail_go/config" @@ -35,22 +36,23 @@ 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 - authCfg config.AuthConfig - banCfg config.BanConfig + engine *gin.Engine + stores *store.Stores + storage *storage.AttachmentStorage + cfg config.WebConfig + storageCfg config.StorageConfig + authCfg config.AuthConfig + banCfg config.BanConfig } // templateFuncs returns custom template functions for rendering. func templateFuncs() template.FuncMap { return template.FuncMap{ - "add": 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 }, - "div": func(a, b int) int { return a / b }, - "mod": 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 }, + "mul": 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 }, "ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) }, "seq": func(n int) []int { result := make([]int, n) @@ -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()) @@ -99,12 +101,13 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag engine.SetHTMLTemplate(tmpl) ws := &WebServer{ - engine: engine, - stores: stores, - storage: attStorage, - cfg: cfg, - authCfg: authCfg, - banCfg: banCfg, + engine: engine, + stores: stores, + storage: attStorage, + cfg: cfg, + storageCfg: storageCfg, + authCfg: authCfg, + banCfg: banCfg, } ws.registerRoutes() @@ -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)) diff --git a/internal/web/templates/admin/domain_form.html b/internal/web/templates/admin/domain_form.html index b7c172d..53812d1 100644 --- a/internal/web/templates/admin/domain_form.html +++ b/internal/web/templates/admin/domain_form.html @@ -51,13 +51,36 @@

勾选后端口将自动切换为 SSL/TLS 标准端口

+ {{if .isEdit}} + + {{end}} {{if .isEdit}}
diff --git a/main.go b/main.go index 237669e..2fcdcf6 100644 --- a/main.go +++ b/main.go @@ -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 {