1use super::{SharedProcess, Tunnel, TunnelProcess, kill_shared, new_shared_process};
2use anyhow::{Result, bail};
3use tokio::io::AsyncBufReadExt;
4use tokio::process::Command;
5
6pub struct OpenVpnTunnel {
14 config_file: String,
15 auth_file: Option<String>,
16 advertise_address: Option<String>,
17 connect_timeout_secs: u64,
18 extra_args: Vec<String>,
19 proc: SharedProcess,
20}
21
22impl OpenVpnTunnel {
23 pub fn new(
31 config_file: String,
32 auth_file: Option<String>,
33 advertise_address: Option<String>,
34 connect_timeout_secs: u64,
35 extra_args: Vec<String>,
36 ) -> Self {
37 Self {
38 config_file,
39 auth_file,
40 advertise_address,
41 connect_timeout_secs,
42 extra_args,
43 proc: new_shared_process(),
44 }
45 }
46
47 fn build_args(&self) -> Vec<String> {
49 let mut args = vec!["--config".to_string(), self.config_file.clone()];
50
51 if let Some(ref auth) = self.auth_file {
52 args.push("--auth-user-pass".to_string());
53 args.push(auth.clone());
54 }
55
56 args.extend(self.extra_args.iter().cloned());
57 args
58 }
59}
60
61#[async_trait::async_trait]
62impl Tunnel for OpenVpnTunnel {
63 fn name(&self) -> &str {
64 "openvpn"
65 }
66
67 async fn start(&self, local_host: &str, local_port: u16) -> Result<String> {
70 if !std::path::Path::new(&self.config_file).exists() {
72 bail!("OpenVPN config file not found: {}", self.config_file);
73 }
74
75 let args = self.build_args();
76
77 let mut child = Command::new("openvpn")
78 .args(&args)
79 .stdout(std::process::Stdio::null())
80 .stderr(std::process::Stdio::piped())
81 .kill_on_drop(true)
82 .spawn()?;
83
84 let stderr = child.stderr.take().ok_or_else(|| {
86 ::zeroclaw_log::record!(
87 ERROR,
88 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
89 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
90 .with_attrs(
91 ::serde_json::json!({"tunnel_provider": "openvpn", "stream": "stderr"})
92 ),
93 "tunnel process: failed to capture child stream"
94 );
95 anyhow::Error::msg("Failed to capture openvpn stderr")
96 })?;
97
98 let mut reader = tokio::io::BufReader::new(stderr).lines();
99 let deadline = tokio::time::Instant::now()
100 + tokio::time::Duration::from_secs(self.connect_timeout_secs);
101
102 let mut connected = false;
103 while tokio::time::Instant::now() < deadline {
104 let line =
105 tokio::time::timeout(tokio::time::Duration::from_secs(3), reader.next_line()).await;
106
107 match line {
108 Ok(Ok(Some(l))) => {
109 ::zeroclaw_log::record!(
110 DEBUG,
111 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
112 .with_attrs(::serde_json::json!({"l": l})),
113 "openvpn: "
114 );
115 if l.contains("Initialization Sequence Completed") {
116 connected = true;
117 break;
118 }
119 }
120 Ok(Ok(None)) => {
121 bail!("OpenVPN process exited before connection was established");
122 }
123 Ok(Err(e)) => {
124 bail!("Error reading openvpn output: {e}");
125 }
126 Err(_) => {
127 }
129 }
130 }
131
132 if !connected {
133 child.kill().await.ok();
134 bail!(
135 "OpenVPN connection timed out after {}s waiting for initialization",
136 self.connect_timeout_secs
137 );
138 }
139
140 let public_url = self
141 .advertise_address
142 .clone()
143 .unwrap_or_else(|| format!("http://{local_host}:{local_port}"));
144
145 tokio::spawn(async move {
148 while let Ok(Some(line)) = reader.next_line().await {
149 ::zeroclaw_log::record!(
150 TRACE,
151 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
152 .with_attrs(::serde_json::json!({"line": line})),
153 "openvpn: "
154 );
155 }
156 });
157
158 let mut guard = self.proc.lock().await;
159 *guard = Some(TunnelProcess {
160 child,
161 public_url: public_url.clone(),
162 });
163
164 Ok(public_url)
165 }
166
167 async fn stop(&self) -> Result<()> {
169 kill_shared(&self.proc).await
170 }
171
172 async fn health_check(&self) -> bool {
174 let guard = self.proc.lock().await;
175 guard.as_ref().is_some_and(|tp| tp.child.id().is_some())
176 }
177
178 fn public_url(&self) -> Option<String> {
180 self.proc
181 .try_lock()
182 .ok()
183 .and_then(|g| g.as_ref().map(|tp| tp.public_url.clone()))
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn constructor_stores_fields() {
193 let tunnel = OpenVpnTunnel::new(
194 "/etc/openvpn/client.ovpn".into(),
195 Some("/etc/openvpn/auth.txt".into()),
196 Some("10.8.0.2:42617".into()),
197 45,
198 vec!["--verb".into(), "3".into()],
199 );
200 assert_eq!(tunnel.config_file, "/etc/openvpn/client.ovpn");
201 assert_eq!(tunnel.auth_file.as_deref(), Some("/etc/openvpn/auth.txt"));
202 assert_eq!(tunnel.advertise_address.as_deref(), Some("10.8.0.2:42617"));
203 assert_eq!(tunnel.connect_timeout_secs, 45);
204 assert_eq!(tunnel.extra_args, vec!["--verb", "3"]);
205 }
206
207 #[test]
208 fn build_args_basic() {
209 let tunnel = OpenVpnTunnel::new("client.ovpn".into(), None, None, 30, vec![]);
210 let args = tunnel.build_args();
211 assert_eq!(args, vec!["--config", "client.ovpn"]);
212 }
213
214 #[test]
215 fn build_args_with_auth_and_extras() {
216 let tunnel = OpenVpnTunnel::new(
217 "client.ovpn".into(),
218 Some("auth.txt".into()),
219 None,
220 30,
221 vec!["--verb".into(), "5".into()],
222 );
223 let args = tunnel.build_args();
224 assert_eq!(
225 args,
226 vec![
227 "--config",
228 "client.ovpn",
229 "--auth-user-pass",
230 "auth.txt",
231 "--verb",
232 "5"
233 ]
234 );
235 }
236
237 #[test]
238 fn public_url_is_none_before_start() {
239 let tunnel = OpenVpnTunnel::new("client.ovpn".into(), None, None, 30, vec![]);
240 assert!(tunnel.public_url().is_none());
241 }
242
243 #[tokio::test]
244 async fn health_check_is_false_before_start() {
245 let tunnel = OpenVpnTunnel::new("client.ovpn".into(), None, None, 30, vec![]);
246 assert!(!tunnel.health_check().await);
247 }
248
249 #[tokio::test]
250 async fn stop_without_started_process_is_ok() {
251 let tunnel = OpenVpnTunnel::new("client.ovpn".into(), None, None, 30, vec![]);
252 let result = tunnel.stop().await;
253 assert!(result.is_ok());
254 }
255
256 #[tokio::test]
257 async fn start_with_missing_config_file_errors() {
258 let tunnel = OpenVpnTunnel::new(
259 "/nonexistent/path/to/client.ovpn".into(),
260 None,
261 None,
262 30,
263 vec![],
264 );
265 let result = tunnel.start("127.0.0.1", 8080).await;
266 assert!(result.is_err());
267 assert!(
268 result
269 .unwrap_err()
270 .to_string()
271 .contains("config file not found")
272 );
273 }
274}