use crate::openvpn_state::OpenVpnState;
use crate::version_info::VersionInfo;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LoadStats {
pub nclients: u64,
pub bytesin: u64,
pub bytesout: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseResponseError {
#[error("missing 'pid=' prefix in: {0:?}")]
MissingPidPrefix(String),
#[error("missing 'hold=' prefix in: {0:?}")]
MissingHoldPrefix(String),
#[error("invalid hold value: {0:?}")]
InvalidHoldValue(String),
#[error("invalid integer for field {field:?}: {value:?}")]
InvalidInteger {
field: &'static str,
value: String,
},
#[error("missing field {0:?} in load-stats payload")]
MissingField(&'static str),
#[error("unexpected field {0:?} in load-stats payload")]
UnexpectedField(String),
#[error("state entry has too few fields (need >= 2, got {0})")]
StateTooFewFields(usize),
#[error("invalid timestamp in state entry: {0:?}")]
InvalidTimestamp(String),
#[error("invalid state name: {0}")]
InvalidStateName(#[from] crate::openvpn_state::ParseOpenVpnStateError),
#[error("state history is empty")]
EmptyStateHistory,
}
pub fn parse_pid(payload: &str) -> Result<u32, ParseResponseError> {
let val = payload
.strip_prefix("pid=")
.ok_or_else(|| ParseResponseError::MissingPidPrefix(payload.to_string()))?;
val.parse().map_err(|_| ParseResponseError::InvalidInteger {
field: "pid",
value: val.to_string(),
})
}
pub fn parse_load_stats(payload: &str) -> Result<LoadStats, ParseResponseError> {
let mut nclients = None;
let mut bytesin = None;
let mut bytesout = None;
for part in payload.split(',') {
if let Some((key, val)) = part.split_once('=') {
let parsed = |field| {
val.parse().map_err(|_| ParseResponseError::InvalidInteger {
field,
value: val.to_string(),
})
};
match key {
"nclients" => nclients = Some(parsed("nclients")?),
"bytesin" => bytesin = Some(parsed("bytesin")?),
"bytesout" => bytesout = Some(parsed("bytesout")?),
other => return Err(ParseResponseError::UnexpectedField(other.to_string())),
}
}
}
Ok(LoadStats {
nclients: nclients.ok_or(ParseResponseError::MissingField("nclients"))?,
bytesin: bytesin.ok_or(ParseResponseError::MissingField("bytesin"))?,
bytesout: bytesout.ok_or(ParseResponseError::MissingField("bytesout"))?,
})
}
pub fn parse_hold(payload: &str) -> Result<bool, ParseResponseError> {
let val = payload
.strip_prefix("hold=")
.ok_or_else(|| ParseResponseError::MissingHoldPrefix(payload.to_string()))?;
match val {
"1" => Ok(true),
"0" => Ok(false),
_ => Err(ParseResponseError::InvalidHoldValue(val.to_string())),
}
}
pub fn parse_version(lines: &[String]) -> VersionInfo {
VersionInfo::parse(lines)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateEntry {
pub timestamp: u64,
pub name: OpenVpnState,
pub description: String,
pub local_ip: String,
pub remote_ip: String,
pub remote_port: Option<u16>,
pub local_addr: String,
pub local_port: Option<u16>,
pub local_ipv6: String,
}
pub fn parse_state_entry(line: &str) -> Result<StateEntry, ParseResponseError> {
let fields: Vec<&str> = line.splitn(9, ',').collect();
if fields.len() < 2 {
return Err(ParseResponseError::StateTooFewFields(fields.len()));
}
let timestamp = fields[0]
.parse::<u64>()
.map_err(|_| ParseResponseError::InvalidTimestamp(fields[0].to_string()))?;
let name = fields[1].parse::<OpenVpnState>()?;
let get = |idx: usize| fields.get(idx).copied().unwrap_or("").to_string();
let get_port = |idx: usize| {
fields.get(idx).and_then(|field| {
if field.is_empty() {
None
} else {
field
.parse::<u16>()
.inspect_err(
|error| tracing::warn!(%error, field, "non-numeric port in state entry"),
)
.ok()
}
})
};
Ok(StateEntry {
timestamp,
name,
description: get(2),
local_ip: get(3),
remote_ip: get(4),
remote_port: get_port(5),
local_addr: get(6),
local_port: get_port(7),
local_ipv6: get(8),
})
}
pub fn parse_state_history(lines: &[String]) -> Result<Vec<StateEntry>, ParseResponseError> {
lines.iter().map(|line| parse_state_entry(line)).collect()
}
pub fn parse_current_state(lines: &[String]) -> Result<StateEntry, ParseResponseError> {
parse_state_history(lines)?
.into_iter()
.last()
.ok_or(ParseResponseError::EmptyStateHistory)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pid_normal() {
assert_eq!(parse_pid("pid=42"), Ok(42));
}
#[test]
fn pid_zero() {
assert_eq!(parse_pid("pid=0"), Ok(0));
}
#[test]
fn pid_missing_prefix() {
assert!(parse_pid("42").is_err());
}
#[test]
fn pid_not_a_number() {
assert!(parse_pid("pid=abc").is_err());
}
#[test]
fn load_stats_normal() {
let stats = parse_load_stats("nclients=10,bytesin=123456,bytesout=789012").unwrap();
assert_eq!(stats.nclients, 10);
assert_eq!(stats.bytesin, 123456);
assert_eq!(stats.bytesout, 789012);
}
#[test]
fn load_stats_reordered() {
let stats = parse_load_stats("bytesout=1,nclients=2,bytesin=3").unwrap();
assert_eq!(stats.nclients, 2);
assert_eq!(stats.bytesin, 3);
assert_eq!(stats.bytesout, 1);
}
#[test]
fn load_stats_missing_field() {
let err = parse_load_stats("nclients=1,bytesin=2").unwrap_err();
assert!(matches!(err, ParseResponseError::MissingField("bytesout")));
}
#[test]
fn load_stats_non_numeric_value() {
let err = parse_load_stats("nclients=abc,bytesin=2,bytesout=3").unwrap_err();
assert!(matches!(
err,
ParseResponseError::InvalidInteger {
field: "nclients",
..
}
));
}
#[test]
fn load_stats_unexpected_field() {
let err = parse_load_stats("nclients=1,bytesin=2,bytesout=3,extra=99").unwrap_err();
assert!(matches!(err, ParseResponseError::UnexpectedField(f) if f == "extra"));
}
#[test]
fn hold_active() {
assert_eq!(parse_hold("hold=1"), Ok(true));
}
#[test]
fn hold_inactive() {
assert_eq!(parse_hold("hold=0"), Ok(false));
}
#[test]
fn hold_missing_prefix() {
assert!(parse_hold("garbage").is_err());
}
#[test]
fn hold_invalid_value() {
assert!(parse_hold("hold=maybe").is_err());
}
#[test]
fn state_entry_full() {
let entry =
parse_state_entry("1711234567,CONNECTED,SUCCESS,10.8.0.6,198.51.100.1,1194,0.0.0.0,0")
.unwrap();
assert_eq!(entry.timestamp, 1711234567);
assert_eq!(entry.name.to_string(), "CONNECTED");
assert_eq!(entry.description, "SUCCESS");
assert_eq!(entry.local_ip, "10.8.0.6");
assert_eq!(entry.remote_ip, "198.51.100.1");
assert_eq!(entry.remote_port, Some(1194));
assert_eq!(entry.local_addr, "0.0.0.0");
assert_eq!(entry.local_port, Some(0));
}
#[test]
fn state_entry_minimal() {
let entry = parse_state_entry("0,CONNECTING").unwrap();
assert_eq!(entry.timestamp, 0);
assert!(entry.description.is_empty());
assert!(entry.remote_port.is_none());
}
#[test]
fn state_entry_optional_ports_empty() {
let entry = parse_state_entry("100,WAIT,desc,10.0.0.1,1.2.3.4,,eth0,").unwrap();
assert_eq!(entry.remote_ip, "1.2.3.4");
assert_eq!(entry.remote_port, None);
assert_eq!(entry.local_addr, "eth0");
assert_eq!(entry.local_port, None);
}
#[test]
fn state_entry_too_few_fields() {
assert!(matches!(
parse_state_entry("just_one"),
Err(ParseResponseError::StateTooFewFields(1))
));
}
#[test]
fn state_entry_bad_timestamp() {
assert!(matches!(
parse_state_entry("notanumber,CONNECTED"),
Err(ParseResponseError::InvalidTimestamp(_))
));
}
#[test]
fn state_entry_bad_state_name() {
assert!(parse_state_entry("0,BOGUS_STATE").is_err());
}
#[test]
fn state_history_roundtrip() {
let lines = vec![
"100,CONNECTING,,,,,,,".to_string(),
"200,CONNECTED,SUCCESS,10.8.0.6,,,".to_string(),
];
let entries = parse_state_history(&lines).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name.to_string(), "CONNECTING");
assert_eq!(entries[1].name.to_string(), "CONNECTED");
}
#[test]
fn current_state_returns_last() {
let lines = vec![
"100,CONNECTING,,,,,,,".to_string(),
"200,CONNECTED,SUCCESS,,,,,".to_string(),
];
let current = parse_current_state(&lines).unwrap();
assert_eq!(current.timestamp, 200);
}
#[test]
fn current_state_empty_history() {
let empty: Vec<String> = vec![];
assert!(matches!(
parse_current_state(&empty),
Err(ParseResponseError::EmptyStateHistory)
));
}
#[test]
fn version_roundtrip() {
let lines = vec![
"OpenVPN Version: OpenVPN 2.5.0".to_string(),
"Management Interface Version: 4".to_string(),
];
let info = parse_version(&lines);
assert_eq!(info.management_version(), Some(4));
}
}