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#[async_trait::async_trait]
31pub trait Tunnel: Send + Sync {
32 fn name(&self) -> &str;
34
35 async fn start(&self, local_host: &str, local_port: u16) -> Result<String>;
38
39 async fn stop(&self) -> Result<()>;
41
42 async fn health_check(&self) -> bool;
44
45 fn public_url(&self) -> Option<String>;
47}
48
49pub 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
63pub 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
74pub 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#[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 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}