feat: 日志中心 + GRE协议支持 + 流量监控 + 部署脚本
- 新增日志中心功能 (api/logapi.go + Logs.vue) - 新增GRE协议服务支持 (service/gre/) - 新增流量监控组件 (TrafficChart.vue) - 新增L2TP/IPSec测试用例 - 新增日志轮转服务 (logrotate.go) - 新增部署脚本 (scripts/install.sh, uninstall.sh, service文件) - 更新i18n国际化文件 - 优化后端API和前端路由
This commit is contained in:
1 parent
7de1f70905
commit
a14d9e62e6
25 files changed
+2285
-25
No files matched your search
@@ -231,6 +231,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
auth.GET("/tunnels", s.listTunnels)
|
||||
auth.GET("/tunnels/:id", s.getTunnel)
|
||||
auth.GET("/tunnels/:id/status", s.tunnelStatus)
|
||||
auth.GET("/tunnels/:id/traffic/history", s.tunnelTrafficHistory)
|
||||
auth.GET("/tunnels/:id/policy", s.getTrafficPolicy)
|
||||
auth.GET("/ipsec/templates", s.ipsecTemplates)
|
||||
auth.GET("/nodes", s.listNodes)
|
||||
@@ -239,6 +240,11 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
auth.GET("/sessions/log", s.listConnectionLogs)
|
||||
auth.GET("/traffic/alerts", s.listTrafficAlerts)
|
||||
|
||||
// 日志中心(分页、筛选、导出)
|
||||
auth.GET("/audit-logs", s.listAuditPaged)
|
||||
auth.GET("/connection-logs", s.listConnectionLogsPaged)
|
||||
auth.GET("/logs/stats", s.logStats)
|
||||
|
||||
// 专线运维 operator+
|
||||
ops := auth.Group("")
|
||||
ops.Use(requireRole("admin", "operator"))
|
||||
@@ -305,6 +311,9 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
admin.PUT("/plugins/:id", s.updatePlugin)
|
||||
admin.DELETE("/plugins/:id", s.deletePlugin)
|
||||
admin.GET("/audit", s.listAudit)
|
||||
admin.GET("/audit-logs/export", s.exportAuditLogs)
|
||||
admin.GET("/connection-logs/export", s.exportConnectionLogs)
|
||||
admin.POST("/logs/cleanup", s.cleanupLogs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1031,6 +1040,112 @@ func (s *Server) tunnelStatus(c *gin.Context) {
|
||||
c.JSON(200, st)
|
||||
}
|
||||
|
||||
// tunnelTrafficHistory 返回隧道流量时间序列数据
|
||||
// granularity: minute(最近 1 小时,每分钟聚合), hour(默认,最近 24 小时,每小时聚合), day(最近 30 天,每天聚合)
|
||||
// hours: 覆盖时间范围(默认 24)
|
||||
func (s *Server) tunnelTrafficHistory(c *gin.Context) {
|
||||
t, err := s.mustTunnel(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
|
||||
if hours < 1 {
|
||||
hours = 1
|
||||
}
|
||||
if hours > 720 { // max 30 days
|
||||
hours = 720
|
||||
}
|
||||
granularity := c.DefaultQuery("granularity", "hour")
|
||||
if granularity != "minute" && granularity != "hour" && granularity != "day" {
|
||||
granularity = "hour"
|
||||
}
|
||||
|
||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
type row struct {
|
||||
Time time.Time `json:"time"`
|
||||
Rx int64 `json:"rx"`
|
||||
Tx int64 `json:"tx"`
|
||||
DeltaRx int64 `json:"delta_rx"`
|
||||
DeltaTx int64 `json:"delta_tx"`
|
||||
}
|
||||
|
||||
var results []row
|
||||
|
||||
switch granularity {
|
||||
case "minute":
|
||||
// Group by minute for fine granularity (last 1 hour typical)
|
||||
var raw []model.TrafficSample
|
||||
store.DB.Where("tunnel_id = ? AND created_at >= ?", t.ID, since).
|
||||
Order("created_at asc").Find(&raw)
|
||||
// Bucket by minute
|
||||
buckets := map[time.Time]*row{}
|
||||
for _, s := range raw {
|
||||
key := s.CreatedAt.Truncate(time.Minute)
|
||||
if _, ok := buckets[key]; !ok {
|
||||
buckets[key] = &row{Time: key}
|
||||
}
|
||||
b := buckets[key]
|
||||
b.Rx = s.RxBytes // last value in bucket wins
|
||||
b.Tx = s.TxBytes
|
||||
b.DeltaRx += s.DeltaRx
|
||||
b.DeltaTx += s.DeltaTx
|
||||
}
|
||||
for _, v := range buckets {
|
||||
results = append(results, *v)
|
||||
}
|
||||
case "day":
|
||||
// Group by day
|
||||
var raw []model.TrafficSample
|
||||
store.DB.Where("tunnel_id = ? AND created_at >= ?", t.ID, since).
|
||||
Order("created_at asc").Find(&raw)
|
||||
buckets := map[time.Time]*row{}
|
||||
for _, s := range raw {
|
||||
key := time.Date(s.CreatedAt.Year(), s.CreatedAt.Month(), s.CreatedAt.Day(), 0, 0, 0, 0, s.CreatedAt.Location())
|
||||
if _, ok := buckets[key]; !ok {
|
||||
buckets[key] = &row{Time: key}
|
||||
}
|
||||
b := buckets[key]
|
||||
b.Rx = s.RxBytes
|
||||
b.Tx = s.TxBytes
|
||||
b.DeltaRx += s.DeltaRx
|
||||
b.DeltaTx += s.DeltaTx
|
||||
}
|
||||
for _, v := range buckets {
|
||||
results = append(results, *v)
|
||||
}
|
||||
default: // "hour"
|
||||
var raw []model.TrafficSample
|
||||
store.DB.Where("tunnel_id = ? AND created_at >= ?", t.ID, since).
|
||||
Order("created_at asc").Find(&raw)
|
||||
buckets := map[time.Time]*row{}
|
||||
for _, s := range raw {
|
||||
key := s.CreatedAt.Truncate(time.Hour)
|
||||
if _, ok := buckets[key]; !ok {
|
||||
buckets[key] = &row{Time: key}
|
||||
}
|
||||
b := buckets[key]
|
||||
b.Rx = s.RxBytes
|
||||
b.Tx = s.TxBytes
|
||||
b.DeltaRx += s.DeltaRx
|
||||
b.DeltaTx += s.DeltaTx
|
||||
}
|
||||
for _, v := range buckets {
|
||||
results = append(results, *v)
|
||||
}
|
||||
}
|
||||
|
||||
if results == nil {
|
||||
results = []row{}
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"tunnel_id": t.ID,
|
||||
"granularity": granularity,
|
||||
"hours": hours,
|
||||
"samples": results,
|
||||
})
|
||||
}
|
||||
|
||||
// tunnelLogs 通过 SSE 实时推送专线实例日志。
|
||||
// 读取 <data>/instances/<id>-<protocol>/instance.log,先发送尾部 100 行历史,再 tail 新增行。
|
||||
func (s *Server) tunnelLogs(c *gin.Context) {
|
||||
|
||||
@@ -224,7 +224,7 @@ func (s *Server) batchSetPolicy(c *gin.Context) {
|
||||
policy.ResetCycle = req.ResetCycle
|
||||
}
|
||||
if err := store.DB.Save(&policy).Error; err != nil {
|
||||
errors = append(errors, fmt.Sprintf("隧道 %d 策略保存失败: %s", t.Name, err.Error()))
|
||||
errors = append(errors, fmt.Sprintf("隧道 %s 策略保存失败: %s", t.Name, err.Error()))
|
||||
continue
|
||||
}
|
||||
success++
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// Package api 日志管理 API:审计日志/连接日志的分页、筛选、导出、清理
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// ---- 审计日志 ----
|
||||
|
||||
// listAuditPaged 分页查询审计日志(支持筛选)
|
||||
// GET /api/audit-logs?page=1&page_size=20&username=xxx&action=xxx&keyword=xxx&start=2026-01-01&end=2026-12-31
|
||||
func (s *Server) listAuditPaged(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
q := store.DB.Model(&model.AuditLog{})
|
||||
|
||||
if v := c.Query("username"); v != "" {
|
||||
q = q.Where("username = ?", v)
|
||||
}
|
||||
if v := c.Query("action"); v != "" {
|
||||
q = q.Where("action LIKE ?", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("keyword"); v != "" {
|
||||
q = q.Where("(target LIKE ? OR detail LIKE ?)", "%"+v+"%", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("start"); v != "" {
|
||||
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||
q = q.Where("created_at >= ?", t)
|
||||
}
|
||||
}
|
||||
if v := c.Query("end"); v != "" {
|
||||
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||
q = q.Where("created_at < ?", t.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
|
||||
var logs []model.AuditLog
|
||||
q.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"items": logs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// exportAuditLogs 导出审计日志为 CSV
|
||||
// GET /api/audit-logs/export?username=xxx&action=xxx&start=xxx&end=xxx
|
||||
func (s *Server) exportAuditLogs(c *gin.Context) {
|
||||
q := store.DB.Model(&model.AuditLog{})
|
||||
if v := c.Query("username"); v != "" {
|
||||
q = q.Where("username = ?", v)
|
||||
}
|
||||
if v := c.Query("action"); v != "" {
|
||||
q = q.Where("action LIKE ?", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("keyword"); v != "" {
|
||||
q = q.Where("(target LIKE ? OR detail LIKE ?)", "%"+v+"%", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("start"); v != "" {
|
||||
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||
q = q.Where("created_at >= ?", t)
|
||||
}
|
||||
}
|
||||
if v := c.Query("end"); v != "" {
|
||||
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||
q = q.Where("created_at < ?", t.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
var logs []model.AuditLog
|
||||
q.Order("id desc").Limit(10000).Find(&logs)
|
||||
|
||||
filename := fmt.Sprintf("audit-logs-%s.csv", time.Now().Format("20060102-150405"))
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM
|
||||
|
||||
w := csv.NewWriter(c.Writer)
|
||||
w.Write([]string{"ID", "时间", "用户", "操作", "目标", "详情"})
|
||||
for _, l := range logs {
|
||||
w.Write([]string{
|
||||
strconv.FormatUint(uint64(l.ID), 10),
|
||||
l.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
l.Username,
|
||||
l.Action,
|
||||
l.Target,
|
||||
l.Detail,
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// ---- 连接日志 ----
|
||||
|
||||
// listConnectionLogsPaged 分页查询连接日志
|
||||
// GET /api/connection-logs?page=1&page_size=20&username=xxx&event=xxx&tunnel_id=xxx&start=xxx&end=xxx
|
||||
func (s *Server) listConnectionLogsPaged(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
q := store.DB.Model(&model.ConnectionLog{})
|
||||
|
||||
if v := c.Query("username"); v != "" {
|
||||
q = q.Where("username = ?", v)
|
||||
}
|
||||
if v := c.Query("event"); v != "" {
|
||||
events := strings.Split(v, ",")
|
||||
q = q.Where("event IN ?", events)
|
||||
}
|
||||
if v := c.Query("tunnel_id"); v != "" {
|
||||
if tid, err := strconv.Atoi(v); err == nil {
|
||||
q = q.Where("tunnel_id = ?", tid)
|
||||
}
|
||||
}
|
||||
if v := c.Query("keyword"); v != "" {
|
||||
q = q.Where("(detail LIKE ? OR ip_address LIKE ?)", "%"+v+"%", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("start"); v != "" {
|
||||
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||
q = q.Where("created_at >= ?", t)
|
||||
}
|
||||
}
|
||||
if v := c.Query("end"); v != "" {
|
||||
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||
q = q.Where("created_at < ?", t.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
|
||||
var logs []model.ConnectionLog
|
||||
q.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"items": logs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// exportConnectionLogs 导出连接日志为 CSV
|
||||
func (s *Server) exportConnectionLogs(c *gin.Context) {
|
||||
q := store.DB.Model(&model.ConnectionLog{})
|
||||
if v := c.Query("username"); v != "" {
|
||||
q = q.Where("username = ?", v)
|
||||
}
|
||||
if v := c.Query("event"); v != "" {
|
||||
events := strings.Split(v, ",")
|
||||
q = q.Where("event IN ?", events)
|
||||
}
|
||||
if v := c.Query("tunnel_id"); v != "" {
|
||||
if tid, err := strconv.Atoi(v); err == nil {
|
||||
q = q.Where("tunnel_id = ?", tid)
|
||||
}
|
||||
}
|
||||
if v := c.Query("start"); v != "" {
|
||||
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||
q = q.Where("created_at >= ?", t)
|
||||
}
|
||||
}
|
||||
if v := c.Query("end"); v != "" {
|
||||
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||
q = q.Where("created_at < ?", t.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
var logs []model.ConnectionLog
|
||||
q.Order("id desc").Limit(10000).Find(&logs)
|
||||
|
||||
filename := fmt.Sprintf("connection-logs-%s.csv", time.Now().Format("20060102-150405"))
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
|
||||
w := csv.NewWriter(c.Writer)
|
||||
w.Write([]string{"ID", "时间", "隧道ID", "用户名", "事件", "IP地址", "详情"})
|
||||
for _, l := range logs {
|
||||
w.Write([]string{
|
||||
strconv.FormatUint(uint64(l.ID), 10),
|
||||
l.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
strconv.FormatUint(uint64(l.TunnelID), 10),
|
||||
l.Username,
|
||||
l.Event,
|
||||
l.IPAddress,
|
||||
l.Detail,
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// ---- 日志清理 ----
|
||||
|
||||
// cleanupLogs 清理过期日志
|
||||
// POST /api/logs/cleanup {"days": 30}
|
||||
func (s *Server) cleanupLogs(c *gin.Context) {
|
||||
var req struct {
|
||||
Days int `json:"days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Days < 1 {
|
||||
req.Days = 30
|
||||
}
|
||||
|
||||
cutoff := time.Now().AddDate(0, 0, -req.Days)
|
||||
|
||||
var auditDeleted, connDeleted, sessionDeleted int64
|
||||
|
||||
store.DB.Where("created_at < ?", cutoff).Delete(&model.AuditLog{}).Count(&auditDeleted)
|
||||
store.DB.Where("created_at < ?", cutoff).Delete(&model.ConnectionLog{}).Count(&connDeleted)
|
||||
store.DB.Where("connected_at < ? AND status = ?", cutoff, "disconnected").Delete(&model.VPNSession{}).Count(&sessionDeleted)
|
||||
|
||||
store.Audit(c.GetString("username"), "cleanup_logs", "", fmt.Sprintf("清理 %d 天前日志: 审计=%d 连接=%d 会话=%d", req.Days, auditDeleted, connDeleted, sessionDeleted))
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"ok": true,
|
||||
"audit_deleted": auditDeleted,
|
||||
"conn_deleted": connDeleted,
|
||||
"session_deleted": sessionDeleted,
|
||||
"cutoff": cutoff.Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
|
||||
// logStats 日志统计(各表记录数 + 最早/最新记录时间)
|
||||
// GET /api/logs/stats
|
||||
func (s *Server) logStats(c *gin.Context) {
|
||||
type stat struct {
|
||||
Count int64 `json:"count"`
|
||||
Oldest string `json:"oldest"`
|
||||
Newest string `json:"newest"`
|
||||
}
|
||||
|
||||
getStat := func(modelName interface{}) stat {
|
||||
var st stat
|
||||
store.DB.Model(modelName).Count(&st.Count)
|
||||
// SQLite 的 MIN/MAX 返回字符串,直接扫 string 更兼容
|
||||
store.DB.Model(modelName).Select("COALESCE(MIN(created_at),'')").Scan(&st.Oldest)
|
||||
store.DB.Model(modelName).Select("COALESCE(MAX(created_at),'')").Scan(&st.Newest)
|
||||
return st
|
||||
}
|
||||
|
||||
// vpn_sessions 用 connected_at
|
||||
getSessionStat := func() stat {
|
||||
var st stat
|
||||
store.DB.Model(&model.VPNSession{}).Count(&st.Count)
|
||||
store.DB.Model(&model.VPNSession{}).Select("COALESCE(MIN(connected_at),'')").Scan(&st.Oldest)
|
||||
store.DB.Model(&model.VPNSession{}).Select("COALESCE(MAX(connected_at),'')").Scan(&st.Newest)
|
||||
return st
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"audit": getStat(&model.AuditLog{}),
|
||||
"connection": getStat(&model.ConnectionLog{}),
|
||||
"session": getSessionStat(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 自动清理(启动时调用)----
|
||||
|
||||
// AutoCleanupLogs 自动清理超过 keepDays 天的旧日志(0 = 不清理)
|
||||
func AutoCleanupLogs(keepDays int) {
|
||||
if keepDays <= 0 || store.DB == nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -keepDays)
|
||||
store.DB.Where("created_at < ?", cutoff).Delete(&model.AuditLog{})
|
||||
store.DB.Where("created_at < ?", cutoff).Delete(&model.ConnectionLog{})
|
||||
store.DB.Where("connected_at < ? AND status = ?", cutoff, "disconnected").Delete(&model.VPNSession{})
|
||||
store.DB.Where("created_at < ?", cutoff).Delete(&model.TrafficSample{}) // 保留最近 30 天流量采样
|
||||
}
|
||||
Reference in new issue
Block a user