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:
@@ -626,15 +626,15 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ParseStatusReader 解析 OpenVPN status 文件。
|
// ParseStatusReader 解析 OpenVPN status 文件。
|
||||||
// 同时支持 status-version 1(人类可读列名)和 status-version 3(CSV)。
|
// 同时支持 status-version 1(人类可读列名)和 status-version 3(CSV/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 3,11 字段):
|
// v3 格式(status-version 3,11-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])),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user