feat: configurable traffic threshold and Feishu bot alerts
This commit is contained in:
1 parent
3e6e481b79
commit
1273f226fd
16 files changed
+658
-18
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 与签名密钥 | 阻塞真实送达验收 | 管理员在设置页填写,不交给代码或日志 |
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "载入设备模板",
|
||||
|
||||
@@ -54,9 +54,13 @@
|
||||
<template #header>
|
||||
<div class="row-between">
|
||||
<b style="color:var(--color-danger)">{{ $t('logs.alertTitle') }}</b>
|
||||
<div>
|
||||
<el-button v-if="auth.isAdmin()" link type="primary" @click="$router.push('/settings?tab=alerts')">{{ $t('alertSettings.configure') }}</el-button>
|
||||
<el-button link @click="loadAlerts">{{ $t('common.refresh') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="alert-hint">{{ $t('alertSettings.logHint') }}</p>
|
||||
<el-table :data="alerts" size="small" v-loading="alertLoading">
|
||||
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.gatewayTab')" name="gateway">
|
||||
<GatewaySettings />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="auth.isAdmin()" :label="$t('alertSettings.title')" name="alerts">
|
||||
<AlertSettings />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.hostTab')" name="host">
|
||||
<div class="settings-column settings-column-wide">
|
||||
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
|
||||
@@ -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 } })
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="alert-settings">
|
||||
<el-card class="page-card" shadow="never" v-loading="loading">
|
||||
<template #header><b>{{ $t('alertSettings.title') }}</b></template>
|
||||
<p class="setting-hint">{{ $t('alertSettings.hint') }}</p>
|
||||
<el-form label-position="top" class="alert-form">
|
||||
<el-form-item :label="$t('alertSettings.threshold')">
|
||||
<el-input-number v-model="form.rate_threshold_mbps" :min="1" :max="100000" :step="10" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('alertSettings.webhook')">
|
||||
<el-input v-model="form.webhook" type="password" show-password autocomplete="new-password"
|
||||
:placeholder="status.configured ? $t('alertSettings.keepWebhook') : 'https://open.feishu.cn/open-apis/bot/v2/hook/…'" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('alertSettings.secret')">
|
||||
<el-input v-model="form.signing_secret" type="password" show-password autocomplete="new-password"
|
||||
:placeholder="status.signed ? $t('alertSettings.keepSecret') : $t('alertSettings.optional')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('alertSettings.enabled')"><el-switch v-model="form.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
<p class="setting-hint">{{ $t('alertSettings.secretHint') }}</p>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="saving" @click="save">{{ $t('alertSettings.save') }}</el-button>
|
||||
<el-button :disabled="!status.enabled || !status.configured" :loading="testing" @click="test">{{ $t('alertSettings.test') }}</el-button>
|
||||
<el-button v-if="status.configured" type="danger" plain @click="clear">{{ $t('alertSettings.clear') }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../../../api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const status = ref({ enabled: false, configured: false, signed: false })
|
||||
const form = ref({ rate_threshold_mbps: 50, enabled: false, webhook: '', signing_secret: '' })
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = (await api.get('/system/alerts')).data
|
||||
form.value.rate_threshold_mbps = status.value.rate_threshold_mbps
|
||||
form.value.enabled = status.value.enabled
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('alertSettings.loadFailed')) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
status.value = (await api.put('/system/alerts', form.value)).data
|
||||
form.value.webhook = ''
|
||||
form.value.signing_secret = ''
|
||||
ElMessage.success(t('alertSettings.saved'))
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('alertSettings.saveFailed')) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function test() {
|
||||
testing.value = true
|
||||
try { await api.post('/system/alerts/test'); ElMessage.success(t('alertSettings.testSent')) }
|
||||
catch (e) { ElMessage.error(e.response?.data?.error || t('alertSettings.testFailed')) }
|
||||
finally { testing.value = false }
|
||||
}
|
||||
async function clear() {
|
||||
try { await ElMessageBox.confirm(t('alertSettings.clearConfirm'), t('alertSettings.clear'), { type: 'warning' }) }
|
||||
catch { return }
|
||||
try {
|
||||
status.value = (await api.delete('/system/alerts')).data
|
||||
form.value.enabled = false
|
||||
form.value.webhook = ''
|
||||
form.value.signing_secret = ''
|
||||
ElMessage.success(t('alertSettings.cleared'))
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('alertSettings.saveFailed')) }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alert-settings { max-width: 800px; }
|
||||
.setting-hint { color: var(--el-text-color-secondary); line-height: 1.6; font-size: 13px; margin: 0 0 16px; }
|
||||
.alert-form { max-width: 650px; }
|
||||
.alert-form :deep(.el-input-number) { width: 190px; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.actions .el-button + .el-button { margin-left: 0; }
|
||||
</style>
|
||||
Reference in new issue
Block a user