1#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct StatusResponse {
62 pub title: Option<String>,
65
66 pub timestamp: Option<u64>,
69
70 pub updated: Option<String>,
73
74 pub clients: Vec<ConnectedClient>,
76
77 pub routes: Vec<RoutingEntry>,
79
80 pub global_stats: Vec<(String, String)>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ConnectedClient {
92 pub common_name: String,
94 pub real_address: String,
96 pub virtual_address: String,
98 pub virtual_ipv6: String,
100 pub bytes_in: u64,
102 pub bytes_out: u64,
104 pub connected_since: String,
106 pub connected_since_t: Option<u64>,
108 pub username: Option<String>,
110 pub cid: Option<u64>,
112 pub peer_id: Option<u64>,
114 pub cipher: Option<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct RoutingEntry {
121 pub virtual_address: String,
123 pub common_name: String,
125 pub real_address: String,
127 pub last_ref: String,
129 pub last_ref_t: Option<u64>,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
139pub struct ClientStatistics {
140 pub tun_tap_read_bytes: u64,
142 pub tun_tap_write_bytes: u64,
144 pub tcp_udp_read_bytes: u64,
146 pub tcp_udp_write_bytes: u64,
148 pub auth_read_bytes: u64,
150 pub pre_compress_bytes: Option<u64>,
152 pub post_compress_bytes: Option<u64>,
154 pub pre_decompress_bytes: Option<u64>,
156 pub post_decompress_bytes: Option<u64>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
162pub enum ParseStatusError {
163 #[error("invalid integer for field {field:?}: {value:?}")]
165 InvalidInteger {
166 field: &'static str,
168 value: String,
170 },
171
172 #[error("CLIENT_LIST has too few fields (need >= 5, got {0})")]
174 ClientListTooFewFields(usize),
175
176 #[error("ROUTING_TABLE has too few fields (need >= 4, got {0})")]
178 RoutingTableTooFewFields(usize),
179
180 #[error("missing statistics key: {0:?}")]
182 MissingStatisticsKey(&'static str),
183}
184
185fn 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
222pub fn parse_status(lines: &[String]) -> Result<StatusResponse, ParseStatusError> {
254 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
266fn 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 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 match section {
309 Section::Header => {
310 if fields.first() == Some(&"Common Name") {
311 section = Section::ClientList;
312 continue;
313 }
314 }
315 Section::ClientList => {
316 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(), 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(","), connected_since_t: None,
329 username: None,
330 cid: None,
331 peer_id: None,
332 cipher: None,
333 });
334 }
335 Section::RoutingTable => {
336 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
364fn 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 }
396 "CLIENT_LIST" => {
397 let cols = &fields[1..]; let has_ipv6_column = cols.len() >= 12;
406
407 if has_ipv6_column {
408 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 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 }
466 }
467 }
468
469 Ok(status)
470}
471
472pub 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 _ => {} }
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 #[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 #[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 assert_eq!(status.routes[1].virtual_address, "2002:232:324:12::8");
654
655 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 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 #[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 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 #[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 #[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 #[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 #[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 "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 #[test]
864 fn v2v3_global_stats_with_only_key_is_ignored() {
865 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 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 #[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 "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 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 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 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 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 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 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}