From 3d849b4b432e523254ecd8a11da8ff7d4f4f5609 Mon Sep 17 00:00:00 2001 From: Bot Date: Thu, 16 Apr 2026 21:36:49 +0800 Subject: [PATCH] feat: implement VPS/Domain expiry notifications and native SMTP support --- cmd/dashboard/controller/setting.go | 1 + cmd/dashboard/main.go | 9 ++++ model/config.go | 3 +- model/notification.go | 54 ++++++++++++++++++++-- model/server.go | 2 + model/setting_api.go | 1 + service/singleton/domain.go | 69 +++++++++++++++++++++++++++++ 7 files changed, 134 insertions(+), 5 deletions(-) diff --git a/cmd/dashboard/controller/setting.go b/cmd/dashboard/controller/setting.go index f6588f1..a3d1fd6 100644 --- a/cmd/dashboard/controller/setting.go +++ b/cmd/dashboard/controller/setting.go @@ -99,6 +99,7 @@ func updateConfig(c *gin.Context) (any, error) { singleton.Conf.InstallHost = sf.InstallHost singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID + singleton.Conf.ExpiryNotificationGroupID = sf.ExpiryNotificationGroupID singleton.Conf.SiteName = sf.SiteName singleton.Conf.DNSServers = sf.DNSServers singleton.Conf.CustomCode = sf.CustomCode diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 63752a2..12eedc9 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -75,6 +75,15 @@ func initSystem(bus chan<- *model.Service) error { if _, err := singleton.CronShared.AddFunc("0 0 * * * *", func() { singleton.RecordTransferHourlyUsage() }); err != nil { return err } + + // 每天 12:00 检查域名与服务器到期 + if _, err := singleton.CronShared.AddFunc("0 0 12 * * *", func() { + singleton.CronJobForDomainStatus() + singleton.CronJobForServerStatus() + }); err != nil { + return err + } + return nil } diff --git a/model/config.go b/model/config.go index 0f9bc84..37962bc 100644 --- a/model/config.go +++ b/model/config.go @@ -42,7 +42,8 @@ type ConfigDashboard struct { UserTemplate string `koanf:"user_template" json:"user_template,omitempty"` AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"` - EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码 + EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码 + ExpiryNotificationGroupID uint64 `koanf:"expiry_notification_group_id" json:"expiry_notification_group_id"` // IP变更提醒 EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"` diff --git a/model/notification.go b/model/notification.go index db32e6b..47a4ac2 100644 --- a/model/notification.go +++ b/model/notification.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "net/smtp" "net/url" "strings" "time" @@ -31,14 +32,21 @@ type NotificationServerBundle struct { Loc *time.Location } +const ( + _ = iota + NotificationTypeWebhook + NotificationTypeSMTP +) + type Notification struct { Common Name string `json:"name"` - URL string `json:"url"` + Type uint8 `json:"type"` // 0: Webhook, 1: SMTP + URL string `json:"url"` // SMTP: host:port, Webhook: url RequestMethod uint8 `json:"request_method"` RequestType uint8 `json:"request_type"` - RequestHeader string `json:"request_header" gorm:"type:longtext"` - RequestBody string `json:"request_body" gorm:"type:longtext"` + RequestHeader string `json:"request_header" gorm:"type:longtext"` // SMTP: user, Webhook: header + RequestBody string `json:"request_body" gorm:"type:longtext"` // SMTP: pass, Webhook: body VerifyTLS *bool `json:"verify_tls,omitempty"` FormatMetricUnits *bool `json:"format_metric_units,omitempty"` } @@ -111,8 +119,12 @@ func (n *Notification) setRequestHeader(req *http.Request) error { } func (ns *NotificationServerBundle) Send(message string) error { - var client *http.Client n := ns.Notification + if n.Type == NotificationTypeSMTP { + return ns.sendSMTP(message) + } + + var client *http.Client if n.VerifyTLS != nil && *n.VerifyTLS { client = utils.HttpClient } else { @@ -158,6 +170,40 @@ func (ns *NotificationServerBundle) Send(message string) error { return nil } +func (ns *NotificationServerBundle) sendSMTP(message string) error { + n := ns.Notification + // RequestHeader: user:pass + // RequestBody: to_email + // URL: host:port + authInfo := strings.SplitN(n.RequestHeader, ":", 2) + if len(authInfo) < 2 { + return errors.New("SMTP认证信息格式错误 (user:pass)") + } + user := authInfo[0] + pass := authInfo[1] + to := n.RequestBody + + hp := strings.SplitN(n.URL, ":", 2) + if len(hp) < 2 { + return errors.New("SMTP服务器地址格式错误 (host:port)") + } + + auth := smtp.PlainAuth("", user, pass, hp[0]) + + subject := "Nezha Monitoring Alert" + if ns.Server != nil { + subject = fmt.Sprintf("Nezha Alert: %s", ns.Server.Name) + } + + body := fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s", to, subject, message) + + err := smtp.SendMail(n.URL, auth, user, []string{to}, []byte(body)) + if err != nil { + return err + } + return nil +} + // replaceParamInString 替换字符串中的占位符 func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string { if mod == nil { diff --git a/model/server.go b/model/server.go index 165d7ac..91eb7f4 100644 --- a/model/server.go +++ b/model/server.go @@ -6,6 +6,7 @@ import ( "time" "github.com/goccy/go-json" + "gorm.io/datatypes" "gorm.io/gorm" pb "github.com/nezhahq/nezha/proto" @@ -21,6 +22,7 @@ type Server struct { DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前 HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏 EnableDDNS bool `json:"enable_ddns,omitempty"` // 启用DDNS + BillingData datatypes.JSON `gorm:"type:json" json:"billing_data,omitempty"` DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"` OverrideDDNSDomainsRaw string `gorm:"default:'{}';column:override_ddns_domains_raw" json:"-"` diff --git a/model/setting_api.go b/model/setting_api.go index f3b849f..d62c54a 100644 --- a/model/setting_api.go +++ b/model/setting_api.go @@ -22,6 +22,7 @@ type SettingForm struct { AgentTLS bool `json:"tls,omitempty" validate:"optional"` EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"` EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"` + ExpiryNotificationGroupID uint64 `json:"expiry_notification_group_id,omitempty" validate:"optional"` } type Setting struct { diff --git a/service/singleton/domain.go b/service/singleton/domain.go index 82501c0..1ddb441 100644 --- a/service/singleton/domain.go +++ b/service/singleton/domain.go @@ -158,6 +158,24 @@ func CronJobForDomainStatus() { continue } + daysLeft := int(endDate.Sub(now).Hours() / 24) + + // 只有在到期前一定天数通知,且避开重复通知 (简单逻辑:每天通知一次) + if Conf.ExpiryNotificationGroupID != 0 { + msg := "" + switch daysLeft { + case 7, 3, 1: + msg = fmt.Sprintf("域名 [%s] 即将到期,剩余 %d 天。到期时间: %s", d.Domain, daysLeft, endDate.Format("2006-01-02")) + case 0: + if now.After(endDate) { + msg = fmt.Sprintf("域名 [%s] 已到期!到期时间: %s", d.Domain, endDate.Format("2006-01-02")) + } + } + if msg != "" { + NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-domain-%d-%d", d.ID, daysLeft)) + } + } + if now.After(endDate) { if billing.AutoRenewal == "1" { var newEndDate time.Time @@ -192,3 +210,54 @@ func CronJobForDomainStatus() { } log.Println("NEZHA>> Cron::域名状态检查任务执行完毕") } + +// CronJobForServerStatus 检查服务器/VPS 到期通知 +func CronJobForServerStatus() { + log.Println("NEZHA>> Cron::开始执行服务器到期检查任务") + var servers []model.Server + if err := DB.Find(&servers).Error; err != nil { + log.Printf("NEZHA>> Cron::Error fetching servers: %v", err) + return + } + + now := time.Now() + + for i := range servers { + s := servers[i] + if s.BillingData == nil { + continue + } + + var billing model.BillingDataMod + if err := json.Unmarshal(s.BillingData, &billing); err != nil { + continue + } + + if billing.EndDate == "" { + continue + } + + endDate, err := time.Parse(time.RFC3339, billing.EndDate) + if err != nil { + continue + } + + daysLeft := int(endDate.Sub(now).Hours() / 24) + + if Conf.ExpiryNotificationGroupID != 0 { + msg := "" + switch daysLeft { + case 15, 7, 3, 1: + msg = fmt.Sprintf("VPS [%s] 即将到期,剩余 %d 天。到期时间: %s", s.Name, daysLeft, endDate.Format("2006-01-02")) + case 0: + if now.After(endDate) { + msg = fmt.Sprintf("VPS [%s] 已到期!到期时间: %s", s.Name, endDate.Format("2006-01-02")) + } + } + if msg != "" { + NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-server-%d-%d", s.ID, daysLeft)) + } + } + } + log.Println("NEZHA>> Cron::服务器到期检查任务执行完毕") +}