feat: create standalone native fnOS repository

This commit is contained in:
2026-10-02 15:19:09 +08:00
parent 69f3c30fab
commit 6725fc131d
492 changed files with 94023 additions and 3 deletions
+188
View File
@@ -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,
}
}
+142
View File
@@ -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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
)
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")
}
+28
View File
@@ -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())
}
}
+219
View File
@@ -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
}
+163
View File
@@ -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 分支")
}
}
+260
View File
@@ -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
}
}
+47
View File
@@ -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)
}
}
+90
View File
@@ -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()
}
+17
View File
@@ -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)
}
}
+565
View File
@@ -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
}
+86
View File
@@ -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)
}
}
+78
View File
@@ -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
}