Fix status parser: support status-version 1 (default OpenVPN format)

Root cause: user's status.log uses default OpenVPN format (status-version 1)
with human-readable column names. Our parser only handled status-version 3
(CSV with 11 fields per CLIENT_LIST line).

v1 format observed on production:
  Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
  test,123.118.73.196:18257,423700,455528,2026-08-09 16:14:10

Changes:
  pkg/openvpn/manager.go:
  - Detect format by scanning first line: TITLE, → v3, OpenVPN CLIENT LIST → v1
  - Add reCli1 regex for v1 5-column data rows
  - v1 mode: read full file, parse CLIENT_LIST then parse ROUTING_TABLE
    separately to associate CN→VPNAddress (v1 doesn't include VPN IP
    in the client data row)
  - Add bytes import

  Both formats now work side-by-side.
This commit is contained in:
cnbugs
2026-08-10 00:16:28 +08:00
parent 1706552b33
commit 9f24c74527
+97 -7
View File
@@ -2,6 +2,7 @@ package openvpn
import ( import (
"bufio" "bufio"
"bytes"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
@@ -616,35 +617,124 @@ var (
reHdr = regexp.MustCompile(`^Updated,([^,]+),`) reHdr = regexp.MustCompile(`^Updated,([^,]+),`)
// status-version 3 CLIENT_LIST 格式(11个字段): // status-version 3 CLIENT_LIST 格式(11个字段):
// CLIENT_LIST,CommonName,RealAddress,VirtualAddress,VirtualIPv6,BytesRecv,BytesSent,ConnectedSince,Username,ClientID,PeerID // CLIENT_LIST,CommonName,RealAddress,VirtualAddress,VirtualIPv6,BytesRecv,BytesSent,ConnectedSince,Username,ClientID,PeerID
reCli = regexp.MustCompile(`^CLIENT_LIST,([^,]+),([^,]+),([^,]+),([^,]*),(\d+),(\d+),([^,]+),`) reCli3 = regexp.MustCompile(`^CLIENT_LIST,([^,]+),([^,]+),([^,]+),([^,]*),(\d+),(\d+),([^,]+),`)
// status-version 1 CLIENT 数据行(5 列):
// 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"
reCli1 = regexp.MustCompile(`^([^,]+),([^,]+),(\d+),(\d+),"?([^",]+)"?,?`)
reTime = regexp.MustCompile(`^Connected Since,([^,]+),`) reTime = regexp.MustCompile(`^Connected Since,([^,]+),`)
) )
// ParseStatusReader 解析 OpenVPN status 文件。
// 同时支持 status-version 1(人类可读列名)和 status-version 3CSV)。
//
// v1 格式(默认,OpenVPN 未指定 status-version 时使用):
// 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
//
// v3 格式(status-version 311 字段):
// CLIENT_LIST,CN,RealAddr,VPNAddr,IPv6,BytesRecv,BytesSent,ConnectedSince,Username,ClientID,PeerID
// CLIENT_LIST,test,1.2.3.4:1234,10.8.0.2,,1024,2048,Sat Aug 9 16:14:10 2025,UNDEF,0,0
func ParseStatusReader(r io.Reader) ([]StatusEntry, error) { func ParseStatusReader(r io.Reader) ([]StatusEntry, error) {
sc := bufio.NewScanner(r) // 先全读入内存,方便做两次扫描(v1 需要从 ROUTING_TABLE 取 VPN IP
b, err := io.ReadAll(r)
if err != nil {
return nil, err
}
sc := bufio.NewScanner(bytes.NewReader(b))
sc.Buffer(make([]byte, 64*1024), 1024*1024) sc.Buffer(make([]byte, 64*1024), 1024*1024)
var out []StatusEntry var out []StatusEntry
mode := "" // "" / "v1" / "v3"
for sc.Scan() { for sc.Scan() {
line := sc.Text() line := sc.Text()
switch { if mode == "" {
case strings.HasPrefix(line, "CLIENT_LIST,"): if strings.HasPrefix(line, "TITLE,") {
m := reCli.FindStringSubmatch(line) mode = "v3"
} else if strings.HasPrefix(line, "OpenVPN CLIENT LIST") {
mode = "v1"
} else {
continue
}
continue
}
switch mode {
case "v3":
if !strings.HasPrefix(line, "CLIENT_LIST,") {
continue
}
m := reCli3.FindStringSubmatch(line)
if m == nil { if m == nil {
continue continue
} }
out = append(out, StatusEntry{ out = append(out, StatusEntry{
CommonName: m[1], CommonName: m[1],
RealAddress: m[2], // RealAddress (IP:port) RealAddress: m[2],
VPNAddress: m[3], // VirtualAddress VPNAddress: m[3],
BytesRecv: atoi64(m[5]), BytesRecv: atoi64(m[5]),
BytesSent: atoi64(m[6]), BytesSent: atoi64(m[6]),
ConnectedAt: parseConnTime(m[7]), ConnectedAt: parseConnTime(m[7]),
}) })
case "v1":
// 跳过表头和节标记
if strings.HasPrefix(line, "Common Name,") || strings.HasPrefix(line, "HEADER,") ||
strings.HasPrefix(line, "ROUTING TABLE") || strings.HasPrefix(line, "Virtual Address,") ||
strings.HasPrefix(line, "GLOBAL STATS") || strings.HasPrefix(line, "END") ||
strings.HasPrefix(line, "Updated,") {
continue
}
m := reCli1.FindStringSubmatch(line)
if m == nil {
continue
}
out = append(out, StatusEntry{
CommonName: m[1],
RealAddress: m[2],
VPNAddress: "",
BytesRecv: atoi64(m[3]),
BytesSent: atoi64(m[4]),
ConnectedAt: parseConnTime(m[5]),
})
}
}
// v1 模式:用 ROUTING_TABLE 补充 VPNAddress
if mode == "v1" {
v1VPN := parseV1RoutingTable(bytes.NewReader(b))
for i := range out {
if ip, ok := v1VPN[out[i].CommonName]; ok {
out[i].VPNAddress = ip
}
} }
} }
return out, sc.Err() return out, sc.Err()
} }
// parseV1RoutingTable 从 v1 status 中解析 ROUTING_TABLE 部分,返回 CN→VPNIP。
func parseV1RoutingTable(r io.Reader) map[string]string {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
out := map[string]string{}
inRouting := false
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "ROUTING TABLE") {
inRouting = true
continue
}
if !inRouting {
continue
}
if strings.HasPrefix(line, "GLOBAL STATS") || strings.HasPrefix(line, "END") {
break
}
parts := strings.Split(line, ",")
if len(parts) < 2 {
continue
}
out[parts[1]] = parts[0]
}
return out
}
// IsRunning 通过 TCP 探测 openvpn 端口是否可连,仅供参考。 // IsRunning 通过 TCP 探测 openvpn 端口是否可连,仅供参考。
func (m *Manager) IsRunning(host string, port int) bool { func (m *Manager) IsRunning(host string, port int) bool {
addr := fmt.Sprintf("%s:%d", host, port) addr := fmt.Sprintf("%s:%d", host, port)