Skip to main content

openvpn_mgmt_codec/
version_info.rs

1use std::fmt;
2
3/// Parsed output from the `version` command's multi-line response.
4///
5/// The response from OpenVPN looks like:
6///
7/// ```text
8/// OpenVPN Version: OpenVPN 2.6.9 x86_64-pc-linux-gnu [SSL (OpenSSL)] ...
9/// Management Interface Version: 5
10/// END
11/// ```
12///
13/// Note: OpenVPN ≥ 2.6.16 shortened the header to `Management Version: 5`
14/// (without "Interface"). Both forms are accepted.
15///
16/// This struct extracts the management interface version (which is the
17/// field most consumers need for feature-gating) and keeps the raw lines
18/// for anything else.
19///
20/// # Examples
21///
22/// ```
23/// use openvpn_mgmt_codec::VersionInfo;
24///
25/// let lines = vec![
26///     "OpenVPN Version: OpenVPN 2.6.9 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [MH/PKTINFO] [AEAD]".to_string(),
27///     "Management Interface Version: 5".to_string(),
28/// ];
29/// let info = VersionInfo::parse(&lines);
30/// assert_eq!(info.management_version(), Some(5));
31/// assert!(info.openvpn_version_line().unwrap().contains("2.6.9"));
32/// ```
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct VersionInfo {
35    /// The management interface version number, if found.
36    management_version: Option<u32>,
37    /// The full OpenVPN version line, if found.
38    openvpn_version_line: Option<String>,
39    /// All raw lines from the response, for forward compatibility.
40    raw_lines: Vec<String>,
41}
42
43impl VersionInfo {
44    /// Parse a `version` command's multi-line response into structured data.
45    ///
46    /// Lines that don't match known prefixes are preserved in
47    /// [`raw_lines`](Self::raw_lines) for forward compatibility.
48    pub fn parse(lines: &[String]) -> Self {
49        let mut management_version = None;
50        let mut openvpn_version_line = None;
51
52        for line in lines {
53            let lower = line.to_ascii_lowercase();
54
55            // Match the management version line regardless of exact
56            // wording. Known variants:
57            //   "Management Interface Version: 5"  (OpenVPN ≤ 2.6.9)
58            //   "Management Version: 5"            (OpenVPN ≥ 2.6.16)
59            // Future-proof: accept any line starting with "management"
60            // that contains "version" followed by a number.
61            if management_version.is_none()
62                && lower.starts_with("management")
63                && lower.contains("version")
64            {
65                management_version = line
66                    .rsplit(|c: char| !c.is_ascii_digit())
67                    .find(|s| !s.is_empty())
68                    .and_then(|s| s.parse().ok());
69            } else if lower.starts_with("openvpn version") {
70                openvpn_version_line = Some(line.clone());
71            }
72        }
73
74        Self {
75            management_version,
76            openvpn_version_line,
77            raw_lines: lines.to_vec(),
78        }
79    }
80
81    /// The management interface protocol version (e.g. `5`).
82    ///
83    /// Returns `None` if the line was missing or unparseable. Use this to
84    /// gate features: for instance, `client-pending-auth` requires
85    /// management version >= 5 (OpenVPN 2.5+).
86    pub fn management_version(&self) -> Option<u32> {
87        self.management_version
88    }
89
90    /// The full `OpenVPN Version:` line (e.g. `"OpenVPN Version: OpenVPN 2.6.9 ..."`).
91    pub fn openvpn_version_line(&self) -> Option<&str> {
92        self.openvpn_version_line.as_deref()
93    }
94
95    /// All raw lines from the response, for anything not explicitly parsed.
96    pub fn raw_lines(&self) -> &[String] {
97        &self.raw_lines
98    }
99}
100
101impl fmt::Display for VersionInfo {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        if let Some(line) = &self.openvpn_version_line {
104            write!(f, "{line}")?;
105            if let Some(v) = self.management_version {
106                write!(f, " (management v{v})")?;
107            }
108        } else if let Some(v) = self.management_version {
109            write!(f, "Management Interface Version: {v}")?;
110        } else {
111            f.write_str("(unknown version)")?;
112        }
113        Ok(())
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn parse_typical_version_output() {
123        let lines = vec![
124            "OpenVPN Version: OpenVPN 2.6.9 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [MH/PKTINFO] [AEAD]".to_string(),
125            "Management Interface Version: 5".to_string(),
126        ];
127        let info = VersionInfo::parse(&lines);
128        assert_eq!(info.management_version(), Some(5));
129        assert!(info.openvpn_version_line().unwrap().contains("2.6.9"));
130        assert_eq!(info.raw_lines().len(), 2);
131    }
132
133    #[test]
134    fn parse_short_management_version_header() {
135        // OpenVPN ≥ 2.6.16 uses "Management Version:" without "Interface".
136        let lines = vec![
137            "OpenVPN Version: OpenVPN 2.6.16 x86_64-alpine-linux-musl [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [MH/PKTINFO] [AEAD]".to_string(),
138            "Management Version: 5".to_string(),
139        ];
140        let info = VersionInfo::parse(&lines);
141        assert_eq!(info.management_version(), Some(5));
142        assert!(info.openvpn_version_line().unwrap().contains("2.6.16"));
143    }
144
145    #[test]
146    fn parse_old_version_without_management_line() {
147        let lines = vec!["OpenVPN Version: OpenVPN 2.3.2 i686-pc-linux-gnu".to_string()];
148        let info = VersionInfo::parse(&lines);
149        assert_eq!(info.management_version(), None);
150        assert!(info.openvpn_version_line().is_some());
151    }
152
153    #[test]
154    fn parse_empty_response() {
155        let info = VersionInfo::parse(&[]);
156        assert_eq!(info.management_version(), None);
157        assert_eq!(info.openvpn_version_line(), None);
158        assert!(info.raw_lines().is_empty());
159    }
160
161    #[test]
162    fn parse_hypothetical_future_format() {
163        // Resilient to wording changes as long as "management" and
164        // "version" appear and a trailing number is present.
165        let lines = vec!["Management Protocol Version: 6".to_string()];
166        let info = VersionInfo::parse(&lines);
167        assert_eq!(info.management_version(), Some(6));
168    }
169
170    #[test]
171    fn display_typical() {
172        let lines = vec![
173            "OpenVPN Version: OpenVPN 2.6.9".to_string(),
174            "Management Interface Version: 5".to_string(),
175        ];
176        let info = VersionInfo::parse(&lines);
177        let s = info.to_string();
178        assert!(s.contains("2.6.9"));
179        assert!(s.contains("management v5"));
180    }
181
182    #[test]
183    fn display_unknown() {
184        let info = VersionInfo::parse(&[]);
185        assert_eq!(info.to_string(), "(unknown version)");
186    }
187}