diff --git a/backend/pkg/openvpn/manager.go b/backend/pkg/openvpn/manager.go index 5b218b5..691e23c 100644 --- a/backend/pkg/openvpn/manager.go +++ b/backend/pkg/openvpn/manager.go @@ -626,15 +626,15 @@ var ( ) // ParseStatusReader 解析 OpenVPN status 文件。 -// 同时支持 status-version 1(人类可读列名)和 status-version 3(CSV)。 +// 同时支持 status-version 1(人类可读列名)和 status-version 3(CSV/TAB)。 // // 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 +// v3 格式(status-version 3,11-13 字段): +// CLIENT_LIST,CN,RealAddr,VPNAddr,IPv6,BytesRecv,BytesSent,ConnectedSince[,Username,ClientID,PeerID[,Cipher]] +// 注意不同 OpenVPN 版本 IPv6 字段可能存在/缺失/为空,不能依赖固定位置,改为按字段名匹配。 func ParseStatusReader(r io.Reader) ([]StatusEntry, error) { // 先全读入内存,方便做两次扫描(v1 需要从 ROUTING_TABLE 取 VPN IP) b, err := io.ReadAll(r) @@ -648,7 +648,7 @@ func ParseStatusReader(r io.Reader) ([]StatusEntry, error) { for sc.Scan() { line := sc.Text() if mode == "" { - if strings.HasPrefix(line, "TITLE,") { + if strings.HasPrefix(line, "TITLE,") || strings.HasPrefix(line, "TITLE ") { mode = "v3" } else if strings.HasPrefix(line, "OpenVPN CLIENT LIST") { mode = "v1" @@ -659,21 +659,67 @@ func ParseStatusReader(r io.Reader) ([]StatusEntry, error) { } switch mode { 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 } - m := reCli3.FindStringSubmatch(line) - if m == nil { + sep := " " + if !strings.Contains(line, " ") { + sep = "," + } + parts := strings.Split(line, sep) + if len(parts) < 7 { continue } - out = append(out, StatusEntry{ - CommonName: m[1], - RealAddress: m[2], - VPNAddress: m[3], - BytesRecv: atoi64(m[5]), - BytesSent: atoi64(m[6]), - ConnectedAt: parseConnTime(m[7]), - }) + // 检测是否有 "Virtual IPv6 Address" 字段:扫描整行, + // CLIENT_LIST 后面第 4 个 tab 段如果是空字符串 -> 有 IPv6 字段 + data := parts[1:] + // 找第二个非空 tab 段:如果 [3] 是空且 [4] 也是空 -> IPv6 在 [3];否则 IPv6 不存在 + // 用更简单的策略:按数据值猜测 + // [1]=RealAddr 必含 ":" 或纯数字IP; [2]=VPNAddr 必为 IP(可能是空 IPv4,IPv4 全数字) + // 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": // 跳过表头和节标记 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,") { continue } - m := reCli1.FindStringSubmatch(line) - if m == nil { + parts := strings.Split(line, ",") + if len(parts) < 5 { continue } out = append(out, StatusEntry{ - CommonName: m[1], - RealAddress: m[2], + CommonName: strings.TrimSpace(parts[0]), + RealAddress: strings.TrimSpace(parts[1]), VPNAddress: "", - BytesRecv: atoi64(m[3]), - BytesSent: atoi64(m[4]), - ConnectedAt: parseConnTime(m[5]), + BytesRecv: atoi64(parts[2]), + BytesSent: atoi64(parts[3]), + ConnectedAt: parseConnTime(strings.TrimSpace(parts[4])), }) } }