diff --git a/cmd/dashboard/controller/controller.go b/cmd/dashboard/controller/controller.go
index 0cb1aae..0229b07 100644
--- a/cmd/dashboard/controller/controller.go
+++ b/cmd/dashboard/controller/controller.go
@@ -156,6 +156,7 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
auth.POST("/domains", commonHandler(AddDomain))
auth.POST("/domains/:id/verify", commonHandler(VerifyDomain))
auth.POST("/domains/:id/sync", commonHandler(SyncDomainWHOIS))
+ auth.POST("/domains/sync-all", commonHandler(SyncAllDomains))
auth.PUT("/domains/:id", commonHandler(UpdateDomain))
auth.DELETE("/domains/:id", commonHandler(DeleteDomain))
diff --git a/cmd/dashboard/controller/domain.go b/cmd/dashboard/controller/domain.go
index b15b8be..3216a28 100644
--- a/cmd/dashboard/controller/domain.go
+++ b/cmd/dashboard/controller/domain.go
@@ -137,3 +137,8 @@ func SyncDomainWHOIS(c *gin.Context) (any, error) {
return domain, nil
}
+
+func SyncAllDomains(c *gin.Context) (any, error) {
+ singleton.SyncAllDomains()
+ return nil, nil
+}
diff --git a/cmd/dashboard/controller/notification.go b/cmd/dashboard/controller/notification.go
index 774ecf2..a40a706 100644
--- a/cmd/dashboard/controller/notification.go
+++ b/cmd/dashboard/controller/notification.go
@@ -57,6 +57,7 @@ func createNotification(c *gin.Context) (uint64, error) {
n.RequestHeader = nf.RequestHeader
n.RequestBody = nf.RequestBody
n.URL = nf.URL
+ n.Type = nf.Type
verifyTLS := nf.VerifyTLS
n.VerifyTLS = &verifyTLS
formatMetricUnits := nf.FormatMetricUnits
@@ -120,6 +121,7 @@ func updateNotification(c *gin.Context) (any, error) {
n.RequestHeader = nf.RequestHeader
n.RequestBody = nf.RequestBody
n.URL = nf.URL
+ n.Type = nf.Type
verifyTLS := nf.VerifyTLS
n.VerifyTLS = &verifyTLS
formatMetricUnits := nf.FormatMetricUnits
diff --git a/cmd/dashboard/controller/setting.go b/cmd/dashboard/controller/setting.go
index a3d1fd6..8d3b1cb 100644
--- a/cmd/dashboard/controller/setting.go
+++ b/cmd/dashboard/controller/setting.go
@@ -111,8 +111,8 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.Conf.CustomLogo = sf.CustomLogo
singleton.Conf.CustomDescription = sf.CustomDescription
singleton.Conf.CustomLinks = sf.CustomLinks
- singleton.Conf.BackgroundImageDay = sf.BackgroundImageDay
- singleton.Conf.BackgroundImageNight = sf.BackgroundImageNight
+ singleton.Conf.TelegramBotToken = sf.TelegramBotToken
+ singleton.Conf.TelegramAdminChatID = sf.TelegramAdminChatID
if err := singleton.Conf.Save(); err != nil {
return nil, newGormError("%v", err)
diff --git a/model/config.go b/model/config.go
index 37962bc..65d8b7c 100644
--- a/model/config.go
+++ b/model/config.go
@@ -43,7 +43,6 @@ type ConfigDashboard struct {
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不打码
- 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"`
@@ -51,7 +50,10 @@ type ConfigDashboard struct {
Cover uint8 `koanf:"cover" json:"cover"` // 覆盖范围(0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器;)
IgnoredIPNotification string `koanf:"ignored_ip_notification" json:"ignored_ip_notification,omitempty"` // 特定服务器IP(多个服务器用逗号分隔)
- DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"`
+ DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"`
+ ExpiryNotificationGroupID uint64 `koanf:"expiry_notification_group_id" json:"expiry_notification_group_id,omitempty"`
+ TelegramBotToken string `koanf:"telegram_bot_token" json:"telegram_bot_token,omitempty"`
+ TelegramAdminChatID string `koanf:"telegram_admin_chat_id" json:"telegram_admin_chat_id,omitempty"`
}
type Config struct {
diff --git a/model/notification.go b/model/notification.go
index 983251d..d8e156d 100644
--- a/model/notification.go
+++ b/model/notification.go
@@ -1,9 +1,12 @@
package model
import (
+ "crypto/tls"
"errors"
"fmt"
+ "html"
"io"
+ "net"
"net/http"
"net/smtp"
"net/url"
@@ -176,9 +179,6 @@ func (ns *NotificationServerBundle) Send(message string) error {
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)")
@@ -187,31 +187,129 @@ func (ns *NotificationServerBundle) sendSMTP(message string) error {
pass := authInfo[1]
to := n.RequestBody
- hp := strings.SplitN(n.URL, ":", 2)
- if len(hp) < 2 {
+ host, port, err := net.SplitHostPort(n.URL)
+ if err != nil {
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
+ // 提取真实的发件人邮箱 (处理 username != email 的情况)
+ fromEmail := user
+ if !strings.Contains(user, "@") {
+ // 如果用户名不是邮箱,为了防止被拦截,构造一个合法的From
+ fromEmail = fmt.Sprintf("nezha@%s", host)
}
+
+ // 遵循 RFC 2822
+ header := make(map[string]string)
+ header["From"] = fmt.Sprintf("Nezha Monitoring <%s>", fromEmail)
+ header["To"] = to
+ header["Subject"] = subject
+ header["Date"] = time.Now().Format(time.RFC1123Z)
+ header["Content-Type"] = "text/plain; charset=UTF-8"
+
+ var msg strings.Builder
+ for k, v := range header {
+ msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
+ }
+ msg.WriteString("\r\n")
+ msg.WriteString(message)
+
+ tlsConfig := &tls.Config{
+ InsecureSkipVerify: n.VerifyTLS == nil || !*n.VerifyTLS,
+ ServerName: host,
+ }
+
+ auth := smtp.PlainAuth("", user, pass, host)
+
+ if port == "465" {
+ // SMTPS (Implicit SSL)
+ conn, err := tls.Dial("tcp", n.URL, tlsConfig)
+ if err != nil {
+ return fmt.Errorf("SMTP SSL Dial error: %w", err)
+ }
+ defer conn.Close()
+
+ client, err := smtp.NewClient(conn, host)
+ if err != nil {
+ return fmt.Errorf("SMTP NewClient error: %w", err)
+ }
+ defer client.Quit()
+
+ if err = client.Auth(auth); err != nil {
+ return fmt.Errorf("SMTP Auth error: %w", err)
+ }
+ if err = client.Mail(fromEmail); err != nil {
+ return fmt.Errorf("SMTP Mail error: %w", err)
+ }
+ if err = client.Rcpt(to); err != nil {
+ return fmt.Errorf("SMTP Rcpt error: %w", err)
+ }
+ w, err := client.Data()
+ if err != nil {
+ return fmt.Errorf("SMTP Data error: %w", err)
+ }
+ _, err = w.Write([]byte(msg.String()))
+ if err != nil {
+ return fmt.Errorf("SMTP Write error: %w", err)
+ }
+ err = w.Close()
+ if err != nil {
+ return fmt.Errorf("SMTP Close error: %w", err)
+ }
+ return nil
+ }
+
+ // STARTTLS (Port 25, 587, etc.)
+ conn, err := net.Dial("tcp", n.URL)
+ if err != nil {
+ return fmt.Errorf("SMTP Dial error: %w", err)
+ }
+ defer conn.Close()
+
+ client, err := smtp.NewClient(conn, host)
+ if err != nil {
+ return fmt.Errorf("SMTP NewClient error: %w", err)
+ }
+ defer client.Quit()
+
+ if ok, _ := client.Extension("STARTTLS"); ok {
+ if err = client.StartTLS(tlsConfig); err != nil {
+ return fmt.Errorf("SMTP StartTLS error: %w", err)
+ }
+ }
+
+ if err = client.Auth(auth); err != nil {
+ return fmt.Errorf("SMTP Auth error: %w", err)
+ }
+ if err = client.Mail(fromEmail); err != nil {
+ return fmt.Errorf("SMTP Mail error: %w", err)
+ }
+ if err = client.Rcpt(to); err != nil {
+ return fmt.Errorf("SMTP Rcpt error: %w", err)
+ }
+ w, err := client.Data()
+ if err != nil {
+ return fmt.Errorf("SMTP Data error: %w", err)
+ }
+ _, err = w.Write([]byte(msg.String()))
+ if err != nil {
+ return fmt.Errorf("SMTP Write error: %w", err)
+ }
+ err = w.Close()
+ if err != nil {
+ return fmt.Errorf("SMTP Close error: %w", err)
+ }
+
return nil
}
func (ns *NotificationServerBundle) sendTelegram(message string) error {
n := ns.Notification
- // URL: bot_token
- // RequestHeader: chat_id
token := n.URL
chatID := n.RequestHeader
@@ -219,10 +317,23 @@ func (ns *NotificationServerBundle) sendTelegram(message string) error {
params := url.Values{}
params.Add("chat_id", chatID)
- params.Add("text", message)
+ params.Add("text", html.EscapeString(message))
params.Add("parse_mode", "HTML")
- resp, err := http.PostForm(apiURL, params)
+ var client *http.Client
+ if n.VerifyTLS != nil && *n.VerifyTLS {
+ client = utils.HttpClient
+ } else {
+ client = utils.HttpClientSkipTlsVerify
+ }
+
+ req, err := http.NewRequest(http.MethodPost, apiURL, strings.NewReader(params.Encode()))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := client.Do(req)
if err != nil {
return err
}
diff --git a/model/notification_api.go b/model/notification_api.go
index a30971e..e9e6d5a 100644
--- a/model/notification_api.go
+++ b/model/notification_api.go
@@ -10,4 +10,5 @@ type NotificationForm struct {
VerifyTLS bool `json:"verify_tls,omitempty" validate:"optional"`
SkipCheck bool `json:"skip_check,omitempty" validate:"optional"`
FormatMetricUnits bool `json:"format_metric_units,omitempty" validate:"optional"`
+ Type uint8 `json:"type,omitempty" validate:"optional"`
}
diff --git a/model/setting_api.go b/model/setting_api.go
index d62c54a..b1e664c 100644
--- a/model/setting_api.go
+++ b/model/setting_api.go
@@ -17,12 +17,14 @@ type SettingForm struct {
CustomDescription string `json:"custom_description,omitempty" validate:"optional"`
CustomLinks string `json:"custom_links,omitempty" validate:"optional"`
BackgroundImageDay string `json:"background_image_day,omitempty" validate:"optional"`
- BackgroundImageNight string `json:"background_image_night,omitempty" validate:"optional"`
+ BackgroundImageNight string `json:"background_image_night,omitempty" validate:"optional"`
- 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"`
+ 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"`
+ TelegramBotToken string `json:"telegram_bot_token,omitempty" validate:"optional"`
+ TelegramAdminChatID string `json:"telegram_admin_chat_id,omitempty" validate:"optional"`
}
type Setting struct {
diff --git a/service/singleton/domain.go b/service/singleton/domain.go
index 804ddab..5c7109b 100644
--- a/service/singleton/domain.go
+++ b/service/singleton/domain.go
@@ -195,6 +195,32 @@ func SyncDomainWHOIS(d *model.Domain) error {
return nil
}
+// SyncAllDomains 异步批量同步所有已验证域名的 Whois 和价格信息
+func SyncAllDomains() {
+ go func() {
+ log.Println("NEZHA>> 开始批量同步所有域名的 Whois 和价格信息...")
+ domains, err := GetDomains("admin")
+ if err != nil {
+ log.Printf("NEZHA>> 批量同步域名失败: %v", err)
+ return
+ }
+
+ successCount := 0
+ for _, d := range domains {
+ if d.Status == "verified" {
+ if err := SyncDomainWHOIS(&d); err != nil {
+ log.Printf("NEZHA>> 域名 %s 同步失败: %v", d.Domain, err)
+ } else {
+ successCount++
+ }
+ // 避免并发过高被 API 限制
+ time.Sleep(2 * time.Second)
+ }
+ }
+ log.Printf("NEZHA>> 批量同步域名结束,成功 %d/%d", successCount, len(domains))
+ }()
+}
+
// GetDomains 获取所有域名记录
func GetDomains(scope string) ([]model.Domain, error) {
var domains []model.Domain
@@ -354,7 +380,7 @@ func CronJobForDomainStatus() {
msg := ""
switch daysLeft + 1 {
case 60, 30, 15, 7, 3, 1:
- msg = fmt.Sprintf("域名 [%s] 即将到期,剩余 %d 天。到期时间: %s", d.Domain, daysLeft+1, endDate.Format("2006-01-02"))
+ msg = fmt.Sprintf("域名 [%s] 即通知期,剩余 %d 天。到期时间: %s", d.Domain, daysLeft+1, endDate.Format("2006-01-02"))
case 0:
msg = fmt.Sprintf("域名 [%s] 已到期!到期时间: %s", d.Domain, endDate.Format("2006-01-02"))
}
diff --git a/service/singleton/singleton.go b/service/singleton/singleton.go
index 694f336..66fa982 100644
--- a/service/singleton/singleton.go
+++ b/service/singleton/singleton.go
@@ -61,6 +61,9 @@ func LoadSingleton(bus chan<- *model.Service) (err error) {
CronShared = NewCronClass()
// 最后初始化 ServiceSentinel
ServiceSentinelShared, err = NewServiceSentinel(bus)
+ if err == nil {
+ InitTelegramBot()
+ }
return
}
diff --git a/service/singleton/telegram_bot.go b/service/singleton/telegram_bot.go
new file mode 100644
index 0000000..adb950d
--- /dev/null
+++ b/service/singleton/telegram_bot.go
@@ -0,0 +1,208 @@
+package singleton
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/nezhahq/nezha/model"
+ "github.com/nezhahq/nezha/pkg/utils"
+)
+
+type tgUpdate struct {
+ UpdateID int `json:"update_id"`
+ Message *struct {
+ MessageID int `json:"message_id"`
+ From *struct {
+ ID int64 `json:"id"`
+ } `json:"from"`
+ Chat *struct {
+ ID int64 `json:"id"`
+ } `json:"chat"`
+ Text string `json:"text"`
+ } `json:"message"`
+}
+
+func InitTelegramBot() {
+ if Conf.TelegramBotToken == "" {
+ log.Println("NEZHA>> TG Bot Token 未配置,跳过启动互动机器人")
+ return
+ }
+
+ log.Println("NEZHA>> 正在启动 Telegram 互动机器人...")
+ go func() {
+ offset := 0
+ for {
+ updates, err := getTGUpdates(Conf.TelegramBotToken, offset)
+ if err != nil {
+ // 避免过于频繁报错
+ time.Sleep(30 * time.Second)
+ continue
+ }
+
+ for _, update := range updates {
+ offset = update.UpdateID + 1
+ if update.Message != nil {
+ handleTGUpdate(update)
+ }
+ }
+ time.Sleep(3 * time.Second)
+ }
+ }()
+}
+
+func getTGUpdates(token string, offset int) ([]tgUpdate, error) {
+ apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?offset=%d&timeout=20", token, offset)
+ req, err := http.NewRequest(http.MethodGet, apiURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := utils.HttpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var result struct {
+ OK bool `json:"ok"`
+ Result []tgUpdate `json:"result"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, err
+ }
+ return result.Result, nil
+}
+
+func handleTGUpdate(update tgUpdate) {
+ if update.Message == nil || update.Message.Chat == nil {
+ return
+ }
+
+ chatID := update.Message.Chat.ID
+ adminChatID, _ := strconv.ParseInt(Conf.TelegramAdminChatID, 10, 64)
+
+ // 权限检查
+ if adminChatID != 0 && chatID != adminChatID {
+ sendTGMessage(chatID, "🚫 您没有权限操作此机器人。")
+ return
+ }
+
+ text := update.Message.Text
+ switch {
+ case text == "/start" || text == "/help":
+ sendTGMainMenu(chatID)
+ case text == "/status" || text == "📊 运行状态":
+ sendTGStatus(chatID)
+ case text == "/domains" || text == "🌐 域名监控":
+ sendTGDomains(chatID)
+ default:
+ if strings.HasPrefix(text, "/") {
+ sendTGMessage(chatID, "❓ 未知命令,请输入 /start 查看菜单。")
+ }
+ }
+}
+
+func sendTGMainMenu(chatID int64) {
+ menu := "👋 您好!我是哪吒监控助手。\n\n请选择以下操作:"
+ keyboard := map[string]interface{}{
+ "keyboard": [][]map[string]string{
+ {{"text": "📊 运行状态"}, {"text": "🌐 域名监控"}},
+ },
+ "resize_keyboard": true,
+ }
+ kbJSON, _ := json.Marshal(keyboard)
+ sendTGRequest("sendMessage", url.Values{
+ "chat_id": {strconv.FormatInt(chatID, 10)},
+ "text": {menu},
+ "reply_markup": {string(kbJSON)},
+ })
+}
+
+func sendTGStatus(chatID int64) {
+ var sb strings.Builder
+ sb.WriteString("📊 服务器实时状态\n\n")
+
+ ServerShared.Range(func(id uint64, s *model.Server) bool {
+ statusIcon := "🟢"
+ if !s.LastActive.After(time.Now().Add(-time.Second * 30)) {
+ statusIcon = "🔴"
+ }
+ sb.WriteString(fmt.Sprintf("%s %s\n", statusIcon, s.Name))
+ sb.WriteString(fmt.Sprintf("├ CPU: %.1f%% | Mem: %.1f%%\n", s.State.CPU, float64(s.State.MemUsed)/float64(s.Host.MemTotal)*100))
+ sb.WriteString(fmt.Sprintf("└ Net: ↓%s/s ↑%s/s\n\n", utils.Bytes(s.State.NetInSpeed), utils.Bytes(s.State.NetOutSpeed)))
+ return true
+ })
+
+ if sb.Len() < 50 {
+ sb.WriteString("暂无在线服务器。")
+ }
+
+ sendTGMessage(chatID, sb.String())
+}
+
+func sendTGDomains(chatID int64) {
+ domains, err := GetDomains("admin")
+ if err != nil {
+ sendTGMessage(chatID, "❌ 获取域名列表失败。")
+ return
+ }
+
+ var sb strings.Builder
+ sb.WriteString("🌐 域名监控状态\n\n")
+
+ now := time.Now()
+ for _, d := range domains {
+ statusIcon := "✅"
+ if d.Status == "pending" {
+ statusIcon = "⏳"
+ } else if d.Status == "expired" {
+ statusIcon = "❌"
+ }
+
+ expiresInfo := "N/A"
+ if d.BillingData != nil {
+ var billing model.BillingDataMod
+ if json.Unmarshal(d.BillingData, &billing) == nil && billing.EndDate != "" {
+ if endDate, err := time.Parse(time.RFC3339, billing.EndDate); err == nil {
+ daysLeft := int(endDate.Sub(now).Hours() / 24)
+ expiresInfo = fmt.Sprintf("%d 天", daysLeft)
+ }
+ }
+ }
+
+ sb.WriteString(fmt.Sprintf("%s %s\n", statusIcon, d.Domain))
+ sb.WriteString(fmt.Sprintf("└ 剩余: %s | 状态: %s\n\n", expiresInfo, d.Status))
+ }
+
+ if len(domains) == 0 {
+ sb.WriteString("暂无监控中的域名。")
+ }
+
+ sendTGMessage(chatID, sb.String())
+}
+
+func sendTGMessage(chatID int64, text string) {
+ sendTGRequest("sendMessage", url.Values{
+ "chat_id": {strconv.FormatInt(chatID, 10)},
+ "text": {text},
+ "parse_mode": {"HTML"},
+ })
+}
+
+func sendTGRequest(method string, params url.Values) {
+ apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/%s", Conf.TelegramBotToken, method)
+ req, err := http.NewRequest(http.MethodPost, apiURL, strings.NewReader(params.Encode()))
+ if err != nil {
+ return
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := utils.HttpClient.Do(req)
+ if err == nil {
+ resp.Body.Close()
+ }
+}