Skip to main content

zeroclaw_runtime/tunnel/
mod.rs

1mod cloudflare;
2mod custom;
3mod ngrok;
4mod none;
5mod openvpn;
6mod pinggy;
7mod tailscale;
8
9pub use cloudflare::CloudflareTunnel;
10pub use custom::CustomTunnel;
11pub use ngrok::NgrokTunnel;
12#[allow(unused_imports)]
13pub use none::NoneTunnel;
14pub use openvpn::OpenVpnTunnel;
15pub use pinggy::PinggyTunnel;
16pub use tailscale::TailscaleTunnel;
17
18use anyhow::{Result, bail};
19use std::sync::Arc;
20use tokio::sync::Mutex;
21use zeroclaw_config::schema::{TailscaleTunnelConfig, TunnelConfig};
22
23// ── Tunnel trait ─────────────────────────────────────────────────
24
25/// Agnostic tunnel abstraction — bring your own tunnel model_provider.
26///
27/// Implementations wrap an external tunnel binary (cloudflared, tailscale,
28/// ngrok, etc.) or a custom command. The gateway calls `start()` after
29/// binding its local port and `stop()` on shutdown.
30#[async_trait::async_trait]
31pub trait Tunnel: Send + Sync {
32    /// Human-readable model_provider name (e.g. "cloudflare", "tailscale")
33    fn name(&self) -> &str;
34
35    /// Start the tunnel, exposing `local_host:local_port` externally.
36    /// Returns the public URL on success.
37    async fn start(&self, local_host: &str, local_port: u16) -> Result<String>;
38
39    /// Stop the tunnel process gracefully.
40    async fn stop(&self) -> Result<()>;
41
42    /// Check if the tunnel is still alive.
43    async fn health_check(&self) -> bool;
44
45    /// Return the public URL if the tunnel is running.
46    fn public_url(&self) -> Option<String>;
47}
48
49// ── Shared child-process handle ──────────────────────────────────
50
51/// Wraps a spawned tunnel child process so implementations can share it.
52pub struct TunnelProcess {
53    pub child: tokio::process::Child,
54    pub public_url: String,
55}
56
57pub type SharedProcess = Arc<Mutex<Option<TunnelProcess>>>;
58
59pub fn new_shared_process() -> SharedProcess {
60    Arc::new(Mutex::new(None))
61}
62
63/// Kill a shared tunnel process if running.
64pub async fn kill_shared(proc: &SharedProcess) -> Result<()> {
65    let mut guard = proc.lock().await;
66    if let Some(ref mut tp) = *guard {
67        tp.child.kill().await.ok();
68        tp.child.wait().await.ok();
69    }
70    *guard = None;
71    Ok(())
72}
73
74// ── Factory ──────────────────────────────────────────────────────
75
76/// Create a tunnel from config. Returns `None` for tunnel_provider "none".
77pub fn create_tunnel(config: &TunnelConfig) -> Result<Option<Box<dyn Tunnel>>> {
78    match config.tunnel_provider.as_str() {
79        "none" | "" => Ok(None),
80
81        "cloudflare" => {
82            let cf = config.cloudflare.as_ref().ok_or_else(|| {
83                {
84                ::zeroclaw_log::record!(
85                    ERROR,
86                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
87                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
88                        .with_attrs(::serde_json::json!({"tunnel_provider": "cloudflare"})),
89                    "tunnel create refused: provider selected but config block missing"
90                );
91                anyhow::Error::msg(
92                    "tunnel.tunnel_provider = \"cloudflare\" but [tunnel.cloudflare] section is missing"
93                )
94            }
95            })?;
96            Ok(Some(Box::new(CloudflareTunnel::new(cf.token.clone()))))
97        }
98
99        "tailscale" => {
100            let ts = config.tailscale.as_ref().unwrap_or(&TailscaleTunnelConfig {
101                funnel: false,
102                hostname: None,
103            });
104            Ok(Some(Box::new(TailscaleTunnel::new(
105                ts.funnel,
106                ts.hostname.clone(),
107            ))))
108        }
109
110        "ngrok" => {
111            let ng = config.ngrok.as_ref().ok_or_else(|| {
112                ::zeroclaw_log::record!(
113                    ERROR,
114                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
115                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
116                        .with_attrs(::serde_json::json!({"tunnel_provider": "ngrok"})),
117                    "tunnel create refused: provider selected but config block missing"
118                );
119                anyhow::Error::msg(
120                    "tunnel.tunnel_provider = \"ngrok\" but [tunnel.ngrok] section is missing",
121                )
122            })?;
123            Ok(Some(Box::new(NgrokTunnel::new(
124                ng.auth_token.clone(),
125                ng.domain.clone(),
126            ))))
127        }
128
129        "openvpn" => {
130            let ov = config.openvpn.as_ref().ok_or_else(|| {
131                ::zeroclaw_log::record!(
132                    ERROR,
133                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
134                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
135                        .with_attrs(::serde_json::json!({"tunnel_provider": "openvpn"})),
136                    "tunnel create refused: provider selected but config block missing"
137                );
138                anyhow::Error::msg(
139                    "tunnel.tunnel_provider = \"openvpn\" but [tunnel.openvpn] section is missing",
140                )
141            })?;
142            Ok(Some(Box::new(OpenVpnTunnel::new(
143                ov.config_file.clone(),
144                ov.auth_file.clone(),
145                ov.advertise_address.clone(),
146                ov.connect_timeout_secs,
147                ov.extra_args.clone(),
148            ))))
149        }
150
151        "custom" => {
152            let cu = config.custom.as_ref().ok_or_else(|| {
153                ::zeroclaw_log::record!(
154                    ERROR,
155                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
156                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
157                        .with_attrs(::serde_json::json!({"tunnel_provider": "custom"})),
158                    "tunnel create refused: provider selected but config block missing"
159                );
160                anyhow::Error::msg(
161                    "tunnel.tunnel_provider = \"custom\" but [tunnel.custom] section is missing",
162                )
163            })?;
164            Ok(Some(Box::new(CustomTunnel::new(
165                cu.start_command.clone(),
166                cu.health_url.clone(),
167                cu.url_pattern.clone(),
168            ))))
169        }
170
171        "pinggy" => {
172            let pg = config.pinggy.as_ref().ok_or_else(|| {
173                ::zeroclaw_log::record!(
174                    ERROR,
175                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
176                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
177                        .with_attrs(::serde_json::json!({"tunnel_provider": "pinggy"})),
178                    "tunnel create refused: provider selected but config block missing"
179                );
180                anyhow::Error::msg(
181                    "tunnel.tunnel_provider = \"pinggy\" but [tunnel.pinggy] section is missing",
182                )
183            })?;
184            Ok(Some(Box::new(PinggyTunnel::new(
185                pg.token.clone(),
186                pg.region.clone(),
187            ))))
188        }
189
190        other => bail!(
191            "Unknown tunnel_provider: \"{other}\". Valid: none, cloudflare, tailscale, ngrok, openvpn, pinggy, custom"
192        ),
193    }
194}
195
196// ── Tests ────────────────────────────────────────────────────────
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use tokio::process::Command;
202    use zeroclaw_config::schema::{
203        CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, OpenVpnTunnelConfig,
204        PinggyTunnelConfig, TunnelConfig,
205    };
206
207    /// Helper: assert `create_tunnel` returns an error containing `needle`.
208    fn assert_tunnel_err(cfg: &TunnelConfig, needle: &str) {
209        match create_tunnel(cfg) {
210            Err(e) => assert!(
211                e.to_string().contains(needle),
212                "Expected error containing \"{needle}\", got: {e}"
213            ),
214            Ok(_) => panic!("Expected error containing \"{needle}\", but got Ok"),
215        }
216    }
217
218    #[test]
219    fn factory_none_returns_none() {
220        let cfg = TunnelConfig::default();
221        let t = create_tunnel(&cfg).unwrap();
222        assert!(t.is_none());
223    }
224
225    #[test]
226    fn factory_empty_string_returns_none() {
227        let cfg = TunnelConfig {
228            tunnel_provider: String::new(),
229            ..TunnelConfig::default()
230        };
231        let t = create_tunnel(&cfg).unwrap();
232        assert!(t.is_none());
233    }
234
235    #[test]
236    fn factory_unknown_provider_errors() {
237        let cfg = TunnelConfig {
238            tunnel_provider: "wireguard".into(),
239            ..TunnelConfig::default()
240        };
241        assert_tunnel_err(&cfg, "Unknown tunnel_provider");
242    }
243
244    #[test]
245    fn factory_cloudflare_missing_config_errors() {
246        let cfg = TunnelConfig {
247            tunnel_provider: "cloudflare".into(),
248            ..TunnelConfig::default()
249        };
250        assert_tunnel_err(&cfg, "[tunnel.cloudflare]");
251    }
252
253    #[test]
254    fn factory_cloudflare_with_config_ok() {
255        let cfg = TunnelConfig {
256            tunnel_provider: "cloudflare".into(),
257            cloudflare: Some(CloudflareTunnelConfig {
258                token: "test-token".into(),
259            }),
260            ..TunnelConfig::default()
261        };
262        let t = create_tunnel(&cfg).unwrap();
263        assert!(t.is_some());
264        assert_eq!(t.unwrap().name(), "cloudflare");
265    }
266
267    #[test]
268    fn factory_tailscale_defaults_ok() {
269        let cfg = TunnelConfig {
270            tunnel_provider: "tailscale".into(),
271            ..TunnelConfig::default()
272        };
273        let t = create_tunnel(&cfg).unwrap();
274        assert!(t.is_some());
275        assert_eq!(t.unwrap().name(), "tailscale");
276    }
277
278    #[test]
279    fn factory_ngrok_missing_config_errors() {
280        let cfg = TunnelConfig {
281            tunnel_provider: "ngrok".into(),
282            ..TunnelConfig::default()
283        };
284        assert_tunnel_err(&cfg, "[tunnel.ngrok]");
285    }
286
287    #[test]
288    fn factory_ngrok_with_config_ok() {
289        let cfg = TunnelConfig {
290            tunnel_provider: "ngrok".into(),
291            ngrok: Some(NgrokTunnelConfig {
292                auth_token: "tok".into(),
293                domain: None,
294            }),
295            ..TunnelConfig::default()
296        };
297        let t = create_tunnel(&cfg).unwrap();
298        assert!(t.is_some());
299        assert_eq!(t.unwrap().name(), "ngrok");
300    }
301
302    #[test]
303    fn factory_custom_missing_config_errors() {
304        let cfg = TunnelConfig {
305            tunnel_provider: "custom".into(),
306            ..TunnelConfig::default()
307        };
308        assert_tunnel_err(&cfg, "[tunnel.custom]");
309    }
310
311    #[test]
312    fn factory_custom_with_config_ok() {
313        let cfg = TunnelConfig {
314            tunnel_provider: "custom".into(),
315            custom: Some(CustomTunnelConfig {
316                start_command: "echo tunnel".into(),
317                health_url: None,
318                url_pattern: None,
319            }),
320            ..TunnelConfig::default()
321        };
322        let t = create_tunnel(&cfg).unwrap();
323        assert!(t.is_some());
324        assert_eq!(t.unwrap().name(), "custom");
325    }
326
327    #[test]
328    fn factory_pinggy_missing_config_errors() {
329        let cfg = TunnelConfig {
330            tunnel_provider: "pinggy".into(),
331            ..TunnelConfig::default()
332        };
333        assert_tunnel_err(&cfg, "[tunnel.pinggy]");
334    }
335
336    #[test]
337    fn factory_pinggy_with_config_ok() {
338        let cfg = TunnelConfig {
339            tunnel_provider: "pinggy".into(),
340            pinggy: Some(PinggyTunnelConfig {
341                token: Some("tok".into()),
342                region: None,
343            }),
344            ..TunnelConfig::default()
345        };
346        let t = create_tunnel(&cfg).unwrap();
347        assert!(t.is_some());
348        assert_eq!(t.unwrap().name(), "pinggy");
349    }
350
351    #[test]
352    fn none_tunnel_name() {
353        let t = NoneTunnel;
354        assert_eq!(t.name(), "none");
355    }
356
357    #[test]
358    fn none_tunnel_public_url_is_none() {
359        let t = NoneTunnel;
360        assert!(t.public_url().is_none());
361    }
362
363    #[tokio::test]
364    async fn none_tunnel_health_always_true() {
365        let t = NoneTunnel;
366        assert!(t.health_check().await);
367    }
368
369    #[tokio::test]
370    async fn none_tunnel_start_returns_local() {
371        let t = NoneTunnel;
372        let url = t.start("127.0.0.1", 8080).await.unwrap();
373        assert_eq!(url, "http://127.0.0.1:8080");
374    }
375
376    #[test]
377    fn cloudflare_tunnel_name() {
378        let t = CloudflareTunnel::new("tok".into());
379        assert_eq!(t.name(), "cloudflare");
380        assert!(t.public_url().is_none());
381    }
382
383    #[test]
384    fn tailscale_tunnel_name() {
385        let t = TailscaleTunnel::new(false, None);
386        assert_eq!(t.name(), "tailscale");
387        assert!(t.public_url().is_none());
388    }
389
390    #[test]
391    fn tailscale_funnel_mode() {
392        let t = TailscaleTunnel::new(true, Some("myhost".into()));
393        assert_eq!(t.name(), "tailscale");
394    }
395
396    #[test]
397    fn ngrok_tunnel_name() {
398        let t = NgrokTunnel::new("tok".into(), None);
399        assert_eq!(t.name(), "ngrok");
400        assert!(t.public_url().is_none());
401    }
402
403    #[test]
404    fn ngrok_with_domain() {
405        let t = NgrokTunnel::new("tok".into(), Some("my.ngrok.io".into()));
406        assert_eq!(t.name(), "ngrok");
407    }
408
409    #[test]
410    fn custom_tunnel_name() {
411        let t = CustomTunnel::new("echo hi".into(), None, None);
412        assert_eq!(t.name(), "custom");
413        assert!(t.public_url().is_none());
414    }
415
416    #[test]
417    fn factory_openvpn_missing_config_errors() {
418        let cfg = TunnelConfig {
419            tunnel_provider: "openvpn".into(),
420            ..TunnelConfig::default()
421        };
422        assert_tunnel_err(&cfg, "[tunnel.openvpn]");
423    }
424
425    #[test]
426    fn factory_openvpn_with_config_ok() {
427        let cfg = TunnelConfig {
428            tunnel_provider: "openvpn".into(),
429            openvpn: Some(OpenVpnTunnelConfig {
430                config_file: "client.ovpn".into(),
431                auth_file: None,
432                advertise_address: None,
433                connect_timeout_secs: 30,
434                extra_args: vec![],
435            }),
436            ..TunnelConfig::default()
437        };
438        let t = create_tunnel(&cfg).unwrap();
439        assert!(t.is_some());
440        assert_eq!(t.unwrap().name(), "openvpn");
441    }
442
443    #[test]
444    fn openvpn_tunnel_name() {
445        let t = OpenVpnTunnel::new("client.ovpn".into(), None, None, 30, vec![]);
446        assert_eq!(t.name(), "openvpn");
447        assert!(t.public_url().is_none());
448    }
449
450    #[tokio::test]
451    async fn openvpn_health_false_before_start() {
452        let tunnel = OpenVpnTunnel::new("client.ovpn".into(), None, None, 30, vec![]);
453        assert!(!tunnel.health_check().await);
454    }
455
456    #[tokio::test]
457    async fn kill_shared_no_process_is_ok() {
458        let proc = new_shared_process();
459        let result = kill_shared(&proc).await;
460
461        assert!(result.is_ok());
462        assert!(proc.lock().await.is_none());
463    }
464
465    #[tokio::test]
466    async fn kill_shared_terminates_and_clears_child() {
467        let proc = new_shared_process();
468
469        let child = Command::new("sleep")
470            .arg("30")
471            .stdout(std::process::Stdio::null())
472            .stderr(std::process::Stdio::null())
473            .spawn()
474            .expect("sleep should spawn for lifecycle test");
475
476        {
477            let mut guard = proc.lock().await;
478            *guard = Some(TunnelProcess {
479                child,
480                public_url: "https://example.test".into(),
481            });
482        }
483
484        kill_shared(&proc).await.unwrap();
485
486        let guard = proc.lock().await;
487        assert!(guard.is_none());
488    }
489
490    #[tokio::test]
491    async fn cloudflare_health_false_before_start() {
492        let tunnel = CloudflareTunnel::new("tok".into());
493        assert!(!tunnel.health_check().await);
494    }
495
496    #[tokio::test]
497    async fn ngrok_health_false_before_start() {
498        let tunnel = NgrokTunnel::new("tok".into(), None);
499        assert!(!tunnel.health_check().await);
500    }
501
502    #[tokio::test]
503    async fn tailscale_health_false_before_start() {
504        let tunnel = TailscaleTunnel::new(false, None);
505        assert!(!tunnel.health_check().await);
506    }
507
508    #[tokio::test]
509    async fn custom_health_false_before_start_without_health_url() {
510        let tunnel = CustomTunnel::new("echo hi".into(), None, Some("https://".into()));
511        assert!(!tunnel.health_check().await);
512    }
513
514    #[test]
515    fn pinggy_tunnel_name() {
516        let t = PinggyTunnel::new(Some("tok".into()), None);
517        assert_eq!(t.name(), "pinggy");
518        assert!(t.public_url().is_none());
519    }
520
521    #[test]
522    fn pinggy_without_token() {
523        let t = PinggyTunnel::new(None, None);
524        assert_eq!(t.name(), "pinggy");
525    }
526
527    #[tokio::test]
528    async fn pinggy_health_false_before_start() {
529        let tunnel = PinggyTunnel::new(None, None);
530        assert!(!tunnel.health_check().await);
531    }
532}