Skip to main content

openvpn_mgmt_codec/
status.rs

1//! Typed parsers for `status` command responses.
2//!
3//! The `status` command returns a multi-line response whose format varies
4//! by version (V1/V2/V3) and mode (server vs client). This module parses
5//! the raw lines into typed structs.
6//!
7//! # Format overview
8//!
9//! | Version | Separator | Prefix lines | Notes |
10//! |---------|-----------|-------------|-------|
11//! | V1 | `,` | `OpenVPN CLIENT LIST` / `OpenVPN STATISTICS` | No `TITLE`/`TIME` prefix, no `time_t` fields in older versions |
12//! | V2 | `,` | `TITLE,` / `TIME,` / `HEADER,` | Adds `time_t` columns |
13//! | V3 | `\t` | `TITLE\t` / `TIME\t` / `HEADER\t` | Same as V2 but tab-delimited |
14//!
15//! # Client mode
16//!
17//! In client mode, `status` returns `OpenVPN STATISTICS` — a simple
18//! key-value list of byte counters, not a client table. Use
19//! [`parse_client_statistics`] for this case.
20//!
21//! # Examples
22//!
23//! ```
24//! use openvpn_mgmt_codec::status::{parse_status, parse_client_statistics};
25//!
26//! // V3 server status (tab-separated)
27//! let lines = vec![
28//!     "TITLE\tOpenVPN 2.6.8".to_string(),
29//!     "TIME\t2024-03-21 14:30:00\t1711031400".to_string(),
30//!     "HEADER\tCLIENT_LIST\tCommon Name\tReal Address\tVirtual Address\tVirtual IPv6 Address\tBytes Received\tBytes Sent\tConnected Since\tConnected Since (time_t)\tUsername\tClient ID\tPeer ID\tData Channel Cipher".to_string(),
31//!     "CLIENT_LIST\tclient1\t203.0.113.10:52841\t10.8.0.6\t\t1548576\t984320\t2024-03-21 09:15:00\t1711012500\tUNDEF\t0\t0\tAES-256-GCM".to_string(),
32//!     "HEADER\tROUTING_TABLE\tVirtual Address\tCommon Name\tReal Address\tLast Ref\tLast Ref (time_t)".to_string(),
33//!     "ROUTING_TABLE\t10.8.0.6\tclient1\t203.0.113.10:52841\t2024-03-21 14:29:50\t1711031390".to_string(),
34//!     "GLOBAL_STATS\tMax bcast/mcast queue length\t3".to_string(),
35//! ];
36//! let status = parse_status(&lines).unwrap();
37//! assert_eq!(status.clients.len(), 1);
38//! assert_eq!(status.clients[0].common_name, "client1");
39//! assert_eq!(status.routes.len(), 1);
40//!
41//! // Client statistics
42//! let lines = vec![
43//!     "OpenVPN STATISTICS".to_string(),
44//!     "Updated,2024-03-21 14:30:00".to_string(),
45//!     "TUN/TAP read bytes,1548576".to_string(),
46//!     "TUN/TAP write bytes,984320".to_string(),
47//!     "TCP/UDP read bytes,1600000".to_string(),
48//!     "TCP/UDP write bytes,1020000".to_string(),
49//!     "Auth read bytes,0".to_string(),
50//! ];
51//! let stats = parse_client_statistics(&lines).unwrap();
52//! assert_eq!(stats.tun_tap_read_bytes, 1548576);
53//! ```
54
55/// Parsed server-mode status response.
56///
57/// Contains the connected client list, routing table, and global stats.
58/// Works with V1 (comma-separated), V2 (comma with headers), and V3
59/// (tab-delimited) formats.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct StatusResponse {
62    /// Title line (e.g. `"OpenVPN 2.6.8 x86_64-pc-linux-gnu"`).
63    /// Present in V2/V3, absent in V1.
64    pub title: Option<String>,
65
66    /// Unix timestamp of the status snapshot.
67    /// Present in V2/V3, absent in V1.
68    pub timestamp: Option<u64>,
69
70    /// Human-readable update time (e.g. `"2024-03-21 14:30:00"`).
71    /// Present in V1 (`Updated,...`) and V2/V3 (`TIME,...,...`).
72    pub updated: Option<String>,
73
74    /// Connected clients.
75    pub clients: Vec<ConnectedClient>,
76
77    /// Routing table entries.
78    pub routes: Vec<RoutingEntry>,
79
80    /// Global stats as key-value pairs (e.g. `("Max bcast/mcast queue length", "3")`).
81    pub global_stats: Vec<(String, String)>,
82}
83
84/// A connected client from the `CLIENT_LIST` section.
85///
86/// Field availability varies by OpenVPN version:
87/// - OpenVPN 2.3 (V2): no `virtual_ipv6`, `peer_id`, or `cipher`
88/// - OpenVPN 2.4+: all fields present
89/// - V1: no `virtual_ipv6`, `connected_since_t`, `username`, `cid`, `peer_id`, `cipher`
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ConnectedClient {
92    /// Certificate common name.
93    pub common_name: String,
94    /// Real IP:port address.
95    pub real_address: String,
96    /// Virtual IPv4 address assigned by OpenVPN.
97    pub virtual_address: String,
98    /// Virtual IPv6 address (empty if not assigned). V2/V3 2.4+ only.
99    pub virtual_ipv6: String,
100    /// Bytes received from this client.
101    pub bytes_in: u64,
102    /// Bytes sent to this client.
103    pub bytes_out: u64,
104    /// Human-readable connection time.
105    pub connected_since: String,
106    /// Unix timestamp of connection. V2/V3 only.
107    pub connected_since_t: Option<u64>,
108    /// Username (`UNDEF` if not using `--auth-user-pass`). V2/V3 only.
109    pub username: Option<String>,
110    /// Client ID. V2/V3 only.
111    pub cid: Option<u64>,
112    /// Peer ID. V2/V3 2.4+ only.
113    pub peer_id: Option<u64>,
114    /// Data channel cipher (e.g. `AES-256-GCM`). V2/V3 2.4+ only.
115    pub cipher: Option<String>,
116}
117
118/// A routing table entry from the `ROUTING_TABLE` section.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct RoutingEntry {
121    /// Virtual address (IPv4 or IPv6).
122    pub virtual_address: String,
123    /// Certificate common name.
124    pub common_name: String,
125    /// Real IP:port address.
126    pub real_address: String,
127    /// Human-readable last reference time.
128    pub last_ref: String,
129    /// Unix timestamp of last reference. V2/V3 only.
130    pub last_ref_t: Option<u64>,
131}
132
133/// Client-mode statistics from `OpenVPN STATISTICS`.
134///
135/// Returned by `status` in client mode. The fields are byte counters.
136/// Optional fields are absent in older OpenVPN versions or when
137/// compression is disabled.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
139pub struct ClientStatistics {
140    /// Bytes read from TUN/TAP device.
141    pub tun_tap_read_bytes: u64,
142    /// Bytes written to TUN/TAP device.
143    pub tun_tap_write_bytes: u64,
144    /// Bytes read from TCP/UDP socket.
145    pub tcp_udp_read_bytes: u64,
146    /// Bytes written to TCP/UDP socket.
147    pub tcp_udp_write_bytes: u64,
148    /// Auth read bytes (usually 0).
149    pub auth_read_bytes: u64,
150    /// Pre-compression bytes (if compression enabled).
151    pub pre_compress_bytes: Option<u64>,
152    /// Post-compression bytes (if compression enabled).
153    pub post_compress_bytes: Option<u64>,
154    /// Pre-decompression bytes (if compression enabled).
155    pub pre_decompress_bytes: Option<u64>,
156    /// Post-decompression bytes (if compression enabled).
157    pub post_decompress_bytes: Option<u64>,
158}
159
160/// Error returned when a status response cannot be parsed.
161#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
162pub enum ParseStatusError {
163    /// A numeric field could not be parsed.
164    #[error("invalid integer for field {field:?}: {value:?}")]
165    InvalidInteger {
166        /// The field name that failed to parse.
167        field: &'static str,
168        /// The raw value that could not be parsed.
169        value: String,
170    },
171
172    /// A CLIENT_LIST line had too few fields.
173    #[error("CLIENT_LIST has too few fields (need >= 5, got {0})")]
174    ClientListTooFewFields(usize),
175
176    /// A ROUTING_TABLE line had too few fields.
177    #[error("ROUTING_TABLE has too few fields (need >= 4, got {0})")]
178    RoutingTableTooFewFields(usize),
179
180    /// A required statistics key was missing.
181    #[error("missing statistics key: {0:?}")]
182    MissingStatisticsKey(&'static str),
183}
184
185/// Detect the separator used in the status output.
186///
187/// V3 uses tabs, V1/V2 use commas. We check the first line for a tab character.
188fn detect_separator(lines: &[String]) -> char {
189    for line in lines {
190        if line.starts_with("TITLE\t")
191            || line.starts_with("TIME\t")
192            || line.starts_with("HEADER\t")
193            || line.starts_with("CLIENT_LIST\t")
194            || line.starts_with("ROUTING_TABLE\t")
195            || line.starts_with("GLOBAL_STATS\t")
196        {
197            return '\t';
198        }
199    }
200    ','
201}
202
203fn parse_u64(s: &str, field: &'static str) -> Result<u64, ParseStatusError> {
204    s.parse().map_err(|_| ParseStatusError::InvalidInteger {
205        field,
206        value: s.to_string(),
207    })
208}
209
210fn parse_optional_u64(s: &str) -> Option<u64> {
211    if s.is_empty() || s == "UNDEF" {
212        None
213    } else {
214        s.parse()
215            .inspect_err(|error| {
216                tracing::warn!(%error, value = s, "non-numeric optional u64 in status response")
217            })
218            .ok()
219    }
220}
221
222/// Parse a server-mode status response into typed structs.
223///
224/// Accepts V1 (comma-separated), V2 (comma with prefix lines), and V3
225/// (tab-delimited) formats. The format is auto-detected.
226///
227/// V1 format starts with `"OpenVPN CLIENT LIST"` and has a fixed column
228/// layout with fewer fields. V2/V3 use `TITLE`/`TIME`/`HEADER`/`CLIENT_LIST`
229/// prefix markers.
230///
231/// # Examples
232///
233/// ```
234/// use openvpn_mgmt_codec::status::parse_status;
235///
236/// let lines: Vec<String> = vec![
237///     "TITLE\tOpenVPN 2.6.8",
238///     "TIME\t2024-03-21 14:30:00\t1711031400",
239///     "HEADER\tCLIENT_LIST\tCommon Name\tReal Address\tVirtual Address\tVirtual IPv6 Address\tBytes Received\tBytes Sent\tConnected Since\tConnected Since (time_t)\tUsername\tClient ID\tPeer ID\tData Channel Cipher",
240///     "CLIENT_LIST\tpeer1\t203.0.113.10:52841\t10.8.0.6\t\t1548576\t984320\t2024-03-21 09:15:00\t1711012500\tUNDEF\t0\t0\tAES-256-GCM",
241///     "HEADER\tROUTING_TABLE\tVirtual Address\tCommon Name\tReal Address\tLast Ref\tLast Ref (time_t)",
242///     "ROUTING_TABLE\t10.8.0.6\tpeer1\t203.0.113.10:52841\t2024-03-21 14:29:50\t1711031390",
243///     "GLOBAL_STATS\tMax bcast/mcast queue length\t3",
244/// ].into_iter().map(String::from).collect();
245///
246/// let status = parse_status(&lines).unwrap();
247/// assert_eq!(status.title.as_deref(), Some("OpenVPN 2.6.8"));
248/// assert_eq!(status.clients.len(), 1);
249/// assert_eq!(status.clients[0].common_name, "peer1");
250/// assert_eq!(status.clients[0].bytes_in, 1548576);
251/// assert_eq!(status.routes[0].virtual_address, "10.8.0.6");
252/// ```
253pub fn parse_status(lines: &[String]) -> Result<StatusResponse, ParseStatusError> {
254    // Detect V1 by looking for the header line.
255    if lines
256        .first()
257        .is_some_and(|line| line == "OpenVPN CLIENT LIST")
258    {
259        return parse_status_v1(lines);
260    }
261
262    let sep = detect_separator(lines);
263    parse_status_v2v3(lines, sep)
264}
265
266/// Parse V1 format: `OpenVPN CLIENT LIST`, comma-separated, no prefix markers.
267fn parse_status_v1(lines: &[String]) -> Result<StatusResponse, ParseStatusError> {
268    let mut status = StatusResponse {
269        title: None,
270        timestamp: None,
271        updated: None,
272        clients: Vec::new(),
273        routes: Vec::new(),
274        global_stats: Vec::new(),
275    };
276
277    #[derive(PartialEq)]
278    enum Section {
279        Header,
280        ClientList,
281        RoutingTable,
282        GlobalStats,
283    }
284    let mut section = Section::Header;
285
286    for line in lines {
287        // Section transitions
288        if line == "OpenVPN CLIENT LIST" {
289            section = Section::Header;
290            continue;
291        }
292        if line.starts_with("Updated,") {
293            status.updated = Some(line.strip_prefix("Updated,").unwrap_or("").to_string());
294            continue;
295        }
296        if line == "ROUTING TABLE" {
297            section = Section::RoutingTable;
298            continue;
299        }
300        if line == "GLOBAL STATS" {
301            section = Section::GlobalStats;
302            continue;
303        }
304
305        let fields: Vec<&str> = line.split(',').collect();
306
307        // Skip header rows (V1 has a "Common Name,Real Address,..." header)
308        match section {
309            Section::Header => {
310                if fields.first() == Some(&"Common Name") {
311                    section = Section::ClientList;
312                    continue;
313                }
314            }
315            Section::ClientList => {
316                // V1 CLIENT_LIST: CN, Real Address, Bytes Received, Bytes Sent, Connected Since
317                if fields.len() < 5 {
318                    return Err(ParseStatusError::ClientListTooFewFields(fields.len()));
319                }
320                status.clients.push(ConnectedClient {
321                    common_name: fields[0].to_string(),
322                    real_address: fields[1].to_string(),
323                    virtual_address: String::new(), // Not present in V1
324                    virtual_ipv6: String::new(),
325                    bytes_in: parse_u64(fields[2], "bytes_received")?,
326                    bytes_out: parse_u64(fields[3], "bytes_sent")?,
327                    connected_since: fields[4..].join(","), // May contain commas in date
328                    connected_since_t: None,
329                    username: None,
330                    cid: None,
331                    peer_id: None,
332                    cipher: None,
333                });
334            }
335            Section::RoutingTable => {
336                // Skip header row
337                if fields.first() == Some(&"Virtual Address") {
338                    continue;
339                }
340                if fields.len() < 4 {
341                    return Err(ParseStatusError::RoutingTableTooFewFields(fields.len()));
342                }
343                status.routes.push(RoutingEntry {
344                    virtual_address: fields[0].to_string(),
345                    common_name: fields[1].to_string(),
346                    real_address: fields[2].to_string(),
347                    last_ref: fields[3..].join(","),
348                    last_ref_t: None,
349                });
350            }
351            Section::GlobalStats => {
352                if fields.len() >= 2 {
353                    status
354                        .global_stats
355                        .push((fields[0].to_string(), fields[1..].join(",")));
356                }
357            }
358        }
359    }
360
361    Ok(status)
362}
363
364/// Parse V2/V3 format: prefix markers, comma or tab separator.
365fn parse_status_v2v3(lines: &[String], sep: char) -> Result<StatusResponse, ParseStatusError> {
366    let mut status = StatusResponse {
367        title: None,
368        timestamp: None,
369        updated: None,
370        clients: Vec::new(),
371        routes: Vec::new(),
372        global_stats: Vec::new(),
373    };
374
375    for line in lines {
376        let fields: Vec<&str> = line.split(sep).collect();
377        let tag = fields.first().copied().unwrap_or("");
378
379        match tag {
380            "TITLE" => {
381                status.title = fields.get(1).map(|val| val.to_string());
382            }
383            "TIME" => {
384                status.updated = fields.get(1).map(|val| val.to_string());
385                status.timestamp = fields.get(2).and_then(|val| {
386                    val.parse()
387                        .inspect_err(|error| {
388                            tracing::warn!(%error, value = val, "non-numeric timestamp in TIME row")
389                        })
390                        .ok()
391                });
392            }
393            "HEADER" => {
394                // Skip header rows — they describe columns, not data.
395            }
396            "CLIENT_LIST" => {
397                // V2/V3 CLIENT_LIST columns (after the tag):
398                // 0:CN 1:RealAddr 2:VirtAddr 3:VirtIPv6 4:BytesRecv 5:BytesSent
399                // 6:ConnSince 7:ConnSince_t 8:Username 9:CID 10:PeerID 11:Cipher
400                //
401                // Older (2.3) layout omits VirtIPv6, PeerID, Cipher:
402                // 0:CN 1:RealAddr 2:VirtAddr 3:BytesRecv 4:BytesSent
403                // 5:ConnSince 6:ConnSince_t 7:Username
404                let cols = &fields[1..]; // Skip the "CLIENT_LIST" tag
405                let has_ipv6_column = cols.len() >= 12;
406
407                if has_ipv6_column {
408                    // has_ipv6_column requires cols.len() >= 12, so all
409                    // indexed accesses (up to cols[7]) are in-bounds.
410                    status.clients.push(ConnectedClient {
411                        common_name: cols[0].to_string(),
412                        real_address: cols[1].to_string(),
413                        virtual_address: cols[2].to_string(),
414                        virtual_ipv6: cols[3].to_string(),
415                        bytes_in: parse_u64(cols[4], "bytes_received")?,
416                        bytes_out: parse_u64(cols[5], "bytes_sent")?,
417                        connected_since: cols[6].to_string(),
418                        connected_since_t: parse_optional_u64(cols.get(7).copied().unwrap_or("")),
419                        username: cols.get(8).map(|val| val.to_string()),
420                        cid: cols.get(9).and_then(|val| parse_optional_u64(val)),
421                        peer_id: cols.get(10).and_then(|val| parse_optional_u64(val)),
422                        cipher: cols.get(11).map(|val| val.to_string()),
423                    });
424                } else {
425                    // Older layout: no IPv6, no PeerID, no Cipher
426                    if cols.len() < 5 {
427                        return Err(ParseStatusError::ClientListTooFewFields(cols.len()));
428                    }
429                    status.clients.push(ConnectedClient {
430                        common_name: cols[0].to_string(),
431                        real_address: cols[1].to_string(),
432                        virtual_address: cols[2].to_string(),
433                        virtual_ipv6: String::new(),
434                        bytes_in: parse_u64(cols[3], "bytes_received")?,
435                        bytes_out: parse_u64(cols[4], "bytes_sent")?,
436                        connected_since: cols.get(5).unwrap_or(&"").to_string(),
437                        connected_since_t: cols.get(6).and_then(|val| parse_optional_u64(val)),
438                        username: cols.get(7).map(|val| val.to_string()),
439                        cid: None,
440                        peer_id: None,
441                        cipher: None,
442                    });
443                }
444            }
445            "ROUTING_TABLE" => {
446                let cols = &fields[1..];
447                if cols.len() < 4 {
448                    return Err(ParseStatusError::RoutingTableTooFewFields(cols.len()));
449                }
450                status.routes.push(RoutingEntry {
451                    virtual_address: cols[0].to_string(),
452                    common_name: cols[1].to_string(),
453                    real_address: cols[2].to_string(),
454                    last_ref: cols[3].to_string(),
455                    last_ref_t: cols.get(4).and_then(|val| parse_optional_u64(val)),
456                });
457            }
458            "GLOBAL_STATS" if fields.len() >= 3 => {
459                status
460                    .global_stats
461                    .push((fields[1].to_string(), fields[2..].join(&sep.to_string())));
462            }
463            _ => {
464                // Unknown line — skip silently for forward compatibility.
465            }
466        }
467    }
468
469    Ok(status)
470}
471
472/// Parse a client-mode statistics response.
473///
474/// Client mode returns `OpenVPN STATISTICS` — a simple key-value list.
475/// The first line is the header, the second is `Updated,...`.
476///
477/// # Examples
478///
479/// ```
480/// use openvpn_mgmt_codec::status::parse_client_statistics;
481///
482/// let lines: Vec<String> = vec![
483///     "OpenVPN STATISTICS",
484///     "Updated,2024-03-21 14:30:00",
485///     "TUN/TAP read bytes,1548576",
486///     "TUN/TAP write bytes,984320",
487///     "TCP/UDP read bytes,1600000",
488///     "TCP/UDP write bytes,1020000",
489///     "Auth read bytes,0",
490/// ].into_iter().map(String::from).collect();
491///
492/// let stats = parse_client_statistics(&lines).unwrap();
493/// assert_eq!(stats.tun_tap_read_bytes, 1548576);
494/// assert_eq!(stats.tcp_udp_write_bytes, 1020000);
495/// assert!(stats.pre_compress_bytes.is_none());
496/// ```
497pub fn parse_client_statistics(lines: &[String]) -> Result<ClientStatistics, ParseStatusError> {
498    let mut stats = ClientStatistics::default();
499    let mut found_tun_read = false;
500    let mut found_tun_write = false;
501    let mut found_tcp_read = false;
502    let mut found_tcp_write = false;
503    let mut found_auth_read = false;
504
505    for line in lines {
506        if line == "OpenVPN STATISTICS" || line.starts_with("Updated,") {
507            continue;
508        }
509        let Some((key, val)) = line.split_once(',') else {
510            continue;
511        };
512        match key {
513            "TUN/TAP read bytes" => {
514                stats.tun_tap_read_bytes = parse_u64(val, "tun_tap_read_bytes")?;
515                found_tun_read = true;
516            }
517            "TUN/TAP write bytes" => {
518                stats.tun_tap_write_bytes = parse_u64(val, "tun_tap_write_bytes")?;
519                found_tun_write = true;
520            }
521            "TCP/UDP read bytes" => {
522                stats.tcp_udp_read_bytes = parse_u64(val, "tcp_udp_read_bytes")?;
523                found_tcp_read = true;
524            }
525            "TCP/UDP write bytes" => {
526                stats.tcp_udp_write_bytes = parse_u64(val, "tcp_udp_write_bytes")?;
527                found_tcp_write = true;
528            }
529            "Auth read bytes" => {
530                stats.auth_read_bytes = parse_u64(val, "auth_read_bytes")?;
531                found_auth_read = true;
532            }
533            "pre-compress bytes" => {
534                stats.pre_compress_bytes = Some(parse_u64(val, "pre_compress_bytes")?);
535            }
536            "post-compress bytes" => {
537                stats.post_compress_bytes = Some(parse_u64(val, "post_compress_bytes")?);
538            }
539            "pre-decompress bytes" => {
540                stats.pre_decompress_bytes = Some(parse_u64(val, "pre_decompress_bytes")?);
541            }
542            "post-decompress bytes" => {
543                stats.post_decompress_bytes = Some(parse_u64(val, "post_decompress_bytes")?);
544            }
545            _ => {} // Forward-compat: ignore unknown keys
546        }
547    }
548
549    if !found_tun_read {
550        return Err(ParseStatusError::MissingStatisticsKey("TUN/TAP read bytes"));
551    }
552    if !found_tun_write {
553        return Err(ParseStatusError::MissingStatisticsKey(
554            "TUN/TAP write bytes",
555        ));
556    }
557    if !found_tcp_read {
558        return Err(ParseStatusError::MissingStatisticsKey("TCP/UDP read bytes"));
559    }
560    if !found_tcp_write {
561        return Err(ParseStatusError::MissingStatisticsKey(
562            "TCP/UDP write bytes",
563        ));
564    }
565    if !found_auth_read {
566        return Err(ParseStatusError::MissingStatisticsKey("Auth read bytes"));
567    }
568
569    Ok(stats)
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    // --- V3 (tab-separated) ---
577
578    #[test]
579    fn v3_single_client() {
580        let lines: Vec<String> = include_str!("../tests/fixtures/status_v3.txt")
581            .lines()
582            .filter(|line| !line.is_empty() && *line != "END")
583            .map(String::from)
584            .collect();
585        let status = parse_status(&lines).unwrap();
586        assert_eq!(
587            status.title.as_deref(),
588            Some("OpenVPN 2.6.8 x86_64-pc-linux-gnu")
589        );
590        assert_eq!(status.timestamp, Some(1711031400));
591        assert_eq!(status.updated.as_deref(), Some("2024-03-21 14:30:00"));
592        assert_eq!(status.clients.len(), 1);
593        let client = &status.clients[0];
594        assert_eq!(client.common_name, "client1");
595        assert_eq!(client.real_address, "203.0.113.10:52841");
596        assert_eq!(client.virtual_address, "10.8.0.6");
597        assert!(client.virtual_ipv6.is_empty());
598        assert_eq!(client.bytes_in, 1548576);
599        assert_eq!(client.bytes_out, 984320);
600        assert_eq!(client.connected_since_t, Some(1711012500));
601        assert_eq!(client.username.as_deref(), Some("UNDEF"));
602        assert_eq!(client.cid, Some(0));
603        assert_eq!(client.peer_id, Some(0));
604        assert_eq!(client.cipher.as_deref(), Some("AES-256-GCM"));
605
606        assert_eq!(status.routes.len(), 1);
607        let route = &status.routes[0];
608        assert_eq!(route.virtual_address, "10.8.0.6");
609        assert_eq!(route.last_ref_t, Some(1711031390));
610
611        assert_eq!(status.global_stats.len(), 1);
612        assert_eq!(status.global_stats[0].0, "Max bcast/mcast queue length");
613        assert_eq!(status.global_stats[0].1, "3");
614    }
615
616    // --- V2 (comma-separated, modern) ---
617
618    #[test]
619    fn v2_single_client() {
620        let lines: Vec<String> = include_str!("../tests/fixtures/status_v2.txt")
621            .lines()
622            .filter(|line| !line.is_empty() && *line != "END")
623            .map(String::from)
624            .collect();
625        let status = parse_status(&lines).unwrap();
626        assert_eq!(status.clients.len(), 1);
627        assert_eq!(status.clients[0].common_name, "client1");
628        assert_eq!(status.clients[0].cipher.as_deref(), Some("AES-256-GCM"));
629        assert_eq!(status.routes.len(), 1);
630    }
631
632    #[test]
633    fn v2_full_multiple_clients() {
634        let lines: Vec<String> = include_str!("../tests/fixtures/status_v2_full.txt")
635            .lines()
636            .filter(|line| !line.is_empty() && *line != "END")
637            .map(String::from)
638            .collect();
639        let status = parse_status(&lines).unwrap();
640        assert_eq!(status.clients.len(), 2);
641        assert_eq!(status.clients[0].common_name, "ntafs");
642        assert_eq!(status.clients[0].virtual_ipv6, "2002:232:324:12::8");
643        assert_eq!(status.clients[1].common_name, "rdpuser");
644        assert_eq!(status.clients[1].username.as_deref(), Some("rdpuser"));
645        assert_eq!(
646            status.clients[1].cipher.as_deref(),
647            Some("CHACHA20-POLY1305")
648        );
649
650        assert_eq!(status.routes.len(), 3);
651        assert_eq!(status.routes[0].virtual_address, "10.1.1.8");
652        // IPv6 route
653        assert_eq!(status.routes[1].virtual_address, "2002:232:324:12::8");
654
655        // Multiple GLOBAL_STATS lines
656        assert_eq!(status.global_stats.len(), 2);
657    }
658
659    #[test]
660    fn v2_old_openvpn_23() {
661        let lines: Vec<String> = include_str!("../tests/fixtures/status_v2_old.txt")
662            .lines()
663            .filter(|line| !line.is_empty() && *line != "END")
664            .map(String::from)
665            .collect();
666        let status = parse_status(&lines).unwrap();
667        assert_eq!(
668            status.title.as_deref(),
669            Some(
670                "OpenVPN 2.3.2 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [EPOLL] [PKCS11] [eurephia] [MH] [IPv6] built on Dec  2 2014"
671            ),
672        );
673        assert_eq!(status.clients.len(), 2);
674        // Old format: no IPv6, no PeerID, no Cipher
675        assert!(status.clients[0].virtual_ipv6.is_empty());
676        assert_eq!(status.clients[0].peer_id, None);
677        assert_eq!(status.clients[0].cipher, None);
678        assert_eq!(status.clients[1].username.as_deref(), Some("admin"));
679    }
680
681    // --- V1 server ---
682
683    #[test]
684    fn v1_server_two_clients() {
685        let lines: Vec<String> = include_str!("../tests/fixtures/status_v1_server.txt")
686            .lines()
687            .filter(|line| !line.is_empty() && *line != "END")
688            .map(String::from)
689            .collect();
690        let status = parse_status(&lines).unwrap();
691        assert!(status.title.is_none());
692        assert!(status.timestamp.is_none());
693        assert_eq!(status.updated.as_deref(), Some("2024-03-21 14:30:00"));
694        assert_eq!(status.clients.len(), 2);
695        assert_eq!(status.clients[0].common_name, "client1");
696        assert_eq!(status.clients[0].bytes_in, 1548576);
697        assert_eq!(status.clients[1].common_name, "client2");
698        // V1 has no extra fields
699        assert!(status.clients[0].cid.is_none());
700        assert!(status.clients[0].cipher.is_none());
701
702        assert_eq!(status.routes.len(), 2);
703        assert_eq!(status.global_stats.len(), 1);
704    }
705
706    #[test]
707    fn v1_server_empty() {
708        let lines: Vec<String> = include_str!("../tests/fixtures/status_v1_server_empty.txt")
709            .lines()
710            .filter(|line| !line.is_empty() && *line != "END")
711            .map(String::from)
712            .collect();
713        let status = parse_status(&lines).unwrap();
714        assert!(status.clients.is_empty());
715        assert!(status.routes.is_empty());
716        assert_eq!(status.global_stats.len(), 1);
717    }
718
719    #[test]
720    fn v1_server_many_clients() {
721        let lines: Vec<String> =
722            include_str!("../tests/fixtures/status_v1_server_many_clients.txt")
723                .lines()
724                .filter(|line| !line.is_empty() && *line != "END")
725                .map(String::from)
726                .collect();
727        let status = parse_status(&lines).unwrap();
728        assert_eq!(status.clients.len(), 3);
729        assert_eq!(status.routes.len(), 3);
730    }
731
732    // --- Client statistics ---
733
734    #[test]
735    fn client_statistics_basic() {
736        let lines: Vec<String> = include_str!("../tests/fixtures/status_v1_client.txt")
737            .lines()
738            .filter(|line| !line.is_empty() && *line != "END")
739            .map(String::from)
740            .collect();
741        let stats = parse_client_statistics(&lines).unwrap();
742        assert_eq!(stats.tun_tap_read_bytes, 1548576);
743        assert_eq!(stats.tun_tap_write_bytes, 984320);
744        assert_eq!(stats.tcp_udp_read_bytes, 1600000);
745        assert_eq!(stats.tcp_udp_write_bytes, 1020000);
746        assert_eq!(stats.auth_read_bytes, 0);
747        assert!(stats.pre_compress_bytes.is_none());
748    }
749
750    #[test]
751    fn client_statistics_with_compression() {
752        let lines: Vec<String> = include_str!("../tests/fixtures/status_v1_client_full.txt")
753            .lines()
754            .filter(|line| !line.is_empty() && *line != "END")
755            .map(String::from)
756            .collect();
757        let stats = parse_client_statistics(&lines).unwrap();
758        assert_eq!(stats.tun_tap_read_bytes, 153789941);
759        assert_eq!(stats.pre_compress_bytes, Some(45388190));
760        assert_eq!(stats.post_compress_bytes, Some(45446864));
761        assert_eq!(stats.pre_decompress_bytes, Some(162596168));
762        assert_eq!(stats.post_decompress_bytes, Some(216965355));
763    }
764
765    #[test]
766    fn client_statistics_missing_key() {
767        let lines = vec![
768            "OpenVPN STATISTICS".to_string(),
769            "Updated,now".to_string(),
770            "TUN/TAP read bytes,100".to_string(),
771        ];
772        let err = parse_client_statistics(&lines).unwrap_err();
773        assert!(matches!(
774            err,
775            ParseStatusError::MissingStatisticsKey("TUN/TAP write bytes")
776        ));
777    }
778
779    #[test]
780    fn client_statistics_invalid_number() {
781        let lines = vec![
782            "OpenVPN STATISTICS".to_string(),
783            "Updated,now".to_string(),
784            "TUN/TAP read bytes,abc".to_string(),
785        ];
786        let err = parse_client_statistics(&lines).unwrap_err();
787        assert!(matches!(
788            err,
789            ParseStatusError::InvalidInteger {
790                field: "tun_tap_read_bytes",
791                ..
792            }
793        ));
794    }
795
796    // --- detect_separator ---
797
798    #[test]
799    fn detect_separator_each_tab_prefix() {
800        for prefix in [
801            "TITLE\t",
802            "TIME\t",
803            "HEADER\t",
804            "CLIENT_LIST\t",
805            "ROUTING_TABLE\t",
806            "GLOBAL_STATS\t",
807        ] {
808            let lines = vec![format!("{prefix}data")];
809            assert_eq!(
810                detect_separator(&lines),
811                '\t',
812                "should detect tab for line starting with {prefix:?}",
813            );
814        }
815    }
816
817    #[test]
818    fn detect_separator_falls_back_to_comma() {
819        let lines = vec!["no tabs here".to_string()];
820        assert_eq!(detect_separator(&lines), ',');
821    }
822
823    // --- parse_optional_u64 ---
824
825    #[test]
826    fn parse_optional_u64_empty() {
827        assert_eq!(parse_optional_u64(""), None);
828    }
829
830    #[test]
831    fn parse_optional_u64_undef() {
832        assert_eq!(parse_optional_u64("UNDEF"), None);
833    }
834
835    #[test]
836    fn parse_optional_u64_valid() {
837        assert_eq!(parse_optional_u64("42"), Some(42));
838    }
839
840    // --- V1 routing table guards ---
841
842    #[test]
843    fn v1_routing_table_too_few_fields() {
844        let lines = vec![
845            "OpenVPN CLIENT LIST".to_string(),
846            "Updated,2024-03-21 14:30:00".to_string(),
847            "Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since".to_string(),
848            "client1,10.0.0.1,1000,2000,2024-03-21 10:00:00".to_string(),
849            "ROUTING TABLE".to_string(),
850            "Virtual Address,Common Name,Real Address,Last Ref".to_string(),
851            // Only 2 fields — fewer than the required 4.
852            "10.8.0.6,client1".to_string(),
853        ];
854        let err = parse_status(&lines).unwrap_err();
855        assert!(
856            matches!(err, ParseStatusError::RoutingTableTooFewFields(2)),
857            "expected RoutingTableTooFewFields(2), got {err:?}",
858        );
859    }
860
861    // --- V2/V3 GLOBAL_STATS guard ---
862
863    #[test]
864    fn v2v3_global_stats_with_only_key_is_ignored() {
865        // GLOBAL_STATS with only 2 fields (tag + key, no value) — should be silently skipped.
866        let lines = vec!["GLOBAL_STATS\torphan_key".to_string()];
867        let status = parse_status(&lines).unwrap();
868        assert!(
869            status.global_stats.is_empty(),
870            "GLOBAL_STATS with <3 fields should be ignored",
871        );
872    }
873
874    #[test]
875    fn v2v3_global_stats_exactly_three_fields() {
876        // Exactly 3 fields (tag + key + value) — the minimum accepted by the guard.
877        let lines = vec!["GLOBAL_STATS\tMax bcast/mcast queue length\t3".to_string()];
878        let status = parse_status(&lines).unwrap();
879        assert_eq!(status.global_stats.len(), 1);
880        assert_eq!(status.global_stats[0].0, "Max bcast/mcast queue length");
881        assert_eq!(status.global_stats[0].1, "3");
882    }
883
884    // --- Edge cases ---
885
886    #[test]
887    fn empty_input() {
888        let status = parse_status(&[]).unwrap();
889        assert!(status.clients.is_empty());
890        assert!(status.routes.is_empty());
891    }
892
893    #[test]
894    fn v1_client_list_too_few_fields() {
895        let lines = vec![
896            "OpenVPN CLIENT LIST".to_string(),
897            "Updated,2024-03-21 14:30:00".to_string(),
898            "Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since".to_string(),
899            // Only 3 fields — fewer than the required 5.
900            "client1,203.0.113.10:52841,1548576".to_string(),
901        ];
902        let err = parse_status(&lines).unwrap_err();
903        assert!(
904            matches!(err, ParseStatusError::ClientListTooFewFields(3)),
905            "expected ClientListTooFewFields(3), got {err:?}",
906        );
907    }
908
909    #[test]
910    fn v1_client_list_exactly_five_fields() {
911        // Exactly 5 fields — the minimum accepted by the `fields.len() < 5` guard.
912        let lines = vec![
913            "OpenVPN CLIENT LIST".to_string(),
914            "Updated,2024-03-21 14:30:00".to_string(),
915            "Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since".to_string(),
916            "client1,203.0.113.10:52841,1548576,984320,2024-03-21 10:00:00".to_string(),
917        ];
918        let status = parse_status(&lines).unwrap();
919        assert_eq!(status.clients.len(), 1);
920        assert_eq!(status.clients[0].common_name, "client1");
921        assert_eq!(status.clients[0].bytes_in, 1548576);
922        assert_eq!(status.clients[0].bytes_out, 984320);
923    }
924
925    #[test]
926    fn v1_routing_table_exactly_four_fields() {
927        // Exactly 4 fields — the minimum accepted by the `fields.len() < 4` guard.
928        let lines = vec![
929            "OpenVPN CLIENT LIST".to_string(),
930            "Updated,2024-03-21 14:30:00".to_string(),
931            "Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since".to_string(),
932            "ROUTING TABLE".to_string(),
933            "Virtual Address,Common Name,Real Address,Last Ref".to_string(),
934            "10.8.0.6,client1,203.0.113.10:52841,2024-03-21 14:30:00".to_string(),
935        ];
936        let status = parse_status(&lines).unwrap();
937        assert_eq!(status.routes.len(), 1);
938        assert_eq!(status.routes[0].virtual_address, "10.8.0.6");
939        assert_eq!(status.routes[0].common_name, "client1");
940    }
941
942    #[test]
943    fn v2v3_routing_table_too_few_fields() {
944        // ROUTING_TABLE with only 2 data fields (need at least 4).
945        let lines = vec!["ROUTING_TABLE\t10.8.0.6\tclient1".to_string()];
946        let err = parse_status(&lines).unwrap_err();
947        assert!(
948            matches!(err, ParseStatusError::RoutingTableTooFewFields(2)),
949            "expected RoutingTableTooFewFields(2), got {err:?}",
950        );
951    }
952
953    #[test]
954    fn v2v3_routing_table_exactly_four_fields() {
955        // Exactly 4 data fields — the minimum accepted by the guard.
956        let lines = vec![
957            "ROUTING_TABLE\t10.8.0.6\tclient1\t203.0.113.10:52841\t2024-03-21 14:30:00".to_string(),
958        ];
959        let status = parse_status(&lines).unwrap();
960        assert_eq!(status.routes.len(), 1);
961        assert_eq!(status.routes[0].virtual_address, "10.8.0.6");
962        assert_eq!(status.routes[0].common_name, "client1");
963    }
964
965    #[test]
966    fn v2v3_client_list_old_layout_too_few_fields() {
967        // Old layout CLIENT_LIST with only 3 data fields (need at least 5).
968        let lines = vec!["CLIENT_LIST\tclient1\t203.0.113.10:52841\t10.8.0.6".to_string()];
969        let err = parse_status(&lines).unwrap_err();
970        assert!(
971            matches!(err, ParseStatusError::ClientListTooFewFields(3)),
972            "expected ClientListTooFewFields(3), got {err:?}",
973        );
974    }
975
976    #[test]
977    fn v2v3_client_list_old_layout_exactly_five_fields() {
978        // Exactly 5 data fields — the minimum accepted by the old-layout guard.
979        let lines =
980            vec!["CLIENT_LIST\tclient1\t203.0.113.10:52841\t10.8.0.6\t1548576\t984320".to_string()];
981        let status = parse_status(&lines).unwrap();
982        assert_eq!(status.clients.len(), 1);
983        assert_eq!(status.clients[0].common_name, "client1");
984        assert_eq!(status.clients[0].bytes_in, 1548576);
985        assert_eq!(status.clients[0].bytes_out, 984320);
986    }
987
988    #[test]
989    fn v2v3_unknown_lines_ignored() {
990        let lines = vec![
991            "TITLE\tTest".to_string(),
992            "FUTURE_SECTION\tsomething\tnew".to_string(),
993            "GLOBAL_STATS\tkey\tval".to_string(),
994        ];
995        let status = parse_status(&lines).unwrap();
996        assert_eq!(status.title.as_deref(), Some("Test"));
997        assert_eq!(status.global_stats.len(), 1);
998    }
999}