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
@@ -43,3 +43,4 @@ vendor/
|
|||||||
# 环境
|
# 环境
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.nfs*
|
||||||
@@ -231,6 +231,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
|||||||
auth.GET("/tunnels", s.listTunnels)
|
auth.GET("/tunnels", s.listTunnels)
|
||||||
auth.GET("/tunnels/:id", s.getTunnel)
|
auth.GET("/tunnels/:id", s.getTunnel)
|
||||||
auth.GET("/tunnels/:id/status", s.tunnelStatus)
|
auth.GET("/tunnels/:id/status", s.tunnelStatus)
|
||||||
|
auth.GET("/tunnels/:id/traffic/history", s.tunnelTrafficHistory)
|
||||||
auth.GET("/tunnels/:id/policy", s.getTrafficPolicy)
|
auth.GET("/tunnels/:id/policy", s.getTrafficPolicy)
|
||||||
auth.GET("/ipsec/templates", s.ipsecTemplates)
|
auth.GET("/ipsec/templates", s.ipsecTemplates)
|
||||||
auth.GET("/nodes", s.listNodes)
|
auth.GET("/nodes", s.listNodes)
|
||||||
@@ -239,6 +240,11 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
|||||||
auth.GET("/sessions/log", s.listConnectionLogs)
|
auth.GET("/sessions/log", s.listConnectionLogs)
|
||||||
auth.GET("/traffic/alerts", s.listTrafficAlerts)
|
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+
|
// 专线运维 operator+
|
||||||
ops := auth.Group("")
|
ops := auth.Group("")
|
||||||
ops.Use(requireRole("admin", "operator"))
|
ops.Use(requireRole("admin", "operator"))
|
||||||
@@ -305,6 +311,9 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
|||||||
admin.PUT("/plugins/:id", s.updatePlugin)
|
admin.PUT("/plugins/:id", s.updatePlugin)
|
||||||
admin.DELETE("/plugins/:id", s.deletePlugin)
|
admin.DELETE("/plugins/:id", s.deletePlugin)
|
||||||
admin.GET("/audit", s.listAudit)
|
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)
|
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 实时推送专线实例日志。
|
// tunnelLogs 通过 SSE 实时推送专线实例日志。
|
||||||
// 读取 <data>/instances/<id>-<protocol>/instance.log,先发送尾部 100 行历史,再 tail 新增行。
|
// 读取 <data>/instances/<id>-<protocol>/instance.log,先发送尾部 100 行历史,再 tail 新增行。
|
||||||
func (s *Server) tunnelLogs(c *gin.Context) {
|
func (s *Server) tunnelLogs(c *gin.Context) {
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ func (s *Server) batchSetPolicy(c *gin.Context) {
|
|||||||
policy.ResetCycle = req.ResetCycle
|
policy.ResetCycle = req.ResetCycle
|
||||||
}
|
}
|
||||||
if err := store.DB.Save(&policy).Error; err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
success++
|
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 天流量采样
|
||||||
|
}
|
||||||
@@ -3,6 +3,17 @@ package model
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// TrafficSample 流量时间序列采样(每 60 秒一次,用于趋势图) // Logan.Li
|
||||||
|
type TrafficSample struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
TunnelID uint `gorm:"index" json:"tunnel_id"`
|
||||||
|
RxBytes int64 `json:"rx_bytes"`
|
||||||
|
TxBytes int64 `json:"tx_bytes"`
|
||||||
|
DeltaRx int64 `json:"delta_rx"` // bytes since last sample
|
||||||
|
DeltaTx int64 `json:"delta_tx"`
|
||||||
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// User 平台账号
|
// User 平台账号
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
package monitor
|
package monitor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vpn-platform/backend/internal/core"
|
"vpn-platform/backend/internal/core"
|
||||||
"vpn-platform/backend/internal/model"
|
"vpn-platform/backend/internal/model"
|
||||||
|
"vpn-platform/backend/internal/service"
|
||||||
"vpn-platform/backend/internal/sessionmgr"
|
"vpn-platform/backend/internal/sessionmgr"
|
||||||
"vpn-platform/backend/internal/store"
|
"vpn-platform/backend/internal/store"
|
||||||
"vpn-platform/backend/internal/trafficmgr"
|
"vpn-platform/backend/internal/trafficmgr"
|
||||||
@@ -13,6 +19,13 @@ import (
|
|||||||
|
|
||||||
const collectInterval = 15 * time.Second
|
const collectInterval = 15 * time.Second
|
||||||
|
|
||||||
|
// sampleEvery controls how many collect() rounds between TrafficSample inserts.
|
||||||
|
// Default 4 rounds × 15 s ≈ 60 s.
|
||||||
|
var sampleEvery int64 = 4
|
||||||
|
|
||||||
|
// tickCounter is incremented on each collect(); a sample is taken when tick % sampleEvery == 0.
|
||||||
|
var tickCounter int64
|
||||||
|
|
||||||
// Start 启动状态采集循环(每 15 秒刷新一次)
|
// Start 启动状态采集循环(每 15 秒刷新一次)
|
||||||
func Start(mgr *core.Manager, interval time.Duration) {
|
func Start(mgr *core.Manager, interval time.Duration) {
|
||||||
if interval == 0 {
|
if interval == 0 {
|
||||||
@@ -35,6 +48,11 @@ func collect(mgr *core.Manager, interval time.Duration) {
|
|||||||
if err := store.DB.Find(&tunnels).Error; err != nil {
|
if err := store.DB.Find(&tunnels).Error; err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decide whether to insert a TrafficSample this round.
|
||||||
|
tick := atomic.AddInt64(&tickCounter, 1)
|
||||||
|
shouldSample := tick%sampleEvery == 0
|
||||||
|
|
||||||
for i := range tunnels {
|
for i := range tunnels {
|
||||||
t := &tunnels[i]
|
t := &tunnels[i]
|
||||||
p, err := mgr.Get(t.Protocol)
|
p, err := mgr.Get(t.Protocol)
|
||||||
@@ -67,6 +85,22 @@ func collect(mgr *core.Manager, interval time.Duration) {
|
|||||||
store.DB.Model(t).Updates(map[string]interface{}{"rx_bytes": st.RxBytes, "tx_bytes": st.TxBytes})
|
store.DB.Model(t).Updates(map[string]interface{}{"rx_bytes": st.RxBytes, "tx_bytes": st.TxBytes})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- TrafficSample 时间序列写入(每 ~60 秒)---- // Logan.Li
|
||||||
|
if shouldSample && (deltaRx > 0 || deltaTx > 0) {
|
||||||
|
store.DB.Create(&model.TrafficSample{
|
||||||
|
TunnelID: t.ID,
|
||||||
|
RxBytes: st.RxBytes,
|
||||||
|
TxBytes: st.TxBytes,
|
||||||
|
DeltaRx: deltaRx,
|
||||||
|
DeltaTx: deltaTx,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 用户级流量同步(IKEv2 / L2TP / IPSec)---- // Logan.Li
|
||||||
|
if st.Running && (t.Protocol == "ikev2" || t.Protocol == "l2tp" || t.Protocol == "ipsec") {
|
||||||
|
syncUserTraffic(t, st)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 会话同步(阶段 1)----
|
// ---- 会话同步(阶段 1)----
|
||||||
if st.Running {
|
if st.Running {
|
||||||
// WireGuard 等多对端协议:从 Peers 同步
|
// WireGuard 等多对端协议:从 Peers 同步
|
||||||
@@ -106,6 +140,165 @@ func collect(mgr *core.Manager, interval time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncUserTraffic 从 swanctl --list-sas 输出中提取 per-CHILD_SA 字节计数,
|
||||||
|
// 按虚拟 IP 匹配 VPNUser 并更新 rx_bytes / tx_bytes。
|
||||||
|
// 当前仅对 IKEv2 和 L2TP 协议生效(IPSec 站点互联无拨号用户)。
|
||||||
|
// 匹配策略:遍历 VPNUser,查找其虚拟 IP 是否出现在 swanctl SA 条目中。
|
||||||
|
// 若无法精确匹配,则按隧道总流量均摊到所有在线用户(近似值)。
|
||||||
|
func syncUserTraffic(t *model.Tunnel, st core.Status) {
|
||||||
|
bin, ok := service.LookBin("swanctl")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sasOut, err := service.Run(bin, "--list-sas")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载该隧道的拨号用户
|
||||||
|
var users []model.VPNUser
|
||||||
|
if err := store.DB.Where("tunnel_id = ?", t.ID).Find(&users).Error; err != nil || len(users) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 swanctl 输出解析 virtual IP → (rx, tx) 映射
|
||||||
|
// 典型 swanctl --list-sas 格式:
|
||||||
|
// ikev2-rw-1: #1, ESTABLISHED, ...
|
||||||
|
// net-1{1}: INSTALLED, TUNNEL, ...
|
||||||
|
// bytes_i = 1234, bytes_o = 5678
|
||||||
|
// local_ts = 0.0.0.0/0
|
||||||
|
// remote_ts = 10.12.0.1/32 ← 虚拟 IP
|
||||||
|
type saBytes struct {
|
||||||
|
rx, tx int64
|
||||||
|
vip string // remote virtual IP
|
||||||
|
}
|
||||||
|
var sas []saBytes
|
||||||
|
|
||||||
|
lines := strings.Split(sasOut, "\n")
|
||||||
|
var currentSA *saBytes
|
||||||
|
connPrefix := ""
|
||||||
|
switch t.Protocol {
|
||||||
|
case "ikev2":
|
||||||
|
connPrefix = fmt.Sprintf("ikev2-rw-%d", t.ID)
|
||||||
|
case "l2tp":
|
||||||
|
connPrefix = fmt.Sprintf("l2tp-%d", t.ID)
|
||||||
|
case "ipsec":
|
||||||
|
connPrefix = fmt.Sprintf("tunnel-%d", t.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
// Detect child SA header (INSTALLED line under the connection)
|
||||||
|
if strings.Contains(trimmed, "INSTALLED") && strings.Contains(trimmed, connPrefix) {
|
||||||
|
sas = append(sas, saBytes{})
|
||||||
|
currentSA = &sas[len(sas)-1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if currentSA == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Parse bytes_i / bytes_o
|
||||||
|
if strings.Contains(trimmed, "bytes_i") || strings.Contains(trimmed, "bytes_o") {
|
||||||
|
parts := strings.Fields(trimmed)
|
||||||
|
for j, p := range parts {
|
||||||
|
if strings.HasPrefix(p, "bytes_i") && j > 0 {
|
||||||
|
if v, e := strconv.ParseInt(strings.TrimSuffix(parts[j-1], ","), 10, 64); e == nil {
|
||||||
|
currentSA.rx = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(p, "bytes_o") && j > 0 {
|
||||||
|
if v, e := strconv.ParseInt(strings.TrimSuffix(parts[j-1], ","), 10, 64); e == nil {
|
||||||
|
currentSA.tx = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Parse remote_ts to extract virtual IP (e.g. "10.12.0.1/32" → "10.12.0.1")
|
||||||
|
if strings.Contains(trimmed, "remote_ts") {
|
||||||
|
for _, part := range strings.Split(trimmed, "=") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if strings.Contains(part, "/") {
|
||||||
|
ip := strings.Split(part, "/")[0]
|
||||||
|
ip = strings.TrimSpace(ip)
|
||||||
|
if ip != "" && ip != "0.0.0.0" {
|
||||||
|
currentSA.vip = ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// End of child SA block (blank line or new connection)
|
||||||
|
if trimmed == "" && currentSA != nil {
|
||||||
|
currentSA = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build vip → user map from VPNUser virtual addresses stored in sessions
|
||||||
|
// Fallback: match by username pattern or distribute evenly
|
||||||
|
type userTraffic struct {
|
||||||
|
userID uint
|
||||||
|
rx, tx int64
|
||||||
|
}
|
||||||
|
updates := map[uint]*userTraffic{}
|
||||||
|
|
||||||
|
// Try to match SAs to users via VPNSession virtual addresses
|
||||||
|
var sessions []model.VPNSession
|
||||||
|
store.DB.Where("tunnel_id = ? AND status = ?", t.ID, "active").Find(&sessions)
|
||||||
|
vipToUser := map[string]uint{}
|
||||||
|
for _, sess := range sessions {
|
||||||
|
if sess.VirtualAddress != "" {
|
||||||
|
vipToUser[sess.VirtualAddress] = sess.VPNUserID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matched := 0
|
||||||
|
for _, sa := range sas {
|
||||||
|
if sa.vip == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if uid, ok := vipToUser[sa.vip]; ok {
|
||||||
|
if _, exists := updates[uid]; !exists {
|
||||||
|
updates[uid] = &userTraffic{userID: uid}
|
||||||
|
}
|
||||||
|
updates[uid].rx += sa.rx
|
||||||
|
updates[uid].tx += sa.tx
|
||||||
|
matched++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no SA matched by VIP, and there's exactly one active user, assign all to them
|
||||||
|
if matched == 0 && len(users) == 1 && (st.RxBytes > 0 || st.TxBytes > 0) {
|
||||||
|
updates[users[0].ID] = &userTraffic{userID: users[0].ID, rx: st.RxBytes, tx: st.TxBytes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to DB (only update if new > old to avoid counter resets)
|
||||||
|
for _, ut := range updates {
|
||||||
|
var user model.VPNUser
|
||||||
|
if err := store.DB.First(&user, ut.userID).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newRx := ut.rx
|
||||||
|
newTx := ut.tx
|
||||||
|
if newRx < user.RxBytes {
|
||||||
|
newRx = user.RxBytes // keep old if counter wrapped
|
||||||
|
}
|
||||||
|
if newTx < user.TxBytes {
|
||||||
|
newTx = user.TxBytes
|
||||||
|
}
|
||||||
|
if newRx > user.RxBytes || newTx > user.TxBytes {
|
||||||
|
now := time.Now()
|
||||||
|
store.DB.Model(&user).Updates(map[string]interface{}{
|
||||||
|
"rx_bytes": newRx,
|
||||||
|
"tx_bytes": newTx,
|
||||||
|
"last_seen_at": now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) > 0 {
|
||||||
|
log.Printf("[monitor] syncUserTraffic tunnel=%d protocol=%s matched_users=%d", t.ID, t.Protocol, len(updates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AutoStartAll 启动时拉起所有标记为自启的专线
|
// AutoStartAll 启动时拉起所有标记为自启的专线
|
||||||
func AutoStartAll(mgr *core.Manager) {
|
func AutoStartAll(mgr *core.Manager) {
|
||||||
var tunnels []model.Tunnel
|
var tunnels []model.Tunnel
|
||||||
|
|||||||
@@ -120,7 +120,11 @@ func StartDaemon(t *model.Tunnel, name string, args ...string) error {
|
|||||||
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
|
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logFile, err := os.OpenFile(filepath.Join(InstanceDir(t), "instance.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
logPath := filepath.Join(InstanceDir(t), "instance.log")
|
||||||
|
// 日志轮转:默认 50MB x 2 文件,可通过 VPN_INSTANCE_LOG_MAX_SIZE / VPN_INSTANCE_LOG_MAX_FILES 覆盖
|
||||||
|
logRotateCfg := DefaultRotateConfig("VPN_INSTANCE_LOG_MAX_SIZE", 50*1024*1024, 2)
|
||||||
|
_ = RotateIfNeeded(logPath, logRotateCfg)
|
||||||
|
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
// Package gre 实现 GRE 隧道管理(Linux ip tunnel)。
|
||||||
|
// GRE 是简单的 IP-over-IP 封装,依赖 iproute2 的 ip 命令。
|
||||||
|
package gre
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"vpn-platform/backend/internal/core"
|
||||||
|
"vpn-platform/backend/internal/model"
|
||||||
|
"vpn-platform/backend/internal/service"
|
||||||
|
"vpn-platform/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct{}
|
||||||
|
|
||||||
|
func New() *Service { return &Service{} }
|
||||||
|
|
||||||
|
func (s *Service) Name() string { return "gre" }
|
||||||
|
func (s *Service) DisplayName() string { return "GRE 隧道" }
|
||||||
|
|
||||||
|
func (s *Service) Available() (bool, string) {
|
||||||
|
if p, ok := service.LookBin("ip"); ok {
|
||||||
|
return true, p
|
||||||
|
}
|
||||||
|
return false, "未检测到 iproute2(ip),GRE 隧道需要 ip 命令"
|
||||||
|
}
|
||||||
|
|
||||||
|
func ifaceName(t *model.Tunnel) string {
|
||||||
|
return fmt.Sprintf("gre%d", t.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||||
|
if err := service.MustValidate(cfg, "local_endpoint", "remote_endpoint", "local_tunnel_ip", "remote_tunnel_ip"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := service.CloneConfig(cfg)
|
||||||
|
|
||||||
|
// Validate IPs
|
||||||
|
if _, err := netip.ParseAddr(service.Str(out, "local_endpoint")); err != nil {
|
||||||
|
return nil, fmt.Errorf("local_endpoint 必须是有效的 IP 地址")
|
||||||
|
}
|
||||||
|
if _, err := netip.ParseAddr(service.Str(out, "remote_endpoint")); err != nil {
|
||||||
|
return nil, fmt.Errorf("remote_endpoint 必须是有效的 IP 地址")
|
||||||
|
}
|
||||||
|
if _, err := netip.ParsePrefix(service.Str(out, "local_tunnel_ip")); err != nil {
|
||||||
|
return nil, fmt.Errorf("local_tunnel_ip 必须是 CIDR 格式(如 10.10.0.1/30)")
|
||||||
|
}
|
||||||
|
if _, err := netip.ParsePrefix(service.Str(out, "remote_tunnel_ip")); err != nil {
|
||||||
|
return nil, fmt.Errorf("remote_tunnel_ip 必须是 CIDR 格式(如 10.10.0.2/30)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
if service.Num(out, "ttl", 0) == 0 {
|
||||||
|
out["ttl"] = 255
|
||||||
|
}
|
||||||
|
if service.Num(out, "mtu", 0) == 0 {
|
||||||
|
out["mtu"] = 1476
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(t *model.Tunnel) error {
|
||||||
|
cfg, err := store.TunnelConfig(t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg, err = s.Validate(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save canonicalised config
|
||||||
|
public, sec := service.MergeSecrets(cfg)
|
||||||
|
return service.SaveTunnelConfig(t, public, sec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Start(t *model.Tunnel) error {
|
||||||
|
cfg, err := store.TunnelConfig(t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ip, ok := service.LookBin("ip")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("无法启动 GRE:未检测到 ip 命令")
|
||||||
|
}
|
||||||
|
if err := service.RequireRoot("GRE"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ifName := ifaceName(t)
|
||||||
|
localEP := service.Str(cfg, "local_endpoint")
|
||||||
|
remoteEP := service.Str(cfg, "remote_endpoint")
|
||||||
|
ttl := int(service.Num(cfg, "ttl", 255))
|
||||||
|
mtu := int(service.Num(cfg, "mtu", 1476))
|
||||||
|
localTunnelIP := service.Str(cfg, "local_tunnel_ip")
|
||||||
|
|
||||||
|
// Clean up any stale interface first
|
||||||
|
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||||
|
|
||||||
|
// ip tunnel add greN mode gre local X remote Y ttl T
|
||||||
|
if _, err := service.Run(ip, "tunnel", "add", ifName,
|
||||||
|
"mode", "gre",
|
||||||
|
"local", localEP,
|
||||||
|
"remote", remoteEP,
|
||||||
|
"ttl", fmt.Sprintf("%d", ttl),
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("创建 GRE 隧道接口失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ip link set greN mtu M
|
||||||
|
if _, err := service.Run(ip, "link", "set", ifName, "mtu", fmt.Sprintf("%d", mtu)); err != nil {
|
||||||
|
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||||
|
return fmt.Errorf("设置 GRE MTU 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ip addr add local_tunnel_ip dev greN
|
||||||
|
if _, err := service.Run(ip, "addr", "add", localTunnelIP, "dev", ifName); err != nil {
|
||||||
|
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||||
|
return fmt.Errorf("分配 GRE 隧道 IP 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ip link set greN up
|
||||||
|
if _, err := service.Run(ip, "link", "set", ifName, "up"); err != nil {
|
||||||
|
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||||
|
return fmt.Errorf("启用 GRE 接口失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: route remote subnet via remote tunnel IP
|
||||||
|
if remoteSubnet := service.Str(cfg, "remote_subnet"); remoteSubnet != "" {
|
||||||
|
remoteTunnelIP := service.Str(cfg, "remote_tunnel_ip")
|
||||||
|
// Extract just the IP (without prefix) for the route gateway
|
||||||
|
if addr, err := netip.ParsePrefix(remoteTunnelIP); err == nil {
|
||||||
|
gw := addr.Addr().String()
|
||||||
|
_, _ = service.Run(ip, "route", "add", remoteSubnet, "via", gw, "dev", ifName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: NAT / MASQUERADE
|
||||||
|
if service.Bool(cfg, "enable_nat", false) {
|
||||||
|
if err := s.installFirewall(t, cfg); err != nil {
|
||||||
|
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Stop(t *model.Tunnel) error {
|
||||||
|
ip, ok := service.LookBin("ip")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("无法停止 GRE:未检测到 ip 命令")
|
||||||
|
}
|
||||||
|
ifName := ifaceName(t)
|
||||||
|
_, _ = service.Run(ip, "tunnel", "del", ifName)
|
||||||
|
s.removeFirewall(t)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||||
|
ip, ok := service.LookBin("ip")
|
||||||
|
if !ok {
|
||||||
|
return service.NowStatus(false, "undeployed", "未检测到 ip 命令,GRE 隧道未部署"), nil
|
||||||
|
}
|
||||||
|
ifName := ifaceName(t)
|
||||||
|
|
||||||
|
// Check tunnel exists
|
||||||
|
if _, err := service.Run(ip, "tunnel", "show", ifName); err != nil {
|
||||||
|
return service.NowStatus(false, "stopped", "GRE 隧道接口不存在"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check link is UP
|
||||||
|
out, err := service.Run(ip, "link", "show", ifName)
|
||||||
|
if err != nil {
|
||||||
|
return service.NowStatus(false, "stopped", "无法读取 GRE 接口状态"), nil
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "UP") {
|
||||||
|
cfg, _ := store.TunnelConfig(t)
|
||||||
|
detail := fmt.Sprintf("GRE 隧道 %s 运行中", ifName)
|
||||||
|
if le := service.Str(cfg, "local_endpoint"); le != "" {
|
||||||
|
detail += fmt.Sprintf(",本端 %s → 对端 %s", le, service.Str(cfg, "remote_endpoint"))
|
||||||
|
}
|
||||||
|
return service.NowStatus(true, "running", detail), nil
|
||||||
|
}
|
||||||
|
return service.NowStatus(false, "stopped", "GRE 隧道接口存在但未 UP"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Delete(t *model.Tunnel) error {
|
||||||
|
_ = s.Stop(t)
|
||||||
|
return service.RemoveInstance(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||||
|
cfg, err := store.TunnelConfig(t)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ttl := int(service.Num(cfg, "ttl", 255))
|
||||||
|
mtu := int(service.Num(cfg, "mtu", 1476))
|
||||||
|
localEP := service.Str(cfg, "local_endpoint")
|
||||||
|
remoteEP := service.Str(cfg, "remote_endpoint")
|
||||||
|
localTunnelIP := service.Str(cfg, "local_tunnel_ip")
|
||||||
|
remoteTunnelIP := service.Str(cfg, "remote_tunnel_ip")
|
||||||
|
|
||||||
|
// Extract just the address portion for the route gateway
|
||||||
|
gw := remoteTunnelIP
|
||||||
|
if prefix, err := netip.ParsePrefix(remoteTunnelIP); err == nil {
|
||||||
|
gw = prefix.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(`======== GRE 隧道对端参考配置 ========
|
||||||
|
|
||||||
|
本端公网地址(local_endpoint) : %s
|
||||||
|
对端公网地址(remote_endpoint) : %s
|
||||||
|
本端隧道 IP(local_tunnel_ip) : %s
|
||||||
|
对端隧道 IP(remote_tunnel_ip) : %s
|
||||||
|
TTL : %d
|
||||||
|
MTU : %d
|
||||||
|
|
||||||
|
----- 对端配置指引 -----
|
||||||
|
ip tunnel add greN mode gre local %s remote %s ttl %d
|
||||||
|
ip addr add %s dev greN
|
||||||
|
ip link set greN up
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 双端 local_endpoint / remote_endpoint 需交叉对应
|
||||||
|
- 隧道 IP 建议使用同一 /30 子网
|
||||||
|
- 如需 NAT 请在各自防火墙中添加 MASQUERADE 规则
|
||||||
|
- remote_subnet 路由示例: ip route add <子网> via %s dev greN
|
||||||
|
`,
|
||||||
|
localEP,
|
||||||
|
remoteEP,
|
||||||
|
localTunnelIP,
|
||||||
|
remoteTunnelIP,
|
||||||
|
ttl,
|
||||||
|
mtu,
|
||||||
|
remoteEP,
|
||||||
|
localEP,
|
||||||
|
ttl,
|
||||||
|
remoteTunnelIP,
|
||||||
|
gw,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||||
|
outbound := service.Str(cfg, "outbound_interface")
|
||||||
|
if outbound == "" {
|
||||||
|
var err error
|
||||||
|
outbound, err = service.DefaultRouteInterface()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("无法确定 NAT 出口接口,请设置 outbound_interface: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comment := fmt.Sprintf("opsx-vpn-gre-%d", t.ID)
|
||||||
|
remoteSubnet := service.Str(cfg, "remote_subnet")
|
||||||
|
if remoteSubnet == "" {
|
||||||
|
// Use the tunnel IP prefix as the source for MASQUERADE
|
||||||
|
if prefix, err := netip.ParsePrefix(service.Str(cfg, "remote_tunnel_ip")); err == nil {
|
||||||
|
remoteSubnet = prefix.Masked().String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if remoteSubnet != "" {
|
||||||
|
rules := []service.FirewallRule{
|
||||||
|
{Table: "filter", Chain: "FORWARD", Args: []string{"-i", ifaceName(t), "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||||
|
{Table: "filter", Chain: "FORWARD", Args: []string{"-o", ifaceName(t), "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||||
|
{Table: "nat", Chain: "POSTROUTING", Args: []string{"-s", remoteSubnet, "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
|
||||||
|
}
|
||||||
|
return service.ApplyFirewallRules(rules)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) removeFirewall(t *model.Tunnel) {
|
||||||
|
comment := fmt.Sprintf("opsx-vpn-gre-%d", t.ID)
|
||||||
|
iptables, ok := service.LookBin("iptables")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Remove rules matching our comment in FORWARD and POSTROUTING chains
|
||||||
|
for _, chain := range []struct{ table, name string }{
|
||||||
|
{"filter", "FORWARD"},
|
||||||
|
{"nat", "POSTROUTING"},
|
||||||
|
} {
|
||||||
|
for {
|
||||||
|
out, err := service.Run(iptables, "-t", chain.table, "-L", chain.name, "-n", "--line-numbers")
|
||||||
|
if err != nil || !strings.Contains(out, comment) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if !strings.Contains(line, comment) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) > 0 {
|
||||||
|
_, _ = service.Run(iptables, "-t", chain.table, "-D", chain.name, fields[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package ipsec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"vpn-platform/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateDefaults(t *testing.T) {
|
||||||
|
cfg, err := New().Validate(map[string]interface{}{
|
||||||
|
"remote_gateway": "203.0.113.1",
|
||||||
|
"local_subnets": "192.168.1.0/24",
|
||||||
|
"remote_subnets": "192.168.2.0/24",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defaults := []struct {
|
||||||
|
key string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"ike_version", "ikev2"},
|
||||||
|
{"ike_proposal", "aes256-sha256-modp2048"},
|
||||||
|
{"esp_proposal", "aes256-sha256"},
|
||||||
|
{"dpd_action", "restart"},
|
||||||
|
{"local_id", "@vpn-local"},
|
||||||
|
{"remote_id", "@vpn-remote"},
|
||||||
|
{"pfs_group", "modp2048"},
|
||||||
|
}
|
||||||
|
for _, d := range defaults {
|
||||||
|
if got := service.Str(cfg, d.key); got != d.expected {
|
||||||
|
t.Errorf("%s: got %q, want %q", d.key, got, d.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if service.Num(cfg, "ike_lifetime", 0) != 86400 {
|
||||||
|
t.Errorf("ike_lifetime: got %v, want 86400", cfg["ike_lifetime"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsInvalid(t *testing.T) {
|
||||||
|
s := New()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cfg map[string]interface{}
|
||||||
|
}{
|
||||||
|
{"missing remote_gateway", map[string]interface{}{
|
||||||
|
"local_subnets": "10.0.0.0/24", "remote_subnets": "10.1.0.0/24",
|
||||||
|
}},
|
||||||
|
{"missing local_subnets", map[string]interface{}{
|
||||||
|
"remote_gateway": "1.2.3.4", "remote_subnets": "10.1.0.0/24",
|
||||||
|
}},
|
||||||
|
{"missing remote_subnets", map[string]interface{}{
|
||||||
|
"remote_gateway": "1.2.3.4", "local_subnets": "10.0.0.0/24",
|
||||||
|
}},
|
||||||
|
{"invalid gateway", map[string]interface{}{
|
||||||
|
"remote_gateway": "not a gateway{}", "local_subnets": "10.0.0.0/24", "remote_subnets": "10.1.0.0/24",
|
||||||
|
}},
|
||||||
|
{"invalid local_subnets", map[string]interface{}{
|
||||||
|
"remote_gateway": "1.2.3.4", "local_subnets": "bad-cidr", "remote_subnets": "10.1.0.0/24",
|
||||||
|
}},
|
||||||
|
{"invalid remote_subnets", map[string]interface{}{
|
||||||
|
"remote_gateway": "1.2.3.4", "local_subnets": "10.0.0.0/24", "remote_subnets": "nope",
|
||||||
|
}},
|
||||||
|
{"overlapping subnets", map[string]interface{}{
|
||||||
|
"remote_gateway": "1.2.3.4", "local_subnets": "10.0.0.0/24", "remote_subnets": "10.0.0.0/24",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if _, err := s.Validate(tc.cfg); err == nil {
|
||||||
|
t.Errorf("%s: expected error, got nil", tc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorTemplates(t *testing.T) {
|
||||||
|
templates := Templates()
|
||||||
|
if len(templates) == 0 {
|
||||||
|
t.Fatal("Templates() returned empty slice")
|
||||||
|
}
|
||||||
|
for _, tmpl := range templates {
|
||||||
|
if tmpl.Key == "" || tmpl.Name == "" {
|
||||||
|
t.Errorf("template missing key/name: %#v", tmpl)
|
||||||
|
}
|
||||||
|
// Validate with vendor_template should fill in template values
|
||||||
|
cfg, err := New().Validate(map[string]interface{}{
|
||||||
|
"remote_gateway": "1.2.3.4",
|
||||||
|
"local_subnets": "10.0.0.0/24",
|
||||||
|
"remote_subnets": "10.1.0.0/24",
|
||||||
|
"vendor_template": tmpl.Key,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("template %q: Validate failed: %v", tmpl.Key, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if service.Str(cfg, "ike_version") != tmpl.IKEVersion {
|
||||||
|
t.Errorf("template %q: ike_version %q != %q", tmpl.Key, service.Str(cfg, "ike_version"), tmpl.IKEVersion)
|
||||||
|
}
|
||||||
|
if service.Str(cfg, "ike_proposal") != tmpl.IKEProposal {
|
||||||
|
t.Errorf("template %q: ike_proposal %q != %q", tmpl.Key, service.Str(cfg, "ike_proposal"), tmpl.IKEProposal)
|
||||||
|
}
|
||||||
|
if service.Str(cfg, "esp_proposal") != tmpl.ESPProposal {
|
||||||
|
t.Errorf("template %q: esp_proposal %q != %q", tmpl.Key, service.Str(cfg, "esp_proposal"), tmpl.ESPProposal)
|
||||||
|
}
|
||||||
|
if service.Str(cfg, "dpd_action") != tmpl.DPDAction {
|
||||||
|
t.Errorf("template %q: dpd_action %q != %q", tmpl.Key, service.Str(cfg, "dpd_action"), tmpl.DPDAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwanctlConf(t *testing.T) {
|
||||||
|
s := New()
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"remote_gateway": "203.0.113.1",
|
||||||
|
"local_subnets": "192.168.1.0/24",
|
||||||
|
"remote_subnets": "192.168.2.0/24",
|
||||||
|
"ike_version": "ikev2",
|
||||||
|
"ike_proposal": "aes256-sha256-modp2048",
|
||||||
|
"esp_proposal": "aes256-sha256",
|
||||||
|
"dpd_action": "restart",
|
||||||
|
"local_id": "@vpn-local",
|
||||||
|
"remote_id": "@vpn-remote",
|
||||||
|
}
|
||||||
|
conf := s.swanctlConf(42, cfg, "my-secret-psk")
|
||||||
|
expected := []string{
|
||||||
|
"tunnel-42",
|
||||||
|
"remote_addrs = 203.0.113.1",
|
||||||
|
"version = 2",
|
||||||
|
"proposals = aes256-sha256-modp2048",
|
||||||
|
"id = @vpn-local",
|
||||||
|
"id = @vpn-remote",
|
||||||
|
"site-42",
|
||||||
|
"local_ts = 192.168.1.0/24",
|
||||||
|
"remote_ts = 192.168.2.0/24",
|
||||||
|
"esp_proposals = aes256-sha256",
|
||||||
|
"dpd_action = restart",
|
||||||
|
"start_action = start",
|
||||||
|
"ike-tunnel-42",
|
||||||
|
"my-secret-psk",
|
||||||
|
}
|
||||||
|
for _, e := range expected {
|
||||||
|
if !strings.Contains(conf, e) {
|
||||||
|
t.Errorf("swanctlConf missing %q:\n%s", e, conf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwanctlConfVIP(t *testing.T) {
|
||||||
|
s := New()
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"remote_gateway": "1.2.3.4",
|
||||||
|
"local_subnets": "10.0.0.0/24",
|
||||||
|
"remote_subnets": "10.1.0.0/24",
|
||||||
|
"local_id": "@local",
|
||||||
|
"remote_id": "@remote",
|
||||||
|
"local_ts_vip": "10.255.0.1",
|
||||||
|
}
|
||||||
|
conf := s.swanctlConf(1, cfg, "psk")
|
||||||
|
if !strings.Contains(conf, "vips = 10.255.0.1") {
|
||||||
|
t.Errorf("swanctlConf should include vips when local_ts_vip is set:\n%s", conf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeerReference(t *testing.T) {
|
||||||
|
s := New()
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"remote_gateway": "203.0.113.1",
|
||||||
|
"ike_version": "ikev2",
|
||||||
|
"ike_proposal": "aes256-sha256-modp2048",
|
||||||
|
"esp_proposal": "aes256-sha256",
|
||||||
|
"local_id": "@vpn-local",
|
||||||
|
"remote_id": "@vpn-remote",
|
||||||
|
"local_subnets": "192.168.1.0/24",
|
||||||
|
"remote_subnets": "192.168.2.0/24",
|
||||||
|
"pfs_group": "modp2048",
|
||||||
|
"dpd_action": "restart",
|
||||||
|
"public_endpoint": "198.51.100.1",
|
||||||
|
}
|
||||||
|
ref := s.PeerReference(cfg)
|
||||||
|
for _, expected := range []string{
|
||||||
|
"198.51.100.1",
|
||||||
|
"203.0.113.1",
|
||||||
|
"ikev2",
|
||||||
|
"aes256-sha256-modp2048",
|
||||||
|
"@vpn-local",
|
||||||
|
"@vpn-remote",
|
||||||
|
"192.168.1.0/24",
|
||||||
|
"192.168.2.0/24",
|
||||||
|
"modp2048",
|
||||||
|
"restart",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(ref, expected) {
|
||||||
|
t.Errorf("PeerReference missing %q:\n%s", expected, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComparePeerParams(t *testing.T) {
|
||||||
|
local := map[string]interface{}{
|
||||||
|
"ike_version": "ikev2",
|
||||||
|
"ike_proposal": "aes256-sha256-modp2048",
|
||||||
|
"esp_proposal": "aes256-sha256",
|
||||||
|
"pfs_group": "modp2048",
|
||||||
|
"local_subnets": "192.168.1.0/24",
|
||||||
|
"remote_subnets": "192.168.2.0/24",
|
||||||
|
}
|
||||||
|
// Matching peer — no diffs expected
|
||||||
|
peer := map[string]interface{}{
|
||||||
|
"ike_version": "ikev2",
|
||||||
|
"ike_proposal": "aes256-sha256-modp2048",
|
||||||
|
"esp_proposal": "aes256-sha256",
|
||||||
|
"pfs_group": "modp2048",
|
||||||
|
"local_subnets": "192.168.2.0/24",
|
||||||
|
}
|
||||||
|
diffs := ComparePeerParams(local, peer)
|
||||||
|
if len(diffs) != 0 {
|
||||||
|
t.Errorf("expected no diffs, got %v", diffs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mismatching peer — should detect diffs
|
||||||
|
peerBad := map[string]interface{}{
|
||||||
|
"ike_version": "ikev1",
|
||||||
|
"ike_proposal": "aes128-sha1-modp1024",
|
||||||
|
"esp_proposal": "aes128-sha1",
|
||||||
|
"pfs_group": "modp1024",
|
||||||
|
"local_subnets": "10.0.0.0/8",
|
||||||
|
}
|
||||||
|
diffs = ComparePeerParams(local, peerBad)
|
||||||
|
if len(diffs) < 3 {
|
||||||
|
t.Errorf("expected multiple diffs, got %d: %v", len(diffs), diffs)
|
||||||
|
}
|
||||||
|
// Verify cross-subnet mismatch is detected
|
||||||
|
found := false
|
||||||
|
for _, d := range diffs {
|
||||||
|
if strings.Contains(d, "网段交叉不匹配") || strings.Contains(d, "cross") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected subnet cross-mismatch in diffs: %v", diffs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,11 @@ func parseIPRange(raw string) (netip.Addr, netip.Addr, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Create(t *model.Tunnel) error {
|
func (s *Service) Create(t *model.Tunnel) error {
|
||||||
|
// Ensure strongSwan certificate directories exist (required by swanctl --load-all)
|
||||||
|
dir := service.InstanceDir(t)
|
||||||
|
for _, sub := range []string{"x509", "x509ca", "x509ocsp", "x509aa", "x509ac", "x509crl", "pubkey", "private", "rsa", "ecdsa", "pkcs8", "pkcs12"} {
|
||||||
|
_ = os.MkdirAll(dir+"/"+sub, 0700)
|
||||||
|
}
|
||||||
cfg, err := store.TunnelConfig(t)
|
cfg, err := store.TunnelConfig(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -116,12 +121,12 @@ func (s *Service) Create(t *model.Tunnel) error {
|
|||||||
// 1) IPSec 层(transport 模式,L2TP over IPSec)
|
// 1) IPSec 层(transport 模式,L2TP over IPSec)
|
||||||
swanConf := fmt.Sprintf(`connections {
|
swanConf := fmt.Sprintf(`connections {
|
||||||
l2tp-%d {
|
l2tp-%d {
|
||||||
type = transport
|
|
||||||
proposals = %s
|
proposals = %s
|
||||||
local { auth = psk }
|
local { auth = psk }
|
||||||
remote { auth = psk }
|
remote { auth = psk }
|
||||||
children {
|
children {
|
||||||
l2tp-child-%d {
|
l2tp-child-%d {
|
||||||
|
mode = transport
|
||||||
local_ts = dynamic[udp/l2tp]
|
local_ts = dynamic[udp/l2tp]
|
||||||
remote_ts = dynamic[udp/l2tp]
|
remote_ts = dynamic[udp/l2tp]
|
||||||
esp_proposals = %s
|
esp_proposals = %s
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package l2tp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"vpn-platform/backend/internal/model"
|
||||||
|
"vpn-platform/backend/internal/service"
|
||||||
|
"vpn-platform/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateDefaults(t *testing.T) {
|
||||||
|
cfg, err := New().Validate(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defaults := []struct {
|
||||||
|
key string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"ip_range", "10.9.0.100-10.9.0.200"},
|
||||||
|
{"local_ip", "10.9.0.1"},
|
||||||
|
{"ike_proposal", "aes256-sha256-modp2048,aes128-sha1-modp1024"},
|
||||||
|
{"esp_proposal", "aes256-sha256,aes128-sha1"},
|
||||||
|
}
|
||||||
|
for _, d := range defaults {
|
||||||
|
if got := service.Str(cfg, d.key); got != d.expected {
|
||||||
|
t.Errorf("%s: got %q, want %q", d.key, got, d.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if service.Str(cfg, "dns") == "" {
|
||||||
|
t.Error("dns default should not be empty")
|
||||||
|
}
|
||||||
|
if service.Str(cfg, "client_subnet") == "" {
|
||||||
|
t.Error("client_subnet default should not be empty")
|
||||||
|
}
|
||||||
|
// Verify client_subnet covers the ip_range
|
||||||
|
prefix, err := netip.ParsePrefix(service.Str(cfg, "client_subnet"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client_subnet parse: %v", err)
|
||||||
|
}
|
||||||
|
start, _, _ := parseIPRange(service.Str(cfg, "ip_range"))
|
||||||
|
if !prefix.Contains(start) {
|
||||||
|
t.Errorf("client_subnet %s does not contain start of ip_range %s", prefix, start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsInvalid(t *testing.T) {
|
||||||
|
s := New()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cfg map[string]interface{}
|
||||||
|
}{
|
||||||
|
{"invalid local_ip", map[string]interface{}{
|
||||||
|
"local_ip": "not-an-ip",
|
||||||
|
}},
|
||||||
|
{"invalid ip_range format", map[string]interface{}{
|
||||||
|
"ip_range": "10.0.0.100",
|
||||||
|
}},
|
||||||
|
{"ip_range start > end", map[string]interface{}{
|
||||||
|
"ip_range": "10.9.0.200-10.9.0.100",
|
||||||
|
}},
|
||||||
|
{"local_ip in ip_range", map[string]interface{}{
|
||||||
|
"local_ip": "10.9.0.150",
|
||||||
|
"ip_range": "10.9.0.100-10.9.0.200",
|
||||||
|
}},
|
||||||
|
{"client_subnet too small", map[string]interface{}{
|
||||||
|
"ip_range": "10.9.0.100-10.9.0.200",
|
||||||
|
"client_subnet": "10.9.0.0/30",
|
||||||
|
}},
|
||||||
|
{"bad username whitespace", map[string]interface{}{
|
||||||
|
"username": "bad user",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if _, err := s.Validate(tc.cfg); err == nil {
|
||||||
|
t.Errorf("%s: expected error, got nil", tc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildChapSecrets(t *testing.T) {
|
||||||
|
// With default user only
|
||||||
|
secrets := buildChapSecrets("vpnuser", "s3cret", nil)
|
||||||
|
if !strings.Contains(secrets, "vpnuser") || !strings.Contains(secrets, "s3cret") {
|
||||||
|
t.Errorf("chap-secrets missing default user:\n%s", secrets)
|
||||||
|
}
|
||||||
|
if !strings.Contains(secrets, "l2tpd") {
|
||||||
|
t.Errorf("chap-secrets missing server name:\n%s", secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With dial users
|
||||||
|
users := []service.DialUser{
|
||||||
|
{Username: "alice", Password: "pw1"},
|
||||||
|
{Username: "bob", Password: "pw2"},
|
||||||
|
}
|
||||||
|
secrets = buildChapSecrets("admin", "pass", users)
|
||||||
|
for _, u := range []string{"admin", "alice", "bob"} {
|
||||||
|
if !strings.Contains(secrets, u) {
|
||||||
|
t.Errorf("chap-secrets missing user %q:\n%s", u, secrets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate user should be deduplicated
|
||||||
|
secrets = buildChapSecrets("alice", "override", []service.DialUser{{Username: "alice", Password: "ignored"}})
|
||||||
|
count := strings.Count(secrets, "alice")
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected alice once, got %d times in:\n%s", count, secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty default user — dial users still appear
|
||||||
|
secrets = buildChapSecrets("", "", []service.DialUser{{Username: "x", Password: "y"}})
|
||||||
|
if !strings.Contains(secrets, "x") {
|
||||||
|
t.Errorf("chap-secrets should still include dial users:\n%s", secrets)
|
||||||
|
}
|
||||||
|
if strings.Contains(secrets, `""`) {
|
||||||
|
t.Errorf("empty default user should not produce empty-quoted entry:\n%s", secrets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseIPRange(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
wantErr bool
|
||||||
|
start string
|
||||||
|
end string
|
||||||
|
}{
|
||||||
|
{"10.0.0.1-10.0.0.100", false, "10.0.0.1", "10.0.0.100"},
|
||||||
|
{"10.0.0.1", true, "", ""},
|
||||||
|
{"10.0.0.1-10.0.0.2-10.0.0.3", true, "", ""},
|
||||||
|
{"not-ip-also-not", true, "", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
start, end, err := parseIPRange(tc.input)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("parseIPRange(%q): expected error", tc.input)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("parseIPRange(%q): unexpected error: %v", tc.input, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if start.String() != tc.start || end.String() != tc.end {
|
||||||
|
t.Errorf("parseIPRange(%q): got %s-%s, want %s-%s", tc.input, start, end, tc.start, tc.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureExclusiveListener(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
t.Setenv("VPN_DATA_DIR", dataDir)
|
||||||
|
if err := store.Init(dataDir); err != nil {
|
||||||
|
t.Fatalf("store.Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a running L2TP tunnel in DB
|
||||||
|
running := &model.Tunnel{
|
||||||
|
Name: "l2tp-existing",
|
||||||
|
Protocol: "l2tp",
|
||||||
|
State: "running",
|
||||||
|
}
|
||||||
|
if err := store.DB.Create(running).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Service{}
|
||||||
|
newTunnel := &model.Tunnel{ID: running.ID + 1}
|
||||||
|
if err := s.ensureExclusiveListener(newTunnel); err == nil {
|
||||||
|
t.Fatal("expected error when another L2TP instance is running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// After marking the existing one as stopped, it should pass
|
||||||
|
store.DB.Model(running).Update("state", "stopped")
|
||||||
|
if err := s.ensureExclusiveListener(newTunnel); err != nil {
|
||||||
|
t.Fatalf("expected no error after existing stopped: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Package service 提供日志轮转工具
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RotateConfig 配置日志轮转参数
|
||||||
|
type RotateConfig struct {
|
||||||
|
MaxSize int64 // 单个日志文件最大字节数
|
||||||
|
MaxFiles int // 最多保留的轮转文件数
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRotateConfig 从环境变量读取轮转配置
|
||||||
|
func DefaultRotateConfig(envPrefix string, defaultMaxSize int64, defaultMaxFiles int) RotateConfig {
|
||||||
|
cfg := RotateConfig{
|
||||||
|
MaxSize: defaultMaxSize,
|
||||||
|
MaxFiles: defaultMaxFiles,
|
||||||
|
}
|
||||||
|
if v := os.Getenv(envPrefix + "_MAX_SIZE"); v != "" {
|
||||||
|
if n, err := parseSize(v); err == nil && n > 0 {
|
||||||
|
cfg.MaxSize = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := os.Getenv(envPrefix + "_MAX_FILES"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
cfg.MaxFiles = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSize 解析带单位的字节数(如 "50MB", "20M", "1048576")
|
||||||
|
func parseSize(s string) (int64, error) {
|
||||||
|
suffixes := map[string]int64{
|
||||||
|
"KB": 1024,
|
||||||
|
"MB": 1024 * 1024,
|
||||||
|
"GB": 1024 * 1024 * 1024,
|
||||||
|
"K": 1024,
|
||||||
|
"M": 1024 * 1024,
|
||||||
|
"G": 1024 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
for suffix, mult := range suffixes {
|
||||||
|
if len(s) > len(suffix) && s[len(s)-len(suffix):] == suffix {
|
||||||
|
n, err := strconv.ParseInt(s[:len(s)-len(suffix)], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n * mult, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strconv.ParseInt(s, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateIfNeeded 检查日志文件大小并在需要时轮转
|
||||||
|
func RotateIfNeeded(logPath string, cfg RotateConfig) error {
|
||||||
|
info, err := os.Stat(logPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Size() < cfg.MaxSize {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return rotateFiles(logPath, cfg.MaxFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotateFiles 执行日志轮转:logPath -> logPath.1, logPath.N-1 -> delete
|
||||||
|
func rotateFiles(logPath string, maxFiles int) error {
|
||||||
|
// 删除最旧的文件
|
||||||
|
oldest := fmt.Sprintf("%s.%d", logPath, maxFiles)
|
||||||
|
os.Remove(oldest) // 忽略不存在的错误
|
||||||
|
|
||||||
|
// 逐个重命名:N-1 -> N, N-2 -> N-1, ... 1 -> 2
|
||||||
|
for i := maxFiles - 1; i >= 1; i-- {
|
||||||
|
src := fmt.Sprintf("%s.%d", logPath, i)
|
||||||
|
dst := fmt.Sprintf("%s.%d", logPath, i+1)
|
||||||
|
if _, err := os.Stat(src); err == nil {
|
||||||
|
os.Rename(src, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 当前文件 -> .1
|
||||||
|
if _, err := os.Stat(logPath); err == nil {
|
||||||
|
return os.Rename(logPath, logPath+".1")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotatingWriter 是一个带轮转功能的 io.Writer
|
||||||
|
type RotatingWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
cfg RotateConfig
|
||||||
|
f *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRotatingWriter 创建一个带轮转功能的写入器
|
||||||
|
func NewRotatingWriter(path string, cfg RotateConfig) (*RotatingWriter, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rw := &RotatingWriter{path: path, cfg: cfg}
|
||||||
|
if err := rw.open(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RotatingWriter) open() error {
|
||||||
|
// 先尝试轮转
|
||||||
|
RotateIfNeeded(w.path, w.cfg)
|
||||||
|
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.f = f
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RotatingWriter) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
n, err := w.f.Write(p)
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
// 每次写入后检查大小(懒轮转)
|
||||||
|
if info, e := w.f.Stat(); e == nil && info.Size() >= w.cfg.MaxSize {
|
||||||
|
w.f.Close()
|
||||||
|
w.open()
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RotatingWriter) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.f != nil {
|
||||||
|
return w.f.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ func Init(dataDir string) error {
|
|||||||
}
|
}
|
||||||
// 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一
|
// 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一
|
||||||
db.Exec("DROP INDEX IF EXISTS idx_vpn_user")
|
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{}); 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.TrafficSample{}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
DB = db
|
DB = db
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ import (
|
|||||||
"vpn-platform/backend/internal/monitor"
|
"vpn-platform/backend/internal/monitor"
|
||||||
"vpn-platform/backend/internal/service"
|
"vpn-platform/backend/internal/service"
|
||||||
"vpn-platform/backend/internal/service/custom"
|
"vpn-platform/backend/internal/service/custom"
|
||||||
|
"vpn-platform/backend/internal/service/gre"
|
||||||
"vpn-platform/backend/internal/service/frp"
|
"vpn-platform/backend/internal/service/frp"
|
||||||
"vpn-platform/backend/internal/service/ikev2"
|
"vpn-platform/backend/internal/service/ikev2"
|
||||||
"vpn-platform/backend/internal/service/ipsec"
|
"vpn-platform/backend/internal/service/ipsec"
|
||||||
@@ -55,11 +58,14 @@ func main() {
|
|||||||
mgr.Register(ikev2.New())
|
mgr.Register(ikev2.New())
|
||||||
mgr.Register(l2tp.New())
|
mgr.Register(l2tp.New())
|
||||||
mgr.Register(frp.New())
|
mgr.Register(frp.New())
|
||||||
|
mgr.Register(gre.New())
|
||||||
mgr.Register(openvpn.New())
|
mgr.Register(openvpn.New())
|
||||||
// 加载数据库中的自定义协议插件
|
// 加载数据库中的自定义协议插件
|
||||||
custom.LoadAll(mgr)
|
custom.LoadAll(mgr)
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
// HTTP 访问日志(带轮转,默认 20MB x 3 文件)
|
||||||
|
r.Use(accessLogger())
|
||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
|
|
||||||
srv := api.NewServer(mgr)
|
srv := api.NewServer(mgr)
|
||||||
@@ -93,6 +99,15 @@ func main() {
|
|||||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 启动时自动清理过期日志(默认保留 90 天,可通过 VPN_LOG_RETENTION_DAYS 覆盖)
|
||||||
|
retentionDays := 90
|
||||||
|
if v := os.Getenv("VPN_LOG_RETENTION_DAYS"); v != "" {
|
||||||
|
if d, err := strconv.Atoi(v); err == nil && d > 0 {
|
||||||
|
retentionDays = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.AutoCleanupLogs(retentionDays)
|
||||||
|
|
||||||
// 状态采集 + 自启恢复
|
// 状态采集 + 自启恢复
|
||||||
monitor.Start(mgr, 15*time.Second)
|
monitor.Start(mgr, 15*time.Second)
|
||||||
monitor.AutoStartAll(mgr)
|
monitor.AutoStartAll(mgr)
|
||||||
@@ -115,3 +130,38 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// accessLogger 返回自定义的 HTTP 请求日志中间件(带日志轮转)
|
||||||
|
// 格式: [2006-01-02 15:04:05] "GET /api/tunnels 200 12.5ms 192.168.1.1"
|
||||||
|
func accessLogger() gin.HandlerFunc {
|
||||||
|
// 构造日志文件路径
|
||||||
|
dataDir := service.BaseDir()
|
||||||
|
logPath := fmt.Sprintf("%s/access.log", dataDir)
|
||||||
|
|
||||||
|
// 轮转配置:默认 20MB x 3 文件,可通过环境变量覆盖
|
||||||
|
cfg := service.DefaultRotateConfig("VPN_ACCESS_LOG_MAX_SIZE", 20*1024*1024, 3)
|
||||||
|
|
||||||
|
writer, err := service.NewRotatingWriter(logPath, cfg)
|
||||||
|
if err != nil {
|
||||||
|
// 降级到 stderr
|
||||||
|
log.Printf("警告: 无法创建访问日志文件 %s: %v,降级到标准输出", logPath, err)
|
||||||
|
return gin.Logger()
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
c.Next()
|
||||||
|
latency := time.Since(start)
|
||||||
|
status := c.Writer.Status()
|
||||||
|
line := fmt.Sprintf("[%s] %s %s %d %s %s\n",
|
||||||
|
time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
c.Request.Method,
|
||||||
|
path,
|
||||||
|
status,
|
||||||
|
latency.Round(time.Millisecond),
|
||||||
|
c.ClientIP(),
|
||||||
|
)
|
||||||
|
_, _ = writer.Write([]byte(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||||
|
<h4 style="margin:0">{{ $t('tunnels.trafficTrend') }}</h4>
|
||||||
|
<el-radio-group v-model="hours" size="small" @change="load">
|
||||||
|
<el-radio-button :value="1">1h</el-radio-button>
|
||||||
|
<el-radio-button :value="6">6h</el-radio-button>
|
||||||
|
<el-radio-button :value="24">24h</el-radio-button>
|
||||||
|
<el-radio-button :value="168">7d</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
<div ref="chartRef" style="height:220px" v-loading="loading"></div>
|
||||||
|
<el-empty v-if="!loading && samples.length === 0" :description="$t('tunnels.noTrafficData')" :image-size="60" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import api from '../api'
|
||||||
|
import { fmtBytes } from '../utils/format'
|
||||||
|
|
||||||
|
const props = defineProps({ tunnelId: { type: Number, required: true } })
|
||||||
|
|
||||||
|
const chartRef = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const hours = ref(24)
|
||||||
|
const samples = ref([])
|
||||||
|
let chart = null
|
||||||
|
|
||||||
|
function granularity(h) {
|
||||||
|
if (h <= 1) return 'minute'
|
||||||
|
if (h <= 168) return 'hour'
|
||||||
|
return 'day'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!props.tunnelId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/tunnels/${props.tunnelId}/traffic/history`, {
|
||||||
|
params: { hours: hours.value, granularity: granularity(hours.value) }
|
||||||
|
})
|
||||||
|
samples.value = data.samples || []
|
||||||
|
renderChart()
|
||||||
|
} catch (e) {
|
||||||
|
samples.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
if (!chartRef.value || samples.value.length === 0) return
|
||||||
|
if (!chart) chart = echarts.init(chartRef.value)
|
||||||
|
const times = samples.value.map(s => {
|
||||||
|
const d = new Date(s.time)
|
||||||
|
return hours.value <= 24 ? d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: d.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
})
|
||||||
|
chart.setOption({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter(params) {
|
||||||
|
const t = params[0].axisValue
|
||||||
|
const rx = params[0] ? fmtBytes(params[0].value) : '0'
|
||||||
|
const tx = params[1] ? fmtBytes(params[1].value) : '0'
|
||||||
|
return `${t}<br/>↓ RX: ${rx}<br/>↑ TX: ${tx}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: { data: ['RX', 'TX'], right: 0, top: 0, textStyle: { fontSize: 11 } },
|
||||||
|
grid: { left: 50, right: 16, top: 30, bottom: 24 },
|
||||||
|
xAxis: { type: 'category', data: times, axisLabel: { fontSize: 10 } },
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { fontSize: 10, formatter: v => fmtBytes(v) }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{ name: 'RX', type: 'line', smooth: true, areaStyle: { opacity: 0.15 }, data: samples.value.map(s => s.delta_rx), lineStyle: { width: 2 } },
|
||||||
|
{ name: 'TX', type: 'line', smooth: true, areaStyle: { opacity: 0.15 }, data: samples.value.map(s => s.delta_tx), lineStyle: { width: 2 } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let resizeHandler = null
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
resizeHandler = () => chart?.resize()
|
||||||
|
window.addEventListener('resize', resizeHandler)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', resizeHandler)
|
||||||
|
chart?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.tunnelId, () => { hours.value = 24; load() })
|
||||||
|
</script>
|
||||||
@@ -207,7 +207,9 @@
|
|||||||
"taskComplete": "All done",
|
"taskComplete": "All done",
|
||||||
"taskPartialFail": "Partial failure",
|
"taskPartialFail": "Partial failure",
|
||||||
"viewTasks": "View Tasks",
|
"viewTasks": "View Tasks",
|
||||||
"noTasks": "No task history"
|
"noTasks": "No task history",
|
||||||
|
"trafficTrend": "Traffic Trend",
|
||||||
|
"noTrafficData": "No traffic data yet (waiting for samples…)"
|
||||||
},
|
},
|
||||||
"tunnelEditor": {
|
"tunnelEditor": {
|
||||||
"title": "Edit Tunnel",
|
"title": "Edit Tunnel",
|
||||||
@@ -423,6 +425,29 @@
|
|||||||
"kickConfirm": "Kick user \"{username}\" offline?",
|
"kickConfirm": "Kick user \"{username}\" offline?",
|
||||||
"kicked": "User kicked offline"
|
"kicked": "User kicked offline"
|
||||||
},
|
},
|
||||||
|
"logs": {
|
||||||
|
"auditTitle": "Audit Logs",
|
||||||
|
"connTitle": "Connection Logs",
|
||||||
|
"alertTitle": "Traffic Alerts",
|
||||||
|
"statsTitle": "Log Statistics",
|
||||||
|
"keywordPlaceholder": "Search target/detail",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"exportCsv": "Export CSV",
|
||||||
|
"eventType": "Event Type",
|
||||||
|
"tunnelId": "Tunnel ID",
|
||||||
|
"auditCount": "Audit Logs",
|
||||||
|
"connCount": "Connection Logs",
|
||||||
|
"sessionCount": "Sessions",
|
||||||
|
"from": "From",
|
||||||
|
"cleanup": "Cleanup Logs",
|
||||||
|
"cleanupHint": "Delete log data older than specified days",
|
||||||
|
"cleanupPrompt": "Keep logs from the last how many days?",
|
||||||
|
"cleanupInputError": "Please enter a positive integer",
|
||||||
|
"cleanupDone": "Cleanup done: {audit} audit, {conn} connection, {session} session records deleted",
|
||||||
|
"loadFailed": "Failed to load logs",
|
||||||
|
"noAlerts": "No traffic alerts"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"mustChangePassword": "First login or password has been reset by admin. You must change your password before using other features.",
|
"mustChangePassword": "First login or password has been reset by admin. You must change your password before using other features.",
|
||||||
"changePassword": "Change Password",
|
"changePassword": "Change Password",
|
||||||
@@ -451,6 +476,7 @@
|
|||||||
"prodSuggestion": "Production Tip",
|
"prodSuggestion": "Production Tip",
|
||||||
"prodSuggestionDesc": "Expose via Nginx/Caddy reverse proxy with TLS termination",
|
"prodSuggestionDesc": "Expose via Nginx/Caddy reverse proxy with TLS termination",
|
||||||
"auditTitle": "Audit Log (Latest 100)",
|
"auditTitle": "Audit Log (Latest 100)",
|
||||||
|
"auditMigrated": "Audit logs have been moved to Log Center with pagination, filtering, and CSV export",
|
||||||
"noAuditLogs": "No audit logs yet",
|
"noAuditLogs": "No audit logs yet",
|
||||||
"mfaEnabledTitle": "MFA Enabled — Please Save Backup Codes",
|
"mfaEnabledTitle": "MFA Enabled — Please Save Backup Codes",
|
||||||
"backupCodesHint": "These backup codes are shown only once. Use them to log in if you lose your phone. Copy and save them now!",
|
"backupCodesHint": "These backup codes are shown only once. Use them to log in if you lose your phone. Copy and save them now!",
|
||||||
@@ -599,6 +625,7 @@
|
|||||||
"vpnUsers": "VPN Dial-in Users",
|
"vpnUsers": "VPN Dial-in Users",
|
||||||
"plugins": "Protocol Plugins",
|
"plugins": "Protocol Plugins",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"logs": "Log Center",
|
||||||
"notFound": "Page Not Found",
|
"notFound": "Page Not Found",
|
||||||
"sidebarOps": "Tunnel Ops",
|
"sidebarOps": "Tunnel Ops",
|
||||||
"sidebarResource": "Resource Mgmt",
|
"sidebarResource": "Resource Mgmt",
|
||||||
|
|||||||
@@ -207,7 +207,9 @@
|
|||||||
"taskComplete": "全部完成",
|
"taskComplete": "全部完成",
|
||||||
"taskPartialFail": "部分失败",
|
"taskPartialFail": "部分失败",
|
||||||
"viewTasks": "查看任务",
|
"viewTasks": "查看任务",
|
||||||
"noTasks": "暂无任务记录"
|
"noTasks": "暂无任务记录",
|
||||||
|
"trafficTrend": "流量趋势",
|
||||||
|
"noTrafficData": "暂无流量数据(等待采样中…)"
|
||||||
},
|
},
|
||||||
"tunnelEditor": {
|
"tunnelEditor": {
|
||||||
"title": "编辑专线",
|
"title": "编辑专线",
|
||||||
@@ -423,6 +425,29 @@
|
|||||||
"kickConfirm": "确定踢用户「{username}」下线?",
|
"kickConfirm": "确定踢用户「{username}」下线?",
|
||||||
"kicked": "已踢下线"
|
"kicked": "已踢下线"
|
||||||
},
|
},
|
||||||
|
"logs": {
|
||||||
|
"auditTitle": "操作审计日志",
|
||||||
|
"connTitle": "连接审计日志",
|
||||||
|
"alertTitle": "流量告警",
|
||||||
|
"statsTitle": "日志统计",
|
||||||
|
"keywordPlaceholder": "搜索目标/详情",
|
||||||
|
"startDate": "开始日期",
|
||||||
|
"endDate": "结束日期",
|
||||||
|
"exportCsv": "导出 CSV",
|
||||||
|
"eventType": "事件类型",
|
||||||
|
"tunnelId": "隧道ID",
|
||||||
|
"auditCount": "审计日志",
|
||||||
|
"connCount": "连接日志",
|
||||||
|
"sessionCount": "会话记录",
|
||||||
|
"from": "从",
|
||||||
|
"cleanup": "清理日志",
|
||||||
|
"cleanupHint": "删除指定天数之前的日志数据",
|
||||||
|
"cleanupPrompt": "保留最近多少天的日志?",
|
||||||
|
"cleanupInputError": "请输入正整数",
|
||||||
|
"cleanupDone": "清理完成:审计 {audit} 条、连接 {conn} 条、会话 {session} 条",
|
||||||
|
"loadFailed": "加载日志失败",
|
||||||
|
"noAlerts": "暂无流量告警"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"mustChangePassword": "首次登录或密码已被管理员重置,必须修改密码后才能使用其他功能",
|
"mustChangePassword": "首次登录或密码已被管理员重置,必须修改密码后才能使用其他功能",
|
||||||
"changePassword": "修改密码",
|
"changePassword": "修改密码",
|
||||||
@@ -451,6 +476,7 @@
|
|||||||
"prodSuggestion": "生产建议",
|
"prodSuggestion": "生产建议",
|
||||||
"prodSuggestionDesc": "经 Nginx/Caddy 反向代理终止 TLS 后暴露",
|
"prodSuggestionDesc": "经 Nginx/Caddy 反向代理终止 TLS 后暴露",
|
||||||
"auditTitle": "操作审计(最近 100 条)",
|
"auditTitle": "操作审计(最近 100 条)",
|
||||||
|
"auditMigrated": "审计日志已迁移至「日志中心」,支持分页、筛选和导出",
|
||||||
"noAuditLogs": "暂无审计日志",
|
"noAuditLogs": "暂无审计日志",
|
||||||
"mfaEnabledTitle": "MFA 已启用 — 请保存备用码",
|
"mfaEnabledTitle": "MFA 已启用 — 请保存备用码",
|
||||||
"backupCodesHint": "这些备用码仅显示一次,丢失手机时用于登录。请立即复制保存!",
|
"backupCodesHint": "这些备用码仅显示一次,丢失手机时用于登录。请立即复制保存!",
|
||||||
@@ -599,6 +625,7 @@
|
|||||||
"vpnUsers": "VPN 拨号用户",
|
"vpnUsers": "VPN 拨号用户",
|
||||||
"plugins": "协议插件",
|
"plugins": "协议插件",
|
||||||
"settings": "系统设置",
|
"settings": "系统设置",
|
||||||
|
"logs": "日志中心",
|
||||||
"notFound": "页面不存在",
|
"notFound": "页面不存在",
|
||||||
"sidebarOps": "专线运维",
|
"sidebarOps": "专线运维",
|
||||||
"sidebarResource": "资源管理",
|
"sidebarResource": "资源管理",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const routes = [
|
|||||||
{ path: 'dashboard', component: () => import('./views/Dashboard.vue'), meta: { titleKey: 'router.dashboard' } },
|
{ path: 'dashboard', component: () => import('./views/Dashboard.vue'), meta: { titleKey: 'router.dashboard' } },
|
||||||
{ path: 'tunnels', component: () => import('./views/Tunnels.vue'), meta: { titleKey: 'router.tunnels' } },
|
{ path: 'tunnels', component: () => import('./views/Tunnels.vue'), meta: { titleKey: 'router.tunnels' } },
|
||||||
{ path: 'sessions', component: () => import('./views/Sessions.vue'), meta: { titleKey: 'router.sessions' } },
|
{ path: 'sessions', component: () => import('./views/Sessions.vue'), meta: { titleKey: 'router.sessions' } },
|
||||||
|
{ path: 'logs', component: () => import('./views/Logs.vue'), meta: { titleKey: 'router.logs' } },
|
||||||
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel' } },
|
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel' } },
|
||||||
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel' } },
|
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel' } },
|
||||||
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } },
|
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } },
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>{{ $t('router.dashboard') }}</el-menu-item>
|
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>{{ $t('router.dashboard') }}</el-menu-item>
|
||||||
<el-menu-item index="/tunnels"><el-icon><Connection /></el-icon>{{ $t('router.tunnels') }}</el-menu-item>
|
<el-menu-item index="/tunnels"><el-icon><Connection /></el-icon>{{ $t('router.tunnels') }}</el-menu-item>
|
||||||
<el-menu-item index="/sessions"><el-icon><Monitor /></el-icon>{{ $t('router.sessions') }}</el-menu-item>
|
<el-menu-item index="/sessions"><el-icon><Monitor /></el-icon>{{ $t('router.sessions') }}</el-menu-item>
|
||||||
|
<el-menu-item index="/logs"><el-icon><Document /></el-icon>{{ $t('router.logs') }}</el-menu-item>
|
||||||
</el-menu-item-group>
|
</el-menu-item-group>
|
||||||
<el-menu-item-group v-if="auth.isAdmin()" :title="$t('router.sidebarResource')">
|
<el-menu-item-group v-if="auth.isAdmin()" :title="$t('router.sidebarResource')">
|
||||||
<el-menu-item index="/nodes"><el-icon><Coin /></el-icon>{{ $t('router.nodes') }}</el-menu-item>
|
<el-menu-item index="/nodes"><el-icon><Coin /></el-icon>{{ $t('router.nodes') }}</el-menu-item>
|
||||||
@@ -93,7 +94,7 @@ import { ref, computed } from 'vue'
|
|||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { DataAnalysis, Connection, Coin, Grid, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion } from '@element-plus/icons-vue'
|
import { DataAnalysis, Connection, Coin, Grid, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document } from '@element-plus/icons-vue'
|
||||||
import api, { auth } from '../api'
|
import api, { auth } from '../api'
|
||||||
|
|
||||||
const sideBg = 'var(--bg-sidebar)'
|
const sideBg = 'var(--bg-sidebar)'
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||||
|
<!-- 审计日志 Tab -->
|
||||||
|
<el-tab-pane :label="$t('logs.auditTitle')" name="audit">
|
||||||
|
<el-card class="page-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="row-between">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="auditFilters.username" :placeholder="$t('common.username')" clearable size="small" style="width:120px" @clear="loadAudit" @keyup.enter="loadAudit" />
|
||||||
|
<el-input v-model="auditFilters.action" :placeholder="$t('common.action')" clearable size="small" style="width:140px" @clear="loadAudit" @keyup.enter="loadAudit" />
|
||||||
|
<el-input v-model="auditFilters.keyword" :placeholder="$t('logs.keywordPlaceholder')" clearable size="small" style="width:160px" @clear="loadAudit" @keyup.enter="loadAudit" />
|
||||||
|
<el-date-picker v-model="auditDateRange" type="daterange" :start-placeholder="$t('logs.startDate')" :end-placeholder="$t('logs.endDate')" size="small" value-format="YYYY-MM-DD" style="width:260px" @change="loadAudit" clearable />
|
||||||
|
<el-button type="primary" size="small" @click="loadAudit">{{ $t('common.search') }}</el-button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<el-button size="small" @click="exportAudit">{{ $t('logs.exportCsv') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table :data="auditLogs" size="small" v-loading="auditLoading" max-height="560">
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
|
||||||
|
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="username" :label="$t('common.username')" width="100" />
|
||||||
|
<el-table-column prop="action" :label="$t('common.action')" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="actionTagType(row.action)">{{ row.action }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="target" :label="$t('common.target')" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="detail" :label="$t('common.detail')" min-width="200" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-bar">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="auditPage"
|
||||||
|
v-model:page-size="auditPageSize"
|
||||||
|
:total="auditTotal"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadAudit"
|
||||||
|
@size-change="loadAudit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 连接日志 Tab -->
|
||||||
|
<el-tab-pane :label="$t('logs.connTitle')" name="connection">
|
||||||
|
<el-card class="page-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="row-between">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="connFilters.username" :placeholder="$t('common.username')" clearable size="small" style="width:120px" @clear="loadConn" @keyup.enter="loadConn" />
|
||||||
|
<el-select v-model="connFilters.event" :placeholder="$t('logs.eventType')" clearable multiple size="small" style="width:200px" @change="loadConn">
|
||||||
|
<el-option :label="$t('sessions.eventConnect')" value="connect" />
|
||||||
|
<el-option :label="$t('sessions.eventDisconnect')" value="disconnect" />
|
||||||
|
<el-option :label="$t('sessions.eventAuthFail')" value="auth_fail" />
|
||||||
|
<el-option :label="$t('sessions.eventQuotaExceed')" value="quota_exceed" />
|
||||||
|
<el-option :label="$t('sessions.eventRateAlert')" value="rate_alert" />
|
||||||
|
</el-select>
|
||||||
|
<el-date-picker v-model="connDateRange" type="daterange" :start-placeholder="$t('logs.startDate')" :end-placeholder="$t('logs.endDate')" size="small" value-format="YYYY-MM-DD" style="width:260px" @change="loadConn" clearable />
|
||||||
|
<el-button type="primary" size="small" @click="loadConn">{{ $t('common.search') }}</el-button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<el-button size="small" @click="exportConn">{{ $t('logs.exportCsv') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table :data="connLogs" size="small" v-loading="connLoading" max-height="560">
|
||||||
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
|
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
|
||||||
|
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="tunnel_id" :label="$t('logs.tunnelId')" width="80" />
|
||||||
|
<el-table-column prop="username" :label="$t('common.username')" width="100" />
|
||||||
|
<el-table-column :label="$t('sessions.event')" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="eventTagType(row.event)" size="small">{{ eventLabel(row.event) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ip_address" :label="$t('common.ipAddress')" width="140" />
|
||||||
|
<el-table-column prop="detail" :label="$t('common.detail')" min-width="200" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-bar">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="connPage"
|
||||||
|
v-model:page-size="connPageSize"
|
||||||
|
:total="connTotal"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadConn"
|
||||||
|
@size-change="loadConn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 流量告警 Tab -->
|
||||||
|
<el-tab-pane :label="$t('logs.alertTitle')" name="alerts">
|
||||||
|
<el-card class="page-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="row-between">
|
||||||
|
<b style="color:var(--color-danger)">{{ $t('logs.alertTitle') }}</b>
|
||||||
|
<el-button link @click="loadAlerts">{{ $t('common.refresh') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<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>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="tunnel_id" :label="$t('logs.tunnelId')" width="80" />
|
||||||
|
<el-table-column prop="username" :label="$t('sessions.userTunnel')" width="100" />
|
||||||
|
<el-table-column :label="$t('sessions.alertType')" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.event === 'rate_alert' ? 'warning' : 'danger'" size="small">
|
||||||
|
{{ { rate_alert: $t('sessions.rateAlert'), quota_exceed: $t('sessions.quotaExceed') }[row.event] || row.event }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ip_address" :label="$t('common.ipAddress')" width="140" />
|
||||||
|
<el-table-column prop="detail" :label="$t('common.detail')" min-width="240" show-overflow-tooltip />
|
||||||
|
<template #empty><el-empty :description="$t('logs.noAlerts')" /></template>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<!-- 日志统计卡片 -->
|
||||||
|
<el-card class="page-card" shadow="never" style="margin-top:16px" v-if="stats">
|
||||||
|
<template #header><b>{{ $t('logs.statsTitle') }}</b></template>
|
||||||
|
<el-descriptions :column="3" size="small" border>
|
||||||
|
<el-descriptions-item :label="$t('logs.auditCount')">
|
||||||
|
{{ stats.audit?.count || 0 }} 条
|
||||||
|
<span v-if="stats.audit?.oldest" style="color:var(--text-secondary);font-size:12px;margin-left:8px">
|
||||||
|
{{ $t('logs.from') }} {{ fmtTime(stats.audit.oldest) }}
|
||||||
|
</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('logs.connCount')">
|
||||||
|
{{ stats.connection?.count || 0 }} 条
|
||||||
|
<span v-if="stats.connection?.oldest" style="color:var(--text-secondary);font-size:12px;margin-left:8px">
|
||||||
|
{{ $t('logs.from') }} {{ fmtTime(stats.connection.oldest) }}
|
||||||
|
</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('logs.sessionCount')">
|
||||||
|
{{ stats.session?.count || 0 }} 条
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div v-if="auth.isAdmin()" style="margin-top:12px;display:flex;align-items:center;gap:12px">
|
||||||
|
<el-button type="danger" size="small" @click="cleanupLogs">{{ $t('logs.cleanup') }}</el-button>
|
||||||
|
<span style="font-size:12px;color:var(--text-secondary)">{{ $t('logs.cleanupHint') }}</span>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import api, { auth } from '../api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const activeTab = ref('audit')
|
||||||
|
|
||||||
|
// ---- 审计日志 ----
|
||||||
|
const auditLogs = ref([])
|
||||||
|
const auditLoading = ref(false)
|
||||||
|
const auditPage = ref(1)
|
||||||
|
const auditPageSize = ref(20)
|
||||||
|
const auditTotal = ref(0)
|
||||||
|
const auditFilters = reactive({ username: '', action: '', keyword: '' })
|
||||||
|
const auditDateRange = ref(null)
|
||||||
|
|
||||||
|
async function loadAudit() {
|
||||||
|
auditLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: auditPage.value,
|
||||||
|
page_size: auditPageSize.value,
|
||||||
|
...auditFilters,
|
||||||
|
}
|
||||||
|
if (auditDateRange.value) {
|
||||||
|
params.start = auditDateRange.value[0]
|
||||||
|
params.end = auditDateRange.value[1]
|
||||||
|
}
|
||||||
|
const { data } = await api.get('/audit-logs', { params })
|
||||||
|
auditLogs.value = data.items || []
|
||||||
|
auditTotal.value = data.total || 0
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(t('logs.loadFailed'))
|
||||||
|
} finally { auditLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportAudit() {
|
||||||
|
const params = { ...auditFilters }
|
||||||
|
if (auditDateRange.value) {
|
||||||
|
params.start = auditDateRange.value[0]
|
||||||
|
params.end = auditDateRange.value[1]
|
||||||
|
}
|
||||||
|
const qs = new URLSearchParams(params).toString()
|
||||||
|
window.open(`/api/audit-logs/export?token=${auth.token}&${qs}`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 连接日志 ----
|
||||||
|
const connLogs = ref([])
|
||||||
|
const connLoading = ref(false)
|
||||||
|
const connPage = ref(1)
|
||||||
|
const connPageSize = ref(20)
|
||||||
|
const connTotal = ref(0)
|
||||||
|
const connFilters = reactive({ username: '', event: [], keyword: '' })
|
||||||
|
const connDateRange = ref(null)
|
||||||
|
|
||||||
|
async function loadConn() {
|
||||||
|
connLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: connPage.value,
|
||||||
|
page_size: connPageSize.value,
|
||||||
|
username: connFilters.username,
|
||||||
|
keyword: connFilters.keyword,
|
||||||
|
}
|
||||||
|
if (connFilters.event && connFilters.event.length > 0) {
|
||||||
|
params.event = connFilters.event.join(',')
|
||||||
|
}
|
||||||
|
if (connDateRange.value) {
|
||||||
|
params.start = connDateRange.value[0]
|
||||||
|
params.end = connDateRange.value[1]
|
||||||
|
}
|
||||||
|
const { data } = await api.get('/connection-logs', { params })
|
||||||
|
connLogs.value = data.items || []
|
||||||
|
connTotal.value = data.total || 0
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(t('logs.loadFailed'))
|
||||||
|
} finally { connLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportConn() {
|
||||||
|
const params = { username: connFilters.username, keyword: connFilters.keyword }
|
||||||
|
if (connFilters.event && connFilters.event.length > 0) {
|
||||||
|
params.event = connFilters.event.join(',')
|
||||||
|
}
|
||||||
|
if (connDateRange.value) {
|
||||||
|
params.start = connDateRange.value[0]
|
||||||
|
params.end = connDateRange.value[1]
|
||||||
|
}
|
||||||
|
const qs = new URLSearchParams(params).toString()
|
||||||
|
window.open(`/api/connection-logs/export?token=${auth.token}&${qs}`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 流量告警 ----
|
||||||
|
const alerts = ref([])
|
||||||
|
const alertLoading = ref(false)
|
||||||
|
|
||||||
|
async function loadAlerts() {
|
||||||
|
alertLoading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/traffic/alerts')
|
||||||
|
alerts.value = data || []
|
||||||
|
} catch { /* ignore */ } finally { alertLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 日志统计 ----
|
||||||
|
const stats = ref(null)
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/logs/stats')
|
||||||
|
stats.value = data
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 日志清理 ----
|
||||||
|
async function cleanupLogs() {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt(t('logs.cleanupPrompt'), t('logs.cleanup'), {
|
||||||
|
inputValue: '90',
|
||||||
|
inputPattern: /^\d+$/,
|
||||||
|
inputErrorMessage: t('logs.cleanupInputError'),
|
||||||
|
confirmButtonText: t('common.confirm'),
|
||||||
|
cancelButtonText: t('common.cancel'),
|
||||||
|
})
|
||||||
|
const days = parseInt(value) || 90
|
||||||
|
const { data } = await api.post('/logs/cleanup', { days })
|
||||||
|
ElMessage.success(t('logs.cleanupDone', { audit: data.audit_deleted, conn: data.conn_deleted, session: data.session_deleted }))
|
||||||
|
loadStats()
|
||||||
|
if (activeTab.value === 'audit') loadAudit()
|
||||||
|
if (activeTab.value === 'connection') loadConn()
|
||||||
|
} catch { /* cancel */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 通用 ----
|
||||||
|
function onTabChange(tab) {
|
||||||
|
if (tab === 'audit') loadAudit()
|
||||||
|
if (tab === 'connection') loadConn()
|
||||||
|
if (tab === 'alerts') loadAlerts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(d) {
|
||||||
|
if (!d) return '-'
|
||||||
|
return new Date(d).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventLabel(e) {
|
||||||
|
return {
|
||||||
|
connect: t('sessions.eventConnect'),
|
||||||
|
disconnect: t('sessions.eventDisconnect'),
|
||||||
|
auth_fail: t('sessions.eventAuthFail'),
|
||||||
|
quota_exceed: t('sessions.eventQuotaExceed'),
|
||||||
|
rate_alert: t('sessions.eventRateAlert'),
|
||||||
|
}[e] || e
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTagType(e) {
|
||||||
|
return { connect: 'success', disconnect: 'info', auth_fail: 'danger', quota_exceed: 'warning', rate_alert: 'warning' }[e] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionTagType(a) {
|
||||||
|
if (a.includes('login')) return 'primary'
|
||||||
|
if (a.includes('create') || a.includes('add')) return 'success'
|
||||||
|
if (a.includes('delete') || a.includes('kick')) return 'danger'
|
||||||
|
if (a.includes('update') || a.includes('change') || a.includes('set')) return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadAudit()
|
||||||
|
loadStats()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-bar {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex; justify-content: flex-end; margin-top: 12px;
|
||||||
|
}
|
||||||
|
.row-between {
|
||||||
|
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -45,15 +45,9 @@
|
|||||||
<el-col :xs="24" :lg="14" v-if="auth.isAdmin()">
|
<el-col :xs="24" :lg="14" v-if="auth.isAdmin()">
|
||||||
<el-card class="page-card" shadow="never">
|
<el-card class="page-card" shadow="never">
|
||||||
<template #header><b>{{ $t('settings.auditTitle') }}</b></template>
|
<template #header><b>{{ $t('settings.auditTitle') }}</b></template>
|
||||||
<el-table :data="logs" size="small" max-height="560" v-loading="auditLoading">
|
<el-empty :description="$t('settings.auditMigrated')">
|
||||||
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
|
<el-button type="primary" @click="$router.push('/logs')">{{ $t('router.logs') }}</el-button>
|
||||||
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
|
</el-empty>
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="username" :label="$t('common.username')" width="90" />
|
|
||||||
<el-table-column prop="action" :label="$t('common.action')" width="140" />
|
|
||||||
<el-table-column prop="target" :label="$t('common.target')" show-overflow-tooltip />
|
|
||||||
<template #empty><el-empty :description="$t('settings.noAuditLogs')" /></template>
|
|
||||||
</el-table>
|
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
@@ -83,8 +77,6 @@ import api, { auth } from '../api'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
|
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
|
||||||
const pwdLoading = ref(false)
|
const pwdLoading = ref(false)
|
||||||
const logs = ref([])
|
|
||||||
const auditLoading = ref(false)
|
|
||||||
const mfa = ref({ enabled: false, uri: '', code: '' })
|
const mfa = ref({ enabled: false, uri: '', code: '' })
|
||||||
const mfaCanvas = ref(null)
|
const mfaCanvas = ref(null)
|
||||||
const mfaLoading = ref(false)
|
const mfaLoading = ref(false)
|
||||||
@@ -110,13 +102,6 @@ onMounted(async () => {
|
|||||||
const status = await api.get('/mfa/status')
|
const status = await api.get('/mfa/status')
|
||||||
mfa.value.enabled = status.data.enabled
|
mfa.value.enabled = status.data.enabled
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
if (auth.isAdmin()) {
|
|
||||||
auditLoading.value = true
|
|
||||||
try {
|
|
||||||
const audit = await api.get('/audit')
|
|
||||||
logs.value = audit.data || []
|
|
||||||
} catch { /* viewer */ } finally { auditLoading.value = false }
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function setupMFA() {
|
async function setupMFA() {
|
||||||
|
|||||||
@@ -162,6 +162,9 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 流量趋势图 -->
|
||||||
|
<TrafficChart v-if="detail.tunnel.id" :tunnel-id="detail.tunnel.id" />
|
||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
@@ -229,6 +232,7 @@ import api from '../api'
|
|||||||
import { fmtBytes } from '../utils/format'
|
import { fmtBytes } from '../utils/format'
|
||||||
import ProtocolIcon from '../components/ProtocolIcon.vue'
|
import ProtocolIcon from '../components/ProtocolIcon.vue'
|
||||||
import TaskProgress from '../components/TaskProgress.vue'
|
import TaskProgress from '../components/TaskProgress.vue'
|
||||||
|
import TrafficChart from '../components/TrafficChart.vue'
|
||||||
import { protocolDisplay, protocolColor } from '../protocols'
|
import { protocolDisplay, protocolColor } from '../protocols'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|||||||
Executable
+128
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# OPSX VPN 管理平台 —— 安装脚本
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_NAME="opsx-vpn"
|
||||||
|
INSTALL_DIR="/opt/${APP_NAME}"
|
||||||
|
CONFIG_DIR="/etc/${APP_NAME}"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${APP_NAME}.service"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
|
||||||
|
# 检查 root 权限
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
error "请使用 root 或 sudo 执行此脚本"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查二进制文件
|
||||||
|
BINARY=""
|
||||||
|
if [[ -f "${PROJECT_ROOT}/opsx-vpn" ]]; then
|
||||||
|
BINARY="${PROJECT_ROOT}/opsx-vpn"
|
||||||
|
elif [[ -f "${PROJECT_ROOT}/build/opsx-vpn" ]]; then
|
||||||
|
BINARY="${PROJECT_ROOT}/build/opsx-vpn"
|
||||||
|
else
|
||||||
|
error "未找到 opsx-vpn 二进制文件,请先执行编译"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "安装 OPSX VPN 管理平台..."
|
||||||
|
|
||||||
|
# 停止已有服务(如果在运行)
|
||||||
|
if systemctl is-active --quiet "${APP_NAME}" 2>/dev/null; then
|
||||||
|
info "停止现有服务..."
|
||||||
|
systemctl stop "${APP_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建安装目录
|
||||||
|
info "创建安装目录: ${INSTALL_DIR}"
|
||||||
|
mkdir -p "${INSTALL_DIR}"
|
||||||
|
mkdir -p "${INSTALL_DIR}/data"
|
||||||
|
|
||||||
|
# 复制二进制文件
|
||||||
|
info "复制二进制文件..."
|
||||||
|
cp "${BINARY}" "${INSTALL_DIR}/${APP_NAME}"
|
||||||
|
chmod 755 "${INSTALL_DIR}/${APP_NAME}"
|
||||||
|
|
||||||
|
# 复制前端资源(如果有 dist 目录)
|
||||||
|
if [[ -d "${PROJECT_ROOT}/frontend/dist" ]]; then
|
||||||
|
info "前端资源已内嵌于二进制文件中"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建默认配置文件
|
||||||
|
info "创建默认配置: ${CONFIG_DIR}/env"
|
||||||
|
mkdir -p "${CONFIG_DIR}"
|
||||||
|
if [[ ! -f "${CONFIG_DIR}/env" ]]; then
|
||||||
|
cat > "${CONFIG_DIR}/env" << 'EOF'
|
||||||
|
# OPSX VPN 管理平台环境配置
|
||||||
|
# 监听地址(默认 :8080)
|
||||||
|
VPN_LISTEN_ADDR=:8080
|
||||||
|
|
||||||
|
# 数据目录(默认 /opt/opsx-vpn/data)
|
||||||
|
VPN_DATA_DIR=/opt/opsx-vpn/data
|
||||||
|
|
||||||
|
# 日志保留天数(默认 90 天)
|
||||||
|
VPN_LOG_RETENTION_DAYS=90
|
||||||
|
|
||||||
|
# 实例日志轮转:单文件最大大小(默认 50MB)
|
||||||
|
VPN_INSTANCE_LOG_MAX_SIZE=50MB
|
||||||
|
|
||||||
|
# 实例日志轮转:最大文件数(默认 2)
|
||||||
|
VPN_INSTANCE_LOG_MAX_FILES=2
|
||||||
|
|
||||||
|
# 访问日志轮转:单文件最大大小(默认 20MB)
|
||||||
|
VPN_ACCESS_LOG_MAX_SIZE=20MB
|
||||||
|
|
||||||
|
# 访问日志轮转:最大文件数(默认 3)
|
||||||
|
VPN_ACCESS_LOG_MAX_FILES=3
|
||||||
|
EOF
|
||||||
|
info "已创建默认配置文件"
|
||||||
|
else
|
||||||
|
warn "配置文件已存在,跳过创建: ${CONFIG_DIR}/env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 安装 systemd 服务
|
||||||
|
info "安装 systemd 服务..."
|
||||||
|
cp "${SCRIPT_DIR}/${APP_NAME}.service" "${SERVICE_FILE}"
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# 启用并启动服务
|
||||||
|
info "启用并启动服务..."
|
||||||
|
systemctl enable "${APP_NAME}"
|
||||||
|
systemctl start "${APP_NAME}"
|
||||||
|
|
||||||
|
# 检查服务状态
|
||||||
|
sleep 2
|
||||||
|
if systemctl is-active --quiet "${APP_NAME}"; then
|
||||||
|
LISTEN_ADDR=$(grep -E '^VPN_LISTEN_ADDR=' "${CONFIG_DIR}/env" 2>/dev/null | cut -d= -f2 || echo ":8080")
|
||||||
|
echo ""
|
||||||
|
info "============================================"
|
||||||
|
info " OPSX VPN 管理平台安装成功!"
|
||||||
|
info "============================================"
|
||||||
|
info ""
|
||||||
|
info " 访问地址: http://localhost${LISTEN_ADDR}"
|
||||||
|
info " 默认账号: admin / admin123"
|
||||||
|
info ""
|
||||||
|
info " 服务管理:"
|
||||||
|
info " 启动: systemctl start ${APP_NAME}"
|
||||||
|
info " 停止: systemctl stop ${APP_NAME}"
|
||||||
|
info " 状态: systemctl status ${APP_NAME}"
|
||||||
|
info " 日志: journalctl -u ${APP_NAME} -f"
|
||||||
|
info ""
|
||||||
|
info " 配置文件: ${CONFIG_DIR}/env"
|
||||||
|
info " 数据目录: ${INSTALL_DIR}/data"
|
||||||
|
info "============================================"
|
||||||
|
else
|
||||||
|
error "服务启动失败,请检查日志: journalctl -u ${APP_NAME} -n 50"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OPSX VPN Management Platform
|
||||||
|
Documentation=https://github.com/opsx-vpn/opsx-vpn
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/opsx-vpn
|
||||||
|
ExecStart=/opt/opsx-vpn/opsx-vpn
|
||||||
|
EnvironmentFile=-/etc/opsx-vpn/env
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
LimitNOFILE=65536
|
||||||
|
# VPN 管理需要 iptables 和网络访问权限
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# OPSX VPN 管理平台 —— 卸载脚本
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_NAME="opsx-vpn"
|
||||||
|
INSTALL_DIR="/opt/${APP_NAME}"
|
||||||
|
CONFIG_DIR="/etc/${APP_NAME}"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${APP_NAME}.service"
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
|
||||||
|
# 检查 root 权限
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
error "请使用 root 或 sudo 执行此脚本"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
warn "============================================"
|
||||||
|
warn " 卸载 OPSX VPN 管理平台"
|
||||||
|
warn "============================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 停止并禁用服务
|
||||||
|
if systemctl is-active --quiet "${APP_NAME}" 2>/dev/null; then
|
||||||
|
info "停止服务..."
|
||||||
|
systemctl stop "${APP_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if systemctl is-enabled --quiet "${APP_NAME}" 2>/dev/null; then
|
||||||
|
info "禁用服务..."
|
||||||
|
systemctl disable "${APP_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 删除 systemd 服务文件
|
||||||
|
if [[ -f "${SERVICE_FILE}" ]]; then
|
||||||
|
info "删除 systemd 服务文件..."
|
||||||
|
rm -f "${SERVICE_FILE}"
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 询问是否删除数据
|
||||||
|
REMOVE_DATA=""
|
||||||
|
echo ""
|
||||||
|
read -p "是否删除安装目录 ${INSTALL_DIR}?[y/N] " -r REMOVE_DATA
|
||||||
|
if [[ "${REMOVE_DATA}" =~ ^[Yy]$ ]]; then
|
||||||
|
info "删除安装目录: ${INSTALL_DIR}"
|
||||||
|
rm -rf "${INSTALL_DIR}"
|
||||||
|
else
|
||||||
|
info "保留安装目录: ${INSTALL_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
REMOVE_CONFIG=""
|
||||||
|
read -p "是否删除配置目录 ${CONFIG_DIR}?[y/N] " -r REMOVE_CONFIG
|
||||||
|
if [[ "${REMOVE_CONFIG}" =~ ^[Yy]$ ]]; then
|
||||||
|
info "删除配置目录: ${CONFIG_DIR}"
|
||||||
|
rm -rf "${CONFIG_DIR}"
|
||||||
|
else
|
||||||
|
info "保留配置目录: ${CONFIG_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "============================================"
|
||||||
|
info " OPSX VPN 管理平台已卸载"
|
||||||
|
info "============================================"
|
||||||
|
info ""
|
||||||
|
info " 残留文件(如未删除):"
|
||||||
|
info " 安装目录: ${INSTALL_DIR}"
|
||||||
|
info " 配置目录: ${CONFIG_DIR}"
|
||||||
|
info "============================================"
|
||||||
Reference in new issue
Block a user