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 @@
+
+
+
通知配置
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 发送测试
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 多个邮箱用逗号分隔
+
+
+
+ 发送测试
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 保存配置
+
+
+
+
+
+
+ 通知说明
+
+
+
1. 配置保存后自动持久化到 data/notify.json 文件
+
2. 系统每天上午 8:00 自动检查证书到期情况并发送通知
+
3. 启动后 10 秒 也会执行一次初始检查
+
4. 环境变量 FEISHU_WEBHOOK_URL / SMTP_* 会覆盖文件配置(向后兼容)
+
5. 点击"发送测试"可验证通知通道是否配置正确
+
+
+
+
+
+
+
+