2d70a15307
- JWT-based login with ADMIN_USER/ADMIN_PASSWORD env config - Login page with auth guard for Vue frontend - Feishu bot webhook notification for expiring certificates - SMTP email notification for expiring certificates - Daily 8:00 AM cron for expiry checks - Startup health check notification
306 lines
7.7 KiB
Go
306 lines
7.7 KiB
Go
package notify
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/smtp"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jordan-wright/email"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// LoadNotifyConfig reads notification config from environment
|
|
func LoadNotifyConfig() *Config {
|
|
return &Config{
|
|
FeishuWebhookURL: os.Getenv("FEISHU_WEBHOOK_URL"),
|
|
SMTPHost: os.Getenv("SMTP_HOST"),
|
|
SMTPPort: os.Getenv("SMTP_PORT"),
|
|
SMTPUser: os.Getenv("SMTP_USER"),
|
|
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
|
SMTPFrom: os.Getenv("SMTP_FROM"),
|
|
NotifyTo: os.Getenv("NOTIFY_TO"),
|
|
}
|
|
}
|
|
|
|
// IsConfigured returns true if at least one notification channel is configured
|
|
func (c *Config) IsConfigured() bool {
|
|
return c.FeishuWebhookURL != "" || (c.SMTPHost != "" && c.NotifyTo != "")
|
|
}
|
|
|
|
// CertInfo holds certificate information for notifications
|
|
type CertInfo struct {
|
|
Domain string `json:"domain"`
|
|
Status string `json:"status"`
|
|
ExpiresAt *time.Time `json:"expires_at"`
|
|
DaysLeft int `json:"days_left"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// NotifyCertExpiring sends notifications for expiring certificates
|
|
func (c *Config) NotifyCertExpiring(certs []CertInfo) error {
|
|
if len(certs) == 0 {
|
|
return nil
|
|
}
|
|
if !c.IsConfigured() {
|
|
log.Println("Notification not configured, skipping")
|
|
return nil
|
|
}
|
|
|
|
var errs []string
|
|
|
|
if c.FeishuWebhookURL != "" {
|
|
if err := c.sendFeishu(certs); err != nil {
|
|
errs = append(errs, fmt.Sprintf("feishu: %v", err))
|
|
}
|
|
}
|
|
|
|
if c.SMTPHost != "" && c.NotifyTo != "" {
|
|
if err := c.sendEmail(certs); err != nil {
|
|
errs = append(errs, fmt.Sprintf("email: %v", err))
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("notification errors: %s", strings.Join(errs, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Feishu message format
|
|
type feishuCard struct {
|
|
MsgType string `json:"msg_type"`
|
|
Card *feishuCardContent `json:"card"`
|
|
}
|
|
|
|
type feishuCardContent struct {
|
|
Header feishuHeader `json:"header"`
|
|
Elems []feishuElement `json:"elements"`
|
|
}
|
|
|
|
type feishuHeader struct {
|
|
Title feishuText `json:"title"`
|
|
Template string `json:"template"`
|
|
}
|
|
|
|
type feishuText struct {
|
|
Tag string `json:"tag"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type feishuElement struct {
|
|
Tag string `json:"tag"`
|
|
Text *feishuText `json:"text,omitempty"`
|
|
Fields *[]feishuField `json:"fields,omitempty"`
|
|
}
|
|
|
|
type feishuField struct {
|
|
IsShort bool `json:"is_short"`
|
|
Text feishuText `json:"text"`
|
|
}
|
|
|
|
func (c *Config) sendFeishu(certs []CertInfo) error {
|
|
// Build card content
|
|
elements := []feishuElement{}
|
|
|
|
for _, cert := range certs {
|
|
domainText := fmt.Sprintf("**域名:** %s", cert.Domain)
|
|
statusText := fmt.Sprintf("**状态:** %s", certStatusLabel(cert.Status))
|
|
daysText := fmt.Sprintf("**剩余天数:** %d", cert.DaysLeft)
|
|
expiresText := "**过期时间:** "
|
|
if cert.ExpiresAt != nil {
|
|
expiresText += cert.ExpiresAt.Format("2006-01-02 15:04")
|
|
} else {
|
|
expiresText += "未知"
|
|
}
|
|
|
|
elements = append(elements,
|
|
feishuElement{
|
|
Tag: "div",
|
|
Fields: &[]feishuField{
|
|
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: domainText}},
|
|
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: statusText}},
|
|
},
|
|
},
|
|
feishuElement{
|
|
Tag: "div",
|
|
Fields: &[]feishuField{
|
|
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: daysText}},
|
|
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: expiresText}},
|
|
},
|
|
},
|
|
feishuElement{Tag: "hr"},
|
|
)
|
|
}
|
|
|
|
// Summary
|
|
expiringCount := 0
|
|
for _, cert := range certs {
|
|
if cert.DaysLeft <= 7 {
|
|
expiringCount++
|
|
}
|
|
}
|
|
|
|
// Determine template color
|
|
template := "blue"
|
|
if expiringCount > 0 {
|
|
template = "red"
|
|
}
|
|
|
|
card := &feishuCardContent{
|
|
Header: feishuHeader{
|
|
Title: feishuText{
|
|
Tag: "lark_md",
|
|
Content: fmt.Sprintf("🔐 证书到期提醒 (%d 个证书即将到期)", len(certs)),
|
|
},
|
|
Template: template,
|
|
},
|
|
Elems: elements,
|
|
}
|
|
|
|
payload := feishuCard{
|
|
MsgType: "interactive",
|
|
Card: card,
|
|
}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal feishu message failed: %v", err)
|
|
}
|
|
|
|
resp, err := http.Post(c.FeishuWebhookURL, "application/json", bytes.NewReader(data))
|
|
if err != nil {
|
|
return fmt.Errorf("send feishu message failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
var result map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
return fmt.Errorf("feishu webhook returned %d: %v", resp.StatusCode, result)
|
|
}
|
|
|
|
log.Printf("Feishu notification sent for %d certificates", len(certs))
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) sendEmail(certs []CertInfo) error {
|
|
// Build HTML email
|
|
var sb strings.Builder
|
|
sb.WriteString(`
|
|
<html>
|
|
<head><meta charset="utf-8"></head>
|
|
<body style="font-family: Arial, sans-serif; padding: 20px; background: #f5f5f5;">
|
|
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
|
<h2 style="color: #1a1a2e;">🔐 证书到期提醒</h2>
|
|
<p style="color: #666;">以下证书即将到期或已过期,请及时处理:</p>
|
|
<table style="width: 100%; border-collapse: collapse; margin-top: 16px;">
|
|
<tr style="background: #1890ff; color: white;">
|
|
<th style="padding: 10px; text-align: left;">域名</th>
|
|
<th style="padding: 10px; text-align: left;">状态</th>
|
|
<th style="padding: 10px; text-align: left;">剩余天数</th>
|
|
<th style="padding: 10px; text-align: left;">过期时间</th>
|
|
</tr>
|
|
`)
|
|
|
|
for i, cert := range certs {
|
|
bgColor := "#fff"
|
|
if i%2 == 0 {
|
|
bgColor = "#f8f9fa"
|
|
}
|
|
color := "#333"
|
|
if cert.DaysLeft <= 7 {
|
|
color = "#f5222d"
|
|
} else if cert.DaysLeft <= 30 {
|
|
color = "#fa8c16"
|
|
}
|
|
|
|
expiresStr := "未知"
|
|
if cert.ExpiresAt != nil {
|
|
expiresStr = cert.ExpiresAt.Format("2006-01-02 15:04")
|
|
}
|
|
|
|
statusStr := certStatusLabel(cert.Status)
|
|
|
|
sb.WriteString(fmt.Sprintf(`
|
|
<tr style="background: %s;">
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee; font-weight: 600; color: %s;">%s</td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">%s</td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee; color: %s; font-weight: 700;">%d 天</td>
|
|
<td style="padding: 10px; border-bottom: 1px solid #eee;">%s</td>
|
|
</tr>
|
|
`, bgColor, color, cert.Domain, statusStr, color, cert.DaysLeft, expiresStr))
|
|
}
|
|
|
|
sb.WriteString(`
|
|
</table>
|
|
<p style="color: #999; font-size: 12px; margin-top: 24px;">
|
|
此邮件由 AutoSSL 证书管理系统自动发送,请勿回复。
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`)
|
|
|
|
htmlContent := sb.String()
|
|
|
|
// Subject
|
|
subject := fmt.Sprintf("🔐 [AutoSSL] %d 个证书即将到期 - 请及时处理", len(certs))
|
|
|
|
e := email.NewEmail()
|
|
e.From = c.SMTPFrom
|
|
if e.From == "" {
|
|
e.From = c.SMTPUser
|
|
}
|
|
e.To = strings.Split(c.NotifyTo, ",")
|
|
e.Subject = subject
|
|
e.HTML = []byte(htmlContent)
|
|
|
|
addr := fmt.Sprintf("%s:%s", c.SMTPHost, c.SMTPPort)
|
|
var auth smtp.Auth
|
|
if c.SMTPUser != "" && c.SMTPPassword != "" {
|
|
auth = smtp.PlainAuth("", c.SMTPUser, c.SMTPPassword, c.SMTPHost)
|
|
}
|
|
|
|
if err := e.Send(addr, auth); err != nil {
|
|
return fmt.Errorf("send email failed: %v", err)
|
|
}
|
|
|
|
log.Printf("Email notification sent to %s for %d certificates", c.NotifyTo, len(certs))
|
|
return nil
|
|
}
|
|
|
|
func certStatusLabel(status string) string {
|
|
switch status {
|
|
case "active":
|
|
return "有效"
|
|
case "expired":
|
|
return "已过期"
|
|
case "error":
|
|
return "错误"
|
|
case "renewing":
|
|
return "续期中"
|
|
case "pending":
|
|
return "申请中"
|
|
default:
|
|
return status
|
|
}
|
|
}
|