Skip to main content

corevpn_cli/
ovpn.rs

1//! OpenVPN configuration file (.ovpn) parser
2//!
3//! Parses .ovpn files including inline certificate blocks for use
4//! with the CoreVPN client.
5
6use std::net::SocketAddr;
7use std::path::Path;
8
9use anyhow::{Context, Result, bail};
10
11/// Parsed OpenVPN client configuration
12#[derive(Debug)]
13pub struct OvpnConfig {
14    /// Remote server address and port
15    pub remote: SocketAddr,
16    /// Protocol (udp or tcp)
17    pub protocol: String,
18    /// Cipher name (e.g., CHACHA20-POLY1305)
19    pub cipher: String,
20    /// Data ciphers for NCP negotiation
21    pub data_ciphers: Vec<String>,
22    /// Auth digest (e.g., SHA256)
23    pub auth: String,
24    /// Verbosity level
25    pub verb: u8,
26    /// CA certificate (PEM)
27    pub ca_pem: String,
28    /// Client certificate (PEM)
29    pub cert_pem: String,
30    /// Client private key (PEM)
31    pub key_pem: String,
32    /// TLS-auth static key (hex-encoded lines)
33    pub tls_auth_key: Option<Vec<u8>>,
34    /// Key direction for tls-auth (0 or 1)
35    pub key_direction: Option<u8>,
36    /// Whether remote-cert-tls server is enabled
37    pub remote_cert_tls: bool,
38    /// Device type (tun or tap)
39    pub dev: String,
40}
41
42impl OvpnConfig {
43    /// Parse an .ovpn configuration file
44    pub fn parse_file(path: &Path) -> Result<Self> {
45        let content = std::fs::read_to_string(path)
46            .with_context(|| format!("Failed to read .ovpn file: {}", path.display()))?;
47        Self::parse(&content)
48    }
49
50    /// Parse .ovpn configuration from string content
51    pub fn parse(content: &str) -> Result<Self> {
52        let mut remote_host = String::new();
53        let mut remote_port: u16 = 1194;
54        let mut protocol = "udp".to_string();
55        let mut cipher = "AES-256-GCM".to_string();
56        let mut data_ciphers = Vec::new();
57        let mut auth = "SHA256".to_string();
58        let mut verb: u8 = 3;
59        let mut remote_cert_tls = false;
60        let mut key_direction: Option<u8> = None;
61        let mut dev = "tun".to_string();
62
63        // Extract inline blocks
64        let ca_pem = extract_inline_block(content, "ca")
65            .context("Missing <ca> block in .ovpn file")?;
66        let cert_pem = extract_inline_block(content, "cert")
67            .context("Missing <cert> block in .ovpn file")?;
68        let key_pem = extract_inline_block(content, "key")
69            .context("Missing <key> block in .ovpn file")?;
70        let tls_auth_raw = extract_inline_block(content, "tls-auth");
71
72        // Parse tls-auth key from hex
73        let tls_auth_key = if let Some(ref raw) = tls_auth_raw {
74            Some(parse_static_key(raw)?)
75        } else {
76            None
77        };
78
79        // Parse directives
80        for line in content.lines() {
81            let line = line.trim();
82
83            // Skip empty lines, comments, and inline block content
84            if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
85                continue;
86            }
87
88            let mut parts = line.split_whitespace();
89            match parts.next() {
90                Some("remote") => {
91                    if let Some(host) = parts.next() {
92                        remote_host = host.to_string();
93                    }
94                    if let Some(port) = parts.next() {
95                        remote_port = port.parse().unwrap_or(1194);
96                    }
97                }
98                Some("proto") => {
99                    if let Some(p) = parts.next() {
100                        protocol = p.to_string();
101                    }
102                }
103                Some("cipher") => {
104                    if let Some(c) = parts.next() {
105                        cipher = c.to_string();
106                    }
107                }
108                Some("data-ciphers") => {
109                    if let Some(ciphers) = parts.next() {
110                        data_ciphers = ciphers.split(':').map(|s| s.to_string()).collect();
111                    }
112                }
113                Some("auth") => {
114                    if let Some(a) = parts.next() {
115                        auth = a.to_string();
116                    }
117                }
118                Some("verb") => {
119                    if let Some(v) = parts.next() {
120                        verb = v.parse().unwrap_or(3);
121                    }
122                }
123                Some("remote-cert-tls") => {
124                    remote_cert_tls = true;
125                }
126                Some("key-direction") => {
127                    if let Some(d) = parts.next() {
128                        key_direction = Some(d.parse().unwrap_or(1));
129                    }
130                }
131                Some("dev") => {
132                    if let Some(d) = parts.next() {
133                        dev = d.to_string();
134                    }
135                }
136                _ => {} // Ignore unknown directives
137            }
138        }
139
140        if remote_host.is_empty() {
141            bail!("Missing 'remote' directive in .ovpn file");
142        }
143
144        // Resolve remote address
145        let remote: SocketAddr = format!("{}:{}", remote_host, remote_port)
146            .parse()
147            .with_context(|| format!("Invalid remote address: {}:{}", remote_host, remote_port))?;
148
149        Ok(Self {
150            remote,
151            protocol,
152            cipher,
153            data_ciphers,
154            auth,
155            verb,
156            ca_pem,
157            cert_pem,
158            key_pem,
159            tls_auth_key,
160            key_direction,
161            remote_cert_tls,
162            dev,
163        })
164    }
165}
166
167/// Extract an inline block like <ca>...</ca> from .ovpn content
168fn extract_inline_block(content: &str, tag: &str) -> Option<String> {
169    let start_tag = format!("<{}>", tag);
170    let end_tag = format!("</{}>", tag);
171
172    let start_idx = content.find(&start_tag)?;
173    let end_idx = content.find(&end_tag)?;
174
175    if start_idx >= end_idx {
176        return None;
177    }
178
179    let block_start = start_idx + start_tag.len();
180    let block = &content[block_start..end_idx];
181    Some(block.trim().to_string())
182}
183
184/// Parse OpenVPN static key V1 format (hex lines) into 256-byte key
185fn parse_static_key(pem_block: &str) -> Result<Vec<u8>> {
186    let mut hex_data = String::new();
187
188    for line in pem_block.lines() {
189        let line = line.trim();
190        // Skip comments and markers
191        if line.is_empty()
192            || line.starts_with('#')
193            || line.starts_with('-')
194            || line.contains("OpenVPN Static key")
195        {
196            continue;
197        }
198        hex_data.push_str(line);
199    }
200
201    // Decode hex to bytes
202    let bytes = hex_decode(&hex_data)
203        .context("Failed to decode tls-auth key hex data")?;
204
205    if bytes.len() != 256 {
206        bail!(
207            "tls-auth key has wrong length: expected 256 bytes, got {}",
208            bytes.len()
209        );
210    }
211
212    Ok(bytes)
213}
214
215/// Simple hex decoder
216fn hex_decode(hex: &str) -> Result<Vec<u8>> {
217    if hex.len() % 2 != 0 {
218        bail!("Hex string has odd length");
219    }
220
221    let mut bytes = Vec::with_capacity(hex.len() / 2);
222    for i in (0..hex.len()).step_by(2) {
223        let byte = u8::from_str_radix(&hex[i..i + 2], 16)
224            .with_context(|| format!("Invalid hex at position {}", i))?;
225        bytes.push(byte);
226    }
227    Ok(bytes)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_extract_inline_block() {
236        let content = "<ca>\n-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n</ca>";
237        let block = extract_inline_block(content, "ca").unwrap();
238        assert!(block.contains("BEGIN CERTIFICATE"));
239    }
240
241    #[test]
242    fn test_hex_decode() {
243        let bytes = hex_decode("deadbeef").unwrap();
244        assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
245    }
246}