#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusResponse {
pub title: Option<String>,
pub timestamp: Option<u64>,
pub updated: Option<String>,
pub clients: Vec<ConnectedClient>,
pub routes: Vec<RoutingEntry>,
pub global_stats: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnectedClient {
pub common_name: String,
pub real_address: String,
pub virtual_address: String,
pub virtual_ipv6: String,
pub bytes_in: u64,
pub bytes_out: u64,
pub connected_since: String,
pub connected_since_t: Option<u64>,
pub username: Option<String>,
pub cid: Option<u64>,
pub peer_id: Option<u64>,
pub cipher: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoutingEntry {
pub virtual_address: String,
pub common_name: String,
pub real_address: String,
pub last_ref: String,
pub last_ref_t: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ClientStatistics {
pub tun_tap_read_bytes: u64,
pub tun_tap_write_bytes: u64,
pub tcp_udp_read_bytes: u64,
pub tcp_udp_write_bytes: u64,
pub auth_read_bytes: u64,
pub pre_compress_bytes: Option<u64>,
pub post_compress_bytes: Option<u64>,
pub pre_decompress_bytes: Option<u64>,
pub post_decompress_bytes: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseStatusError {
#[error("invalid integer for field {field:?}: {value:?}")]
InvalidInteger {
field: &'static str,
value: String,
},
#[error("CLIENT_LIST has too few fields (need >= 5, got {0})")]
ClientListTooFewFields(usize),
#[error("ROUTING_TABLE has too few fields (need >= 4, got {0})")]
RoutingTableTooFewFields(usize),
#[error("missing statistics key: {0:?}")]
MissingStatisticsKey(&'static str),
}
fn detect_separator(lines: &[String]) -> char {
for line in lines {
if line.starts_with("TITLE\t")
|| line.starts_with("TIME\t")
|| line.starts_with("HEADER\t")
|| line.starts_with("CLIENT_LIST\t")
|| line.starts_with("ROUTING_TABLE\t")
|| line.starts_with("GLOBAL_STATS\t")
{
return '\t';
}
}
','
}
fn parse_u64(s: &str, field: &'static str) -> Result<u64, ParseStatusError> {
s.parse().map_err(|_| ParseStatusError::InvalidInteger {
field,
value: s.to_string(),
})
}
fn parse_optional_u64(s: &str) -> Option<u64> {
if s.is_empty() || s == "UNDEF" {
None
} else {
s.parse()
.inspect_err(|error| {
tracing::warn!(%error, value = s, "non-numeric optional u64 in status response")
})
.ok()
}
}
pub fn parse_status(lines: &[String]) -> Result<StatusResponse, ParseStatusError> {
if lines
.first()
.is_some_and(|line| line == "OpenVPN CLIENT LIST")
{
return parse_status_v1(lines);
}
let sep = detect_separator(lines);
parse_status_v2v3(lines, sep)
}
fn parse_status_v1(lines: &[String]) -> Result<StatusResponse, ParseStatusError> {
let mut status = StatusResponse {
title: None,
timestamp: None,
updated: None,
clients: Vec::new(),
routes: Vec::new(),
global_stats: Vec::new(),
};
#[derive(PartialEq)]
enum Section {
Header,
ClientList,
RoutingTable,
GlobalStats,
}
let mut section = Section::Header;
for line in lines {
if line == "OpenVPN CLIENT LIST" {
section = Section::Header;
continue;
}
if line.starts_with("Updated,") {
status.updated = Some(line.strip_prefix("Updated,").unwrap_or("").to_string());
continue;
}
if line == "ROUTING TABLE" {
section = Section::RoutingTable;
continue;
}
if line == "GLOBAL STATS" {
section = Section::GlobalStats;
continue;
}
let fields: Vec<&str> = line.split(',').collect();
match section {
Section::Header => {
if fields.first() == Some(&"Common Name") {
section = Section::ClientList;
continue;
}
}
Section::ClientList => {
if fields.len() < 5 {
return Err(ParseStatusError::ClientListTooFewFields(fields.len()));
}
status.clients.push(ConnectedClient {
common_name: fields[0].to_string(),
real_address: fields[1].to_string(),
virtual_address: String::new(), virtual_ipv6: String::new(),
bytes_in: parse_u64(fields[2], "bytes_received")?,
bytes_out: parse_u64(fields[3], "bytes_sent")?,
connected_since: fields[4..].join(","), connected_since_t: None,
username: None,
cid: None,
peer_id: None,
cipher: None,
});
}
Section::RoutingTable => {
if fields.first() == Some(&"Virtual Address") {
continue;
}
if fields.len() < 4 {
return Err(ParseStatusError::RoutingTableTooFewFields(fields.len()));
}
status.routes.push(RoutingEntry {
virtual_address: fields[0].to_string(),
common_name: fields[1].to_string(),
real_address: fields[2].to_string(),
last_ref: fields[3..].join(","),
last_ref_t: None,
});
}
Section::GlobalStats => {
if fields.len() >= 2 {
status
.global_stats
.push((fields[0].to_string(), fields[1..].join(",")));
}
}
}
}
Ok(status)
}
fn parse_status_v2v3(lines: &[String], sep: char) -> Result<StatusResponse, ParseStatusError> {
let mut status = StatusResponse {
title: None,
timestamp: None,
updated: None,
clients: Vec::new(),
routes: Vec::new(),
global_stats: Vec::new(),
};
for line in lines {
let fields: Vec<&str> = line.split(sep).collect();
let tag = fields.first().copied().unwrap_or("");
match tag {
"TITLE" => {
status.title = fields.get(1).map(|val| val.to_string());
}
"TIME" => {
status.updated = fields.get(1).map(|val| val.to_string());
status.timestamp = fields.get(2).and_then(|val| {
val.parse()
.inspect_err(|error| {
tracing::warn!(%error, value = val, "non-numeric timestamp in TIME row")
})
.ok()
});
}
"HEADER" => {
}
"CLIENT_LIST" => {
let cols = &fields[1..]; let has_ipv6_column = cols.len() >= 12;
if has_ipv6_column {
status.clients.push(ConnectedClient {
common_name: cols[0].to_string(),
real_address: cols[1].to_string(),
virtual_address: cols[2].to_string(),
virtual_ipv6: cols[3].to_string(),
bytes_in: parse_u64(cols[4], "bytes_received")?,
bytes_out: parse_u64(cols[5], "bytes_sent")?,
connected_since: cols[6].to_string(),
connected_since_t: parse_optional_u64(cols.get(7).copied().unwrap_or("")),
username: cols.get(8).map(|val| val.to_string()),
cid: cols.get(9).and_then(|val| parse_optional_u64(val)),
peer_id: cols.get(10).and_then(|val| parse_optional_u64(val)),
cipher: cols.get(11).map(|val| val.to_string()),
});
} else {
if cols.len() < 5 {
return Err(ParseStatusError::ClientListTooFewFields(cols.len()));
}
status.clients.push(ConnectedClient {
common_name: cols[0].to_string(),
real_address: cols[1].to_string(),
virtual_address: cols[2].to_string(),
virtual_ipv6: String::new(),
bytes_in: parse_u64(cols[3], "bytes_received")?,
bytes_out: parse_u64(cols[4], "bytes_sent")?,
connected_since: cols.get(5).unwrap_or(&"").to_string(),
connected_since_t: cols.get(6).and_then(|val| parse_optional_u64(val)),
username: cols.get(7).map(|val| val.to_string()),
cid: None,
peer_id: None,
cipher: None,
});
}
}
"ROUTING_TABLE" => {
let cols = &fields[1..];
if cols.len() < 4 {
return Err(ParseStatusError::RoutingTableTooFewFields(cols.len()));
}
status.routes.push(RoutingEntry {
virtual_address: cols[0].to_string(),
common_name: cols[1].to_string(),
real_address: cols[2].to_string(),
last_ref: cols[3].to_string(),
last_ref_t: cols.get(4).and_then(|val| parse_optional_u64(val)),
});
}
"GLOBAL_STATS" if fields.len() >= 3 => {
status
.global_stats
.push((fields[1].to_string(), fields[2..].join(&sep.to_string())));
}
_ => {
}
}
}
Ok(status)
}
pub fn parse_client_statistics(lines: &[String]) -> Result<ClientStatistics, ParseStatusError> {
let mut stats = ClientStatistics::default();
let mut found_tun_read = false;
let mut found_tun_write = false;
let mut found_tcp_read = false;
let mut found_tcp_write = false;
let mut found_auth_read = false;
for line in lines {
if line == "OpenVPN STATISTICS" || line.starts_with("Updated,") {
continue;
}
let Some((key, val)) = line.split_once(',') else {
continue;
};
match key {
"TUN/TAP read bytes" => {
stats.tun_tap_read_bytes = parse_u64(val, "tun_tap_read_bytes")?;
found_tun_read = true;
}
"TUN/TAP write bytes" => {
stats.tun_tap_write_bytes = parse_u64(val, "tun_tap_write_bytes")?;
found_tun_write = true;
}
"TCP/UDP read bytes" => {
stats.tcp_udp_read_bytes = parse_u64(val, "tcp_udp_read_bytes")?;
found_tcp_read = true;
}
"TCP/UDP write bytes" => {
stats.tcp_udp_write_bytes = parse_u64(val, "tcp_udp_write_bytes")?;
found_tcp_write = true;
}
"Auth read bytes" => {
stats.auth_read_bytes = parse_u64(val, "auth_read_bytes")?;
found_auth_read = true;
}
"pre-compress bytes" => {
stats.pre_compress_bytes = Some(parse_u64(val, "pre_compress_bytes")?);
}
"post-compress bytes" => {
stats.post_compress_bytes = Some(parse_u64(val, "post_compress_bytes")?);
}
"pre-decompress bytes" => {
stats.pre_decompress_bytes = Some(parse_u64(val, "pre_decompress_bytes")?);
}
"post-decompress bytes" => {
stats.post_decompress_bytes = Some(parse_u64(val, "post_decompress_bytes")?);
}
_ => {} }
}
if !found_tun_read {
return Err(ParseStatusError::MissingStatisticsKey("TUN/TAP read bytes"));
}
if !found_tun_write {
return Err(ParseStatusError::MissingStatisticsKey(
"TUN/TAP write bytes",
));
}
if !found_tcp_read {
return Err(ParseStatusError::MissingStatisticsKey("TCP/UDP read bytes"));
}
if !found_tcp_write {
return Err(ParseStatusError::MissingStatisticsKey(
"TCP/UDP write bytes",
));
}
if !found_auth_read {
return Err(ParseStatusError::MissingStatisticsKey("Auth read bytes"));
}
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn v3_single_client() {
let lines: Vec<String> = include_str!("../tests/fixtures/status_v3.txt")
.lines()
.filter(|line| !line.is_empty() && *line != "END")
.map(String::from)
.collect();
let status = parse_status(&lines).unwrap();
assert_eq!(
status.title.as_deref(),
Some("OpenVPN 2.6.8 x86_64-pc-linux-gnu")
);
assert_eq!(status.timestamp, Some(1711031400));
assert_eq!(status.updated.as_deref(), Some("2024-03-21 14:30:00"));
assert_eq!(status.clients.len(), 1);
let client = &status.clients[0];
assert_eq!(client.common_name, "client1");
assert_eq!(client.real_address, "203.0.113.10:52841");
assert_eq!(client.virtual_address, "10.8.0.6");
assert!(client.virtual_ipv6.is_empty());
assert_eq!(client.bytes_in, 1548576);
assert_eq!(client.bytes_out, 984320);
assert_eq!(client.connected_since_t, Some(1711012500));
assert_eq!(client.username.as_deref(), Some("UNDEF"));
assert_eq!(client.cid, Some(0));
assert_eq!(client.peer_id, Some(0));
assert_eq!(client.cipher.as_deref(), Some("AES-256-GCM"));
assert_eq!(status.routes.len(), 1);
let route = &status.routes[0];
assert_eq!(route.virtual_address, "10.8.0.6");
assert_eq!(route.last_ref_t, Some(1711031390));
assert_eq!(status.global_stats.len(), 1);
assert_eq!(status.global_stats[0].0, "Max bcast/mcast queue length");
assert_eq!(status.global_stats[0].1, "3");
}
#[test]
fn v2_single_client() {
let lines: Vec<String> = include_str!("../tests/fixtures/status_v2.txt")
.lines()
.filter(|line| !line.is_empty() && *line != "END")
.map(String::from)
.collect();
let status = parse_status(&lines).unwrap();
assert_eq!(status.clients.len(), 1);
assert_eq!(status.clients[0].common_name, "client1");
assert_eq!(status.clients[0].cipher.as_deref(), Some("AES-256-GCM"));
assert_eq!(status.routes.len(), 1);
}
#[test]
fn v2_full_multiple_clients() {
let lines: Vec<String> = include_str!("../tests/fixtures/status_v2_full.txt")
.lines()
.filter(|line| !line.is_empty() && *line != "END")
.map(String::from)
.collect();
let status = parse_status(&lines).unwrap();
assert_eq!(status.clients.len(), 2);
assert_eq!(status.clients[0].common_name, "ntafs");
assert_eq!(status.clients[0].virtual_ipv6, "2002:232:324:12::8");
assert_eq!(status.clients[1].common_name, "rdpuser");
assert_eq!(status.clients[1].username.as_deref(), Some("rdpuser"));
assert_eq!(
status.clients[1].cipher.as_deref(),
Some("CHACHA20-POLY1305")
);
assert_eq!(status.routes.len(), 3);
assert_eq!(status.routes[0].virtual_address, "10.1.1.8");
assert_eq!(status.routes[1].virtual_address, "2002:232:324:12::8");
assert_eq!(status.global_stats.len(), 2);
}
#[test]
fn v2_old_openvpn_23() {
let lines: Vec<String> = include_str!("../tests/fixtures/status_v2_old.txt")
.lines()
.filter(|line| !line.is_empty() && *line != "END")
.map(String::from)
.collect();
let status = parse_status(&lines).unwrap();
assert_eq!(
status.title.as_deref(),
Some(
"OpenVPN 2.3.2 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [EPOLL] [PKCS11] [eurephia] [MH] [IPv6] built on Dec 2 2014"
),
);
assert_eq!(status.clients.len(), 2);
assert!(status.clients[0].virtual_ipv6.is_empty());
assert_eq!(status.clients[0].peer_id, None);
assert_eq!(status.clients[0].cipher, None);
assert_eq!(status.clients[1].username.as_deref(), Some("admin"));
}
#[test]
fn v1_server_two_clients() {
let lines: Vec<String> = include_str!("../tests/fixtures/status_v1_server.txt")
.lines()
.filter(|line| !line.is_empty() && *line != "END")
.map(String::from)
.collect();
let status = parse_status(&lines).unwrap();
assert!(status.title.is_none());
assert!(status.timestamp.is_none());
assert_eq!(status.updated.as_deref(), Some("2024-03-21 14:30:00"));
assert_eq!(status.clients.len(), 2);
assert_eq!(status.clients[0].common_name, "client1");
assert_eq!(status.clients[0].bytes_in, 1548576);
assert_eq!(status.clients[1].common_name, "client2");
assert!(status.clients[0].cid.is_none());
assert!(status.clients[0].cipher.is_none());
assert_eq!(status.routes.len(), 2);
assert_eq!(status.global_stats.len(), 1);
}
#[test]
fn v1_server_empty() {
let lines: Vec<String> = include_str!("../tests/fixtures/status_v1_server_empty.txt")
.lines()
.filter(|line| !line.is_empty() && *line != "END")
.map(String::from)
.collect();
let status = parse_status(&lines).unwrap();
assert!(status.clients.is_empty());
assert!(status.routes.is_empty());
assert_eq!(status.global_stats.len(), 1);
}
#[test]
fn v1_server_many_clients() {
let lines: Vec<String> =
include_str!("../tests/fixtures/status_v1_server_many_clients.txt")
.lines()
.filter(|line| !line.is_empty() && *line != "END")
.map(String::from)
.collect();
let status = parse_status(&lines).unwrap();
assert_eq!(status.clients.len(), 3);
assert_eq!(status.routes.len(), 3);
}
#[test]
fn client_statistics_basic() {
let lines: Vec<String> = include_str!("../tests/fixtures/status_v1_client.txt")
.lines()
.filter(|line| !line.is_empty() && *line != "END")
.map(String::from)
.collect();
let stats = parse_client_statistics(&lines).unwrap();
assert_eq!(stats.tun_tap_read_bytes, 1548576);
assert_eq!(stats.tun_tap_write_bytes, 984320);
assert_eq!(stats.tcp_udp_read_bytes, 1600000);
assert_eq!(stats.tcp_udp_write_bytes, 1020000);
assert_eq!(stats.auth_read_bytes, 0);
assert!(stats.pre_compress_bytes.is_none());
}
#[test]
fn client_statistics_with_compression() {
let lines: Vec<String> = include_str!("../tests/fixtures/status_v1_client_full.txt")
.lines()
.filter(|line| !line.is_empty() && *line != "END")
.map(String::from)
.collect();
let stats = parse_client_statistics(&lines).unwrap();
assert_eq!(stats.tun_tap_read_bytes, 153789941);
assert_eq!(stats.pre_compress_bytes, Some(45388190));
assert_eq!(stats.post_compress_bytes, Some(45446864));
assert_eq!(stats.pre_decompress_bytes, Some(162596168));
assert_eq!(stats.post_decompress_bytes, Some(216965355));
}
#[test]
fn client_statistics_missing_key() {
let lines = vec![
"OpenVPN STATISTICS".to_string(),
"Updated,now".to_string(),
"TUN/TAP read bytes,100".to_string(),
];
let err = parse_client_statistics(&lines).unwrap_err();
assert!(matches!(
err,
ParseStatusError::MissingStatisticsKey("TUN/TAP write bytes")
));
}
#[test]
fn client_statistics_invalid_number() {
let lines = vec![
"OpenVPN STATISTICS".to_string(),
"Updated,now".to_string(),
"TUN/TAP read bytes,abc".to_string(),
];
let err = parse_client_statistics(&lines).unwrap_err();
assert!(matches!(
err,
ParseStatusError::InvalidInteger {
field: "tun_tap_read_bytes",
..
}
));
}
#[test]
fn detect_separator_each_tab_prefix() {
for prefix in [
"TITLE\t",
"TIME\t",
"HEADER\t",
"CLIENT_LIST\t",
"ROUTING_TABLE\t",
"GLOBAL_STATS\t",
] {
let lines = vec![format!("{prefix}data")];
assert_eq!(
detect_separator(&lines),
'\t',
"should detect tab for line starting with {prefix:?}",
);
}
}
#[test]
fn detect_separator_falls_back_to_comma() {
let lines = vec!["no tabs here".to_string()];
assert_eq!(detect_separator(&lines), ',');
}
#[test]
fn parse_optional_u64_empty() {
assert_eq!(parse_optional_u64(""), None);
}
#[test]
fn parse_optional_u64_undef() {
assert_eq!(parse_optional_u64("UNDEF"), None);
}
#[test]
fn parse_optional_u64_valid() {
assert_eq!(parse_optional_u64("42"), Some(42));
}
#[test]
fn v1_routing_table_too_few_fields() {
let lines = vec![
"OpenVPN CLIENT LIST".to_string(),
"Updated,2024-03-21 14:30:00".to_string(),
"Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since".to_string(),
"client1,10.0.0.1,1000,2000,2024-03-21 10:00:00".to_string(),
"ROUTING TABLE".to_string(),
"Virtual Address,Common Name,Real Address,Last Ref".to_string(),
"10.8.0.6,client1".to_string(),
];
let err = parse_status(&lines).unwrap_err();
assert!(
matches!(err, ParseStatusError::RoutingTableTooFewFields(2)),
"expected RoutingTableTooFewFields(2), got {err:?}",
);
}
#[test]
fn v2v3_global_stats_with_only_key_is_ignored() {
let lines = vec!["GLOBAL_STATS\torphan_key".to_string()];
let status = parse_status(&lines).unwrap();
assert!(
status.global_stats.is_empty(),
"GLOBAL_STATS with <3 fields should be ignored",
);
}
#[test]
fn v2v3_global_stats_exactly_three_fields() {
let lines = vec!["GLOBAL_STATS\tMax bcast/mcast queue length\t3".to_string()];
let status = parse_status(&lines).unwrap();
assert_eq!(status.global_stats.len(), 1);
assert_eq!(status.global_stats[0].0, "Max bcast/mcast queue length");
assert_eq!(status.global_stats[0].1, "3");
}
#[test]
fn empty_input() {
let status = parse_status(&[]).unwrap();
assert!(status.clients.is_empty());
assert!(status.routes.is_empty());
}
#[test]
fn v1_client_list_too_few_fields() {
let lines = vec![
"OpenVPN CLIENT LIST".to_string(),
"Updated,2024-03-21 14:30:00".to_string(),
"Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since".to_string(),
"client1,203.0.113.10:52841,1548576".to_string(),
];
let err = parse_status(&lines).unwrap_err();
assert!(
matches!(err, ParseStatusError::ClientListTooFewFields(3)),
"expected ClientListTooFewFields(3), got {err:?}",
);
}
#[test]
fn v1_client_list_exactly_five_fields() {
let lines = vec![
"OpenVPN CLIENT LIST".to_string(),
"Updated,2024-03-21 14:30:00".to_string(),
"Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since".to_string(),
"client1,203.0.113.10:52841,1548576,984320,2024-03-21 10:00:00".to_string(),
];
let status = parse_status(&lines).unwrap();
assert_eq!(status.clients.len(), 1);
assert_eq!(status.clients[0].common_name, "client1");
assert_eq!(status.clients[0].bytes_in, 1548576);
assert_eq!(status.clients[0].bytes_out, 984320);
}
#[test]
fn v1_routing_table_exactly_four_fields() {
let lines = vec![
"OpenVPN CLIENT LIST".to_string(),
"Updated,2024-03-21 14:30:00".to_string(),
"Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since".to_string(),
"ROUTING TABLE".to_string(),
"Virtual Address,Common Name,Real Address,Last Ref".to_string(),
"10.8.0.6,client1,203.0.113.10:52841,2024-03-21 14:30:00".to_string(),
];
let status = parse_status(&lines).unwrap();
assert_eq!(status.routes.len(), 1);
assert_eq!(status.routes[0].virtual_address, "10.8.0.6");
assert_eq!(status.routes[0].common_name, "client1");
}
#[test]
fn v2v3_routing_table_too_few_fields() {
let lines = vec!["ROUTING_TABLE\t10.8.0.6\tclient1".to_string()];
let err = parse_status(&lines).unwrap_err();
assert!(
matches!(err, ParseStatusError::RoutingTableTooFewFields(2)),
"expected RoutingTableTooFewFields(2), got {err:?}",
);
}
#[test]
fn v2v3_routing_table_exactly_four_fields() {
let lines = vec![
"ROUTING_TABLE\t10.8.0.6\tclient1\t203.0.113.10:52841\t2024-03-21 14:30:00".to_string(),
];
let status = parse_status(&lines).unwrap();
assert_eq!(status.routes.len(), 1);
assert_eq!(status.routes[0].virtual_address, "10.8.0.6");
assert_eq!(status.routes[0].common_name, "client1");
}
#[test]
fn v2v3_client_list_old_layout_too_few_fields() {
let lines = vec!["CLIENT_LIST\tclient1\t203.0.113.10:52841\t10.8.0.6".to_string()];
let err = parse_status(&lines).unwrap_err();
assert!(
matches!(err, ParseStatusError::ClientListTooFewFields(3)),
"expected ClientListTooFewFields(3), got {err:?}",
);
}
#[test]
fn v2v3_client_list_old_layout_exactly_five_fields() {
let lines =
vec!["CLIENT_LIST\tclient1\t203.0.113.10:52841\t10.8.0.6\t1548576\t984320".to_string()];
let status = parse_status(&lines).unwrap();
assert_eq!(status.clients.len(), 1);
assert_eq!(status.clients[0].common_name, "client1");
assert_eq!(status.clients[0].bytes_in, 1548576);
assert_eq!(status.clients[0].bytes_out, 984320);
}
#[test]
fn v2v3_unknown_lines_ignored() {
let lines = vec![
"TITLE\tTest".to_string(),
"FUTURE_SECTION\tsomething\tnew".to_string(),
"GLOBAL_STATS\tkey\tval".to_string(),
];
let status = parse_status(&lines).unwrap();
assert_eq!(status.title.as_deref(), Some("Test"));
assert_eq!(status.global_stats.len(), 1);
}
}