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 (
|
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 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) {
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user