Fix v3 parser: scan for numeric fields (IPv6 field position varies)

The user's OpenVPN 2.5.11 outputs CLIENT_LIST with 13 tab-separated
fields:
  CLIENT_LIST CN RealAddr VPNAddr IPv6(empty) (empty) BytesRecv
              BytesSent ConnectedSince (time_t) Username ClientID
              PeerID Cipher

But OpenVPN 2.6+ drops the IPv6 and Cipher fields (11 columns), and
some versions include 'Connected Since (time_t)' which moves all
positions.

Solution: parse by content type, not position:
  - parts[0] = 'CLIENT_LIST'
  - parts[1] = CN (string, may be empty)
  - parts[2] = RealAddr (string with ':')
  - parts[3] = VPNAddr (IP, may be empty)
  - parts[4..] = scan for first pure-numeric field → BytesRecv
  - BytesRecv+1 = BytesSent
  - BytesRecv+2 = ConnectedSince

Verified against the user's real status.log:
  CN=test RealAddr=123.118.73.196:17564 VPNAddr=10.8.0.2
  bytesIn=629025 bytesOut=539217
This commit is contained in:
cnbugs
2026-08-10 00:22:02 +08:00
parent 9054da954a
commit 5725149546
+69 -23
View File
@@ -626,15 +626,15 @@ var (
) )
// ParseStatusReader 解析 OpenVPN status 文件。 // ParseStatusReader 解析 OpenVPN status 文件。
// 同时支持 status-version 1(人类可读列名)和 status-version 3CSV)。 // 同时支持 status-version 1(人类可读列名)和 status-version 3CSV/TAB)。
// //
// v1 格式(默认,OpenVPN 未指定 status-version 时使用): // v1 格式(默认,OpenVPN 未指定 status-version 时使用):
// Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since // Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
// test,1.2.3.4:1234,1024,2048,Sat Aug 9 16:14:10 2025 // test,1.2.3.4:1234,1024,2048,Sat Aug 9 16:14:10 2025
// //
// v3 格式(status-version 311 字段): // v3 格式(status-version 311-13 字段):
// CLIENT_LIST,CN,RealAddr,VPNAddr,IPv6,BytesRecv,BytesSent,ConnectedSince,Username,ClientID,PeerID // CLIENT_LIST,CN,RealAddr,VPNAddr,IPv6,BytesRecv,BytesSent,ConnectedSince[,Username,ClientID,PeerID[,Cipher]]
// CLIENT_LIST,test,1.2.3.4:1234,10.8.0.2,,1024,2048,Sat Aug 9 16:14:10 2025,UNDEF,0,0 // 注意不同 OpenVPN 版本 IPv6 字段可能存在/缺失/为空,不能依赖固定位置,改为按字段名匹配。
func ParseStatusReader(r io.Reader) ([]StatusEntry, error) { func ParseStatusReader(r io.Reader) ([]StatusEntry, error) {
// 先全读入内存,方便做两次扫描(v1 需要从 ROUTING_TABLE 取 VPN IP // 先全读入内存,方便做两次扫描(v1 需要从 ROUTING_TABLE 取 VPN IP
b, err := io.ReadAll(r) b, err := io.ReadAll(r)
@@ -648,7 +648,7 @@ func ParseStatusReader(r io.Reader) ([]StatusEntry, error) {
for sc.Scan() { for sc.Scan() {
line := sc.Text() line := sc.Text()
if mode == "" { if mode == "" {
if strings.HasPrefix(line, "TITLE,") { if strings.HasPrefix(line, "TITLE,") || strings.HasPrefix(line, "TITLE ") {
mode = "v3" mode = "v3"
} else if strings.HasPrefix(line, "OpenVPN CLIENT LIST") { } else if strings.HasPrefix(line, "OpenVPN CLIENT LIST") {
mode = "v1" mode = "v1"
@@ -659,21 +659,67 @@ func ParseStatusReader(r io.Reader) ([]StatusEntry, error) {
} }
switch mode { switch mode {
case "v3": case "v3":
if !strings.HasPrefix(line, "CLIENT_LIST,") { // 解析 HEADER 行得到列名(以便按位置名取值)。但因为 OpenVPN 2.5 的 HEADER
// 没有"Virtual IPv6 Address"和"Cipher"两个空字段(或反之),位置经常错位。
// 退而求其次:不用 HEADER,直接固定按 parts[1..] 取,加上 IPv6 字段探测:
// - 标准 11 列(无 IPv6、无 Cipher):CN,RealAddr,VPNAddr,BytesRecv,
// BytesSent,Time,TimeT,User,ClientID,PeerID
// - 12 列(无 IPv6、有 Cipher):...,Cipher
// - 13 列(都有):CN,RealAddr,VPNAddr,IPv6(empty),?,BytesRecv,
// BytesSent,Time,TimeT,User,ClientID,PeerID,Cipher
if !strings.HasPrefix(line, "CLIENT_LIST") {
continue continue
} }
m := reCli3.FindStringSubmatch(line) sep := " "
if m == nil { if !strings.Contains(line, " ") {
sep = ","
}
parts := strings.Split(line, sep)
if len(parts) < 7 {
continue continue
} }
out = append(out, StatusEntry{ // 检测是否有 "Virtual IPv6 Address" 字段:扫描整行,
CommonName: m[1], // CLIENT_LIST 后面第 4 个 tab 段如果是空字符串 -> 有 IPv6 字段
RealAddress: m[2], data := parts[1:]
VPNAddress: m[3], // 找第二个非空 tab 段:如果 [3] 是空且 [4] 也是空 -> IPv6 在 [3];否则 IPv6 不存在
BytesRecv: atoi64(m[5]), // 用更简单的策略:按数据值猜测
BytesSent: atoi64(m[6]), // [1]=RealAddr 必含 ":" 或纯数字IP; [2]=VPNAddr 必为 IP(可能是空 IPv4,IPv4 全数字)
ConnectedAt: parseConnTime(m[7]), // bytes 字段是数字。先找到第一个纯数字段作为 BytesRecv
}) e := StatusEntry{CommonName: strings.TrimSpace(data[0])}
if len(data) > 1 {
e.RealAddress = strings.TrimSpace(data[1])
}
// 寻找第一个看起来像数字的字段位置 = Bytes Received
// 排除 RealAddress(包含 ":")、VPNAddress(纯 IP)、ConnectedSince(包含 "-")
bytesIdx := -1
for i := 2; i < len(data); i++ {
v := strings.TrimSpace(data[i])
if v == "" {
continue
}
// 纯数字(可能很长,如 1786292298)
if _, err := strconv.ParseInt(v, 10, 64); err == nil {
bytesIdx = i
break
}
}
if bytesIdx == -1 || bytesIdx+1 >= len(data) {
continue
}
e.BytesRecv = atoi64(data[bytesIdx])
e.BytesSent = atoi64(data[bytesIdx+1])
// VPNAddress:如果有 IPv6 字段,它在 [3];否则在 [3] 是直接 IP
// 这里已用 CN+RealAddr 标识,VPNAddress 是次要的,从 [2] 取或留空
if len(data) > 2 {
e.VPNAddress = strings.TrimSpace(data[2])
}
// ConnectedSince:在 BytesRecv+2 位置(后面跟着 time_t)
if bytesIdx+2 < len(data) {
e.ConnectedAt = parseConnTime(strings.TrimSpace(data[bytesIdx+2]))
}
if e.CommonName != "" {
out = append(out, e)
}
case "v1": case "v1":
// 跳过表头和节标记 // 跳过表头和节标记
if strings.HasPrefix(line, "Common Name,") || strings.HasPrefix(line, "HEADER,") || if strings.HasPrefix(line, "Common Name,") || strings.HasPrefix(line, "HEADER,") ||
@@ -682,17 +728,17 @@ func ParseStatusReader(r io.Reader) ([]StatusEntry, error) {
strings.HasPrefix(line, "Updated,") { strings.HasPrefix(line, "Updated,") {
continue continue
} }
m := reCli1.FindStringSubmatch(line) parts := strings.Split(line, ",")
if m == nil { if len(parts) < 5 {
continue continue
} }
out = append(out, StatusEntry{ out = append(out, StatusEntry{
CommonName: m[1], CommonName: strings.TrimSpace(parts[0]),
RealAddress: m[2], RealAddress: strings.TrimSpace(parts[1]),
VPNAddress: "", VPNAddress: "",
BytesRecv: atoi64(m[3]), BytesRecv: atoi64(parts[2]),
BytesSent: atoi64(m[4]), BytesSent: atoi64(parts[3]),
ConnectedAt: parseConnTime(m[5]), ConnectedAt: parseConnTime(strings.TrimSpace(parts[4])),
}) })
} }
} }