Fix connection logs: real-time status.log parsing

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
This commit is contained in:
cnbugs
2026-08-10 00:06:40 +08:00
parent 19c0b190ea
commit 81544d2335
3 changed files with 113 additions and 13 deletions
+70 -2
View File
@@ -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 ----
+8 -4
View File
@@ -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)
}
}
}