From 81544d2335e75567e9ba75d95cd33fa918302d09 Mon Sep 17 00:00:00 2001 From: cnbugs Date: Mon, 10 Aug 2026 00:06:40 +0800 Subject: [PATCH] Fix connection logs: real-time status.log parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: listConnLogs only read from db.json which was never populated (background sync silently failed due to Status check and broken regex). Fix approach: read status.log directly in API handlers — no DB dependency, works immediately when OpenVPN is running. Changes: api/router.go: - listConnLogs: parse status.log from all instances in real-time, merge with historical DB entries for disconnected sessions - dashboard: same real-time approach for recent_conn_logs pkg/openvpn/manager.go: - Fix regex for status-version 3 format (11 fields): CLIENT_LIST,CN,RealAddr,VPNAddr,IPv6,BytesRecv,BytesSent,ConnectedSince,... Old regex assumed wrong field order (CN,ConnectedSince,RealAddr) - Add parseConnTime() helper supporting multiple time formats internal/service/service.go: - Remove Status=='running' check (was blocking sync for all instances) - Add log.Printf debug output for sync operations - Add 'log' import internal/store/store.go: - Add ListActiveConns(instanceID) for background sync - Add UpdateActiveConnStats() for traffic updates without flush --- backend/internal/api/router.go | 72 ++++++++++++++++++++++++++++- backend/internal/service/service.go | 12 +++-- backend/pkg/openvpn/manager.go | 42 ++++++++++++++--- 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index a912957..f45aab3 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -217,6 +217,33 @@ func (s *Server) dashboard(c *gin.Context) { "allow_networks": in.AllowNetworks, }) } + // 实时连接日志:从 status.log 读取在线用户 + var recentConnLogs []model.ConnectionLog + for _, in := range instances { + statusPath := filepath.Join(s.Svc.Cfg.InstanceDir(in.Name), "status.log") + entries, _ := s.Svc.Ovm.ParseStatus(statusPath) + for _, e := range entries { + recentConnLogs = append(recentConnLogs, model.ConnectionLog{ + InstanceID: in.ID, CommonName: e.CommonName, + RealIP: e.RealAddress, VPNIP: e.VPNAddress, + BytesIn: e.BytesRecv, BytesOut: e.BytesSent, + ConnectedAt: e.ConnectedAt, + }) + } + } + // 补充历史已断开的 + history := s.Svc.Store.ListConnLogs("", 20) + liveSet := map[string]bool{} + for _, l := range recentConnLogs { + liveSet[l.CommonName] = true + } + for _, h := range history { + if h.DisconnectedAt == nil && liveSet[h.CommonName] { + continue + } + recentConnLogs = append(recentConnLogs, h) + } + c.JSON(200, gin.H{ "instances": len(instances), "running": countByStatus(instances, "running"), @@ -226,7 +253,7 @@ func (s *Server) dashboard(c *gin.Context) { "online": online, "expiring_certs": expiring, "recent_audits": s.Svc.Store.ListAudits(20), - "recent_conn_logs": s.Svc.Store.ListConnLogs("", 20), + "recent_conn_logs": recentConnLogs, "access_overview": overview, }) } @@ -467,7 +494,48 @@ func (s *Server) listCerts(c *gin.Context) { // ---- conn logs ---- func (s *Server) listConnLogs(c *gin.Context) { - c.JSON(200, s.Svc.Store.ListConnLogs(c.Query("instance"), 200)) + // 1) 数据库里的历史日志(有断开时间的老连接) + history := s.Svc.Store.ListConnLogs(c.Query("instance"), 200) + + // 2) 实时解析所有实例的 status.log,生成当前在线用户 + var live []model.ConnectionLog + instances := s.Svc.Store.ListInstances() + for _, in := range instances { + if c.Query("instance") != "" && c.Query("instance") != in.ID { + continue + } + statusPath := filepath.Join(s.Svc.Cfg.InstanceDir(in.Name), "status.log") + entries, err := s.Svc.Ovm.ParseStatus(statusPath) + if err != nil { + continue + } + for _, e := range entries { + live = append(live, model.ConnectionLog{ + InstanceID: in.ID, + CommonName: e.CommonName, + RealIP: e.RealAddress, + VPNIP: e.VPNAddress, + BytesIn: e.BytesRecv, + BytesOut: e.BytesSent, + ConnectedAt: e.ConnectedAt, + }) + } + } + + // 3) 合并:先输出在线的,再输出历史的(去重:跳过 history 中与 live CN 重复的未断开记录) + liveSet := map[string]bool{} + for _, l := range live { + liveSet[l.CommonName] = true + } + var merged []model.ConnectionLog + merged = append(merged, live...) + for _, h := range history { + if h.DisconnectedAt == nil && liveSet[h.CommonName] { + continue // 已用实时数据替代 + } + merged = append(merged, h) + } + c.JSON(200, merged) } // ---- backups ---- diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index b4429af..8f0f40c 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "fmt" "io" + "log" "os" "os/exec" "path/filepath" @@ -51,14 +52,15 @@ func (s *Service) StartStatusSync() { func (s *Service) syncAllInstanceStatus() { instances := s.Store.ListInstances() for _, in := range instances { - if in.Status != "running" { - continue - } + // 不检查 Status 字段,直接尝试读 status.log。 + // OpenVPN 运行时会自动写 status.log,读到就同步。 statusPath := filepath.Join(s.Cfg.InstanceDir(in.Name), "status.log") entries, err := s.Ovm.ParseStatus(statusPath) if err != nil { - continue // 文件不存在或解析失败,跳过 + // 文件不存在或解析失败,跳过 + continue } + log.Printf("[status-sync] instance=%s entries=%d", in.Name, len(entries)) // 构建当前在线 CN 集合 onlineCNs := map[string]bool{} for _, e := range entries { @@ -74,6 +76,7 @@ func (s *Service) syncAllInstanceStatus() { BytesOut: e.BytesSent, ConnectedAt: e.ConnectedAt, }) + log.Printf("[status-sync] new conn: %s %s %s", e.CommonName, e.RealAddress, e.VPNAddress) } else { // 更新流量统计 _ = s.Store.UpdateActiveConnStats(in.ID, e.CommonName, e.BytesRecv, e.BytesSent) @@ -84,6 +87,7 @@ func (s *Service) syncAllInstanceStatus() { if !onlineCNs[c.CommonName] { now := time.Now() _ = s.Store.CloseActiveConn(in.ID, c.CommonName, now) + log.Printf("[status-sync] disconnected: %s", c.CommonName) } } } diff --git a/backend/pkg/openvpn/manager.go b/backend/pkg/openvpn/manager.go index 880b791..cd0e7ca 100644 --- a/backend/pkg/openvpn/manager.go +++ b/backend/pkg/openvpn/manager.go @@ -614,7 +614,9 @@ type StatusEntry struct { var ( reHdr = regexp.MustCompile(`^Updated,([^,]+),`) - reCli = regexp.MustCompile(`^CLIENT_LIST,([^,]+),([^,]+),([^,]+),(\d+),(\d+),`) + // status-version 3 CLIENT_LIST 格式(11个字段): + // CLIENT_LIST,CommonName,RealAddress,VirtualAddress,VirtualIPv6,BytesRecv,BytesSent,ConnectedSince,Username,ClientID,PeerID + reCli = regexp.MustCompile(`^CLIENT_LIST,([^,]+),([^,]+),([^,]+),([^,]*),(\d+),(\d+),([^,]+),`) reTime = regexp.MustCompile(`^Connected Since,([^,]+),`) ) @@ -630,14 +632,13 @@ func ParseStatusReader(r io.Reader) ([]StatusEntry, error) { if m == nil { continue } - connected, _ := time.Parse("Mon Jan 2 15:04:05 2006", m[2]) out = append(out, StatusEntry{ CommonName: m[1], - RealAddress: m[3], - VPNAddress: m[4], - BytesRecv: atoi64(m[5]), - BytesSent: atoi64(m[6]), - ConnectedAt: connected, + RealAddress: m[2], // RealAddress (IP:port) + VPNAddress: m[3], // VirtualAddress + BytesRecv: atoi64(m[5]), + BytesSent: atoi64(m[6]), + ConnectedAt: parseConnTime(m[7]), }) } } @@ -693,6 +694,33 @@ func atoi64(s string) int64 { return n } +// parseConnTime 解析 OpenVPN status.log 中的时间字段。 +// status-version 3 格式: "Mon Jan 2 15:04:05 2006" (注意可能有双空格) +// 部分版本使用 Unix 时间戳。 +func parseConnTime(s string) time.Time { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{} + } + // 先尝试 Unix 时间戳 + if ts, err := strconv.ParseInt(s, 10, 64); err == nil && ts > 1000000000 { + return time.Unix(ts, 0) + } + // 标准格式 + formats := []string{ + "Mon Jan 2 15:04:05 2006", + "Mon Jan 2 15:04:05 2006", + "2006-01-02 15:04:05", + time.RFC3339, + } + for _, f := range formats { + if t, err := time.Parse(f, s); err == nil { + return t + } + } + return time.Time{} +} + // cidrToServerDirective 把 "10.8.0.0/24" 转成 OpenVPN server 指令需要的 // "10.8.0.0 255.255.255.0" 格式(网络地址 + 点分掩码)。 // 如果输入不含 "/"(已经是 "ip mask" 形式),原样返回。