feat: create standalone native fnOS repository
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package notify
|
||||
|
||||
const (
|
||||
EventDDNSIPChange = "ddns_ip_change"
|
||||
EventDDNSFailure = "ddns_failure"
|
||||
EventCertExpiry = "cert_expiry"
|
||||
EventCertRenewSuccess = "cert_renew_success"
|
||||
EventCertRenewFailure = "cert_renew_failure"
|
||||
EventIPFrequentAccess = "ip_frequent_access"
|
||||
EventLoginFailure = "login_failure"
|
||||
EventNginxReloadFailure = "nginx_reload_failure"
|
||||
EventResourceThreshold = "resource_threshold"
|
||||
EventTunnelDown = "tunnel_down"
|
||||
EventFrpsServerDown = "frps_server_down"
|
||||
EventAgentUnreachable = "agent_unreachable"
|
||||
EventRouteDrift = "route_drift"
|
||||
EventBackupFailure = "backup_failure"
|
||||
|
||||
MaskedSecret = "********"
|
||||
|
||||
DefaultBarkServer = "https://api.day.app"
|
||||
DefaultNtfyServer = "https://ntfy.sh"
|
||||
DefaultWecomServer = "https://qyapi.weixin.qq.com"
|
||||
DefaultDingtalkServer = "https://oapi.dingtalk.com"
|
||||
DefaultFeishuServer = "https://open.feishu.cn"
|
||||
DefaultIPFrequentThreshold = 100
|
||||
DefaultIPFrequentWindowSec = 60
|
||||
DefaultIPFrequentAlertCooldown = 15 * 60 // seconds
|
||||
DefaultLoginFailureThreshold = 5
|
||||
DefaultLoginFailureWindowSec = 300
|
||||
DefaultLoginFailureAlertCooldown = 15 * 60 // seconds
|
||||
DefaultCPUThreshold = 90
|
||||
DefaultMemoryThreshold = 90
|
||||
DefaultDiskThreshold = 85
|
||||
DefaultTunnelFailThreshold = 3
|
||||
DefaultFrpsFailThreshold = 2
|
||||
DefaultAgentFailThreshold = 3
|
||||
DefaultDriftCooldownHours = 24
|
||||
// DefaultResourceAlertCooldown 是资源类告警的静默期:持续高占用时每半小时提醒一次,
|
||||
// 期间恢复正常会清掉冷却,再次超标可立即再报。
|
||||
DefaultResourceAlertCooldown = 30 * 60 // seconds
|
||||
)
|
||||
|
||||
type NotifyType string
|
||||
|
||||
const (
|
||||
NotifyTypeEmail NotifyType = "email"
|
||||
NotifyTypeWebhook NotifyType = "webhook"
|
||||
NotifyTypeTelegram NotifyType = "telegram"
|
||||
)
|
||||
|
||||
type WebhookProvider string
|
||||
|
||||
const (
|
||||
WebhookBark WebhookProvider = "bark"
|
||||
WebhookNtfy WebhookProvider = "ntfy"
|
||||
WebhookGotify WebhookProvider = "gotify"
|
||||
WebhookWecom WebhookProvider = "wecom"
|
||||
WebhookDingtalk WebhookProvider = "dingtalk"
|
||||
WebhookFeishu WebhookProvider = "feishu"
|
||||
WebhookCustom WebhookProvider = "custom"
|
||||
)
|
||||
|
||||
// providerUsesKey 表示该预设把凭据放在 Webhook.Key(而不是 WebhookSecret)里。
|
||||
// 保存时的「已配置」判定与密钥落库都按这份名单走,新增预设时只需改这一处。
|
||||
func providerUsesKey(provider WebhookProvider) bool {
|
||||
switch provider {
|
||||
case WebhookBark, WebhookNtfy, WebhookGotify, WebhookWecom, WebhookDingtalk, WebhookFeishu:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
TLS bool `json:"tls"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
}
|
||||
|
||||
type WebhookConfig struct {
|
||||
Provider WebhookProvider `json:"provider"`
|
||||
Server string `json:"server"`
|
||||
Key string `json:"key"`
|
||||
Topic string `json:"topic"`
|
||||
URL string `json:"url"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
}
|
||||
|
||||
type TelegramConfig struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
HasBotToken bool `json:"has_bot_token"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Type NotifyType `json:"type"`
|
||||
Email EmailConfig `json:"email"`
|
||||
Webhook WebhookConfig `json:"webhook"`
|
||||
Telegram TelegramConfig `json:"telegram"`
|
||||
OnDDNSIPChange bool `json:"on_ddns_ip_change"`
|
||||
OnDDNSFailure bool `json:"on_ddns_failure"`
|
||||
OnCertExpiry bool `json:"on_cert_expiry"`
|
||||
OnCertRenewSuccess bool `json:"on_cert_renew_success"`
|
||||
OnCertRenewFailure bool `json:"on_cert_renew_failure"`
|
||||
OnIPFrequentAccess bool `json:"on_ip_frequent_access"`
|
||||
OnLoginFailure bool `json:"on_login_failure"`
|
||||
OnNginxReloadFailure bool `json:"on_nginx_reload_failure"`
|
||||
IPFrequentThreshold int `json:"ip_frequent_threshold"`
|
||||
IPFrequentWindowSec int `json:"ip_frequent_window_sec"`
|
||||
LoginFailureThreshold int `json:"login_failure_threshold"`
|
||||
LoginFailureWindowSec int `json:"login_failure_window_sec"`
|
||||
OnResourceThreshold bool `json:"on_resource_threshold"`
|
||||
OnTunnelDown bool `json:"on_tunnel_down"`
|
||||
CPUThreshold int `json:"cpu_threshold"`
|
||||
MemoryThreshold int `json:"memory_threshold"`
|
||||
DiskThreshold int `json:"disk_threshold"`
|
||||
TunnelFailThreshold int `json:"tunnel_fail_threshold"`
|
||||
OnFrpsDown bool `json:"on_frps_down"`
|
||||
OnAgentUnreachable bool `json:"on_agent_unreachable"`
|
||||
OnRouteDrift bool `json:"on_route_drift"`
|
||||
FrpsFailThreshold int `json:"frps_fail_threshold"`
|
||||
AgentFailThreshold int `json:"agent_fail_threshold"`
|
||||
DriftCooldownHours int `json:"drift_cooldown_hours"`
|
||||
OnBackupFailure bool `json:"on_backup_failure"`
|
||||
}
|
||||
|
||||
type RuntimeConfig struct {
|
||||
Config
|
||||
SMTPPassword string
|
||||
WebhookSecret string
|
||||
TelegramToken string
|
||||
}
|
||||
|
||||
type SaveInput struct {
|
||||
Type NotifyType
|
||||
Email EmailConfig
|
||||
SMTPPassword string
|
||||
Webhook WebhookConfig
|
||||
WebhookSecret string
|
||||
Telegram TelegramConfig
|
||||
TelegramToken string
|
||||
OnDDNSIPChange bool
|
||||
OnDDNSFailure bool
|
||||
OnCertExpiry bool
|
||||
OnCertRenewSuccess bool
|
||||
OnCertRenewFailure bool
|
||||
OnIPFrequentAccess bool
|
||||
OnLoginFailure bool
|
||||
OnNginxReloadFailure bool
|
||||
IPFrequentThreshold int
|
||||
IPFrequentWindowSec int
|
||||
LoginFailureThreshold int
|
||||
LoginFailureWindowSec int
|
||||
OnResourceThreshold bool
|
||||
OnTunnelDown bool
|
||||
CPUThreshold int
|
||||
MemoryThreshold int
|
||||
DiskThreshold int
|
||||
TunnelFailThreshold int
|
||||
OnFrpsDown bool
|
||||
OnAgentUnreachable bool
|
||||
OnRouteDrift bool
|
||||
FrpsFailThreshold int
|
||||
AgentFailThreshold int
|
||||
DriftCooldownHours int
|
||||
OnBackupFailure bool
|
||||
}
|
||||
|
||||
type TestInput struct {
|
||||
Config
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
WebhookSecret string `json:"webhook_secret"`
|
||||
TelegramToken string `json:"telegram_token"`
|
||||
}
|
||||
|
||||
func (c Config) Runtime(password, webhookSecret, telegramToken string) RuntimeConfig {
|
||||
return RuntimeConfig{
|
||||
Config: c,
|
||||
SMTPPassword: password,
|
||||
WebhookSecret: webhookSecret,
|
||||
TelegramToken: telegramToken,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func formatFooter(at time.Time) string {
|
||||
return "Havline · " + at.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
type eventMeta struct {
|
||||
icon string
|
||||
category string
|
||||
}
|
||||
|
||||
type AlertContent struct {
|
||||
Event string
|
||||
Subject string
|
||||
PushTitle string
|
||||
PlainBody string
|
||||
HTMLBody string
|
||||
Category string
|
||||
Icon string
|
||||
Detail string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
func FormatAlert(event, title, message string) AlertContent {
|
||||
return FormatAlertAt(event, title, message, time.Now())
|
||||
}
|
||||
|
||||
func FormatAlertAt(event, title, message string, at time.Time) AlertContent {
|
||||
meta := eventMetaFor(event)
|
||||
title = strings.TrimSpace(title)
|
||||
message = strings.TrimSpace(message)
|
||||
pushTitle := strings.TrimSpace(meta.icon + " " + title)
|
||||
subject := "[Havline] " + pushTitle
|
||||
footer := formatFooter(at)
|
||||
|
||||
detail := message
|
||||
if detail == "" {
|
||||
detail = "暂无更多详情"
|
||||
}
|
||||
|
||||
plain := strings.Join([]string{
|
||||
pushTitle,
|
||||
strings.Repeat("─", 28),
|
||||
"",
|
||||
detail,
|
||||
"",
|
||||
strings.Repeat("─", 28),
|
||||
footer,
|
||||
}, "\n")
|
||||
|
||||
html := formatTelegramHTML(pushTitle, detail, at)
|
||||
|
||||
return AlertContent{
|
||||
Event: event,
|
||||
Subject: subject,
|
||||
PushTitle: pushTitle,
|
||||
PlainBody: plain,
|
||||
HTMLBody: html,
|
||||
Category: meta.category,
|
||||
Icon: meta.icon,
|
||||
Detail: detail,
|
||||
Timestamp: at,
|
||||
}
|
||||
}
|
||||
|
||||
func eventMetaFor(event string) eventMeta {
|
||||
switch event {
|
||||
case EventDDNSIPChange:
|
||||
return eventMeta{"🌐", "DDNS"}
|
||||
case EventDDNSFailure:
|
||||
return eventMeta{"⚠️", "DDNS"}
|
||||
case EventCertExpiry:
|
||||
return eventMeta{"📅", "证书"}
|
||||
case EventCertRenewSuccess:
|
||||
return eventMeta{"✅", "证书"}
|
||||
case EventCertRenewFailure:
|
||||
return eventMeta{"❌", "证书"}
|
||||
case EventIPFrequentAccess:
|
||||
return eventMeta{"🛡️", "安全"}
|
||||
case EventLoginFailure:
|
||||
return eventMeta{"🔐", "安全"}
|
||||
case EventNginxReloadFailure:
|
||||
return eventMeta{"⚙️", "系统"}
|
||||
case EventResourceThreshold:
|
||||
return eventMeta{"📈", "系统"}
|
||||
case EventTunnelDown:
|
||||
return eventMeta{"🔌", "隧道"}
|
||||
case EventFrpsServerDown:
|
||||
return eventMeta{"🛑", "隧道"}
|
||||
case EventAgentUnreachable:
|
||||
return eventMeta{"📡", "隧道"}
|
||||
case EventRouteDrift:
|
||||
return eventMeta{"🧭", "配置"}
|
||||
case EventBackupFailure:
|
||||
return eventMeta{"💾", "系统"}
|
||||
case "test":
|
||||
return eventMeta{"🔔", "系统"}
|
||||
default:
|
||||
return eventMeta{"📣", "告警"}
|
||||
}
|
||||
}
|
||||
|
||||
func formatTelegramHTML(title, detail string, at time.Time) string {
|
||||
lines := []string{
|
||||
"<b>" + escapeHTML(title) + "</b>",
|
||||
"",
|
||||
"<pre>" + escapeHTML(detail) + "</pre>",
|
||||
"",
|
||||
"<i>" + escapeHTML(formatFooter(at)) + "</i>",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
func (c AlertContent) webhookPayload() map[string]string {
|
||||
return map[string]string{
|
||||
"event": c.Event,
|
||||
"category": c.Category,
|
||||
"icon": c.Icon,
|
||||
"title": strings.TrimPrefix(c.PushTitle, c.Icon+" "),
|
||||
"message": c.Detail,
|
||||
"formatted": c.PlainBody,
|
||||
"time": c.Timestamp.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (c AlertContent) barkMessage() string {
|
||||
return strings.Join([]string{c.Detail, "", "────────", formatFooter(c.Timestamp)}, "\n")
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatAlert(t *testing.T) {
|
||||
at := time.Date(2026, 9, 18, 15, 49, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
content := FormatAlertAt(EventDDNSIPChange, "DDNS IP 已变更", "example.com\nIPv4: 1.2.3.4 → 5.6.7.8", at)
|
||||
|
||||
if content.Subject != "[Havline] 🌐 DDNS IP 已变更" {
|
||||
t.Fatalf("unexpected subject: %s", content.Subject)
|
||||
}
|
||||
if !strings.Contains(content.PlainBody, "example.com") {
|
||||
t.Fatalf("missing detail: %s", content.PlainBody)
|
||||
}
|
||||
if !strings.Contains(content.PlainBody, "Havline · 2026-09-18 15:49:00") {
|
||||
t.Fatalf("missing footer: %s", content.PlainBody)
|
||||
}
|
||||
if !strings.Contains(content.HTMLBody, "<b>") || !strings.Contains(content.HTMLBody, "<pre>") {
|
||||
t.Fatalf("unexpected html: %s", content.HTMLBody)
|
||||
}
|
||||
if content.webhookPayload()["category"] != "DDNS" {
|
||||
t.Fatalf("unexpected webhook category: %v", content.webhookPayload())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func SendWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
|
||||
switch cfg.Webhook.Provider {
|
||||
case WebhookBark:
|
||||
return sendBark(ctx, client, cfg, content.PushTitle, content.barkMessage())
|
||||
case WebhookNtfy:
|
||||
return sendNtfy(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookGotify:
|
||||
return sendGotify(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookWecom:
|
||||
return sendWecom(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookDingtalk:
|
||||
return sendDingtalk(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookFeishu:
|
||||
return sendFeishu(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookCustom:
|
||||
return sendCustomWebhook(ctx, client, cfg, content)
|
||||
default:
|
||||
return fmt.Errorf("不支持的 Webhook 预设")
|
||||
}
|
||||
}
|
||||
|
||||
func webhookSecret(cfg RuntimeConfig) string {
|
||||
if strings.TrimSpace(cfg.WebhookSecret) != "" {
|
||||
return strings.TrimSpace(cfg.WebhookSecret)
|
||||
}
|
||||
return strings.TrimSpace(cfg.Webhook.Key)
|
||||
}
|
||||
|
||||
func sendBark(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
key := webhookSecret(cfg)
|
||||
if key == "" {
|
||||
return fmt.Errorf("Bark Device Key 未配置")
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = DefaultBarkServer
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/%s/%s/%s", server, url.PathEscape(key), url.PathEscape(title), url.PathEscape(message))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return doRequest(client, req)
|
||||
}
|
||||
|
||||
func sendNtfy(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
topic := strings.TrimSpace(cfg.Webhook.Topic)
|
||||
if topic == "" {
|
||||
return fmt.Errorf("ntfy Topic 未配置")
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = DefaultNtfyServer
|
||||
}
|
||||
endpoint := joinURL(server, topic)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(message))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Title", title)
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if token := webhookSecret(cfg); token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return doRequest(client, req)
|
||||
}
|
||||
|
||||
func sendGotify(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
token := webhookSecret(cfg)
|
||||
if token == "" {
|
||||
return fmt.Errorf("Gotify App Token 未配置")
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
return fmt.Errorf("Gotify 服务地址未配置")
|
||||
}
|
||||
endpoint := joinURL(server, "message") + "?token=" + url.QueryEscape(token)
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"title": title,
|
||||
"message": message,
|
||||
"priority": 5,
|
||||
})
|
||||
}
|
||||
|
||||
// 企业微信群机器人:POST /cgi-bin/webhook/send?key=<key>,markdown 正文
|
||||
func sendWecom(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
key := webhookSecret(cfg)
|
||||
if key == "" {
|
||||
return fmt.Errorf("企业微信机器人 Key 未配置")
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = DefaultWecomServer
|
||||
}
|
||||
endpoint := joinURL(server, "cgi-bin", "webhook", "send") + "?key=" + url.QueryEscape(key)
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]string{"content": markdownBody(title, message)},
|
||||
})
|
||||
}
|
||||
|
||||
// 钉钉群机器人:POST /robot/send?access_token=<key>。
|
||||
// 正文固定带 “Havline · ” 前缀,机器人用「自定义关键词 = Havline」的安全设置即可命中。
|
||||
func sendDingtalk(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
key := webhookSecret(cfg)
|
||||
if key == "" {
|
||||
return fmt.Errorf("钉钉机器人 Access Token 未配置")
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = DefaultDingtalkServer
|
||||
}
|
||||
titled := "Havline · " + title
|
||||
endpoint := joinURL(server, "robot", "send") + "?access_token=" + url.QueryEscape(key)
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]string{"title": titled, "text": markdownBody(titled, message)},
|
||||
})
|
||||
}
|
||||
|
||||
// 飞书自定义机器人:POST /open-apis/bot/v2/hook/<key>,纯文本消息
|
||||
func sendFeishu(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
key := webhookSecret(cfg)
|
||||
if key == "" {
|
||||
return fmt.Errorf("飞书机器人 Key 未配置")
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = DefaultFeishuServer
|
||||
}
|
||||
endpoint := joinURL(server, "open-apis", "bot", "v2", "hook", key)
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"msg_type": "text",
|
||||
"content": map[string]string{"text": title + "\n" + message},
|
||||
})
|
||||
}
|
||||
|
||||
// markdownBody 拼出国内 IM 机器人都能渲染的 markdown(企业微信 / 钉钉都认 ## 标题 + 正文)
|
||||
func markdownBody(title, message string) string {
|
||||
return "## " + title + "\n" + message
|
||||
}
|
||||
|
||||
func sendCustomWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
|
||||
target := strings.TrimSpace(cfg.Webhook.URL)
|
||||
if target == "" {
|
||||
return fmt.Errorf("Webhook URL 未配置")
|
||||
}
|
||||
return postJSON(ctx, client, target, content.webhookPayload())
|
||||
}
|
||||
|
||||
// postJSON 发一个 JSON POST:各国内 IM 机器人都是这个形状,只是 URL 与 payload 不同。
|
||||
func postJSON(ctx context.Context, client *http.Client, endpoint string, payload any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return doRequest(client, req)
|
||||
}
|
||||
|
||||
func doRequest(client *http.Client, req *http.Request) error {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
if len(body) > 0 {
|
||||
return fmt.Errorf("请求失败 (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return fmt.Errorf("请求失败 (%d)", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeServerURL(server string) string {
|
||||
server = strings.TrimSpace(server)
|
||||
server = strings.TrimRight(server, "/")
|
||||
if server == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(server, "://") {
|
||||
server = "https://" + server
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
func joinURL(base string, parts ...string) string {
|
||||
base = strings.TrimRight(normalizeServerURL(base), "/")
|
||||
for _, part := range parts {
|
||||
part = strings.Trim(part, "/")
|
||||
if part != "" {
|
||||
base += "/" + part
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendCustomWebhook(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := RuntimeConfig{
|
||||
Config: Config{
|
||||
Type: NotifyTypeWebhook,
|
||||
Webhook: WebhookConfig{
|
||||
Provider: WebhookCustom,
|
||||
URL: server.URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
content := FormatAlert("test", "title", "message")
|
||||
if err := SendWebhook(context.Background(), server.Client(), cfg, content); err != nil {
|
||||
t.Fatalf("send custom webhook: %v", err)
|
||||
}
|
||||
if !strings.Contains(body, `"title":"title"`) || !strings.Contains(body, `"message":"message"`) {
|
||||
t.Fatalf("unexpected body: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"formatted":`) {
|
||||
t.Fatalf("missing formatted body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendNtfyUsesTopic(t *testing.T) {
|
||||
var gotPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := RuntimeConfig{
|
||||
Config: Config{
|
||||
Type: NotifyTypeWebhook,
|
||||
Webhook: WebhookConfig{
|
||||
Provider: WebhookNtfy,
|
||||
Server: server.URL,
|
||||
Topic: "alerts",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "title", "message")); err != nil {
|
||||
t.Fatalf("send ntfy: %v", err)
|
||||
}
|
||||
if gotPath != "/alerts" {
|
||||
t.Fatalf("expected /alerts got %s", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinURL(t *testing.T) {
|
||||
got := joinURL("https://ntfy.sh", "topic")
|
||||
if got != "https://ntfy.sh/topic" {
|
||||
t.Fatalf("unexpected url: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 国内 IM 机器人:固定路径 + 固定 payload 形状,这里把三家的端点与请求体锁住
|
||||
func TestSendWecomUsesKeyAndMarkdown(t *testing.T) {
|
||||
var path, query, body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
query = r.URL.RawQuery
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := RuntimeConfig{Config: Config{
|
||||
Type: NotifyTypeWebhook,
|
||||
Webhook: WebhookConfig{Provider: WebhookWecom, Server: server.URL, Key: "abc"},
|
||||
}}
|
||||
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
|
||||
t.Fatalf("send wecom: %v", err)
|
||||
}
|
||||
if path != "/cgi-bin/webhook/send" || query != "key=abc" {
|
||||
t.Fatalf("unexpected endpoint: %s?%s", path, query)
|
||||
}
|
||||
if !strings.Contains(body, `"msgtype":"markdown"`) || !strings.Contains(body, "标题") {
|
||||
t.Fatalf("unexpected body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendDingtalkUsesAccessTokenAndKeyword(t *testing.T) {
|
||||
var path, query, body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
query = r.URL.RawQuery
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := RuntimeConfig{Config: Config{
|
||||
Type: NotifyTypeWebhook,
|
||||
Webhook: WebhookConfig{Provider: WebhookDingtalk, Server: server.URL, Key: "tok"},
|
||||
}}
|
||||
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
|
||||
t.Fatalf("send dingtalk: %v", err)
|
||||
}
|
||||
if path != "/robot/send" || query != "access_token=tok" {
|
||||
t.Fatalf("unexpected endpoint: %s?%s", path, query)
|
||||
}
|
||||
// 「自定义关键词」安全设置靠正文里的 Havline 命中(PushTitle 前面还有事件图标)
|
||||
if !strings.Contains(body, "Havline · ") || !strings.Contains(body, "标题") {
|
||||
t.Fatalf("missing keyword prefix: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFeishuUsesHookPath(t *testing.T) {
|
||||
var path, body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := RuntimeConfig{Config: Config{
|
||||
Type: NotifyTypeWebhook,
|
||||
Webhook: WebhookConfig{Provider: WebhookFeishu, Server: server.URL, Key: "hookkey"},
|
||||
}}
|
||||
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
|
||||
t.Fatalf("send feishu: %v", err)
|
||||
}
|
||||
if path != "/open-apis/bot/v2/hook/hookkey" {
|
||||
t.Fatalf("unexpected path: %s", path)
|
||||
}
|
||||
if !strings.Contains(body, `"msg_type":"text"`) {
|
||||
t.Fatalf("unexpected body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUsesKeyCoversKeyBasedPresets(t *testing.T) {
|
||||
for _, provider := range []WebhookProvider{WebhookBark, WebhookNtfy, WebhookGotify, WebhookWecom, WebhookDingtalk, WebhookFeishu} {
|
||||
if !providerUsesKey(provider) {
|
||||
t.Fatalf("%s 的凭据存在 Webhook.Key,密钥落库路径必须覆盖它", provider)
|
||||
}
|
||||
}
|
||||
if providerUsesKey(WebhookCustom) {
|
||||
t.Fatal("自定义 Webhook 的凭据来自 URL,不应走 Key 分支")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/secret"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
settings *settings.Store
|
||||
store *Store
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
|
||||
accessMu sync.Mutex
|
||||
accessHits map[string][]time.Time
|
||||
accessAlerted map[string]time.Time
|
||||
|
||||
loginMu sync.Mutex
|
||||
loginHits map[string][]time.Time
|
||||
loginAlerted map[string]time.Time
|
||||
}
|
||||
|
||||
func New(settingsStore *settings.Store, secretBox *secret.Box, logger *slog.Logger) *Service {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Service{
|
||||
settings: settingsStore,
|
||||
store: NewStore(settingsStore, secretBox),
|
||||
logger: logger,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
accessHits: map[string][]time.Time{},
|
||||
accessAlerted: map[string]time.Time{},
|
||||
loginHits: map[string][]time.Time{},
|
||||
loginAlerted: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Alert(ctx context.Context, event, title, message string) {
|
||||
if s == nil || s.store == nil {
|
||||
return
|
||||
}
|
||||
cfg, err := s.store.LoadRuntime(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("notify load config failed", "error", err.Error())
|
||||
return
|
||||
}
|
||||
if !s.enabledFor(cfg, event) {
|
||||
return
|
||||
}
|
||||
if err := s.send(ctx, cfg, event, title, message); err != nil {
|
||||
s.logger.Warn("notify send failed", "event", event, "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) RecordAccessHit(ctx context.Context, ip string) {
|
||||
s.recordThresholdHit(ctx, ip, EventIPFrequentAccess, func(cfg RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool) {
|
||||
enabled = s.enabledFor(cfg, EventIPFrequentAccess)
|
||||
threshold = cfg.IPFrequentThreshold
|
||||
if threshold <= 0 {
|
||||
threshold = DefaultIPFrequentThreshold
|
||||
}
|
||||
windowSec = cfg.IPFrequentWindowSec
|
||||
if windowSec <= 0 {
|
||||
windowSec = DefaultIPFrequentWindowSec
|
||||
}
|
||||
return threshold, windowSec, DefaultIPFrequentAlertCooldown, enabled
|
||||
}, &s.accessMu, s.accessHits, s.accessAlerted, func(ip string, windowSec, count, threshold int) string {
|
||||
return fmt.Sprintf("IP %s 在 %d 秒内访问 %d 次(阈值 %d)", ip, windowSec, count, threshold)
|
||||
}, "IP 频繁访问")
|
||||
}
|
||||
|
||||
func (s *Service) RecordLoginFailure(ctx context.Context, ip string) {
|
||||
s.recordThresholdHit(ctx, ip, EventLoginFailure, func(cfg RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool) {
|
||||
enabled = s.enabledFor(cfg, EventLoginFailure)
|
||||
threshold = cfg.LoginFailureThreshold
|
||||
if threshold <= 0 {
|
||||
threshold = DefaultLoginFailureThreshold
|
||||
}
|
||||
windowSec = cfg.LoginFailureWindowSec
|
||||
if windowSec <= 0 {
|
||||
windowSec = DefaultLoginFailureWindowSec
|
||||
}
|
||||
return threshold, windowSec, DefaultLoginFailureAlertCooldown, enabled
|
||||
}, &s.loginMu, s.loginHits, s.loginAlerted, func(ip string, windowSec, count, threshold int) string {
|
||||
return fmt.Sprintf("IP %s 在 %d 秒内登录失败 %d 次(阈值 %d)", ip, windowSec, count, threshold)
|
||||
}, "登录异常")
|
||||
}
|
||||
|
||||
func (s *Service) recordThresholdHit(
|
||||
ctx context.Context,
|
||||
ip string,
|
||||
event string,
|
||||
cfgFn func(RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool),
|
||||
mu *sync.Mutex,
|
||||
hits map[string][]time.Time,
|
||||
alerted map[string]time.Time,
|
||||
messageFn func(ip string, windowSec, count, threshold int) string,
|
||||
title string,
|
||||
) {
|
||||
if s == nil || s.store == nil {
|
||||
return
|
||||
}
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" || ip == "-" {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.store.LoadRuntime(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
threshold, windowSec, cooldownSec, enabled := cfgFn(cfg)
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
|
||||
window := time.Duration(windowSec) * time.Second
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-window)
|
||||
|
||||
mu.Lock()
|
||||
prev := hits[ip]
|
||||
pruned := make([]time.Time, 0, len(prev)+1)
|
||||
for _, at := range prev {
|
||||
if !at.Before(cutoff) {
|
||||
pruned = append(pruned, at)
|
||||
}
|
||||
}
|
||||
pruned = append(pruned, now)
|
||||
hits[ip] = pruned
|
||||
|
||||
count := len(pruned)
|
||||
lastAlert := alerted[ip]
|
||||
cooldown := time.Duration(cooldownSec) * time.Second
|
||||
shouldAlert := count >= threshold && now.Sub(lastAlert) >= cooldown
|
||||
if shouldAlert {
|
||||
alerted[ip] = now
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
if !shouldAlert {
|
||||
return
|
||||
}
|
||||
s.Alert(ctx, event, title, messageFn(ip, windowSec, count, threshold))
|
||||
}
|
||||
|
||||
func (s *Service) SendTest(ctx context.Context, in TestInput) error {
|
||||
runtime, err := s.resolveTestRuntime(ctx, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Type == "" {
|
||||
return fmt.Errorf("请先选择通知方式")
|
||||
}
|
||||
if err := s.send(ctx, runtime, "test", "测试通知", "配置验证通过,当前通知通道可正常使用。"); err != nil {
|
||||
s.logger.Warn("notify test send failed", "error", err.Error())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveTestRuntime(ctx context.Context, in TestInput) (RuntimeConfig, error) {
|
||||
smtpPassword := strings.TrimSpace(in.SMTPPassword)
|
||||
webhookSecret := strings.TrimSpace(in.WebhookSecret)
|
||||
telegramToken := strings.TrimSpace(in.TelegramToken)
|
||||
|
||||
if key := strings.TrimSpace(in.Webhook.Key); key != "" && key != MaskedSecret {
|
||||
webhookSecret = key
|
||||
}
|
||||
|
||||
needStored := smtpPassword == "" || smtpPassword == MaskedSecret ||
|
||||
webhookSecret == "" || webhookSecret == MaskedSecret ||
|
||||
telegramToken == "" || telegramToken == MaskedSecret
|
||||
|
||||
if needStored && s.store != nil {
|
||||
stored, err := s.store.LoadRuntime(ctx)
|
||||
if err != nil {
|
||||
return RuntimeConfig{}, err
|
||||
}
|
||||
if (smtpPassword == "" || smtpPassword == MaskedSecret) && in.Email.HasPassword {
|
||||
smtpPassword = stored.SMTPPassword
|
||||
}
|
||||
if (webhookSecret == "" || webhookSecret == MaskedSecret) && in.Webhook.HasSecret {
|
||||
webhookSecret = stored.WebhookSecret
|
||||
}
|
||||
if (telegramToken == "" || telegramToken == MaskedSecret) && in.Telegram.HasBotToken {
|
||||
telegramToken = stored.TelegramToken
|
||||
}
|
||||
}
|
||||
|
||||
return in.Config.Runtime(smtpPassword, webhookSecret, telegramToken), nil
|
||||
}
|
||||
|
||||
func (s *Service) Store() *Store {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.store
|
||||
}
|
||||
|
||||
func (s *Service) send(ctx context.Context, cfg RuntimeConfig, event, title, message string) error {
|
||||
content := FormatAlert(event, title, message)
|
||||
switch cfg.Type {
|
||||
case NotifyTypeEmail:
|
||||
return SendEmail(cfg, content.Subject, content.PlainBody)
|
||||
case NotifyTypeWebhook:
|
||||
return SendWebhook(ctx, s.client, cfg, content)
|
||||
case NotifyTypeTelegram:
|
||||
return SendTelegram(ctx, s.client, cfg, content)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) enabledFor(cfg RuntimeConfig, event string) bool {
|
||||
if cfg.Type == "" {
|
||||
return false
|
||||
}
|
||||
switch event {
|
||||
case EventDDNSIPChange:
|
||||
return cfg.OnDDNSIPChange
|
||||
case EventDDNSFailure:
|
||||
return cfg.OnDDNSFailure
|
||||
case EventCertExpiry:
|
||||
return cfg.OnCertExpiry
|
||||
case EventCertRenewSuccess:
|
||||
return cfg.OnCertRenewSuccess
|
||||
case EventCertRenewFailure:
|
||||
return cfg.OnCertRenewFailure
|
||||
case EventIPFrequentAccess:
|
||||
return cfg.OnIPFrequentAccess
|
||||
case EventLoginFailure:
|
||||
return cfg.OnLoginFailure
|
||||
case EventNginxReloadFailure:
|
||||
return cfg.OnNginxReloadFailure
|
||||
case EventResourceThreshold:
|
||||
return cfg.OnResourceThreshold
|
||||
case EventTunnelDown:
|
||||
return cfg.OnTunnelDown
|
||||
case EventFrpsServerDown:
|
||||
return cfg.OnFrpsDown
|
||||
case EventAgentUnreachable:
|
||||
return cfg.OnAgentUnreachable
|
||||
case EventRouteDrift:
|
||||
return cfg.OnRouteDrift
|
||||
case EventBackupFailure:
|
||||
return cfg.OnBackupFailure
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTestInputDecodesSecretFields(t *testing.T) {
|
||||
raw := `{
|
||||
"type": "telegram",
|
||||
"telegram": {"chat_id": "743955235", "proxy_url": "http://127.0.0.1:7890", "has_bot_token": false},
|
||||
"telegram_token": "123456:ABC-DEF"
|
||||
}`
|
||||
var in TestInput
|
||||
if err := json.Unmarshal([]byte(raw), &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.Type != NotifyTypeTelegram {
|
||||
t.Fatalf("unexpected type: %s", in.Type)
|
||||
}
|
||||
if in.TelegramToken != "123456:ABC-DEF" {
|
||||
t.Fatalf("telegram token not decoded: %q", in.TelegramToken)
|
||||
}
|
||||
if in.Telegram.ChatID != "743955235" {
|
||||
t.Fatalf("chat id not decoded: %q", in.Telegram.ChatID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTestRuntimeUsesPlainToken(t *testing.T) {
|
||||
svc := &Service{}
|
||||
runtime, err := svc.resolveTestRuntime(context.Background(), TestInput{
|
||||
Config: Config{
|
||||
Type: NotifyTypeTelegram,
|
||||
Telegram: TelegramConfig{
|
||||
ChatID: "1",
|
||||
},
|
||||
},
|
||||
TelegramToken: "plain-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.TelegramToken != "plain-token" {
|
||||
t.Fatalf("expected plain-token, got %q", runtime.TelegramToken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SendEmail(cfg RuntimeConfig, title, message string) error {
|
||||
if strings.TrimSpace(cfg.Email.Host) == "" {
|
||||
return fmt.Errorf("SMTP 主机未配置")
|
||||
}
|
||||
if len(cfg.Email.To) == 0 {
|
||||
return fmt.Errorf("收件人未配置")
|
||||
}
|
||||
if strings.TrimSpace(cfg.SMTPPassword) == "" && strings.TrimSpace(cfg.Email.Username) != "" {
|
||||
return fmt.Errorf("SMTP 密码未配置")
|
||||
}
|
||||
|
||||
from := strings.TrimSpace(cfg.Email.From)
|
||||
if from == "" {
|
||||
from = strings.TrimSpace(cfg.Email.Username)
|
||||
}
|
||||
if from == "" {
|
||||
return fmt.Errorf("发件人未配置")
|
||||
}
|
||||
|
||||
body := buildMailBody(from, cfg.Email.To, title, message)
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port)
|
||||
auth := smtp.PlainAuth("", cfg.Email.Username, cfg.SMTPPassword, cfg.Email.Host)
|
||||
|
||||
if cfg.Email.Port == 465 {
|
||||
return sendSMTPS(addr, cfg.Email.Host, auth, from, cfg.Email.To, body)
|
||||
}
|
||||
return smtp.SendMail(addr, auth, from, cfg.Email.To, body)
|
||||
}
|
||||
|
||||
func buildMailBody(from string, to []string, title, message string) []byte {
|
||||
headers := []string{
|
||||
"From: " + from,
|
||||
"To: " + strings.Join(to, ", "),
|
||||
"Subject: " + title,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
}
|
||||
bodyText := strings.TrimSpace(message)
|
||||
if bodyText == "" {
|
||||
bodyText = title
|
||||
}
|
||||
return []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + bodyText + "\r\n")
|
||||
}
|
||||
|
||||
func sendSMTPS(addr, host string, auth smtp.Auth, from string, to []string, body []byte) error {
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rcpt := range to {
|
||||
if err := client.Rcpt(strings.TrimSpace(rcpt)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildMailBody(t *testing.T) {
|
||||
content := FormatAlert("test", "标题", "正文")
|
||||
body := string(buildMailBody("from@example.com", []string{"to@example.com"}, content.Subject, content.PlainBody))
|
||||
if !strings.Contains(body, "Subject: [Havline]") {
|
||||
t.Fatalf("missing subject: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "正文") {
|
||||
t.Fatalf("missing message: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/havline/havline/internal/secret"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
settings *settings.Store
|
||||
secret *secret.Box
|
||||
}
|
||||
|
||||
func NewStore(settingsStore *settings.Store, secretBox *secret.Box) *Store {
|
||||
return &Store{settings: settingsStore, secret: secretBox}
|
||||
}
|
||||
|
||||
func (s *Store) Load(ctx context.Context) (Config, error) {
|
||||
cfg, _, err := s.load(ctx, false)
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func (s *Store) LoadRuntime(ctx context.Context) (RuntimeConfig, error) {
|
||||
cfg, runtime, err := s.load(ctx, true)
|
||||
if err != nil {
|
||||
return RuntimeConfig{}, err
|
||||
}
|
||||
return cfg.Runtime(runtime.smtpPassword, runtime.webhookSecret, runtime.telegramToken), nil
|
||||
}
|
||||
|
||||
type runtimeSecrets struct {
|
||||
smtpPassword string
|
||||
webhookSecret string
|
||||
telegramToken string
|
||||
}
|
||||
|
||||
func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets, error) {
|
||||
cfg := Config{
|
||||
Email: EmailConfig{Port: 587, TLS: true},
|
||||
Webhook: WebhookConfig{Provider: WebhookBark, Server: DefaultBarkServer},
|
||||
Telegram: TelegramConfig{},
|
||||
}
|
||||
secrets := runtimeSecrets{}
|
||||
|
||||
typeRaw, _ := s.settings.Get(ctx, settings.KeyNotifyType)
|
||||
cfg.Type = NotifyType(strings.TrimSpace(typeRaw))
|
||||
|
||||
emailRaw, _ := s.settings.Get(ctx, settings.KeyNotifyEmailJSON)
|
||||
if emailRaw != "" {
|
||||
_ = json.Unmarshal([]byte(emailRaw), &cfg.Email)
|
||||
}
|
||||
if cfg.Email.Port <= 0 {
|
||||
cfg.Email.Port = 587
|
||||
}
|
||||
|
||||
webhookRaw, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookJSON)
|
||||
if webhookRaw != "" {
|
||||
_ = json.Unmarshal([]byte(webhookRaw), &cfg.Webhook)
|
||||
}
|
||||
if cfg.Webhook.Server == "" && cfg.Webhook.Provider == WebhookBark {
|
||||
cfg.Webhook.Server = DefaultBarkServer
|
||||
}
|
||||
|
||||
telegramRaw, _ := s.settings.Get(ctx, settings.KeyNotifyTelegramJSON)
|
||||
if telegramRaw != "" {
|
||||
_ = json.Unmarshal([]byte(telegramRaw), &cfg.Telegram)
|
||||
}
|
||||
|
||||
cfg.IPFrequentThreshold = intSetting(s.settings, ctx, settings.KeyNotifyIPFrequentThreshold, DefaultIPFrequentThreshold)
|
||||
cfg.IPFrequentWindowSec = intSetting(s.settings, ctx, settings.KeyNotifyIPFrequentWindowSec, DefaultIPFrequentWindowSec)
|
||||
|
||||
cfg.LoginFailureThreshold = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
|
||||
cfg.LoginFailureWindowSec = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
|
||||
cfg.CPUThreshold = intSetting(s.settings, ctx, settings.KeyNotifyCPUThreshold, DefaultCPUThreshold)
|
||||
cfg.MemoryThreshold = intSetting(s.settings, ctx, settings.KeyNotifyMemoryThreshold, DefaultMemoryThreshold)
|
||||
cfg.DiskThreshold = intSetting(s.settings, ctx, settings.KeyNotifyDiskThreshold, DefaultDiskThreshold)
|
||||
cfg.TunnelFailThreshold = intSetting(s.settings, ctx, settings.KeyNotifyTunnelFailThreshold, DefaultTunnelFailThreshold)
|
||||
cfg.FrpsFailThreshold = intSetting(s.settings, ctx, settings.KeyNotifyFrpsFailThreshold, DefaultFrpsFailThreshold)
|
||||
cfg.AgentFailThreshold = intSetting(s.settings, ctx, settings.KeyNotifyAgentFailThreshold, DefaultAgentFailThreshold)
|
||||
cfg.DriftCooldownHours = intSetting(s.settings, ctx, settings.KeyNotifyDriftCooldownHours, DefaultDriftCooldownHours)
|
||||
|
||||
if hasNotifyEventSettings(ctx, s.settings) {
|
||||
cfg.OnDDNSIPChange = flag(s.settings, ctx, settings.KeyNotifyOnDDNSIPChange, true)
|
||||
cfg.OnDDNSFailure = flag(s.settings, ctx, settings.KeyNotifyOnDDNSFailure, true)
|
||||
cfg.OnCertExpiry = flag(s.settings, ctx, settings.KeyNotifyOnCertExpiry, true)
|
||||
cfg.OnCertRenewSuccess = flag(s.settings, ctx, settings.KeyNotifyOnCertRenewSuccess, true)
|
||||
cfg.OnCertRenewFailure = flag(s.settings, ctx, settings.KeyNotifyOnCertRenewFailure, true)
|
||||
cfg.OnIPFrequentAccess = flag(s.settings, ctx, settings.KeyNotifyOnIPFrequentAccess, false)
|
||||
cfg.OnLoginFailure = flag(s.settings, ctx, settings.KeyNotifyOnLoginFailure, false)
|
||||
cfg.OnNginxReloadFailure = flag(s.settings, ctx, settings.KeyNotifyOnNginxReloadFailure, true)
|
||||
// 资源与隧道告警默认关闭:升级后不改变现有用户收到的通知
|
||||
cfg.OnResourceThreshold = flag(s.settings, ctx, settings.KeyNotifyOnResourceThreshold, false)
|
||||
cfg.OnTunnelDown = flag(s.settings, ctx, settings.KeyNotifyOnTunnelDown, false)
|
||||
cfg.OnFrpsDown = flag(s.settings, ctx, settings.KeyNotifyOnFrpsDown, false)
|
||||
cfg.OnAgentUnreachable = flag(s.settings, ctx, settings.KeyNotifyOnAgentUnreachable, false)
|
||||
cfg.OnRouteDrift = flag(s.settings, ctx, settings.KeyNotifyOnRouteDrift, false)
|
||||
// 备份失败属于「你以为有备份其实没有」,默认开启
|
||||
cfg.OnBackupFailure = flag(s.settings, ctx, settings.KeyNotifyOnBackupFailure, true)
|
||||
} else {
|
||||
legacyDDNS := flag(s.settings, ctx, settings.KeyNotifyOnDDNSError, true)
|
||||
legacyCert := flag(s.settings, ctx, settings.KeyNotifyOnCertError, true)
|
||||
cfg.OnDDNSFailure = legacyDDNS
|
||||
cfg.OnCertExpiry = legacyCert
|
||||
cfg.OnCertRenewFailure = legacyCert
|
||||
cfg.OnDDNSIPChange = true
|
||||
cfg.OnCertRenewSuccess = true
|
||||
cfg.OnNginxReloadFailure = true
|
||||
cfg.OnIPFrequentAccess = false
|
||||
cfg.OnLoginFailure = false
|
||||
}
|
||||
|
||||
smtpEnc, _ := s.settings.Get(ctx, settings.KeyNotifySMTPPassword)
|
||||
if smtpEnc != "" {
|
||||
cfg.Email.HasPassword = true
|
||||
if decrypt && s.secret != nil {
|
||||
plain, err := s.secret.Decrypt(smtpEnc)
|
||||
if err != nil {
|
||||
// 单个通道的密文损坏(例如更换过加密密钥)不应让整份配置加载失败:
|
||||
// 仅标记该通道不可用并继续解析其它通道,避免 Telegram/Webhook 一起失效
|
||||
cfg.Email.HasPassword = false
|
||||
} else {
|
||||
secrets.smtpPassword = plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webhookEnc, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookSecret)
|
||||
if webhookEnc != "" {
|
||||
cfg.Webhook.HasSecret = true
|
||||
if decrypt && s.secret != nil {
|
||||
plain, err := s.secret.Decrypt(webhookEnc)
|
||||
if err != nil {
|
||||
// 同上:只让该通道不可用,不影响其它通道
|
||||
cfg.Webhook.HasSecret = false
|
||||
} else {
|
||||
secrets.webhookSecret = plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
telegramEnc, _ := s.settings.Get(ctx, settings.KeyNotifyTelegramToken)
|
||||
if telegramEnc != "" {
|
||||
cfg.Telegram.HasBotToken = true
|
||||
if decrypt && s.secret != nil {
|
||||
plain, err := s.secret.Decrypt(telegramEnc)
|
||||
if err != nil {
|
||||
// 同上:只让该通道不可用,不影响其它通道
|
||||
cfg.Telegram.HasBotToken = false
|
||||
} else {
|
||||
secrets.telegramToken = plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Type == "" {
|
||||
legacyURL, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookURL)
|
||||
if strings.TrimSpace(legacyURL) != "" {
|
||||
cfg.Type = NotifyTypeWebhook
|
||||
cfg.Webhook.Provider = WebhookCustom
|
||||
cfg.Webhook.URL = strings.TrimSpace(legacyURL)
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, secrets, nil
|
||||
}
|
||||
|
||||
func flag(store *settings.Store, ctx context.Context, key string, fallback bool) bool {
|
||||
raw, err := store.Get(ctx, key)
|
||||
if err != nil || raw == "" {
|
||||
return fallback
|
||||
}
|
||||
return raw == "1" || strings.EqualFold(raw, "true")
|
||||
}
|
||||
|
||||
func intSetting(store *settings.Store, ctx context.Context, key string, fallback int) int {
|
||||
raw, err := store.Get(ctx, key)
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || n <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func hasNotifyEventSettings(ctx context.Context, store *settings.Store) bool {
|
||||
keys := []string{
|
||||
settings.KeyNotifyOnDDNSIPChange,
|
||||
settings.KeyNotifyOnDDNSFailure,
|
||||
settings.KeyNotifyOnCertExpiry,
|
||||
settings.KeyNotifyOnCertRenewSuccess,
|
||||
settings.KeyNotifyOnIPFrequentAccess,
|
||||
settings.KeyNotifyOnCertRenewFailure,
|
||||
settings.KeyNotifyOnLoginFailure,
|
||||
settings.KeyNotifyOnNginxReloadFailure,
|
||||
settings.KeyNotifyOnResourceThreshold,
|
||||
settings.KeyNotifyOnTunnelDown,
|
||||
settings.KeyNotifyOnFrpsDown,
|
||||
settings.KeyNotifyOnAgentUnreachable,
|
||||
settings.KeyNotifyOnRouteDrift,
|
||||
settings.KeyNotifyOnBackupFailure,
|
||||
}
|
||||
for _, key := range keys {
|
||||
raw, err := store.Get(ctx, key)
|
||||
if err == nil && raw != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Store) Save(ctx context.Context, in SaveInput) error {
|
||||
if err := validateSaveInput(in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.settings.Set(ctx, settings.KeyNotifyType, string(in.Type)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnDDNSIPChange, in.OnDDNSIPChange); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnDDNSFailure, in.OnDDNSFailure); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertExpiry, in.OnCertExpiry); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertRenewSuccess, in.OnCertRenewSuccess); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnIPFrequentAccess, in.OnIPFrequentAccess); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertRenewFailure, in.OnCertRenewFailure); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnLoginFailure, in.OnLoginFailure); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnNginxReloadFailure, in.OnNginxReloadFailure); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnResourceThreshold, in.OnResourceThreshold); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnTunnelDown, in.OnTunnelDown); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyCPUThreshold, clampThreshold(in.CPUThreshold, DefaultCPUThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyMemoryThreshold, clampThreshold(in.MemoryThreshold, DefaultMemoryThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyDiskThreshold, clampThreshold(in.DiskThreshold, DefaultDiskThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyTunnelFailThreshold, clampThreshold(in.TunnelFailThreshold, DefaultTunnelFailThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnFrpsDown, in.OnFrpsDown); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnAgentUnreachable, in.OnAgentUnreachable); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnRouteDrift, in.OnRouteDrift); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnBackupFailure, in.OnBackupFailure); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyFrpsFailThreshold, clampThreshold(in.FrpsFailThreshold, DefaultFrpsFailThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyAgentFailThreshold, clampThreshold(in.AgentFailThreshold, DefaultAgentFailThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyDriftCooldownHours, clampThreshold(in.DriftCooldownHours, DefaultDriftCooldownHours)); err != nil {
|
||||
return err
|
||||
}
|
||||
threshold := in.IPFrequentThreshold
|
||||
if threshold <= 0 {
|
||||
threshold = DefaultIPFrequentThreshold
|
||||
}
|
||||
windowSec := in.IPFrequentWindowSec
|
||||
if windowSec <= 0 {
|
||||
windowSec = DefaultIPFrequentWindowSec
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyIPFrequentThreshold, threshold); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyIPFrequentWindowSec, windowSec); err != nil {
|
||||
return err
|
||||
}
|
||||
loginThreshold := in.LoginFailureThreshold
|
||||
if loginThreshold <= 0 {
|
||||
loginThreshold = DefaultLoginFailureThreshold
|
||||
}
|
||||
loginWindowSec := in.LoginFailureWindowSec
|
||||
if loginWindowSec <= 0 {
|
||||
loginWindowSec = DefaultLoginFailureWindowSec
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyLoginFailureThreshold, loginThreshold); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyLoginFailureWindowSec, loginWindowSec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
email := in.Email
|
||||
email.HasPassword = email.HasPassword || strings.TrimSpace(in.SMTPPassword) != "" && in.SMTPPassword != MaskedSecret
|
||||
emailJSON, err := json.Marshal(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyNotifyEmailJSON, string(emailJSON)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webhook := in.Webhook
|
||||
secretValue := strings.TrimSpace(in.WebhookSecret)
|
||||
if secretValue != "" && secretValue != MaskedSecret {
|
||||
webhook.HasSecret = true
|
||||
} else if providerUsesKey(webhook.Provider) {
|
||||
if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
|
||||
webhook.HasSecret = true
|
||||
}
|
||||
}
|
||||
webhookJSON, err := json.Marshal(webhook)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyNotifyWebhookJSON, string(webhookJSON)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
telegram := in.Telegram
|
||||
if strings.TrimSpace(in.TelegramToken) != "" && in.TelegramToken != MaskedSecret {
|
||||
telegram.HasBotToken = true
|
||||
}
|
||||
telegramJSON, err := json.Marshal(telegram)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyNotifyTelegramJSON, string(telegramJSON)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if in.Type == NotifyTypeWebhook && webhook.Provider == WebhookCustom {
|
||||
if err := s.settings.Set(ctx, settings.KeyNotifyWebhookURL, strings.TrimSpace(webhook.URL)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.saveSecret(ctx, settings.KeyNotifySMTPPassword, in.SMTPPassword, in.Email.HasPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webhookPlain := secretValue
|
||||
if webhookPlain == "" || webhookPlain == MaskedSecret {
|
||||
if providerUsesKey(webhook.Provider) {
|
||||
if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
|
||||
webhookPlain = strings.TrimSpace(webhook.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.saveSecret(ctx, settings.KeyNotifyWebhookSecret, webhookPlain, webhook.HasSecret); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.saveSecret(ctx, settings.KeyNotifyTelegramToken, in.TelegramToken, telegram.HasBotToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) saveSecret(ctx context.Context, key, plain string, hasExisting bool) error {
|
||||
plain = strings.TrimSpace(plain)
|
||||
if plain == "" || plain == MaskedSecret {
|
||||
if !hasExisting {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if s.secret == nil {
|
||||
return fmt.Errorf("加密模块未初始化")
|
||||
}
|
||||
enc, err := s.secret.Encrypt(plain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.settings.Set(ctx, key, enc)
|
||||
}
|
||||
|
||||
func validateSaveInput(in SaveInput) error {
|
||||
switch in.Type {
|
||||
case "":
|
||||
return nil
|
||||
case NotifyTypeEmail:
|
||||
if strings.TrimSpace(in.Email.Host) == "" {
|
||||
return fmt.Errorf("请填写 SMTP 主机")
|
||||
}
|
||||
if len(in.Email.To) == 0 {
|
||||
return fmt.Errorf("请填写收件人邮箱")
|
||||
}
|
||||
password := strings.TrimSpace(in.SMTPPassword)
|
||||
if password == "" || password == MaskedSecret {
|
||||
if !in.Email.HasPassword {
|
||||
return fmt.Errorf("请填写 SMTP 密码")
|
||||
}
|
||||
}
|
||||
case NotifyTypeWebhook:
|
||||
switch in.Webhook.Provider {
|
||||
case WebhookBark:
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写 Bark Device Key")
|
||||
}
|
||||
case WebhookNtfy:
|
||||
if strings.TrimSpace(in.Webhook.Topic) == "" {
|
||||
return fmt.Errorf("请填写 ntfy Topic")
|
||||
}
|
||||
case WebhookGotify:
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写 Gotify App Token")
|
||||
}
|
||||
case WebhookWecom:
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写企业微信机器人 Key")
|
||||
}
|
||||
case WebhookDingtalk:
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写钉钉机器人 Access Token")
|
||||
}
|
||||
case WebhookFeishu:
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写飞书机器人 Key")
|
||||
}
|
||||
case WebhookCustom:
|
||||
if strings.TrimSpace(in.Webhook.URL) == "" {
|
||||
return fmt.Errorf("请填写 Webhook URL")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("不支持的 Webhook 预设")
|
||||
}
|
||||
case NotifyTypeTelegram:
|
||||
token := strings.TrimSpace(in.TelegramToken)
|
||||
if token == "" && !in.Telegram.HasBotToken {
|
||||
return fmt.Errorf("请填写 Telegram Bot Token")
|
||||
}
|
||||
if strings.TrimSpace(in.Telegram.ChatID) == "" {
|
||||
return fmt.Errorf("请填写 Telegram Chat ID")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("不支持的通知类型")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SanitizeSettings(values map[string]string) {
|
||||
delete(values, settings.KeyNotifySMTPPassword)
|
||||
delete(values, settings.KeyNotifyWebhookSecret)
|
||||
delete(values, settings.KeyNotifyTelegramToken)
|
||||
// ZeroSSL API Key 属证书申请凭据:API 永不回明文,只回掩码。前端以「非空」判断是否已配置,
|
||||
// 且只在用户重新输入时才提交该字段,因此掩码不会被当成新密钥写回。
|
||||
if values[settings.KeyZeroSSLAPIKey] != "" {
|
||||
values[settings.KeyZeroSSLAPIKey] = MaskedSecret
|
||||
}
|
||||
// LiteSSL 的 EAB HMAC 同理:属于证书申请凭据,只回掩码(EAB Kid 不是秘密,照常回显)
|
||||
if values[settings.KeyLiteSSLEABHMAC] != "" {
|
||||
values[settings.KeyLiteSSLEABHMAC] = MaskedSecret
|
||||
}
|
||||
}
|
||||
|
||||
func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) {
|
||||
if values[settings.KeyNotifyType] == "" &&
|
||||
values[settings.KeyNotifyEmailJSON] == "" &&
|
||||
values[settings.KeyNotifyWebhookJSON] == "" &&
|
||||
values[settings.KeyNotifyTelegramJSON] == "" &&
|
||||
values[settings.KeyNotifySMTPPassword] == "" &&
|
||||
values[settings.KeyNotifyWebhookSecret] == "" &&
|
||||
values[settings.KeyNotifyTelegramToken] == "" {
|
||||
return SaveInput{}, false
|
||||
}
|
||||
|
||||
in := SaveInput{
|
||||
Type: NotifyType(values[settings.KeyNotifyType]),
|
||||
SMTPPassword: values[settings.KeyNotifySMTPPassword],
|
||||
WebhookSecret: values[settings.KeyNotifyWebhookSecret],
|
||||
TelegramToken: values[settings.KeyNotifyTelegramToken],
|
||||
}
|
||||
if raw := values[settings.KeyNotifyEmailJSON]; raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &in.Email)
|
||||
}
|
||||
if raw := values[settings.KeyNotifyWebhookJSON]; raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &in.Webhook)
|
||||
}
|
||||
if raw := values[settings.KeyNotifyTelegramJSON]; raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &in.Telegram)
|
||||
}
|
||||
in.OnDDNSIPChange = values[settings.KeyNotifyOnDDNSIPChange] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSIPChange], "true")
|
||||
in.OnDDNSFailure = values[settings.KeyNotifyOnDDNSFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSFailure], "true")
|
||||
if !in.OnDDNSFailure {
|
||||
in.OnDDNSFailure = values[settings.KeyNotifyOnDDNSError] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSError], "true")
|
||||
}
|
||||
in.OnCertExpiry = values[settings.KeyNotifyOnCertExpiry] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertExpiry], "true")
|
||||
if !in.OnCertExpiry {
|
||||
in.OnCertExpiry = values[settings.KeyNotifyOnCertError] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertError], "true")
|
||||
}
|
||||
in.OnCertRenewSuccess = values[settings.KeyNotifyOnCertRenewSuccess] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertRenewSuccess], "true")
|
||||
in.OnIPFrequentAccess = values[settings.KeyNotifyOnIPFrequentAccess] == "1" || strings.EqualFold(values[settings.KeyNotifyOnIPFrequentAccess], "true")
|
||||
in.OnCertRenewFailure = values[settings.KeyNotifyOnCertRenewFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertRenewFailure], "true")
|
||||
in.OnLoginFailure = values[settings.KeyNotifyOnLoginFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnLoginFailure], "true")
|
||||
in.OnNginxReloadFailure = values[settings.KeyNotifyOnNginxReloadFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnNginxReloadFailure], "true")
|
||||
in.IPFrequentThreshold = intSettingFromMap(values, settings.KeyNotifyIPFrequentThreshold, DefaultIPFrequentThreshold)
|
||||
in.IPFrequentWindowSec = intSettingFromMap(values, settings.KeyNotifyIPFrequentWindowSec, DefaultIPFrequentWindowSec)
|
||||
in.LoginFailureThreshold = intSettingFromMap(values, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
|
||||
in.LoginFailureWindowSec = intSettingFromMap(values, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
|
||||
in.OnResourceThreshold = values[settings.KeyNotifyOnResourceThreshold] == "1" || strings.EqualFold(values[settings.KeyNotifyOnResourceThreshold], "true")
|
||||
in.OnTunnelDown = values[settings.KeyNotifyOnTunnelDown] == "1" || strings.EqualFold(values[settings.KeyNotifyOnTunnelDown], "true")
|
||||
in.CPUThreshold = intSettingFromMap(values, settings.KeyNotifyCPUThreshold, DefaultCPUThreshold)
|
||||
in.MemoryThreshold = intSettingFromMap(values, settings.KeyNotifyMemoryThreshold, DefaultMemoryThreshold)
|
||||
in.DiskThreshold = intSettingFromMap(values, settings.KeyNotifyDiskThreshold, DefaultDiskThreshold)
|
||||
in.TunnelFailThreshold = intSettingFromMap(values, settings.KeyNotifyTunnelFailThreshold, DefaultTunnelFailThreshold)
|
||||
in.OnFrpsDown = values[settings.KeyNotifyOnFrpsDown] == "1" || strings.EqualFold(values[settings.KeyNotifyOnFrpsDown], "true")
|
||||
in.OnAgentUnreachable = values[settings.KeyNotifyOnAgentUnreachable] == "1" || strings.EqualFold(values[settings.KeyNotifyOnAgentUnreachable], "true")
|
||||
in.OnRouteDrift = values[settings.KeyNotifyOnRouteDrift] == "1" || strings.EqualFold(values[settings.KeyNotifyOnRouteDrift], "true")
|
||||
in.OnBackupFailure = values[settings.KeyNotifyOnBackupFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnBackupFailure], "true")
|
||||
in.FrpsFailThreshold = intSettingFromMap(values, settings.KeyNotifyFrpsFailThreshold, DefaultFrpsFailThreshold)
|
||||
in.AgentFailThreshold = intSettingFromMap(values, settings.KeyNotifyAgentFailThreshold, DefaultAgentFailThreshold)
|
||||
in.DriftCooldownHours = intSettingFromMap(values, settings.KeyNotifyDriftCooldownHours, DefaultDriftCooldownHours)
|
||||
return in, true
|
||||
}
|
||||
|
||||
// clampThreshold 把未配置或越界的阈值拉回合法范围:0/负数用默认值,超过 100 的按 100 处理
|
||||
// (百分比阈值与「连续失败次数」的上限都用这一条)。
|
||||
func clampThreshold(value, fallback int) int {
|
||||
if value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if value > 100 {
|
||||
return 100
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func intSettingFromMap(values map[string]string, key string, fallback int) int {
|
||||
raw := strings.TrimSpace(values[key])
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/havline/havline/internal/db"
|
||||
"github.com/havline/havline/internal/secret"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
func TestStoreSaveTelegramPersistsType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(dir, "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
settingsStore := settings.NewStore(conn)
|
||||
box, err := secret.NewBox("test-secret-key-32bytes-long!!!")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewStore(settingsStore, box)
|
||||
if err := store.Save(context.Background(), SaveInput{
|
||||
Type: NotifyTypeTelegram,
|
||||
Telegram: TelegramConfig{
|
||||
ChatID: "743955235",
|
||||
ProxyURL: "http://127.0.0.1:7890",
|
||||
HasBotToken: true,
|
||||
},
|
||||
TelegramToken: "123456:ABC-DEF",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rawType, err := settingsStore.Get(context.Background(), settings.KeyNotifyType)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rawType != "telegram" {
|
||||
t.Fatalf("notify_type=%q, want telegram", rawType)
|
||||
}
|
||||
cfg, err := store.Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Type != NotifyTypeTelegram || cfg.Telegram.ChatID != "743955235" {
|
||||
t.Fatalf("unexpected loaded config: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMigratesLegacyWebhookURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(dir, "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
settingsStore := settings.NewStore(conn)
|
||||
if err := settingsStore.Set(context.Background(), settings.KeyNotifyWebhookURL, "https://example.com/hook"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
box, err := secret.NewBox("test-secret-key-32bytes-long!!!")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewStore(settingsStore, box)
|
||||
cfg, err := store.Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Type != NotifyTypeWebhook || cfg.Webhook.Provider != WebhookCustom || cfg.Webhook.URL != "https://example.com/hook" {
|
||||
t.Fatalf("unexpected migrated config: %+v", cfg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func SendTelegram(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
|
||||
token := strings.TrimSpace(cfg.TelegramToken)
|
||||
if token == "" {
|
||||
return fmt.Errorf("Telegram Bot Token 未配置")
|
||||
}
|
||||
chatID := strings.TrimSpace(cfg.Telegram.ChatID)
|
||||
if chatID == "" {
|
||||
return fmt.Errorf("Telegram Chat ID 未配置")
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"chat_id": chatID,
|
||||
"text": content.HTMLBody,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := "https://api.telegram.org/bot" + token + "/sendMessage"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
httpClient := client
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
if proxyURL := strings.TrimSpace(cfg.Telegram.ProxyURL); proxyURL != "" {
|
||||
// 常见误填:只写 host:port(无 scheme)。补全后再解析,并校验 scheme 是否受支持
|
||||
if !strings.Contains(proxyURL, "://") {
|
||||
proxyURL = "http://" + proxyURL
|
||||
}
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Telegram 代理地址无效:%s", proxyURL)
|
||||
}
|
||||
switch parsed.Scheme {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
default:
|
||||
return fmt.Errorf("Telegram 代理仅支持 http / https / socks5,当前为 %q", parsed.Scheme)
|
||||
}
|
||||
httpClient = &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(parsed),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
if len(raw) > 0 {
|
||||
return fmt.Errorf("Telegram 请求失败 (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
return fmt.Errorf("Telegram 请求失败 (%d)", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user