chore: 初始提交 Fonu 1.0.2

- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听)
- 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP)
- 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点)
- 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
2026-09-23 09:40:59 +08:00
commit 278f8267b7
333 changed files with 53562 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
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"
MaskedSecret = "********"
DefaultBarkServer = "https://api.day.app"
DefaultIPFrequentThreshold = 100
DefaultIPFrequentWindowSec = 60
DefaultIPFrequentAlertCooldown = 15 * 60 // seconds
DefaultLoginFailureThreshold = 5
DefaultLoginFailureWindowSec = 300
DefaultLoginFailureAlertCooldown = 15 * 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"
WebhookCustom WebhookProvider = "custom"
)
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"`
}
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
}
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,
}
}
+130
View File
@@ -0,0 +1,130 @@
package notify
import (
"strings"
"time"
)
func formatFooter(at time.Time) string {
return "Fonu · " + 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 := "[Fonu] " + 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 "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 != "[Fonu] 🌐 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, "Fonu · 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())
}
}
+154
View File
@@ -0,0 +1,154 @@
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 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 = "https://ntfy.sh"
}
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)
payload := map[string]any{
"title": title,
"message": message,
"priority": 5,
}
body, _ := json.Marshal(payload)
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 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 未配置")
}
body, _ := json.Marshal(content.webhookPayload())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, 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
}
+73
View File
@@ -0,0 +1,73 @@
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)
}
}
+248
View File
@@ -0,0 +1,248 @@
package notify
import (
"context"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/fonu/fonu/internal/secret"
"github.com/fonu/fonu/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
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: [Fonu]") {
t.Fatalf("missing subject: %s", body)
}
if !strings.Contains(body, "正文") {
t.Fatalf("missing message: %s", body)
}
}
+456
View File
@@ -0,0 +1,456 @@
package notify
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/fonu/fonu/internal/secret"
"github.com/fonu/fonu/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)
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)
} 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 {
return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
}
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 {
return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
}
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 {
return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
}
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,
}
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
}
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 webhook.Provider == WebhookBark || webhook.Provider == WebhookGotify {
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 webhook.Provider == WebhookBark || webhook.Provider == WebhookGotify {
if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
webhookPlain = strings.TrimSpace(webhook.Key)
}
} else if webhook.Provider == WebhookNtfy {
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 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)
}
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)
return in, true
}
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/fonu/fonu/internal/db"
"github.com/fonu/fonu/internal/secret"
"github.com/fonu/fonu/internal/settings"
)
func TestStoreSaveTelegramPersistsType(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "fonu.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, "fonu.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)
}
}
+69
View File
@@ -0,0 +1,69 @@
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 != "" {
parsed, err := url.Parse(proxyURL)
if err != nil {
return fmt.Errorf("Telegram 代理地址无效")
}
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
}