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:
@@ -217,6 +217,33 @@ func (s *Server) dashboard(c *gin.Context) {
|
|||||||
"allow_networks": in.AllowNetworks,
|
"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{
|
c.JSON(200, gin.H{
|
||||||
"instances": len(instances),
|
"instances": len(instances),
|
||||||
"running": countByStatus(instances, "running"),
|
"running": countByStatus(instances, "running"),
|
||||||
@@ -226,7 +253,7 @@ func (s *Server) dashboard(c *gin.Context) {
|
|||||||
"online": online,
|
"online": online,
|
||||||
"expiring_certs": expiring,
|
"expiring_certs": expiring,
|
||||||
"recent_audits": s.Svc.Store.ListAudits(20),
|
"recent_audits": s.Svc.Store.ListAudits(20),
|
||||||
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 20),
|
"recent_conn_logs": recentConnLogs,
|
||||||
"access_overview": overview,
|
"access_overview": overview,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -467,7 +494,48 @@ func (s *Server) listCerts(c *gin.Context) {
|
|||||||
// ---- conn logs ----
|
// ---- conn logs ----
|
||||||
|
|
||||||
func (s *Server) listConnLogs(c *gin.Context) {
|
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 ----
|
// ---- backups ----
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -51,14 +52,15 @@ func (s *Service) StartStatusSync() {
|
|||||||
func (s *Service) syncAllInstanceStatus() {
|
func (s *Service) syncAllInstanceStatus() {
|
||||||
instances := s.Store.ListInstances()
|
instances := s.Store.ListInstances()
|
||||||
for _, in := range instances {
|
for _, in := range instances {
|
||||||
if in.Status != "running" {
|
// 不检查 Status 字段,直接尝试读 status.log。
|
||||||
continue
|
// OpenVPN 运行时会自动写 status.log,读到就同步。
|
||||||
}
|
|
||||||
statusPath := filepath.Join(s.Cfg.InstanceDir(in.Name), "status.log")
|
statusPath := filepath.Join(s.Cfg.InstanceDir(in.Name), "status.log")
|
||||||
entries, err := s.Ovm.ParseStatus(statusPath)
|
entries, err := s.Ovm.ParseStatus(statusPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // 文件不存在或解析失败,跳过
|
// 文件不存在或解析失败,跳过
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
log.Printf("[status-sync] instance=%s entries=%d", in.Name, len(entries))
|
||||||
// 构建当前在线 CN 集合
|
// 构建当前在线 CN 集合
|
||||||
onlineCNs := map[string]bool{}
|
onlineCNs := map[string]bool{}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
@@ -74,6 +76,7 @@ func (s *Service) syncAllInstanceStatus() {
|
|||||||
BytesOut: e.BytesSent,
|
BytesOut: e.BytesSent,
|
||||||
ConnectedAt: e.ConnectedAt,
|
ConnectedAt: e.ConnectedAt,
|
||||||
})
|
})
|
||||||
|
log.Printf("[status-sync] new conn: %s %s %s", e.CommonName, e.RealAddress, e.VPNAddress)
|
||||||
} else {
|
} else {
|
||||||
// 更新流量统计
|
// 更新流量统计
|
||||||
_ = s.Store.UpdateActiveConnStats(in.ID, e.CommonName, e.BytesRecv, e.BytesSent)
|
_ = s.Store.UpdateActiveConnStats(in.ID, e.CommonName, e.BytesRecv, e.BytesSent)
|
||||||
@@ -84,6 +87,7 @@ func (s *Service) syncAllInstanceStatus() {
|
|||||||
if !onlineCNs[c.CommonName] {
|
if !onlineCNs[c.CommonName] {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
_ = s.Store.CloseActiveConn(in.ID, c.CommonName, now)
|
_ = s.Store.CloseActiveConn(in.ID, c.CommonName, now)
|
||||||
|
log.Printf("[status-sync] disconnected: %s", c.CommonName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -614,7 +614,9 @@ type StatusEntry struct {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
reHdr = regexp.MustCompile(`^Updated,([^,]+),`)
|
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,([^,]+),`)
|
reTime = regexp.MustCompile(`^Connected Since,([^,]+),`)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -630,14 +632,13 @@ func ParseStatusReader(r io.Reader) ([]StatusEntry, error) {
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
connected, _ := time.Parse("Mon Jan 2 15:04:05 2006", m[2])
|
|
||||||
out = append(out, StatusEntry{
|
out = append(out, StatusEntry{
|
||||||
CommonName: m[1],
|
CommonName: m[1],
|
||||||
RealAddress: m[3],
|
RealAddress: m[2], // RealAddress (IP:port)
|
||||||
VPNAddress: m[4],
|
VPNAddress: m[3], // VirtualAddress
|
||||||
BytesRecv: atoi64(m[5]),
|
BytesRecv: atoi64(m[5]),
|
||||||
BytesSent: atoi64(m[6]),
|
BytesSent: atoi64(m[6]),
|
||||||
ConnectedAt: connected,
|
ConnectedAt: parseConnTime(m[7]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -693,6 +694,33 @@ func atoi64(s string) int64 {
|
|||||||
return n
|
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 指令需要的
|
// cidrToServerDirective 把 "10.8.0.0/24" 转成 OpenVPN server 指令需要的
|
||||||
// "10.8.0.0 255.255.255.0" 格式(网络地址 + 点分掩码)。
|
// "10.8.0.0 255.255.255.0" 格式(网络地址 + 点分掩码)。
|
||||||
// 如果输入不含 "/"(已经是 "ip mask" 形式),原样返回。
|
// 如果输入不含 "/"(已经是 "ip mask" 形式),原样返回。
|
||||||
|
|||||||
Reference in New Issue
Block a user