diff --git a/backend/internal/alertnotify/notify.go b/backend/internal/alertnotify/notify.go new file mode 100644 index 0000000..1354781 --- /dev/null +++ b/backend/internal/alertnotify/notify.go @@ -0,0 +1,262 @@ +package alertnotify + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "gorm.io/gorm" + "vpn-platform/backend/internal/model" + "vpn-platform/backend/internal/store" +) + +const DefaultThreshold = 50 + +type Input struct { + RateThresholdMbps int `json:"rate_threshold_mbps"` + Enabled bool `json:"enabled"` + Webhook string `json:"webhook"` + SigningSecret string `json:"signing_secret"` +} + +type Status struct { + RateThresholdMbps int `json:"rate_threshold_mbps"` + Enabled bool `json:"enabled"` + Configured bool `json:"configured"` + Signed bool `json:"signed"` +} + +func current() (model.AlertSetting, error) { + var row model.AlertSetting + err := store.DB.First(&row, 1).Error + if err == nil { + return row, nil + } + // Missing singleton is normal on older installs; other DB errors must propagate. + if !errors.Is(err, gorm.ErrRecordNotFound) { + return row, err + } + return model.AlertSetting{ID: 1, RateThresholdMbps: DefaultThreshold}, nil +} + +func Get() (Status, error) { + row, err := current() + return Status{RateThresholdMbps: row.RateThresholdMbps, Enabled: row.Enabled, + Configured: row.WebhookEncrypted != "", Signed: row.SigningEncrypted != ""}, err +} + +func Save(in Input) (Status, error) { + if in.RateThresholdMbps < 1 || in.RateThresholdMbps > 100000 { + return Status{}, fmt.Errorf("阈值须为 1–100000 Mbps") + } + if len(in.Webhook) > 512 || len(in.SigningSecret) > 512 { + return Status{}, fmt.Errorf("机器人凭据过长") + } + row, err := current() + if err != nil { + return Status{}, err + } + if in.Webhook != "" { + if !validWebhook(in.Webhook) { + return Status{}, fmt.Errorf("仅支持飞书/Lark 官方群机器人 HTTPS Webhook") + } + row.WebhookEncrypted, err = store.EncryptSecret(in.Webhook) + if err != nil { + return Status{}, err + } + } + if in.SigningSecret != "" { + row.SigningEncrypted, err = store.EncryptSecret(in.SigningSecret) + if err != nil { + return Status{}, err + } + } + if in.Enabled && row.WebhookEncrypted == "" { + return Status{}, fmt.Errorf("启用通知前请填写群机器人 Webhook") + } + row.Enabled = in.Enabled + row.RateThresholdMbps = in.RateThresholdMbps + if err := store.DB.Save(&row).Error; err != nil { + return Status{}, err + } + return Get() +} + +func Clear() (Status, error) { + row, err := current() + if err != nil { + return Status{}, err + } + row.Enabled = false + row.WebhookEncrypted = "" + row.SigningEncrypted = "" + if err := store.DB.Save(&row).Error; err != nil { + return Status{}, err + } + return Get() +} + +func validWebhook(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.User != nil || u.Port() != "" || u.RawQuery != "" || u.Fragment != "" { + return false + } + if u.Host != "open.feishu.cn" && u.Host != "open.larksuite.com" { + return false + } + id := strings.TrimPrefix(u.Path, "/open-apis/bot/v2/hook/") + if id == u.Path || id == "" || len(id) > 128 { + return false + } + for _, ch := range id { + if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '-') { + return false + } + } + return true +} + +var client = &http.Client{Timeout: 6 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + +func send(webhook, secret, content string) error { + if !validWebhook(webhook) { + return errors.New("invalid_webhook") + } + payload := map[string]interface{}{"msg_type": "text", "content": map[string]string{"text": content}} + if secret != "" { + ts := strconv.FormatInt(time.Now().Unix(), 10) + mac := hmac.New(sha256.New, []byte(ts+"\n"+secret)) + payload["timestamp"] = ts + payload["sign"] = base64.StdEncoding.EncodeToString(mac.Sum(nil)) + } + body, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, webhook, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return errors.New("connection_failed") + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return errors.New("http_error") + } + var result struct { + Code *int `json:"code"` + StatusCode *int `json:"StatusCode"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&result); err != nil { + return errors.New("invalid_response") + } + if result.Code != nil && *result.Code == 0 || result.StatusCode != nil && *result.StatusCode == 0 { + return nil + } + return errors.New("provider_rejected") +} + +func Test() error { + row, err := current() + if err != nil { + return err + } + if !row.Enabled || row.WebhookEncrypted == "" { + return errors.New("请先保存并启用飞书通知") + } + webhook, err := store.DecryptSecret(row.WebhookEncrypted) + if err != nil { + return err + } + secret := "" + if row.SigningEncrypted != "" { + secret, err = store.DecryptSecret(row.SigningEncrypted) + if err != nil { + return err + } + } + return send(webhook, secret, "OpsX VPN 通知测试:群机器人配置可用") +} + +type event struct { + tunnelID uint + kind, detail string +} + +var queue = make(chan event, 64) +var once sync.Once +var mu sync.Mutex +var pending = make(map[string]bool) +var lastSent = make(map[string]time.Time) + +// Notify queues one delivery per tunnel and event. Network calls never block the monitor. +func Notify(tunnelID uint, kind, detail string) { + row, err := current() + if err != nil || !row.Enabled || row.WebhookEncrypted == "" { + return + } + key := fmt.Sprintf("%d:%s", tunnelID, kind) + mu.Lock() + if pending[key] || time.Since(lastSent[key]) < 5*time.Minute { + mu.Unlock() + return + } + pending[key] = true + mu.Unlock() + once.Do(func() { go worker() }) + select { + case queue <- event{tunnelID, kind, detail}: + default: + mu.Lock() + delete(pending, key) + mu.Unlock() + log.Print("[alertnotify] 通知队列已满") + } +} + +func worker() { + for ev := range queue { + key := fmt.Sprintf("%d:%s", ev.tunnelID, ev.kind) + err := deliver(ev) + mu.Lock() + delete(pending, key) + if err == nil { + lastSent[key] = time.Now() + } + mu.Unlock() + if err != nil { + log.Printf("[alertnotify] 发送失败 (%s): %v", key, err) + } + } +} + +func deliver(ev event) error { + row, err := current() + if err != nil || !row.Enabled { + return err + } + webhook, err := store.DecryptSecret(row.WebhookEncrypted) + if err != nil { + return err + } + secret := "" + if row.SigningEncrypted != "" { + secret, err = store.DecryptSecret(row.SigningEncrypted) + if err != nil { + return err + } + } + return send(webhook, secret, fmt.Sprintf("OpsX VPN 流量告警:专线 #%d / %s\n%s", ev.tunnelID, ev.kind, ev.detail)) +} diff --git a/backend/internal/alertnotify/notify_test.go b/backend/internal/alertnotify/notify_test.go new file mode 100644 index 0000000..e0195a7 --- /dev/null +++ b/backend/internal/alertnotify/notify_test.go @@ -0,0 +1,71 @@ +package alertnotify + +import ( + "io" + "net/http" + "strings" + "testing" + + "vpn-platform/backend/internal/model" + "vpn-platform/backend/internal/store" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestConfigSecretsAndValidation(t *testing.T) { + if err := store.Init(t.TempDir()); err != nil { + t.Fatal(err) + } + for _, raw := range []string{"http://open.feishu.cn/open-apis/bot/v2/hook/id", "https://localhost/open-apis/bot/v2/hook/id", "https://open.feishu.cn.evil.test/open-apis/bot/v2/hook/id", "https://open.feishu.cn/open-apis/bot/v2/hook/id?x=1"} { + if validWebhook(raw) { + t.Errorf("accepted invalid webhook: %s", raw) + } + } + webhook := "https://open.feishu.cn/open-apis/bot/v2/hook/abc-123" + status, err := Save(Input{RateThresholdMbps: 75, Enabled: true, Webhook: webhook, SigningSecret: "sign-me"}) + if err != nil { + t.Fatal(err) + } + if !status.Configured || !status.Signed || !status.Enabled || status.RateThresholdMbps != 75 { + t.Fatalf("unexpected status: %+v", status) + } + var row model.AlertSetting + if err := store.DB.First(&row, 1).Error; err != nil { + t.Fatal(err) + } + if strings.Contains(row.WebhookEncrypted, webhook) || strings.Contains(row.SigningEncrypted, "sign-me") { + t.Fatal("plaintext credentials stored") + } + if _, err := Save(Input{RateThresholdMbps: 0}); err == nil { + t.Fatal("accepted zero threshold") + } + if _, err := Save(Input{RateThresholdMbps: 75, Enabled: true}); err != nil { + t.Fatal("empty secret fields should retain stored credentials", err) + } +} + +func TestSendChecksProviderResponse(t *testing.T) { + old := client + t.Cleanup(func() { client = old }) + client = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host != "open.feishu.cn" { + t.Fatal("wrong destination") + } + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), `"sign":`) { + t.Fatal("missing bot signature") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0}`)), Header: make(http.Header)}, nil + })} + if err := send("https://open.feishu.cn/open-apis/bot/v2/hook/id", "secret", "test"); err != nil { + t.Fatal(err) + } + client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":19021}`)), Header: make(http.Header)}, nil + }) + if err := send("https://open.feishu.cn/open-apis/bot/v2/hook/id", "", "test"); err == nil { + t.Fatal("accepted provider rejection") + } +} diff --git a/backend/internal/api/alerts.go b/backend/internal/api/alerts.go new file mode 100644 index 0000000..ff7165c --- /dev/null +++ b/backend/internal/api/alerts.go @@ -0,0 +1,52 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "vpn-platform/backend/internal/alertnotify" + "vpn-platform/backend/internal/store" +) + +func (s *Server) getAlertSettings(c *gin.Context) { + status, err := alertnotify.Get() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取告警配置失败"}) + return + } + c.JSON(http.StatusOK, status) +} + +func (s *Server) saveAlertSettings(c *gin.Context) { + var req alertnotify.Input + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数格式错误"}) + return + } + status, err := alertnotify.Save(req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + store.Audit(c.GetString("username"), "update_alert_settings", "traffic", "告警设置已更新") + c.JSON(http.StatusOK, status) +} + +func (s *Server) testAlertSettings(c *gin.Context) { + if err := alertnotify.Test(); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "飞书试发失败:" + err.Error()}) + return + } + store.Audit(c.GetString("username"), "test_alert_notification", "feishu", "测试消息发送成功") + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (s *Server) clearAlertSettings(c *gin.Context) { + status, err := alertnotify.Clear() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "清除告警配置失败"}) + return + } + store.Audit(c.GetString("username"), "clear_alert_settings", "traffic", "群机器人凭据已清除") + c.JSON(http.StatusOK, status) +} diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 777cf64..1a94102 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -310,6 +310,10 @@ func (s *Server) RegisterRoutes(r *gin.Engine) { admin.Use(requireRole("admin")) // 主机环境体检与一键初始化(仅 admin) admin.GET("/system/env", s.systemEnv) + admin.GET("/system/alerts", s.getAlertSettings) + admin.PUT("/system/alerts", s.saveAlertSettings) + admin.DELETE("/system/alerts", s.clearAlertSettings) + admin.POST("/system/alerts/test", s.testAlertSettings) admin.POST("/system/fix", s.systemFix) admin.GET("/system/tls", s.tlsStatus) admin.GET("/system/identity", idp.Status) diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 89f6c1e..9c9b81d 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -291,6 +291,15 @@ type ClientSession struct { CreatedAt time.Time `json:"created_at"` } +// AlertSetting 平台级突发流量阈值与飞书群机器人配置(单例)。 +type AlertSetting struct { + ID uint `gorm:"primaryKey" json:"-"` + RateThresholdMbps int `gorm:"default:50" json:"rate_threshold_mbps"` + Enabled bool `gorm:"default:false" json:"enabled"` + WebhookEncrypted string `gorm:"type:text" json:"-"` + SigningEncrypted string `gorm:"type:text" json:"-"` +} + // TrafficPolicy 流量配额与限速策略(挂在 Tunnel 上) // Logan.Li type TrafficPolicy struct { ID uint `gorm:"primaryKey" json:"id"` diff --git a/backend/internal/nginxconfig/nginxconfig.go b/backend/internal/nginxconfig/nginxconfig.go index 43a3bd3..cfaf756 100644 --- a/backend/internal/nginxconfig/nginxconfig.go +++ b/backend/internal/nginxconfig/nginxconfig.go @@ -16,7 +16,7 @@ import ( var spaPages = []string{ "/", "/login", "/mfa-verify", "/dashboard", "/tunnels", "/tunnels/create", "/sessions", "/logs", "/ipsec-peer", - "/users", "/vpn-users", "/settings", "/favicon.svg", + "/users", "/vpn-users", "/settings", "/identity", "/plugins", "/nodes", "/favicon.svg", } const upstream = " proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto https;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:18080;\n" diff --git a/backend/internal/nginxconfig/nginxconfig_test.go b/backend/internal/nginxconfig/nginxconfig_test.go index 202d3dc..d6cd7f0 100644 --- a/backend/internal/nginxconfig/nginxconfig_test.go +++ b/backend/internal/nginxconfig/nginxconfig_test.go @@ -29,7 +29,7 @@ func TestRenderAllowlistAndHTTP01(t *testing.T) { "if ($host != test-link.appx365.com) { return 444; }", "location ~ ^/api/tunnels/[^/]+/status$", "location ~ ^/api/tunnels/[^/]+/start$", - "location = /login", "location = /settings", "location = /favicon.svg", + "location = /login", "location = /settings", "location = /identity", "location = /plugins", "location = /nodes", "location = /favicon.svg", "location / { return 444; }", } { if !strings.Contains(httpsSite, fragment) { diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 9711ee5..fd3482a 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -44,10 +44,13 @@ func Init(dataDir string) error { } // 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一 db.Exec("DROP INDEX IF EXISTS idx_vpn_user") - if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.TrafficSample{}, &model.ClientDevice{}, &model.ClientSession{}, &model.IdentityProvider{}, &model.IdentityGrant{}, &model.IdentityProfile{}, &model.FeishuTunnelGrant{}, &model.FeishuDirectorySync{}, &model.FeishuDepartment{}, &model.FeishuDirectoryMember{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.AlertSetting{}, &model.TrafficSample{}, &model.ClientDevice{}, &model.ClientSession{}, &model.IdentityProvider{}, &model.IdentityGrant{}, &model.IdentityProfile{}, &model.FeishuTunnelGrant{}, &model.FeishuDirectorySync{}, &model.FeishuDepartment{}, &model.FeishuDirectoryMember{}); err != nil { return err } DB = db + if err := DB.Exec("INSERT OR IGNORE INTO alert_settings (id, rate_threshold_mbps, enabled) VALUES (1, 50, false)").Error; err != nil { + return err + } if err := initKey(dataDir); err != nil { return err diff --git a/backend/internal/trafficmgr/alerts_test.go b/backend/internal/trafficmgr/alerts_test.go new file mode 100644 index 0000000..06b554a --- /dev/null +++ b/backend/internal/trafficmgr/alerts_test.go @@ -0,0 +1,30 @@ +package trafficmgr + +import ( + "testing" + "time" + + "vpn-platform/backend/internal/alertnotify" + "vpn-platform/backend/internal/model" + "vpn-platform/backend/internal/store" +) + +func TestConfigurableRateThreshold(t *testing.T) { + if err := store.Init(t.TempDir()); err != nil { + t.Fatal(err) + } + if _, err := alertnotify.Save(alertnotify.Input{RateThresholdMbps: 80}); err != nil { + t.Fatal(err) + } + if CheckRateAbnormal(1, 100*1024*1024, 0, 15*time.Second) { + t.Fatal("below 80 Mbps should not alert") + } + if !CheckRateAbnormal(1, 200*1024*1024, 0, 15*time.Second) { + t.Fatal("above 80 Mbps should alert") + } + var count int64 + store.DB.Model(&model.ConnectionLog{}).Where("event = ?", "rate_alert").Count(&count) + if count != 1 { + t.Fatalf("want one alert, got %d", count) + } +} diff --git a/backend/internal/trafficmgr/trafficmgr.go b/backend/internal/trafficmgr/trafficmgr.go index 99c23f4..0021ef9 100644 --- a/backend/internal/trafficmgr/trafficmgr.go +++ b/backend/internal/trafficmgr/trafficmgr.go @@ -10,14 +10,12 @@ import ( "strings" "time" + "vpn-platform/backend/internal/alertnotify" "vpn-platform/backend/internal/model" "vpn-platform/backend/internal/sessionmgr" "vpn-platform/backend/internal/store" ) -// 异常流量阈值:15s 内超过此字节数视为突发(约 50Mbps × 15s ≈ 93MB) -const rateAlertThreshold = 90 * 1024 * 1024 - // CheckQuota 检查指定隧道的流量配额,返回超额动作("" 表示未超额或无限额) func CheckQuota(tunnelID uint, deltaRx, deltaTx int64) string { if store.DB == nil { @@ -65,7 +63,7 @@ func ResetIfNeeded(policy *model.TrafficPolicy) { policy.UsedBytes = 0 policy.LastResetAt = now store.DB.Model(policy).Updates(map[string]interface{}{ - "used_bytes": 0, + "used_bytes": 0, "last_reset_at": now, }) } @@ -74,14 +72,20 @@ func ResetIfNeeded(policy *model.TrafficPolicy) { // CheckRateAbnormal 检查流量速率是否异常(突发流量告警) func CheckRateAbnormal(tunnelID uint, deltaRx, deltaTx int64, interval time.Duration) bool { total := deltaRx + deltaTx - if total <= 0 { + if total <= 0 || interval <= 0 { return false } ratePerSec := float64(total) / interval.Seconds() - // 超过阈值(约 50Mbps) - if total > rateAlertThreshold { - sessionmgr.LogConnection(tunnelID, "*", "rate_alert", "", - fmt.Sprintf("突发流量 %.1f MB(%.0f Mbps),15s 内增量异常", float64(total)/(1024*1024), ratePerSec*8/(1024*1024))) + settings, err := alertnotify.Get() + threshold := alertnotify.DefaultThreshold + if err == nil { + threshold = settings.RateThresholdMbps + } + // Mbps 按十进制 1,000,000 bits/s 计算,使用真实采样间隔。 + if ratePerSec*8 > float64(threshold)*1_000_000 { + detail := fmt.Sprintf("突发流量 %.1f MiB(%.0f Mbps),采样间隔 %.0f 秒", float64(total)/(1024*1024), ratePerSec*8/1_000_000, interval.Seconds()) + sessionmgr.LogConnection(tunnelID, "*", "rate_alert", "", detail) + alertnotify.Notify(tunnelID, "rate_alert", detail) return true } return false @@ -91,19 +95,22 @@ func CheckRateAbnormal(tunnelID uint, deltaRx, deltaTx int64, interval time.Dura func HandleExceed(tunnelID uint, action string, tunnelName string) { switch action { case "warn": - sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "", - fmt.Sprintf("隧道「%s」流量配额已用尽(仅告警)", tunnelName)) + detail := fmt.Sprintf("隧道「%s」流量配额已用尽(仅告警)", tunnelName) + sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "", detail) + alertnotify.Notify(tunnelID, "quota_exceed", detail) case "block": - sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "", - fmt.Sprintf("隧道「%s」流量配额已用尽,自动停止", tunnelName)) + detail := fmt.Sprintf("隧道「%s」流量配额已用尽,自动停止", tunnelName) + sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "", detail) + alertnotify.Notify(tunnelID, "quota_exceed", detail) // 由调用方执行 Stop(避免循环依赖,只返回信号) case "throttle": // 降速到 1Mbps var policy model.TrafficPolicy if store.DB.Where("tunnel_id = ?", tunnelID).First(&policy).Error == nil { ApplyRateLimit(tunnelID, 1) - sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "", - fmt.Sprintf("隧道「%s」流量配额已用尽,限速至 1 Mbps", tunnelName)) + detail := fmt.Sprintf("隧道「%s」流量配额已用尽,限速至 1 Mbps", tunnelName) + sessionmgr.LogConnection(tunnelID, "*", "quota_exceed", "", detail) + alertnotify.Notify(tunnelID, "quota_exceed", detail) } } } diff --git a/docs/specs/SDD-006-traffic-alert-notifications.md b/docs/specs/SDD-006-traffic-alert-notifications.md new file mode 100644 index 0000000..a2c6b7d --- /dev/null +++ b/docs/specs/SDD-006-traffic-alert-notifications.md @@ -0,0 +1,56 @@ +# SDD-006:流量阈值与飞书群告警 + +| 字段 | 内容 | +|---|---| +| 版本 / 变更级别 | 0.1 / S3 | +| 设计状态 | Implementing | +| 实现状态 | Implemented locally; remote verification pending | +| 负责人 / 日期 | Codex / 2026-10-23 | +| 关联需求 | 流量告警可配置阈值,设置内配置飞书群机器人 | +| 批准状态 | 用户要求实施;配置具体值待管理员填写 | + +## 1. 背景、目标与范围 +- **当前行为与证据**:`trafficmgr.CheckRateAbnormal` 固定 15 秒 90 MiB 阈值,`monitor` 每 15 秒检查;`/traffic/alerts` 查询 `rate_alert` 和 `quota_exceed`;每条隧道的配额仍由 `/tunnels/:id/policy` 管理。 +- **目标**:管理员可设置突发速率阈值并启用飞书群机器人通知;日志页展示当前阈值和设置入口。 +- **非目标**:改变配额策略的停止/限速动作、飞书通讯录身份认证、实时可靠消息队列。 +- **保留**:旧数据库默认约 50 Mbps,通知默认关闭,已有日志查询保持可用。 + +## 2. 需求与约束 +- FR-01:管理员可设置正整数 Mbps 速率阈值;低于阈值不生成突发告警,禁用通知仍保留站内日志。 +- FR-02:管理员可填写官方飞书/Lark 群机器人 HTTPS Webhook、可选签名密钥、开关;读接口只返回配置状态,不回显密钥;可手动试发。 +- FR-03:突发或配额事件发生时,在通知启用状态向群推送简要隧道事件,失败不影响隧道与监控;相同隧道事件在冷却期内避免重复发送。 +- NFR-01:仅管理员可写/试发;密钥使用已有 `store.EncryptSecret` 存储;仅允许官方域名和固定路径,禁止重定向,限制超时并验证飞书业务响应。 +- 边界:没有机器人地址时不能启用;没有外部群机器人凭据时只做模拟和配置验证,不声称真实送达。 + +## 3. 架构与方案 +- `model`/`store` 增加单例告警设置,迁移只加表;`trafficmgr` 读取阈值,既有监控链路记录事件后异步交给通知模块;`api` 管理员路由暴露配置和试发。 +- 前端设置增加“飞书告警”标签,日志流量告警页显示阈值和设置入口。参考 opsx-sec 的机器人地址校验、加密不回显与试发体验。 +- 数据面避免同步等待外部网络;通知失败写服务日志,冷却期为 5 分钟(内存状态,重启后重新开始)。 + +## 4. 接口、数据与配置契约 +- 新增 `GET/PUT/DELETE /api/system/alerts` 和 `POST /api/system/alerts/test`(admin)。GET 返回 `rate_threshold_mbps`、`enabled`、`configured`、`signed`;PUT 中空密钥保留已存值;DELETE 禁用并清除机器人凭据。默认阈值 50 Mbps、通知禁用。 +- 新增 SQLite 单例设置表;Webhook 与签名密钥加密存储。旧 `/traffic/alerts` 及逐专线 policy 无改动。 + +## 5. 风险与验收 +| 风险或失败场景 | 防护 / 降级 / 恢复 | 可观察信号 | +|---|---|---| +| 无效 URL 导致内网访问 | 主机名、协议、路径白名单和不跟随重定向 | PUT 400 | +| 群机器人故障 | 超时;不阻塞监控 | 日志记录失败代码 | +| 密钥泄漏 | AES-GCM 加密、不回显、不记请求体 | GET 只有 configured/signed | +| 告警风暴 | 隧道+事件 5 分钟冷却 | 同类事件不重复推送 | + +| AC | 对应需求 | 操作 | 预期 | 验证方式 | 结果 | +|---|---|---|---|---|---| +| AC-01 | FR-01 | 设置阈值并以低于/高于阈值增量检查 | 只对超阈值记录 | Go 测试 | Pass | +| AC-02 | FR-02/NFR-01 | 保存、读取、无效地址试发 | 不回显密钥;无效地址拒绝 | Go 测试/API | Pass(单元测试;线上 API 待验证) | +| AC-03 | FR-03 | 模拟飞书成功/失败及冷却 | 监控无阻塞且无重复发送 | Go 测试 | Partial(业务响应模拟通过;冷却由代码检查,真实送达未测) | +| AC-04 | FR-01/02 | 设置与日志页面 | 可配置、可查询 | 前端构建/页面 | Partial(构建通过;页面待验证) | + +## 6. 发布与恢复 +- 先备份远程数据库及旧二进制,再构建前端并编入 Go 二进制;启动后核对健康、设置接口、静态资源及 VPN 进程。 +- 新通知默认禁用,无群机器人配置时无外发;管理员自行填写并试发后才启用。回滚旧二进制可忽略新增表,保留数据库与密钥。失败时切回备份二进制并验证健康。 + +## 7. 待决问题 +| 问题 | 是否阻塞 | 推荐方案 | +|---|---|---| +| 真实机器人 Webhook 与签名密钥 | 阻塞真实送达验收 | 管理员在设置页填写,不交给代码或日志 | diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 6b8c11d..9108ae2 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -778,6 +778,30 @@ "verifySuccess": "Verification successful", "codeError": "Incorrect code. Please try again." }, + "alertSettings": { + "title": "Lark alerts", + "hint": "The rate threshold detects traffic spikes; per-tunnel quotas remain in tunnel details. Notifications are off by default.", + "threshold": "Spike threshold (Mbps)", + "webhook": "Feishu / Lark group bot webhook", + "secret": "Signing secret (optional)", + "optional": "Set if bot signing is enabled", + "keepWebhook": "Configured; leave blank to keep", + "keepSecret": "Configured; leave blank to keep", + "enabled": "Send traffic alerts automatically", + "secretHint": "Webhook and signing secret are never shown after saving. The same tunnel event is sent at most once every 5 minutes.", + "configure": "Alert settings", + "logHint": "Traffic spikes use the platform threshold; quota alerts use each tunnel's traffic policy.", + "save": "Save settings", + "clear": "Remove bot", + "clearConfirm": "This stops notifications and removes bot credentials. In-app alerts remain. Continue?", + "cleared": "Bot configuration removed", + "saved": "Alert settings saved", + "test": "Send test message", + "testSent": "Test message sent; check the group", + "loadFailed": "Could not load alert settings", + "saveFailed": "Could not save alert settings", + "testFailed": "Could not send test message" + }, "ipsecPeer": { "localParams": "Local IPSec Parameters", "loadTemplate": "Load Device Template", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 7b0c654..a6756f8 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -778,6 +778,30 @@ "verifySuccess": "验证成功", "codeError": "验证码错误,请重试" }, + "alertSettings": { + "title": "飞书告警", + "hint": "速率阈值用于识别突发流量;专线配额仍在专线详情设置。飞书通知默认关闭。", + "threshold": "突发速率阈值(Mbps)", + "webhook": "飞书 / Lark 群机器人 Webhook", + "secret": "签名密钥(可选)", + "optional": "机器人开启签名校验时填写", + "keepWebhook": "已配置,留空保持原地址", + "keepSecret": "已配置,留空保持原密钥", + "enabled": "自动推送流量告警", + "secretHint": "Webhook 和签名密钥保存后不回显;同一专线的同类通知 5 分钟内不重复发送。", + "configure": "设置告警", + "logHint": "突发流量按平台阈值记录;配额超额由专线流量策略触发。", + "save": "保存设置", + "clear": "清除机器人", + "clearConfirm": "清除后停止推送并删除机器人凭据,站内告警仍保留。继续?", + "cleared": "机器人配置已清除", + "saved": "告警设置已保存", + "test": "发送测试消息", + "testSent": "测试消息已发送,请到群内确认", + "loadFailed": "读取告警设置失败", + "saveFailed": "保存告警设置失败", + "testFailed": "测试消息发送失败" + }, "ipsecPeer": { "localParams": "本端 IPSec 参数", "loadTemplate": "载入设备模板", diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue index 4374242..0c32297 100644 --- a/frontend/src/views/Logs.vue +++ b/frontend/src/views/Logs.vue @@ -54,9 +54,13 @@ +

{{ $t('alertSettings.logHint') }}

@@ -265,6 +269,7 @@ onMounted(() => { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .log-error { margin-bottom: var(--space-4); } +.alert-hint { color: var(--el-text-color-secondary); font-size: 13px; margin: 0 0 16px; } .pagination-bar { display: flex; justify-content: flex-end; margin-top: 12px; } diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index d2feb8d..1005ebe 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -46,6 +46,9 @@ + + +
@@ -95,13 +98,14 @@ import { ElMessage } from 'element-plus' import { useI18n } from 'vue-i18n' import QRCode from 'qrcode' import GatewaySettings from './settings/components/GatewaySettings.vue' +import AlertSettings from './settings/components/AlertSettings.vue' import api, { auth } from '../api' const { t } = useI18n() const route = useRoute() const router = useRouter() const activeTab = ref('security') -const visibleTab = (name) => auth.isAdmin() && ['gateway', 'host'].includes(name) ? name : 'security' +const visibleTab = (name) => auth.isAdmin() && ['gateway', 'host', 'alerts'].includes(name) ? name : 'security' watch(() => route.query.tab, name => { activeTab.value = visibleTab(name) }, { immediate: true }) function changeTab(name) { if (route.query.tab !== name) router.replace({ path: '/settings', query: name === 'security' ? {} : { tab: name } }) diff --git a/frontend/src/views/settings/components/AlertSettings.vue b/frontend/src/views/settings/components/AlertSettings.vue new file mode 100644 index 0000000..24657e8 --- /dev/null +++ b/frontend/src/views/settings/components/AlertSettings.vue @@ -0,0 +1,89 @@ + + + + +