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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
func (c AlertContent) webhookPayload() map[string]string {
|
||||
return map[string]string{
|
||||
"event": c.Event,
|
||||
"category": c.Category,
|
||||
"icon": c.Icon,
|
||||
"title": strings.TrimPrefix(c.PushTitle, c.Icon+" "),
|
||||
"message": c.Detail,
|
||||
"formatted": c.PlainBody,
|
||||
"time": c.Timestamp.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (c AlertContent) barkMessage() string {
|
||||
return strings.Join([]string{c.Detail, "", "────────", formatFooter(c.Timestamp)}, "\n")
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatAlert(t *testing.T) {
|
||||
at := time.Date(2026, 9, 18, 15, 49, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
content := FormatAlertAt(EventDDNSIPChange, "DDNS IP 已变更", "example.com\nIPv4: 1.2.3.4 → 5.6.7.8", at)
|
||||
|
||||
if content.Subject != "[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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTestInputDecodesSecretFields(t *testing.T) {
|
||||
raw := `{
|
||||
"type": "telegram",
|
||||
"telegram": {"chat_id": "743955235", "proxy_url": "http://127.0.0.1:7890", "has_bot_token": false},
|
||||
"telegram_token": "123456:ABC-DEF"
|
||||
}`
|
||||
var in TestInput
|
||||
if err := json.Unmarshal([]byte(raw), &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.Type != NotifyTypeTelegram {
|
||||
t.Fatalf("unexpected type: %s", in.Type)
|
||||
}
|
||||
if in.TelegramToken != "123456:ABC-DEF" {
|
||||
t.Fatalf("telegram token not decoded: %q", in.TelegramToken)
|
||||
}
|
||||
if in.Telegram.ChatID != "743955235" {
|
||||
t.Fatalf("chat id not decoded: %q", in.Telegram.ChatID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTestRuntimeUsesPlainToken(t *testing.T) {
|
||||
svc := &Service{}
|
||||
runtime, err := svc.resolveTestRuntime(context.Background(), TestInput{
|
||||
Config: Config{
|
||||
Type: NotifyTypeTelegram,
|
||||
Telegram: TelegramConfig{
|
||||
ChatID: "1",
|
||||
},
|
||||
},
|
||||
TelegramToken: "plain-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.TelegramToken != "plain-token" {
|
||||
t.Fatalf("expected plain-token, got %q", runtime.TelegramToken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SendEmail(cfg RuntimeConfig, title, message string) error {
|
||||
if strings.TrimSpace(cfg.Email.Host) == "" {
|
||||
return fmt.Errorf("SMTP 主机未配置")
|
||||
}
|
||||
if len(cfg.Email.To) == 0 {
|
||||
return fmt.Errorf("收件人未配置")
|
||||
}
|
||||
if strings.TrimSpace(cfg.SMTPPassword) == "" && strings.TrimSpace(cfg.Email.Username) != "" {
|
||||
return fmt.Errorf("SMTP 密码未配置")
|
||||
}
|
||||
|
||||
from := strings.TrimSpace(cfg.Email.From)
|
||||
if from == "" {
|
||||
from = strings.TrimSpace(cfg.Email.Username)
|
||||
}
|
||||
if from == "" {
|
||||
return fmt.Errorf("发件人未配置")
|
||||
}
|
||||
|
||||
body := buildMailBody(from, cfg.Email.To, title, message)
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port)
|
||||
auth := smtp.PlainAuth("", cfg.Email.Username, cfg.SMTPPassword, cfg.Email.Host)
|
||||
|
||||
if cfg.Email.Port == 465 {
|
||||
return sendSMTPS(addr, cfg.Email.Host, auth, from, cfg.Email.To, body)
|
||||
}
|
||||
return smtp.SendMail(addr, auth, from, cfg.Email.To, body)
|
||||
}
|
||||
|
||||
func buildMailBody(from string, to []string, title, message string) []byte {
|
||||
headers := []string{
|
||||
"From: " + from,
|
||||
"To: " + strings.Join(to, ", "),
|
||||
"Subject: " + title,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
}
|
||||
bodyText := strings.TrimSpace(message)
|
||||
if bodyText == "" {
|
||||
bodyText = title
|
||||
}
|
||||
return []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + bodyText + "\r\n")
|
||||
}
|
||||
|
||||
func sendSMTPS(addr, host string, auth smtp.Auth, from string, to []string, body []byte) error {
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rcpt := range to {
|
||||
if err := client.Rcpt(strings.TrimSpace(rcpt)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildMailBody(t *testing.T) {
|
||||
content := FormatAlert("test", "标题", "正文")
|
||||
body := string(buildMailBody("from@example.com", []string{"to@example.com"}, content.Subject, content.PlainBody))
|
||||
if !strings.Contains(body, "Subject: [Fonu]") {
|
||||
t.Fatalf("missing subject: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "正文") {
|
||||
t.Fatalf("missing message: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user