pub mod cloudflare;
pub mod ngrok;
pub mod tailscale;
pub mod types;
pub use cloudflare::CloudflareTunnel;
pub use ngrok::NgrokTunnel;
pub use tailscale::TailscaleTunnel;
pub use types::TunnelProvider;
use crate::config::TunnelConfig;
use crate::error::{Result, ZeptoError};
use tracing::info;
pub fn create_tunnel(config: &TunnelConfig) -> Result<Box<dyn TunnelProvider>> {
let provider_name = config.provider.as_deref().unwrap_or("auto");
match provider_name {
"cloudflare" => {
info!("Using Cloudflare tunnel provider");
Ok(Box::new(CloudflareTunnel::new(config.cloudflare.clone())))
}
"ngrok" => {
info!("Using ngrok tunnel provider");
Ok(Box::new(NgrokTunnel::new(config.ngrok.clone())))
}
"tailscale" => {
info!("Using Tailscale tunnel provider");
Ok(Box::new(TailscaleTunnel::new(config.tailscale.clone())))
}
"auto" => {
info!("Auto-detecting tunnel provider");
auto_detect(config)
}
other => Err(ZeptoError::Config(format!(
"Unknown tunnel provider '{}'. Supported: cloudflare, ngrok, tailscale, auto",
other
))),
}
}
fn auto_detect(config: &TunnelConfig) -> Result<Box<dyn TunnelProvider>> {
if which("cloudflared") {
info!("Auto-detected cloudflared on PATH");
return Ok(Box::new(CloudflareTunnel::new(config.cloudflare.clone())));
}
if which("ngrok") {
info!("Auto-detected ngrok on PATH");
return Ok(Box::new(NgrokTunnel::new(config.ngrok.clone())));
}
if which("tailscale") {
info!("Auto-detected tailscale on PATH");
return Ok(Box::new(TailscaleTunnel::new(config.tailscale.clone())));
}
Err(ZeptoError::Config(
"No tunnel provider found on PATH. Install one of: cloudflared, ngrok, tailscale".into(),
))
}
fn which(binary: &str) -> bool {
std::process::Command::new(if cfg!(windows) { "where" } else { "which" })
.arg(binary)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_tunnel_cloudflare() {
let config = TunnelConfig {
provider: Some("cloudflare".into()),
..Default::default()
};
let tunnel = create_tunnel(&config).unwrap();
assert_eq!(tunnel.name(), "cloudflare");
}
#[test]
fn test_create_tunnel_ngrok() {
let config = TunnelConfig {
provider: Some("ngrok".into()),
..Default::default()
};
let tunnel = create_tunnel(&config).unwrap();
assert_eq!(tunnel.name(), "ngrok");
}
#[test]
fn test_create_tunnel_tailscale() {
let config = TunnelConfig {
provider: Some("tailscale".into()),
..Default::default()
};
let tunnel = create_tunnel(&config).unwrap();
assert_eq!(tunnel.name(), "tailscale");
}
#[test]
fn test_create_tunnel_unknown_provider() {
let config = TunnelConfig {
provider: Some("teleport".into()),
..Default::default()
};
let result = create_tunnel(&config);
match result {
Err(e) => {
let msg = e.to_string();
assert!(msg.contains("Unknown tunnel provider"), "got: {}", msg);
assert!(msg.contains("teleport"), "got: {}", msg);
}
Ok(_) => panic!("expected error for unknown provider"),
}
}
#[test]
fn test_create_tunnel_auto_fallback() {
let config = TunnelConfig {
provider: Some("auto".into()),
..Default::default()
};
let result = create_tunnel(&config);
match result {
Ok(tunnel) => {
assert!(["cloudflare", "ngrok", "tailscale"].contains(&tunnel.name()));
}
Err(e) => {
assert!(e.to_string().contains("No tunnel provider found"));
}
}
}
#[test]
fn test_create_tunnel_none_provider_uses_auto() {
let config = TunnelConfig::default();
assert!(config.provider.is_none());
let result = create_tunnel(&config);
match result {
Ok(tunnel) => {
assert!(["cloudflare", "ngrok", "tailscale"].contains(&tunnel.name()));
}
Err(e) => {
assert!(e.to_string().contains("No tunnel provider found"));
}
}
}
#[test]
fn test_which_nonexistent_binary() {
assert!(!which("zeptoclaw_nonexistent_binary_12345"));
}
#[test]
fn test_which_existing_binary() {
if cfg!(unix) {
assert!(which("ls"));
}
}
}