From bbf09fbc8df1b6a455e678481b11dde673374072 Mon Sep 17 00:00:00 2001 From: cnbugs <717192502@qq.com> Date: Thu, 23 Jul 2026 16:31:04 +0800 Subject: [PATCH] feat: add Web UI for notification config (feishu + email) - New config/notify_store.go: persistent notification config (notify.json) - New API: GET/PUT /api/notify/config, POST /api/notify/test-feishu, POST /api/notify/test-email - New Vue page: NotifyConfig.vue with feishu webhook and SMTP settings - Updated cron/init to read from persistent store instead of env vars - Password masked as ******** in API responses, preserved on updates --- backend/config/config.go | 6 + backend/config/notify_store.go | 182 +++++++++++++++++++ backend/dist/index.html | 4 +- backend/main.go | 120 ++++++++++-- backend/notify/notify.go | 52 +++--- frontend/src/App.vue | 4 + frontend/src/api/index.ts | 22 +++ frontend/src/router/index.ts | 7 + frontend/src/views/NotifyConfig.vue | 272 ++++++++++++++++++++++++++++ 9 files changed, 627 insertions(+), 42 deletions(-) create mode 100644 backend/config/notify_store.go create mode 100644 frontend/src/views/NotifyConfig.vue diff --git a/backend/config/config.go b/backend/config/config.go index 92a9666..a3e999e 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -72,6 +72,12 @@ func InitStore(cfg *Config) { log.Println("Cert store initialized successfully") } +// InitAll initializes all stores +func InitAll(cfg *Config) { + InitStore(cfg) + InitNotifyStore(cfg) +} + // Load reads certificates from JSON file into memory func (s *CertStore) Load() error { s.mu.Lock() diff --git a/backend/config/notify_store.go b/backend/config/notify_store.go new file mode 100644 index 0000000..05181bf --- /dev/null +++ b/backend/config/notify_store.go @@ -0,0 +1,182 @@ +package config + +import ( + "encoding/json" + "log" + "os" + "sync" + "time" +) + +// NotifyConfig holds notification configuration that can be set via Web UI +type NotifyConfig struct { + FeishuEnabled bool `json:"feishu_enabled"` + FeishuURL string `json:"feishu_url"` + + SMTPEnabled bool `json:"smtp_enabled"` + SMTPHost string `json:"smtp_host"` + SMTPPort string `json:"smtp_port"` + SMTPUser string `json:"smtp_user"` + SMTPPassword string `json:"smtp_password,omitempty"` + SMTPFrom string `json:"smtp_from"` + NotifyTo string `json:"notify_to"` + + // Notification thresholds + ExpiryDays int `json:"expiry_days"` // Notify when cert expires within this many days + + // Internal + UpdatedAt time.Time `json:"updated_at"` +} + +// DefaultNotifyConfig returns default notification settings +func DefaultNotifyConfig() *NotifyConfig { + return &NotifyConfig{ + FeishuEnabled: false, + SMTPEnabled: false, + ExpiryDays: 30, + } +} + +// NotifyStore manages notification config persistence +type NotifyStore struct { + mu sync.RWMutex + path string + Data *NotifyConfig +} + +var NotifyCfg *NotifyStore + +// InitNotifyStore initializes the notification config store +func InitNotifyStore(cfg *Config) { + NotifyCfg = &NotifyStore{ + path: cfg.DataDir + "/notify.json", + Data: DefaultNotifyConfig(), + } + + if err := NotifyCfg.Load(); err != nil { + log.Printf("No existing notify config: %v, using defaults", err) + } + + // Environment variables override file config (for backward compatibility) + envOverrideNotifyConfig() + + log.Println("Notify config store initialized successfully") +} + +func envOverrideNotifyConfig() { + store := NotifyCfg + if url := os.Getenv("FEISHU_WEBHOOK_URL"); url != "" { + store.Data.FeishuEnabled = true + store.Data.FeishuURL = url + } + if host := os.Getenv("SMTP_HOST"); host != "" { + store.Data.SMTPEnabled = true + store.Data.SMTPHost = host + } + if port := os.Getenv("SMTP_PORT"); port != "" { + store.Data.SMTPPort = port + } + if user := os.Getenv("SMTP_USER"); user != "" { + store.Data.SMTPUser = user + } + if pass := os.Getenv("SMTP_PASSWORD"); pass != "" { + store.Data.SMTPPassword = pass + } + if from := os.Getenv("SMTP_FROM"); from != "" { + store.Data.SMTPFrom = from + } + if to := os.Getenv("NOTIFY_TO"); to != "" { + store.Data.NotifyTo = to + } +} + +// Load reads notification config from JSON file +func (s *NotifyStore) Load() error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.path) + if err != nil { + return err + } + + var nc NotifyConfig + if err := json.Unmarshal(data, &nc); err != nil { + return err + } + + // Keep existing password if not provided in file (security) + if nc.SMTPPassword == "" && s.Data.SMTPPassword != "" { + nc.SMTPPassword = s.Data.SMTPPassword + } + + s.Data = &nc + return nil +} + +// Save persists the notification config to JSON file +func (s *NotifyStore) Save() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.Data.UpdatedAt = time.Now() + data, err := json.MarshalIndent(s.Data, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0600) +} + +// Get returns a copy of the current notify config (without password for API responses) +func (s *NotifyStore) Get() *NotifyConfig { + s.mu.RLock() + defer s.mu.RUnlock() + // Return a copy + nc := *s.Data + // Hide password for API responses + if nc.SMTPPassword != "" { + nc.SMTPPassword = "********" + } + return &nc +} + +// Update saves new notification config, keeping password if masked +func (s *NotifyStore) Update(nc *NotifyConfig) error { + s.mu.Lock() + defer s.mu.Unlock() + + // If password is masked, keep the existing one + if nc.SMTPPassword == "********" { + nc.SMTPPassword = s.Data.SMTPPassword + } + + nc.UpdatedAt = time.Now() + s.Data = nc + data, err := json.MarshalIndent(s.Data, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0600) +} + +// GetConfig returns the raw config (for internal use, with real password) +func (s *NotifyStore) GetConfig() *NotifyConfig { + s.mu.RLock() + defer s.mu.RUnlock() + nc := *s.Data + return &nc +} + +// IsConfigured returns true if at least one notification channel is configured +func (s *NotifyStore) IsConfigured() bool { + s.mu.RLock() + defer s.mu.RUnlock() + if s.Data.FeishuEnabled && s.Data.FeishuURL != "" { + return true + } + if s.Data.SMTPEnabled && s.Data.SMTPHost != "" && s.Data.NotifyTo != "" { + return true + } + // Also check env fallback + return false +} diff --git a/backend/dist/index.html b/backend/dist/index.html index 5880e90..75494d5 100644 --- a/backend/dist/index.html +++ b/backend/dist/index.html @@ -5,8 +5,8 @@ AutoSSL 证书管理 - - + +
diff --git a/backend/main.go b/backend/main.go index 8982d41..e80a880 100644 --- a/backend/main.go +++ b/backend/main.go @@ -21,8 +21,8 @@ import ( func main() { cfg := config.Load() - // Initialize certificate store (JSON file persistence) - config.InitStore(cfg) + // Initialize all stores (certificates + notify config) + config.InitAll(cfg) // Setup Gin gin.SetMode(gin.ReleaseMode) @@ -63,6 +63,12 @@ func main() { api.POST("/certificates/:id/renew", certHandler.RenewCertificate) api.GET("/certificates/:id/files", certHandler.GetCertFiles) + // Notification configuration + api.GET("/notify/config", handleGetNotifyConfig) + api.PUT("/notify/config", handleUpdateNotifyConfig) + api.POST("/notify/test-feishu", handleTestFeishu) + api.POST("/notify/test-email", handleTestEmail) + // Utility api.GET("/renewals/check", certHandler.CheckRenewals) api.GET("/stats", certHandler.Stats) @@ -108,7 +114,8 @@ func main() { // Certificate expiry notification check at 8:00 AM daily c.AddFunc("0 8 * * *", func() { - notifyCfg := notify.LoadNotifyConfig() + nc := config.NotifyCfg.GetConfig() + notifyCfg := notify.ConfigFromStore(nc) if !notifyCfg.IsConfigured() { log.Println("Notification not configured, skipping expiry check notification") return @@ -119,7 +126,6 @@ func main() { c.Start() // Set ACME HTTP-01 challenge port from env (default 8082) - // Nginx on port 80 should proxy .well-known/acme-challenge/ to this port acmePort := os.Getenv("ACME_PORT") if acmePort == "" { acmePort = "8082" @@ -133,7 +139,8 @@ func main() { } } -// handleLogin authenticates user and returns JWT token +// ===================== Login Handler ===================== + func handleLogin(c *gin.Context) { var req auth.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -159,18 +166,17 @@ func handleLogin(c *gin.Context) { }) } -// authMiddleware validates JWT token on protected routes +// ===================== Auth Middleware ===================== + func authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" { - // Also check query param (for websocket / event-stream compatibility) authHeader = c.Query("token") if authHeader == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未授权,请先登录"}) return } - // Validate as-is (query param is the raw token) claims, err := auth.ValidateToken(authHeader) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token已过期或无效"}) @@ -181,7 +187,6 @@ func authMiddleware() gin.HandlerFunc { return } - // Extract Bearer token tokenString := strings.TrimPrefix(authHeader, "Bearer ") if tokenString == authHeader { tokenString = authHeader @@ -198,11 +203,88 @@ func authMiddleware() gin.HandlerFunc { } } -// runExpiryNotification checks all certificates and sends notifications for expiring ones +// ===================== Notification Config Handlers ===================== + +func handleGetNotifyConfig(c *gin.Context) { + nc := config.NotifyCfg.Get() + c.JSON(http.StatusOK, nc) +} + +func handleUpdateNotifyConfig(c *gin.Context) { + var req config.NotifyConfig + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := config.NotifyCfg.Update(&req); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "通知配置已更新"}) +} + +func handleTestFeishu(c *gin.Context) { + nc := config.NotifyCfg.GetConfig() + if !nc.FeishuEnabled || nc.FeishuURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "飞书通知未配置,请先保存配置"}) + return + } + + notifyCfg := notify.ConfigFromStore(nc) + testCerts := []notify.CertInfo{ + { + Domain: "test.example.com", + Status: "active", + DaysLeft: 15, + }, + } + + if err := notifyCfg.NotifyCertExpiring(testCerts); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "飞书测试消息发送失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "飞书测试消息发送成功"}) +} + +func handleTestEmail(c *gin.Context) { + nc := config.NotifyCfg.GetConfig() + if !nc.SMTPEnabled || nc.SMTPHost == "" || nc.NotifyTo == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "邮件通知未配置,请先保存配置"}) + return + } + + notifyCfg := notify.ConfigFromStore(nc) + testCerts := []notify.CertInfo{ + { + Domain: "test.example.com", + Status: "active", + DaysLeft: 15, + }, + } + + if err := notifyCfg.NotifyCertExpiring(testCerts); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "邮件测试消息发送失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "邮件测试消息发送成功"}) +} + +// ===================== Expiry Notification ===================== + func runExpiryNotification(notifyCfg *notify.Config) { certs := config.Store.GetAll() var expiringCerts []notify.CertInfo + nc := config.NotifyCfg.GetConfig() + expiryDays := nc.ExpiryDays + if expiryDays <= 0 { + expiryDays = 30 + } + for _, cert := range certs { if cert.Status != "active" || cert.ExpiresAt == nil { continue @@ -210,8 +292,7 @@ func runExpiryNotification(notifyCfg *notify.Config) { daysLeft := int(time.Until(*cert.ExpiresAt).Hours() / 24) - // Notify if within 30 days of expiry (or already expired) - if daysLeft <= 30 { + if daysLeft <= expiryDays { info := notify.CertInfo{ Domain: cert.Domain, Status: cert.Status, @@ -236,12 +317,15 @@ func runExpiryNotification(notifyCfg *notify.Config) { } func init() { - // Check if notification is configured and run once at startup + // Run startup certificate expiry check after 10 seconds time.AfterFunc(10*time.Second, func() { - notifyCfg := notify.LoadNotifyConfig() - if notifyCfg.IsConfigured() { - log.Println("Running startup certificate expiry check...") - runExpiryNotification(notifyCfg) + if config.NotifyCfg != nil { + nc := config.NotifyCfg.GetConfig() + notifyCfg := notify.ConfigFromStore(nc) + if notifyCfg.IsConfigured() { + log.Println("Running startup certificate expiry check...") + runExpiryNotification(notifyCfg) + } } }) @@ -251,5 +335,5 @@ func init() { if adminUser != "" || adminPass != "" { log.Printf("Admin credentials configured via environment (user: %s)", adminUser) } - _ = fmt.Sprintf // import usage, prevent unused import error + _ = fmt.Sprintf } diff --git a/backend/notify/notify.go b/backend/notify/notify.go index 8362cce..692b7b8 100644 --- a/backend/notify/notify.go +++ b/backend/notify/notify.go @@ -1,6 +1,7 @@ package notify import ( + "auto-ssl/config" "bytes" "encoding/json" "fmt" @@ -16,18 +17,31 @@ import ( // Config holds notification configuration type Config struct { - // Feishu webhook FeishuWebhookURL string - // SMTP - SMTPHost string - SMTPPort string - SMTPUser string - SMTPPassword string - SMTPFrom string - NotifyTo string // comma-separated email recipients + SMTPHost string + SMTPPort string + SMTPUser string + SMTPPassword string + SMTPFrom string + NotifyTo string + ExpiryDays int } -// LoadNotifyConfig reads notification config from environment +// ConfigFromStore converts store config to notify config +func ConfigFromStore(nc *config.NotifyConfig) *Config { + return &Config{ + FeishuWebhookURL: nc.FeishuURL, + SMTPHost: nc.SMTPHost, + SMTPPort: nc.SMTPPort, + SMTPUser: nc.SMTPUser, + SMTPPassword: nc.SMTPPassword, + SMTPFrom: nc.SMTPFrom, + NotifyTo: nc.NotifyTo, + ExpiryDays: nc.ExpiryDays, + } +} + +// LoadNotifyConfig reads notification config from environment (legacy fallback) func LoadNotifyConfig() *Config { return &Config{ FeishuWebhookURL: os.Getenv("FEISHU_WEBHOOK_URL"), @@ -86,18 +100,18 @@ func (c *Config) NotifyCertExpiring(certs []CertInfo) error { // Feishu message format type feishuCard struct { - MsgType string `json:"msg_type"` + MsgType string `json:"msg_type"` Card *feishuCardContent `json:"card"` } type feishuCardContent struct { - Header feishuHeader `json:"header"` - Elems []feishuElement `json:"elements"` + Header feishuHeader `json:"header"` + Elems []feishuElement `json:"elements"` } type feishuHeader struct { - Title feishuText `json:"title"` - Template string `json:"template"` + Title feishuText `json:"title"` + Template string `json:"template"` } type feishuText struct { @@ -106,8 +120,8 @@ type feishuText struct { } type feishuElement struct { - Tag string `json:"tag"` - Text *feishuText `json:"text,omitempty"` + Tag string `json:"tag"` + Text *feishuText `json:"text,omitempty"` Fields *[]feishuField `json:"fields,omitempty"` } @@ -117,7 +131,6 @@ type feishuField struct { } func (c *Config) sendFeishu(certs []CertInfo) error { - // Build card content elements := []feishuElement{} for _, cert := range certs { @@ -150,7 +163,6 @@ func (c *Config) sendFeishu(certs []CertInfo) error { ) } - // Summary expiringCount := 0 for _, cert := range certs { if cert.DaysLeft <= 7 { @@ -158,7 +170,6 @@ func (c *Config) sendFeishu(certs []CertInfo) error { } } - // Determine template color template := "blue" if expiringCount > 0 { template = "red" @@ -202,7 +213,6 @@ func (c *Config) sendFeishu(certs []CertInfo) error { } func (c *Config) sendEmail(certs []CertInfo) error { - // Build HTML email var sb strings.Builder sb.WriteString(` @@ -260,8 +270,6 @@ func (c *Config) sendEmail(certs []CertInfo) error { `) htmlContent := sb.String() - - // Subject subject := fmt.Sprintf("🔐 [AutoSSL] %d 个证书即将到期 - 请及时处理", len(certs)) e := email.NewEmail() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ca2ff99..80ec572 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -49,6 +49,10 @@ 申请证书 + + + 通知配置 + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 0a9ad5a..97b848c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -77,6 +77,20 @@ export interface LoginResponse { expires_at: number } +export interface NotifyConfig { + feishu_enabled: boolean + feishu_url: string + smtp_enabled: boolean + smtp_host: string + smtp_port: string + smtp_user: string + smtp_password: string + smtp_from: string + notify_to: string + expiry_days: number + updated_at: string +} + // Auth API export const authApi = { login: (username: string, password: string) => @@ -114,3 +128,11 @@ export const certApi = { checkRenewals: () => api.get('/renewals/check'), stats: () => api.get('/stats'), } + +// Notification config API +export const notifyApi = { + get: () => api.get('/notify/config'), + update: (data: Partial) => api.put('/notify/config', data), + testFeishu: () => api.post('/notify/test-feishu'), + testEmail: () => api.post('/notify/test-email'), +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6c94952..1e7985b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router' import Dashboard from '../views/Dashboard.vue' import CertList from '../views/CertList.vue' import CertCreate from '../views/CertCreate.vue' +import NotifyConfig from '../views/NotifyConfig.vue' import Login from '../views/Login.vue' import { isLoggedIn } from '../api' @@ -32,6 +33,12 @@ const router = createRouter({ component: CertCreate, meta: { requiresAuth: true }, }, + { + path: '/notify', + name: 'NotifyConfig', + component: NotifyConfig, + meta: { requiresAuth: true }, + }, ], }) diff --git a/frontend/src/views/NotifyConfig.vue b/frontend/src/views/NotifyConfig.vue new file mode 100644 index 0000000..4b8fe7e --- /dev/null +++ b/frontend/src/views/NotifyConfig.vue @@ -0,0 +1,272 @@ + + + + +