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:
opsx-tools committed 2026-07-25 08:50:34 +08:00
1 parent 7de1f70905
commit a14d9e62e6
25 files changed
+2285 -25

No files matched your search

+115
View File
@@ -231,6 +231,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
auth.GET("/tunnels", s.listTunnels)
auth.GET("/tunnels/:id", s.getTunnel)
auth.GET("/tunnels/:id/status", s.tunnelStatus)
auth.GET("/tunnels/:id/traffic/history", s.tunnelTrafficHistory)
auth.GET("/tunnels/:id/policy", s.getTrafficPolicy)
auth.GET("/ipsec/templates", s.ipsecTemplates)
auth.GET("/nodes", s.listNodes)
@@ -239,6 +240,11 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
auth.GET("/sessions/log", s.listConnectionLogs)
auth.GET("/traffic/alerts", s.listTrafficAlerts)
// 日志中心(分页、筛选、导出)
auth.GET("/audit-logs", s.listAuditPaged)
auth.GET("/connection-logs", s.listConnectionLogsPaged)
auth.GET("/logs/stats", s.logStats)
// 专线运维 operator+
ops := auth.Group("")
ops.Use(requireRole("admin", "operator"))
@@ -305,6 +311,9 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
admin.PUT("/plugins/:id", s.updatePlugin)
admin.DELETE("/plugins/:id", s.deletePlugin)
admin.GET("/audit", s.listAudit)
admin.GET("/audit-logs/export", s.exportAuditLogs)
admin.GET("/connection-logs/export", s.exportConnectionLogs)
admin.POST("/logs/cleanup", s.cleanupLogs)
}
}
@@ -1031,6 +1040,112 @@ func (s *Server) tunnelStatus(c *gin.Context) {
c.JSON(200, st)
}
// tunnelTrafficHistory 返回隧道流量时间序列数据
// granularity: minute(最近 1 小时,每分钟聚合), hour(默认,最近 24 小时,每小时聚合), day(最近 30 天,每天聚合)
// hours: 覆盖时间范围(默认 24)
func (s *Server) tunnelTrafficHistory(c *gin.Context) {
t, err := s.mustTunnel(c)
if err != nil {
return
}
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
if hours < 1 {
hours = 1
}
if hours > 720 { // max 30 days
hours = 720
}
granularity := c.DefaultQuery("granularity", "hour")
if granularity != "minute" && granularity != "hour" && granularity != "day" {
granularity = "hour"
}
since := time.Now().Add(-time.Duration(hours) * time.Hour)
type row struct {
Time time.Time `json:"time"`
Rx int64 `json:"rx"`
Tx int64 `json:"tx"`
DeltaRx int64 `json:"delta_rx"`
DeltaTx int64 `json:"delta_tx"`
}
var results []row
switch granularity {
case "minute":
// Group by minute for fine granularity (last 1 hour typical)
var raw []model.TrafficSample
store.DB.Where("tunnel_id = ? AND created_at >= ?", t.ID, since).
Order("created_at asc").Find(&raw)
// Bucket by minute
buckets := map[time.Time]*row{}
for _, s := range raw {
key := s.CreatedAt.Truncate(time.Minute)
if _, ok := buckets[key]; !ok {
buckets[key] = &row{Time: key}
}
b := buckets[key]
b.Rx = s.RxBytes // last value in bucket wins
b.Tx = s.TxBytes
b.DeltaRx += s.DeltaRx
b.DeltaTx += s.DeltaTx
}
for _, v := range buckets {
results = append(results, *v)
}
case "day":
// Group by day
var raw []model.TrafficSample
store.DB.Where("tunnel_id = ? AND created_at >= ?", t.ID, since).
Order("created_at asc").Find(&raw)
buckets := map[time.Time]*row{}
for _, s := range raw {
key := time.Date(s.CreatedAt.Year(), s.CreatedAt.Month(), s.CreatedAt.Day(), 0, 0, 0, 0, s.CreatedAt.Location())
if _, ok := buckets[key]; !ok {
buckets[key] = &row{Time: key}
}
b := buckets[key]
b.Rx = s.RxBytes
b.Tx = s.TxBytes
b.DeltaRx += s.DeltaRx
b.DeltaTx += s.DeltaTx
}
for _, v := range buckets {
results = append(results, *v)
}
default: // "hour"
var raw []model.TrafficSample
store.DB.Where("tunnel_id = ? AND created_at >= ?", t.ID, since).
Order("created_at asc").Find(&raw)
buckets := map[time.Time]*row{}
for _, s := range raw {
key := s.CreatedAt.Truncate(time.Hour)
if _, ok := buckets[key]; !ok {
buckets[key] = &row{Time: key}
}
b := buckets[key]
b.Rx = s.RxBytes
b.Tx = s.TxBytes
b.DeltaRx += s.DeltaRx
b.DeltaTx += s.DeltaTx
}
for _, v := range buckets {
results = append(results, *v)
}
}
if results == nil {
results = []row{}
}
c.JSON(200, gin.H{
"tunnel_id": t.ID,
"granularity": granularity,
"hours": hours,
"samples": results,
})
}
// tunnelLogs 通过 SSE 实时推送专线实例日志。
// 读取 <data>/instances/<id>-<protocol>/instance.log,先发送尾部 100 行历史,再 tail 新增行。
func (s *Server) tunnelLogs(c *gin.Context) {
+1 -1
View File
@@ -224,7 +224,7 @@ func (s *Server) batchSetPolicy(c *gin.Context) {
policy.ResetCycle = req.ResetCycle
}
if err := store.DB.Save(&policy).Error; err != nil {
errors = append(errors, fmt.Sprintf("隧道 %d 策略保存失败: %s", t.Name, err.Error()))
errors = append(errors, fmt.Sprintf("隧道 %s 策略保存失败: %s", t.Name, err.Error()))
continue
}
success++
+297
View File
@@ -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 天流量采样
}
+11
View File
@@ -3,6 +3,17 @@ package model
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 平台账号
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
+193
View File
@@ -2,10 +2,16 @@
package monitor
import (
"fmt"
"log"
"strconv"
"strings"
"sync/atomic"
"time"
"vpn-platform/backend/internal/core"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/sessionmgr"
"vpn-platform/backend/internal/store"
"vpn-platform/backend/internal/trafficmgr"
@@ -13,6 +19,13 @@ import (
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 秒刷新一次)
func Start(mgr *core.Manager, interval time.Duration) {
if interval == 0 {
@@ -35,6 +48,11 @@ func collect(mgr *core.Manager, interval time.Duration) {
if err := store.DB.Find(&tunnels).Error; err != nil {
return
}
// Decide whether to insert a TrafficSample this round.
tick := atomic.AddInt64(&tickCounter, 1)
shouldSample := tick%sampleEvery == 0
for i := range tunnels {
t := &tunnels[i]
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})
}
// ---- 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)----
if st.Running {
// 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 启动时拉起所有标记为自启的专线
func AutoStartAll(mgr *core.Manager) {
var tunnels []model.Tunnel
+5 -1
View File
@@ -120,7 +120,11 @@ func StartDaemon(t *model.Tunnel, name string, args ...string) error {
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
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 {
return err
}
+303
View File
@@ -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)
}
}
+6 -1
View File
@@ -90,6 +90,11 @@ func parseIPRange(raw string) (netip.Addr, netip.Addr, 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)
if err != nil {
return err
@@ -116,12 +121,12 @@ func (s *Service) Create(t *model.Tunnel) error {
// 1) IPSec 层(transport 模式,L2TP over IPSec)
swanConf := fmt.Sprintf(`connections {
l2tp-%d {
type = transport
proposals = %s
local { auth = psk }
remote { auth = psk }
children {
l2tp-child-%d {
mode = transport
local_ts = dynamic[udp/l2tp]
remote_ts = dynamic[udp/l2tp]
esp_proposals = %s
+180
View File
@@ -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)
}
}
+148
View File
@@ -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
}
+1 -1
View File
@@ -44,7 +44,7 @@ func Init(dataDir string) error {
}
// 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一
db.Exec("DROP INDEX IF EXISTS idx_vpn_user")
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}); 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
}
DB = db