diff --git a/.gitignore b/.gitignore index f2dd38e..1182e89 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ vendor/ # 环境 .env .env.local +.nfs* diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 6e05c16..536ba39 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -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 实时推送专线实例日志。 // 读取 /instances/-/instance.log,先发送尾部 100 行历史,再 tail 新增行。 func (s *Server) tunnelLogs(c *gin.Context) { diff --git a/backend/internal/api/batch.go b/backend/internal/api/batch.go index e17c913..b7d49e6 100644 --- a/backend/internal/api/batch.go +++ b/backend/internal/api/batch.go @@ -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++ diff --git a/backend/internal/api/logapi.go b/backend/internal/api/logapi.go new file mode 100644 index 0000000..d2798f1 --- /dev/null +++ b/backend/internal/api/logapi.go @@ -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 天流量采样 +} diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index ad676aa..e89c537 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -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"` diff --git a/backend/internal/monitor/monitor.go b/backend/internal/monitor/monitor.go index 697f4c4..fe29e50 100644 --- a/backend/internal/monitor/monitor.go +++ b/backend/internal/monitor/monitor.go @@ -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 diff --git a/backend/internal/service/common.go b/backend/internal/service/common.go index baddc12..e8e7612 100644 --- a/backend/internal/service/common.go +++ b/backend/internal/service/common.go @@ -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 } diff --git a/backend/internal/service/gre/gre.go b/backend/internal/service/gre/gre.go new file mode 100644 index 0000000..a40e101 --- /dev/null +++ b/backend/internal/service/gre/gre.go @@ -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 + } + } +} diff --git a/backend/internal/service/ipsec/ipsec_test.go b/backend/internal/service/ipsec/ipsec_test.go new file mode 100644 index 0000000..d4cf77b --- /dev/null +++ b/backend/internal/service/ipsec/ipsec_test.go @@ -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) + } +} diff --git a/backend/internal/service/l2tp/l2tp.go b/backend/internal/service/l2tp/l2tp.go index 11ad2ac..c137793 100644 --- a/backend/internal/service/l2tp/l2tp.go +++ b/backend/internal/service/l2tp/l2tp.go @@ -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 diff --git a/backend/internal/service/l2tp/l2tp_test.go b/backend/internal/service/l2tp/l2tp_test.go new file mode 100644 index 0000000..89409dc --- /dev/null +++ b/backend/internal/service/l2tp/l2tp_test.go @@ -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) + } +} diff --git a/backend/internal/service/logrotate.go b/backend/internal/service/logrotate.go new file mode 100644 index 0000000..3686092 --- /dev/null +++ b/backend/internal/service/logrotate.go @@ -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 +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 1eb670c..092cc81 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -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 diff --git a/backend/main.go b/backend/main.go index 1cfce1b..1276b17 100644 --- a/backend/main.go +++ b/backend/main.go @@ -3,10 +3,12 @@ package main import ( "embed" + "fmt" "io/fs" "log" "net/http" "os" + "strconv" "strings" "time" @@ -17,6 +19,7 @@ import ( "vpn-platform/backend/internal/monitor" "vpn-platform/backend/internal/service" "vpn-platform/backend/internal/service/custom" + "vpn-platform/backend/internal/service/gre" "vpn-platform/backend/internal/service/frp" "vpn-platform/backend/internal/service/ikev2" "vpn-platform/backend/internal/service/ipsec" @@ -55,11 +58,14 @@ func main() { mgr.Register(ikev2.New()) mgr.Register(l2tp.New()) mgr.Register(frp.New()) + mgr.Register(gre.New()) mgr.Register(openvpn.New()) // 加载数据库中的自定义协议插件 custom.LoadAll(mgr) r := gin.New() + // HTTP 访问日志(带轮转,默认 20MB x 3 文件) + r.Use(accessLogger()) r.Use(gin.Recovery()) srv := api.NewServer(mgr) @@ -93,6 +99,15 @@ func main() { 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.AutoStartAll(mgr) @@ -115,3 +130,38 @@ func main() { 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)) + } +} diff --git a/frontend/src/components/TrafficChart.vue b/frontend/src/components/TrafficChart.vue new file mode 100644 index 0000000..128dfe5 --- /dev/null +++ b/frontend/src/components/TrafficChart.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index b6eaea8..4301cc7 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -207,7 +207,9 @@ "taskComplete": "All done", "taskPartialFail": "Partial failure", "viewTasks": "View Tasks", - "noTasks": "No task history" + "noTasks": "No task history", + "trafficTrend": "Traffic Trend", + "noTrafficData": "No traffic data yet (waiting for samples…)" }, "tunnelEditor": { "title": "Edit Tunnel", @@ -423,6 +425,29 @@ "kickConfirm": "Kick user \"{username}\" 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": { "mustChangePassword": "First login or password has been reset by admin. You must change your password before using other features.", "changePassword": "Change Password", @@ -451,6 +476,7 @@ "prodSuggestion": "Production Tip", "prodSuggestionDesc": "Expose via Nginx/Caddy reverse proxy with TLS termination", "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", "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!", @@ -599,6 +625,7 @@ "vpnUsers": "VPN Dial-in Users", "plugins": "Protocol Plugins", "settings": "Settings", + "logs": "Log Center", "notFound": "Page Not Found", "sidebarOps": "Tunnel Ops", "sidebarResource": "Resource Mgmt", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 0688122..eb9a87f 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -207,7 +207,9 @@ "taskComplete": "全部完成", "taskPartialFail": "部分失败", "viewTasks": "查看任务", - "noTasks": "暂无任务记录" + "noTasks": "暂无任务记录", + "trafficTrend": "流量趋势", + "noTrafficData": "暂无流量数据(等待采样中…)" }, "tunnelEditor": { "title": "编辑专线", @@ -423,6 +425,29 @@ "kickConfirm": "确定踢用户「{username}」下线?", "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": { "mustChangePassword": "首次登录或密码已被管理员重置,必须修改密码后才能使用其他功能", "changePassword": "修改密码", @@ -451,6 +476,7 @@ "prodSuggestion": "生产建议", "prodSuggestionDesc": "经 Nginx/Caddy 反向代理终止 TLS 后暴露", "auditTitle": "操作审计(最近 100 条)", + "auditMigrated": "审计日志已迁移至「日志中心」,支持分页、筛选和导出", "noAuditLogs": "暂无审计日志", "mfaEnabledTitle": "MFA 已启用 — 请保存备用码", "backupCodesHint": "这些备用码仅显示一次,丢失手机时用于登录。请立即复制保存!", @@ -599,6 +625,7 @@ "vpnUsers": "VPN 拨号用户", "plugins": "协议插件", "settings": "系统设置", + "logs": "日志中心", "notFound": "页面不存在", "sidebarOps": "专线运维", "sidebarResource": "资源管理", diff --git a/frontend/src/router.js b/frontend/src/router.js index 7dd946c..9c9c36f 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -12,6 +12,7 @@ const routes = [ { path: 'dashboard', component: () => import('./views/Dashboard.vue'), meta: { titleKey: 'router.dashboard' } }, { path: 'tunnels', component: () => import('./views/Tunnels.vue'), meta: { titleKey: 'router.tunnels' } }, { 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/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel' } }, { path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } }, diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 45b905b..511376d 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -11,6 +11,7 @@ {{ $t('router.dashboard') }} {{ $t('router.tunnels') }} {{ $t('router.sessions') }} + {{ $t('router.logs') }} {{ $t('router.nodes') }} @@ -93,7 +94,7 @@ import { ref, computed } from 'vue' import { useRouter, useRoute } from 'vue-router' import { ElMessage } from 'element-plus' 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' const sideBg = 'var(--bg-sidebar)' diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue new file mode 100644 index 0000000..c74b49e --- /dev/null +++ b/frontend/src/views/Logs.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index e64b546..9ff765b 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -45,15 +45,9 @@ - - - - - - - - - + + {{ $t('router.logs') }} + @@ -83,8 +77,6 @@ import api, { auth } from '../api' const { t } = useI18n() const pwd = ref({ old_password: '', new_password: '', confirm: '' }) const pwdLoading = ref(false) -const logs = ref([]) -const auditLoading = ref(false) const mfa = ref({ enabled: false, uri: '', code: '' }) const mfaCanvas = ref(null) const mfaLoading = ref(false) @@ -110,13 +102,6 @@ onMounted(async () => { const status = await api.get('/mfa/status') mfa.value.enabled = status.data.enabled } 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() { diff --git a/frontend/src/views/Tunnels.vue b/frontend/src/views/Tunnels.vue index 8dc9aba..3b16562 100644 --- a/frontend/src/views/Tunnels.vue +++ b/frontend/src/views/Tunnels.vue @@ -162,6 +162,9 @@ + + + @@ -229,6 +232,7 @@ import api from '../api' import { fmtBytes } from '../utils/format' import ProtocolIcon from '../components/ProtocolIcon.vue' import TaskProgress from '../components/TaskProgress.vue' +import TrafficChart from '../components/TrafficChart.vue' import { protocolDisplay, protocolColor } from '../protocols' const { t } = useI18n() diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..c4eb3dd --- /dev/null +++ b/scripts/install.sh @@ -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 diff --git a/scripts/opsx-vpn.service b/scripts/opsx-vpn.service new file mode 100644 index 0000000..bb3f0ce --- /dev/null +++ b/scripts/opsx-vpn.service @@ -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 diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..2b1346f --- /dev/null +++ b/scripts/uninstall.sh @@ -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 "============================================"