feat: configurable traffic threshold and Feishu bot alerts

This commit is contained in:
Codex committed 2026-10-23 17:49:19 +08:00
1 parent 3e6e481b79
commit 1273f226fd
16 files changed
+660 -20

No files matched your search

+262
View File
@@ -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")
}
}
+52
View File
@@ -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)
}
+4
View File
@@ -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)
+9
View File
@@ -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"`
+1 -1
View File
@@ -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) {
+4 -1
View File
@@ -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)
}
}
+22 -15
View File
@@ -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)
}
}
}