From 390cbc0a6c79b64a452dedc4bd49477bd81b4831 Mon Sep 17 00:00:00 2001 From: argenis de la rosa Date: Fri, 13 Feb 2026 16:25:01 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20agnostic=20tunnel=20system=20=E2=80=94?= =?UTF-8?q?=20bring=20your=20own=20tunnel=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Tunnel trait + 5 implementations: - NoneTunnel: local-only, no external exposure (default) - CloudflareTunnel: wraps cloudflared binary, extracts public URL - TailscaleTunnel: tailscale serve (tailnet) or funnel (public) - NgrokTunnel: wraps ngrok binary, supports custom domains - CustomTunnel: user-provided command with {port}/{host} placeholders Config schema: - [tunnel] section with provider selector - Provider-specific sub-configs: cloudflare, tailscale, ngrok, custom - Backward compatible (serde default = "none") Gateway integration: - Tunnel starts automatically on 'zeroclaw gateway' - Prints public URL on success, falls back to local on failure 20 new tests (factory, constructors, NoneTunnel async start/health) 649 tests passing, 0 clippy warnings, cargo fmt clean --- README.md | 51 ++++++- src/config/mod.rs | 2 +- src/config/schema.rs | 72 +++++++++ src/gateway/mod.rs | 21 +++ src/main.rs | 1 + src/onboard/wizard.rs | 1 + src/tunnel/cloudflare.rs | 111 ++++++++++++++ src/tunnel/custom.rs | 145 ++++++++++++++++++ src/tunnel/mod.rs | 316 +++++++++++++++++++++++++++++++++++++++ src/tunnel/ngrok.rs | 121 +++++++++++++++ src/tunnel/none.rs | 28 ++++ src/tunnel/tailscale.rs | 102 +++++++++++++ 12 files changed, 967 insertions(+), 4 deletions(-) create mode 100644 src/tunnel/cloudflare.rs create mode 100644 src/tunnel/custom.rs create mode 100644 src/tunnel/mod.rs create mode 100644 src/tunnel/ngrok.rs create mode 100644 src/tunnel/none.rs create mode 100644 src/tunnel/tailscale.rs diff --git a/README.md b/README.md index 5225a76..7cbc822 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The fastest, smallest, fully autonomous AI assistant — deploy anywhere, swap anything. ``` -~3MB binary · <10ms startup · 629 tests · 22 providers · Pluggable everything +~3MB binary · <10ms startup · 649 tests · 22 providers · Pluggable everything ``` ## Quick Start @@ -108,6 +108,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze | **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel | | **Runtime** | `RuntimeAdapter` | Native (Mac/Linux/Pi) | Docker, WASM | | **Security** | `SecurityPolicy` | Sandbox + allowlists + rate limits | — | +| **Tunnel** | `Tunnel` | None, Cloudflare, Tailscale, ngrok, Custom | Any tunnel binary | | **Heartbeat** | Engine | HEARTBEAT.md periodic tasks | — | ### Memory System @@ -145,6 +146,49 @@ Every channel validates the sender **before** the message reaches the agent loop - **`max_actions_per_hour`** — hard cap on tool executions (default: 20) - **`max_cost_per_day_cents`** — daily cost ceiling (default: $5.00) +#### Layer 2.5: Agnostic Tunnel + +Expose your gateway securely to the internet — **bring your own tunnel provider**. ZeroClaw doesn't lock you into Cloudflare or any single vendor. + +| Provider | Binary | Use Case | +|----------|--------|----------| +| **none** | — | Local-only (default) | +| **cloudflare** | `cloudflared` | Cloudflare Zero Trust tunnel | +| **tailscale** | `tailscale` | Tailnet-only (`serve`) or public (`funnel`) | +| **ngrok** | `ngrok` | Quick public URLs, custom domains | +| **custom** | Any | Bring your own: bore, frp, ssh, WireGuard, etc. | + +```toml +[tunnel] +provider = "tailscale" # "none", "cloudflare", "tailscale", "ngrok", "custom" + +[tunnel.tailscale] +funnel = true # true = public internet, false = tailnet only + +# Or use Cloudflare: +# [tunnel] +# provider = "cloudflare" +# [tunnel.cloudflare] +# token = "your-tunnel-token" + +# Or ngrok: +# [tunnel] +# provider = "ngrok" +# [tunnel.ngrok] +# auth_token = "your-ngrok-token" +# domain = "my-zeroclaw.ngrok.io" # optional + +# Or bring your own: +# [tunnel] +# provider = "custom" +# [tunnel.custom] +# start_command = "bore local {port} --to bore.pub" +# url_pattern = "https://" # regex to extract URL from stdout +# health_url = "http://localhost:4040/api/tunnels" # optional +``` + +The tunnel starts automatically with `zeroclaw gateway` and prints the public URL. + #### Layer 3: Tool Sandbox - **Workspace sandboxing** — can't escape workspace directory @@ -298,7 +342,7 @@ interval_minutes = 30 ```bash cargo build # Dev build cargo build --release # Release build (~3MB) -cargo test # 629 tests +cargo test # 649 tests cargo clippy # Lint (0 warnings) # Run the SQLite vs Markdown benchmark @@ -321,7 +365,8 @@ src/ ├── providers/ # Provider trait + 22 providers ├── runtime/ # RuntimeAdapter trait + Native ├── security/ # Sandbox + allowlists + autonomy -└── tools/ # Tool trait + shell/file/memory tools +├── tools/ # Tool trait + shell/file/memory tools +└── tunnel/ # Tunnel trait + Cloudflare/Tailscale/ngrok/Custom examples/ ├── custom_provider.rs ├── custom_channel.rs diff --git a/src/config/mod.rs b/src/config/mod.rs index e3c4ef9..24dba09 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -3,5 +3,5 @@ pub mod schema; pub use schema::{ AutonomyConfig, ChannelsConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SlackConfig, TelegramConfig, - WebhookConfig, + TunnelConfig, WebhookConfig, }; diff --git a/src/config/schema.rs b/src/config/schema.rs index ab6a3bb..7f0173b 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -33,6 +33,9 @@ pub struct Config { #[serde(default)] pub memory: MemoryConfig, + + #[serde(default)] + pub tunnel: TunnelConfig, } // ── Memory ─────────────────────────────────────────────────── @@ -146,6 +149,72 @@ impl Default for HeartbeatConfig { } } +// ── Tunnel ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TunnelConfig { + /// "none", "cloudflare", "tailscale", "ngrok", "custom" + pub provider: String, + + #[serde(default)] + pub cloudflare: Option, + + #[serde(default)] + pub tailscale: Option, + + #[serde(default)] + pub ngrok: Option, + + #[serde(default)] + pub custom: Option, +} + +impl Default for TunnelConfig { + fn default() -> Self { + Self { + provider: "none".into(), + cloudflare: None, + tailscale: None, + ngrok: None, + custom: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudflareTunnelConfig { + /// Cloudflare Tunnel token (from Zero Trust dashboard) + pub token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TailscaleTunnelConfig { + /// Use Tailscale Funnel (public internet) vs Serve (tailnet only) + #[serde(default)] + pub funnel: bool, + /// Optional hostname override + pub hostname: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NgrokTunnelConfig { + /// ngrok auth token + pub auth_token: String, + /// Optional custom domain + pub domain: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomTunnelConfig { + /// Command template to start the tunnel. Use {port} and {host} placeholders. + /// Example: "bore local {port} --to bore.pub" + pub start_command: String, + /// Optional URL to check tunnel health + pub health_url: Option, + /// Optional regex to extract public URL from command stdout + pub url_pattern: Option, +} + // ── Channels ───────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] @@ -236,6 +305,7 @@ impl Default for Config { heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), memory: MemoryConfig::default(), + tunnel: TunnelConfig::default(), } } } @@ -373,6 +443,7 @@ mod tests { matrix: None, }, memory: MemoryConfig::default(), + tunnel: TunnelConfig::default(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); @@ -432,6 +503,7 @@ default_temperature = 0.7 heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), memory: MemoryConfig::default(), + tunnel: TunnelConfig::default(), }; config.save().unwrap(); diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 64d4f29..6d737b9 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -35,7 +35,28 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { .and_then(|w| w.secret.as_deref()) .map(Arc::from); + // ── Tunnel ──────────────────────────────────────────────── + let tunnel = crate::tunnel::create_tunnel(&config.tunnel)?; + let mut tunnel_url: Option = None; + + if let Some(ref tun) = tunnel { + println!("🔗 Starting {} tunnel...", tun.name()); + match tun.start(host, port).await { + Ok(url) => { + println!("🌐 Tunnel active: {url}"); + tunnel_url = Some(url); + } + Err(e) => { + println!("⚠️ Tunnel failed to start: {e}"); + println!(" Falling back to local-only mode."); + } + } + } + println!("🦀 ZeroClaw Gateway listening on http://{addr}"); + if let Some(ref url) = tunnel_url { + println!(" 🌐 Public URL: {url}"); + } println!(" POST /webhook — {{\"message\": \"your prompt\"}}"); println!(" GET /health — health check"); if webhook_secret.is_some() { diff --git a/src/main.rs b/src/main.rs index b4dc424..2a02f27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,7 @@ mod runtime; mod security; mod skills; mod tools; +mod tunnel; use config::Config; diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index bf31273..eaef76b 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -93,6 +93,7 @@ pub fn run_wizard() -> Result { heartbeat: HeartbeatConfig::default(), channels_config, memory: MemoryConfig::default(), // SQLite + auto-save by default + tunnel: crate::config::TunnelConfig::default(), }; println!( diff --git a/src/tunnel/cloudflare.rs b/src/tunnel/cloudflare.rs new file mode 100644 index 0000000..e387099 --- /dev/null +++ b/src/tunnel/cloudflare.rs @@ -0,0 +1,111 @@ +use super::{kill_shared, new_shared_process, SharedProcess, Tunnel, TunnelProcess}; +use anyhow::{bail, Result}; +use tokio::io::AsyncBufReadExt; +use tokio::process::Command; + +/// Cloudflare Tunnel — wraps the `cloudflared` binary. +/// +/// Requires `cloudflared` installed and a tunnel token from the +/// Cloudflare Zero Trust dashboard. +pub struct CloudflareTunnel { + token: String, + proc: SharedProcess, +} + +impl CloudflareTunnel { + pub fn new(token: String) -> Self { + Self { + token, + proc: new_shared_process(), + } + } +} + +#[async_trait::async_trait] +impl Tunnel for CloudflareTunnel { + fn name(&self) -> &str { + "cloudflare" + } + + async fn start(&self, _local_host: &str, local_port: u16) -> Result { + // cloudflared tunnel --no-autoupdate run --token --url http://localhost: + let mut child = Command::new("cloudflared") + .args([ + "tunnel", + "--no-autoupdate", + "run", + "--token", + &self.token, + "--url", + &format!("http://localhost:{local_port}"), + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + + // Read stderr to find the public URL (cloudflared prints it there) + let stderr = child + .stderr + .take() + .ok_or_else(|| anyhow::anyhow!("Failed to capture cloudflared stderr"))?; + + let mut reader = tokio::io::BufReader::new(stderr).lines(); + let mut public_url = String::new(); + + // Wait up to 30s for the tunnel URL to appear + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30); + while tokio::time::Instant::now() < deadline { + let line = + tokio::time::timeout(tokio::time::Duration::from_secs(5), reader.next_line()).await; + + match line { + Ok(Ok(Some(l))) => { + tracing::debug!("cloudflared: {l}"); + // Look for the URL pattern in cloudflared output + if let Some(idx) = l.find("https://") { + let url_part = &l[idx..]; + let end = url_part + .find(|c: char| c.is_whitespace()) + .unwrap_or(url_part.len()); + public_url = url_part[..end].to_string(); + break; + } + } + Ok(Ok(None)) => break, + Ok(Err(e)) => bail!("Error reading cloudflared output: {e}"), + Err(_) => {} // timeout on this line, keep trying + } + } + + if public_url.is_empty() { + child.kill().await.ok(); + bail!("cloudflared did not produce a public URL within 30s. Is the token valid?"); + } + + let mut guard = self.proc.lock().await; + *guard = Some(TunnelProcess { + child, + public_url: public_url.clone(), + }); + + Ok(public_url) + } + + async fn stop(&self) -> Result<()> { + kill_shared(&self.proc).await + } + + async fn health_check(&self) -> bool { + let guard = self.proc.lock().await; + guard.as_ref().is_some_and(|tp| tp.child.id().is_some()) + } + + fn public_url(&self) -> Option { + // Can't block on async lock in a sync fn, so we try_lock + self.proc + .try_lock() + .ok() + .and_then(|g| g.as_ref().map(|tp| tp.public_url.clone())) + } +} diff --git a/src/tunnel/custom.rs b/src/tunnel/custom.rs new file mode 100644 index 0000000..c65ff32 --- /dev/null +++ b/src/tunnel/custom.rs @@ -0,0 +1,145 @@ +use super::{kill_shared, new_shared_process, SharedProcess, Tunnel, TunnelProcess}; +use anyhow::{bail, Result}; +use tokio::io::AsyncBufReadExt; +use tokio::process::Command; + +/// Custom Tunnel — bring your own tunnel binary. +/// +/// Provide a `start_command` with `{port}` and `{host}` placeholders. +/// Optionally provide a `url_pattern` regex to extract the public URL +/// from stdout, and a `health_url` to poll for liveness. +/// +/// Examples: +/// - `bore local {port} --to bore.pub` +/// - `frp -c /etc/frp/frpc.ini` +/// - `ssh -R 80:localhost:{port} serveo.net` +pub struct CustomTunnel { + start_command: String, + health_url: Option, + url_pattern: Option, + proc: SharedProcess, +} + +impl CustomTunnel { + pub fn new( + start_command: String, + health_url: Option, + url_pattern: Option, + ) -> Self { + Self { + start_command, + health_url, + url_pattern, + proc: new_shared_process(), + } + } +} + +#[async_trait::async_trait] +impl Tunnel for CustomTunnel { + fn name(&self) -> &str { + "custom" + } + + async fn start(&self, local_host: &str, local_port: u16) -> Result { + let cmd = self + .start_command + .replace("{port}", &local_port.to_string()) + .replace("{host}", local_host); + + let parts: Vec<&str> = cmd.split_whitespace().collect(); + if parts.is_empty() { + bail!("Custom tunnel start_command is empty"); + } + + let mut child = Command::new(parts[0]) + .args(&parts[1..]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + + let mut public_url = format!("http://{local_host}:{local_port}"); + + // If a URL pattern is provided, try to extract the public URL from stdout + if let Some(ref pattern) = self.url_pattern { + if let Some(stdout) = child.stdout.take() { + let mut reader = tokio::io::BufReader::new(stdout).lines(); + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(15); + + while tokio::time::Instant::now() < deadline { + let line = tokio::time::timeout( + tokio::time::Duration::from_secs(3), + reader.next_line(), + ) + .await; + + match line { + Ok(Ok(Some(l))) => { + tracing::debug!("custom-tunnel: {l}"); + // Simple substring match on the pattern + if l.contains(pattern) + || l.contains("https://") + || l.contains("http://") + { + // Extract URL from the line + if let Some(idx) = l.find("https://") { + let url_part = &l[idx..]; + let end = url_part + .find(|c: char| c.is_whitespace()) + .unwrap_or(url_part.len()); + public_url = url_part[..end].to_string(); + break; + } else if let Some(idx) = l.find("http://") { + let url_part = &l[idx..]; + let end = url_part + .find(|c: char| c.is_whitespace()) + .unwrap_or(url_part.len()); + public_url = url_part[..end].to_string(); + break; + } + } + } + Ok(Ok(None) | Err(_)) => break, + Err(_) => {} + } + } + } + } + + let mut guard = self.proc.lock().await; + *guard = Some(TunnelProcess { + child, + public_url: public_url.clone(), + }); + + Ok(public_url) + } + + async fn stop(&self) -> Result<()> { + kill_shared(&self.proc).await + } + + async fn health_check(&self) -> bool { + // If a health URL is configured, try to reach it + if let Some(ref url) = self.health_url { + return reqwest::Client::new() + .get(url) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + .is_ok(); + } + + // Otherwise check if the process is still alive + let guard = self.proc.lock().await; + guard.as_ref().is_some_and(|tp| tp.child.id().is_some()) + } + + fn public_url(&self) -> Option { + self.proc + .try_lock() + .ok() + .and_then(|g| g.as_ref().map(|tp| tp.public_url.clone())) + } +} diff --git a/src/tunnel/mod.rs b/src/tunnel/mod.rs new file mode 100644 index 0000000..4cb73bc --- /dev/null +++ b/src/tunnel/mod.rs @@ -0,0 +1,316 @@ +mod cloudflare; +mod custom; +mod ngrok; +mod none; +mod tailscale; + +pub use cloudflare::CloudflareTunnel; +pub use custom::CustomTunnel; +pub use ngrok::NgrokTunnel; +#[allow(unused_imports)] +pub use none::NoneTunnel; +pub use tailscale::TailscaleTunnel; + +use crate::config::schema::{TailscaleTunnelConfig, TunnelConfig}; +use anyhow::{bail, Result}; +use std::sync::Arc; +use tokio::sync::Mutex; + +// ── Tunnel trait ───────────────────────────────────────────────── + +/// Agnostic tunnel abstraction — bring your own tunnel provider. +/// +/// Implementations wrap an external tunnel binary (cloudflared, tailscale, +/// ngrok, etc.) or a custom command. The gateway calls `start()` after +/// binding its local port and `stop()` on shutdown. +#[async_trait::async_trait] +pub trait Tunnel: Send + Sync { + /// Human-readable provider name (e.g. "cloudflare", "tailscale") + fn name(&self) -> &str; + + /// Start the tunnel, exposing `local_host:local_port` externally. + /// Returns the public URL on success. + async fn start(&self, local_host: &str, local_port: u16) -> Result; + + /// Stop the tunnel process gracefully. + async fn stop(&self) -> Result<()>; + + /// Check if the tunnel is still alive. + async fn health_check(&self) -> bool; + + /// Return the public URL if the tunnel is running. + fn public_url(&self) -> Option; +} + +// ── Shared child-process handle ────────────────────────────────── + +/// Wraps a spawned tunnel child process so implementations can share it. +pub(crate) struct TunnelProcess { + pub child: tokio::process::Child, + pub public_url: String, +} + +pub(crate) type SharedProcess = Arc>>; + +pub(crate) fn new_shared_process() -> SharedProcess { + Arc::new(Mutex::new(None)) +} + +/// Kill a shared tunnel process if running. +pub(crate) async fn kill_shared(proc: &SharedProcess) -> Result<()> { + let mut guard = proc.lock().await; + if let Some(ref mut tp) = *guard { + tp.child.kill().await.ok(); + tp.child.wait().await.ok(); + } + *guard = None; + Ok(()) +} + +// ── Factory ────────────────────────────────────────────────────── + +/// Create a tunnel from config. Returns `None` for provider "none". +pub fn create_tunnel(config: &TunnelConfig) -> Result>> { + match config.provider.as_str() { + "none" | "" => Ok(None), + + "cloudflare" => { + let cf = config + .cloudflare + .as_ref() + .ok_or_else(|| anyhow::anyhow!("tunnel.provider = \"cloudflare\" but [tunnel.cloudflare] section is missing"))?; + Ok(Some(Box::new(CloudflareTunnel::new(cf.token.clone())))) + } + + "tailscale" => { + let ts = config.tailscale.as_ref().unwrap_or(&TailscaleTunnelConfig { + funnel: false, + hostname: None, + }); + Ok(Some(Box::new(TailscaleTunnel::new( + ts.funnel, + ts.hostname.clone(), + )))) + } + + "ngrok" => { + let ng = config + .ngrok + .as_ref() + .ok_or_else(|| anyhow::anyhow!("tunnel.provider = \"ngrok\" but [tunnel.ngrok] section is missing"))?; + Ok(Some(Box::new(NgrokTunnel::new( + ng.auth_token.clone(), + ng.domain.clone(), + )))) + } + + "custom" => { + let cu = config + .custom + .as_ref() + .ok_or_else(|| anyhow::anyhow!("tunnel.provider = \"custom\" but [tunnel.custom] section is missing"))?; + Ok(Some(Box::new(CustomTunnel::new( + cu.start_command.clone(), + cu.health_url.clone(), + cu.url_pattern.clone(), + )))) + } + + other => bail!("Unknown tunnel provider: \"{other}\". Valid: none, cloudflare, tailscale, ngrok, custom"), + } +} + +// ── Tests ──────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::schema::{ + CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TunnelConfig, + }; + + /// Helper: assert create_tunnel returns an error containing `needle`. + fn assert_tunnel_err(cfg: &TunnelConfig, needle: &str) { + match create_tunnel(cfg) { + Err(e) => assert!( + e.to_string().contains(needle), + "Expected error containing \"{needle}\", got: {e}" + ), + Ok(_) => panic!("Expected error containing \"{needle}\", but got Ok"), + } + } + + #[test] + fn factory_none_returns_none() { + let cfg = TunnelConfig::default(); + let t = create_tunnel(&cfg).unwrap(); + assert!(t.is_none()); + } + + #[test] + fn factory_empty_string_returns_none() { + let cfg = TunnelConfig { + provider: String::new(), + ..TunnelConfig::default() + }; + let t = create_tunnel(&cfg).unwrap(); + assert!(t.is_none()); + } + + #[test] + fn factory_unknown_provider_errors() { + let cfg = TunnelConfig { + provider: "wireguard".into(), + ..TunnelConfig::default() + }; + assert_tunnel_err(&cfg, "Unknown tunnel provider"); + } + + #[test] + fn factory_cloudflare_missing_config_errors() { + let cfg = TunnelConfig { + provider: "cloudflare".into(), + ..TunnelConfig::default() + }; + assert_tunnel_err(&cfg, "[tunnel.cloudflare]"); + } + + #[test] + fn factory_cloudflare_with_config_ok() { + let cfg = TunnelConfig { + provider: "cloudflare".into(), + cloudflare: Some(CloudflareTunnelConfig { + token: "test-token".into(), + }), + ..TunnelConfig::default() + }; + let t = create_tunnel(&cfg).unwrap(); + assert!(t.is_some()); + assert_eq!(t.unwrap().name(), "cloudflare"); + } + + #[test] + fn factory_tailscale_defaults_ok() { + let cfg = TunnelConfig { + provider: "tailscale".into(), + ..TunnelConfig::default() + }; + let t = create_tunnel(&cfg).unwrap(); + assert!(t.is_some()); + assert_eq!(t.unwrap().name(), "tailscale"); + } + + #[test] + fn factory_ngrok_missing_config_errors() { + let cfg = TunnelConfig { + provider: "ngrok".into(), + ..TunnelConfig::default() + }; + assert_tunnel_err(&cfg, "[tunnel.ngrok]"); + } + + #[test] + fn factory_ngrok_with_config_ok() { + let cfg = TunnelConfig { + provider: "ngrok".into(), + ngrok: Some(NgrokTunnelConfig { + auth_token: "tok".into(), + domain: None, + }), + ..TunnelConfig::default() + }; + let t = create_tunnel(&cfg).unwrap(); + assert!(t.is_some()); + assert_eq!(t.unwrap().name(), "ngrok"); + } + + #[test] + fn factory_custom_missing_config_errors() { + let cfg = TunnelConfig { + provider: "custom".into(), + ..TunnelConfig::default() + }; + assert_tunnel_err(&cfg, "[tunnel.custom]"); + } + + #[test] + fn factory_custom_with_config_ok() { + let cfg = TunnelConfig { + provider: "custom".into(), + custom: Some(CustomTunnelConfig { + start_command: "echo tunnel".into(), + health_url: None, + url_pattern: None, + }), + ..TunnelConfig::default() + }; + let t = create_tunnel(&cfg).unwrap(); + assert!(t.is_some()); + assert_eq!(t.unwrap().name(), "custom"); + } + + #[test] + fn none_tunnel_name() { + let t = NoneTunnel; + assert_eq!(t.name(), "none"); + } + + #[test] + fn none_tunnel_public_url_is_none() { + let t = NoneTunnel; + assert!(t.public_url().is_none()); + } + + #[tokio::test] + async fn none_tunnel_health_always_true() { + let t = NoneTunnel; + assert!(t.health_check().await); + } + + #[tokio::test] + async fn none_tunnel_start_returns_local() { + let t = NoneTunnel; + let url = t.start("127.0.0.1", 8080).await.unwrap(); + assert_eq!(url, "http://127.0.0.1:8080"); + } + + #[test] + fn cloudflare_tunnel_name() { + let t = CloudflareTunnel::new("tok".into()); + assert_eq!(t.name(), "cloudflare"); + assert!(t.public_url().is_none()); + } + + #[test] + fn tailscale_tunnel_name() { + let t = TailscaleTunnel::new(false, None); + assert_eq!(t.name(), "tailscale"); + assert!(t.public_url().is_none()); + } + + #[test] + fn tailscale_funnel_mode() { + let t = TailscaleTunnel::new(true, Some("myhost".into())); + assert_eq!(t.name(), "tailscale"); + } + + #[test] + fn ngrok_tunnel_name() { + let t = NgrokTunnel::new("tok".into(), None); + assert_eq!(t.name(), "ngrok"); + assert!(t.public_url().is_none()); + } + + #[test] + fn ngrok_with_domain() { + let t = NgrokTunnel::new("tok".into(), Some("my.ngrok.io".into())); + assert_eq!(t.name(), "ngrok"); + } + + #[test] + fn custom_tunnel_name() { + let t = CustomTunnel::new("echo hi".into(), None, None); + assert_eq!(t.name(), "custom"); + assert!(t.public_url().is_none()); + } +} diff --git a/src/tunnel/ngrok.rs b/src/tunnel/ngrok.rs new file mode 100644 index 0000000..e993e79 --- /dev/null +++ b/src/tunnel/ngrok.rs @@ -0,0 +1,121 @@ +use super::{kill_shared, new_shared_process, SharedProcess, Tunnel, TunnelProcess}; +use anyhow::{bail, Result}; +use tokio::io::AsyncBufReadExt; +use tokio::process::Command; + +/// ngrok Tunnel — wraps the `ngrok` binary. +/// +/// Requires `ngrok` installed. Optionally set a custom domain +/// (requires ngrok paid plan). +pub struct NgrokTunnel { + auth_token: String, + domain: Option, + proc: SharedProcess, +} + +impl NgrokTunnel { + pub fn new(auth_token: String, domain: Option) -> Self { + Self { + auth_token, + domain, + proc: new_shared_process(), + } + } +} + +#[async_trait::async_trait] +impl Tunnel for NgrokTunnel { + fn name(&self) -> &str { + "ngrok" + } + + async fn start(&self, _local_host: &str, local_port: u16) -> Result { + // Set auth token + Command::new("ngrok") + .args(["config", "add-authtoken", &self.auth_token]) + .output() + .await?; + + // Build command: ngrok http [--domain ] + let mut args = vec!["http".to_string(), local_port.to_string()]; + if let Some(ref domain) = self.domain { + args.push("--domain".into()); + args.push(domain.clone()); + } + // Output log to stdout for URL extraction + args.push("--log".into()); + args.push("stdout".into()); + args.push("--log-format".into()); + args.push("logfmt".into()); + + let mut child = Command::new("ngrok") + .args(&args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("Failed to capture ngrok stdout"))?; + + let mut reader = tokio::io::BufReader::new(stdout).lines(); + let mut public_url = String::new(); + + // Wait up to 15s for the tunnel URL + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(15); + while tokio::time::Instant::now() < deadline { + let line = + tokio::time::timeout(tokio::time::Duration::from_secs(3), reader.next_line()).await; + + match line { + Ok(Ok(Some(l))) => { + tracing::debug!("ngrok: {l}"); + // ngrok logfmt: url=https://xxxx.ngrok-free.app + if let Some(idx) = l.find("url=https://") { + let url_start = idx + 4; // skip "url=" + let url_part = &l[url_start..]; + let end = url_part + .find(|c: char| c.is_whitespace()) + .unwrap_or(url_part.len()); + public_url = url_part[..end].to_string(); + break; + } + } + Ok(Ok(None)) => break, + Ok(Err(e)) => bail!("Error reading ngrok output: {e}"), + Err(_) => {} + } + } + + if public_url.is_empty() { + child.kill().await.ok(); + bail!("ngrok did not produce a public URL within 15s. Is the auth token valid?"); + } + + let mut guard = self.proc.lock().await; + *guard = Some(TunnelProcess { + child, + public_url: public_url.clone(), + }); + + Ok(public_url) + } + + async fn stop(&self) -> Result<()> { + kill_shared(&self.proc).await + } + + async fn health_check(&self) -> bool { + let guard = self.proc.lock().await; + guard.as_ref().is_some_and(|tp| tp.child.id().is_some()) + } + + fn public_url(&self) -> Option { + self.proc + .try_lock() + .ok() + .and_then(|g| g.as_ref().map(|tp| tp.public_url.clone())) + } +} diff --git a/src/tunnel/none.rs b/src/tunnel/none.rs new file mode 100644 index 0000000..a8de838 --- /dev/null +++ b/src/tunnel/none.rs @@ -0,0 +1,28 @@ +use super::Tunnel; +use anyhow::Result; + +/// No-op tunnel — direct local access, no external exposure. +pub struct NoneTunnel; + +#[async_trait::async_trait] +impl Tunnel for NoneTunnel { + fn name(&self) -> &str { + "none" + } + + async fn start(&self, local_host: &str, local_port: u16) -> Result { + Ok(format!("http://{local_host}:{local_port}")) + } + + async fn stop(&self) -> Result<()> { + Ok(()) + } + + async fn health_check(&self) -> bool { + true + } + + fn public_url(&self) -> Option { + None + } +} diff --git a/src/tunnel/tailscale.rs b/src/tunnel/tailscale.rs new file mode 100644 index 0000000..4a69038 --- /dev/null +++ b/src/tunnel/tailscale.rs @@ -0,0 +1,102 @@ +use super::{kill_shared, new_shared_process, SharedProcess, Tunnel, TunnelProcess}; +use anyhow::{bail, Result}; +use tokio::process::Command; + +/// Tailscale Tunnel — uses `tailscale serve` (tailnet-only) or +/// `tailscale funnel` (public internet). +/// +/// Requires Tailscale installed and authenticated (`tailscale up`). +pub struct TailscaleTunnel { + funnel: bool, + hostname: Option, + proc: SharedProcess, +} + +impl TailscaleTunnel { + pub fn new(funnel: bool, hostname: Option) -> Self { + Self { + funnel, + hostname, + proc: new_shared_process(), + } + } +} + +#[async_trait::async_trait] +impl Tunnel for TailscaleTunnel { + fn name(&self) -> &str { + "tailscale" + } + + async fn start(&self, _local_host: &str, local_port: u16) -> Result { + let subcommand = if self.funnel { "funnel" } else { "serve" }; + + // Get the tailscale hostname for URL construction + let hostname = if let Some(ref h) = self.hostname { + h.clone() + } else { + // Query tailscale for the current hostname + let output = Command::new("tailscale") + .args(["status", "--json"]) + .output() + .await?; + + if !output.status.success() { + bail!( + "tailscale status failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let status: serde_json::Value = + serde_json::from_slice(&output.stdout).unwrap_or_default(); + status["Self"]["DNSName"] + .as_str() + .unwrap_or("localhost") + .trim_end_matches('.') + .to_string() + }; + + // tailscale serve|funnel + let child = Command::new("tailscale") + .args([subcommand, &local_port.to_string()]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + + let public_url = format!("https://{hostname}:{local_port}"); + + let mut guard = self.proc.lock().await; + *guard = Some(TunnelProcess { + child, + public_url: public_url.clone(), + }); + + Ok(public_url) + } + + async fn stop(&self) -> Result<()> { + // Also reset the tailscale serve/funnel + let subcommand = if self.funnel { "funnel" } else { "serve" }; + Command::new("tailscale") + .args([subcommand, "reset"]) + .output() + .await + .ok(); + + kill_shared(&self.proc).await + } + + async fn health_check(&self) -> bool { + let guard = self.proc.lock().await; + guard.as_ref().is_some_and(|tp| tp.child.id().is_some()) + } + + fn public_url(&self) -> Option { + self.proc + .try_lock() + .ok() + .and_then(|g| g.as_ref().map(|tp| tp.public_url.clone())) + } +}