feat: implement VPS/Domain expiry notifications and native SMTP support

This commit is contained in:
Bot
2026-04-16 21:36:49 +08:00
parent bc32f8fce4
commit 3d849b4b43
7 changed files with 134 additions and 5 deletions
+1
View File
@@ -99,6 +99,7 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.Conf.InstallHost = sf.InstallHost singleton.Conf.InstallHost = sf.InstallHost
singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification
singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID
singleton.Conf.ExpiryNotificationGroupID = sf.ExpiryNotificationGroupID
singleton.Conf.SiteName = sf.SiteName singleton.Conf.SiteName = sf.SiteName
singleton.Conf.DNSServers = sf.DNSServers singleton.Conf.DNSServers = sf.DNSServers
singleton.Conf.CustomCode = sf.CustomCode singleton.Conf.CustomCode = sf.CustomCode
+9
View File
@@ -75,6 +75,15 @@ func initSystem(bus chan<- *model.Service) error {
if _, err := singleton.CronShared.AddFunc("0 0 * * * *", func() { singleton.RecordTransferHourlyUsage() }); err != nil { if _, err := singleton.CronShared.AddFunc("0 0 * * * *", func() { singleton.RecordTransferHourlyUsage() }); err != nil {
return err return err
} }
// 每天 12:00 检查域名与服务器到期
if _, err := singleton.CronShared.AddFunc("0 0 12 * * *", func() {
singleton.CronJobForDomainStatus()
singleton.CronJobForServerStatus()
}); err != nil {
return err
}
return nil return nil
} }
+2 -1
View File
@@ -42,7 +42,8 @@ type ConfigDashboard struct {
UserTemplate string `koanf:"user_template" json:"user_template,omitempty"` UserTemplate string `koanf:"user_template" json:"user_template,omitempty"`
AdminTemplate string `koanf:"admin_template" json:"admin_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变更提醒 // IP变更提醒
EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"` EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"`
+50 -4
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/smtp"
"net/url" "net/url"
"strings" "strings"
"time" "time"
@@ -31,14 +32,21 @@ type NotificationServerBundle struct {
Loc *time.Location Loc *time.Location
} }
const (
_ = iota
NotificationTypeWebhook
NotificationTypeSMTP
)
type Notification struct { type Notification struct {
Common Common
Name string `json:"name"` 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"` RequestMethod uint8 `json:"request_method"`
RequestType uint8 `json:"request_type"` RequestType uint8 `json:"request_type"`
RequestHeader string `json:"request_header" gorm:"type:longtext"` RequestHeader string `json:"request_header" gorm:"type:longtext"` // SMTP: user, Webhook: header
RequestBody string `json:"request_body" gorm:"type:longtext"` RequestBody string `json:"request_body" gorm:"type:longtext"` // SMTP: pass, Webhook: body
VerifyTLS *bool `json:"verify_tls,omitempty"` VerifyTLS *bool `json:"verify_tls,omitempty"`
FormatMetricUnits *bool `json:"format_metric_units,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 { func (ns *NotificationServerBundle) Send(message string) error {
var client *http.Client
n := ns.Notification n := ns.Notification
if n.Type == NotificationTypeSMTP {
return ns.sendSMTP(message)
}
var client *http.Client
if n.VerifyTLS != nil && *n.VerifyTLS { if n.VerifyTLS != nil && *n.VerifyTLS {
client = utils.HttpClient client = utils.HttpClient
} else { } else {
@@ -158,6 +170,40 @@ func (ns *NotificationServerBundle) Send(message string) error {
return nil 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 替换字符串中的占位符 // replaceParamInString 替换字符串中的占位符
func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string { func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string {
if mod == nil { if mod == nil {
+2
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/goccy/go-json" "github.com/goccy/go-json"
"gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
pb "github.com/nezhahq/nezha/proto" pb "github.com/nezhahq/nezha/proto"
@@ -21,6 +22,7 @@ type Server struct {
DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前 DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前
HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏 HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏
EnableDDNS bool `json:"enable_ddns,omitempty"` // 启用DDNS 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:"-"` DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"`
OverrideDDNSDomainsRaw string `gorm:"default:'{}';column:override_ddns_domains_raw" json:"-"` OverrideDDNSDomainsRaw string `gorm:"default:'{}';column:override_ddns_domains_raw" json:"-"`
+1
View File
@@ -22,6 +22,7 @@ type SettingForm struct {
AgentTLS bool `json:"tls,omitempty" validate:"optional"` AgentTLS bool `json:"tls,omitempty" validate:"optional"`
EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"` EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"`
EnablePlainIPInNotification bool `json:"enable_plain_ip_in_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 { type Setting struct {
+69
View File
@@ -158,6 +158,24 @@ func CronJobForDomainStatus() {
continue 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 now.After(endDate) {
if billing.AutoRenewal == "1" { if billing.AutoRenewal == "1" {
var newEndDate time.Time var newEndDate time.Time
@@ -192,3 +210,54 @@ func CronJobForDomainStatus() {
} }
log.Println("NEZHA>> Cron::域名状态检查任务执行完毕") 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::服务器到期检查任务执行完毕")
}