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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user