Skip to main content

zeroclaw_runtime/tunnel/
openvpn.rs

1use super::{SharedProcess, Tunnel, TunnelProcess, kill_shared, new_shared_process};
2use anyhow::{Result, bail};
3use tokio::io::AsyncBufReadExt;
4use tokio::process::Command;
5
6/// OpenVPN Tunnel — uses the `openvpn` CLI to establish a VPN connection.
7///
8/// Requires the `openvpn` binary installed and accessible. On most systems,
9/// OpenVPN requires root/administrator privileges to create tun/tap devices.
10///
11/// The tunnel exposes the gateway via the VPN network using a configured
12/// `advertise_address` (e.g., `"10.8.0.2:42617"`).
13pub 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    /// Create a new OpenVPN tunnel instance.
24    ///
25    /// * `config_file` — path to the `.ovpn` configuration file.
26    /// * `auth_file` — optional path to a credentials file for `--auth-user-pass`.
27    /// * `advertise_address` — optional public address to advertise once connected.
28    /// * `connect_timeout_secs` — seconds to wait for the initialization sequence.
29    /// * `extra_args` — additional CLI arguments forwarded to the `openvpn` binary.
30    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    /// Build the openvpn command arguments.
48    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    /// Spawn the `openvpn` process and wait for the "Initialization Sequence
68    /// Completed" marker on stderr. Returns the public URL on success.
69    async fn start(&self, local_host: &str, local_port: u16) -> Result<String> {
70        // Validate config file exists before spawning
71        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        // Wait for "Initialization Sequence Completed" in stderr
85        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                    // Timeout on individual line read, continue waiting
128                }
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        // Drain stderr in background to prevent OS pipe buffer from filling and
146        // blocking the openvpn process.
147        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    /// Kill the openvpn child process and release its resources.
168    async fn stop(&self) -> Result<()> {
169        kill_shared(&self.proc).await
170    }
171
172    /// Return `true` if the openvpn child process is still running.
173    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    /// Return the public URL if the tunnel has been started.
179    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}