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:
+100
-10
@@ -2,6 +2,7 @@ package openvpn
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
@@ -616,35 +617,124 @@ var (
|
||||
reHdr = regexp.MustCompile(`^Updated,([^,]+),`)
|
||||
// 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+),([^,]+),`)
|
||||
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,([^,]+),`)
|
||||
)
|
||||
|
||||
// ParseStatusReader 解析 OpenVPN status 文件。
|
||||
// 同时支持 status-version 1(人类可读列名)和 status-version 3(CSV)。
|
||||
//
|
||||
// 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 3,11 字段):
|
||||
// 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) {
|
||||
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)
|
||||
var out []StatusEntry
|
||||
mode := "" // "" / "v1" / "v3"
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "CLIENT_LIST,"):
|
||||
m := reCli.FindStringSubmatch(line)
|
||||
if mode == "" {
|
||||
if strings.HasPrefix(line, "TITLE,") {
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
out = append(out, StatusEntry{
|
||||
CommonName: m[1],
|
||||
RealAddress: m[2], // RealAddress (IP:port)
|
||||
VPNAddress: m[3], // VirtualAddress
|
||||
BytesRecv: atoi64(m[5]),
|
||||
BytesSent: atoi64(m[6]),
|
||||
ConnectedAt: parseConnTime(m[7]),
|
||||
RealAddress: m[2],
|
||||
VPNAddress: m[3],
|
||||
BytesRecv: atoi64(m[5]),
|
||||
BytesSent: atoi64(m[6]),
|
||||
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()
|
||||
}
|
||||
|
||||
// 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 端口是否可连,仅供参考。
|
||||
func (m *Manager) IsRunning(host string, port int) bool {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
Reference in New Issue
Block a user