feat: add login authentication and certificate expiry notifications
- 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
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user