feat: agnostic tunnel system — bring your own tunnel provider

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
This commit is contained in:
argenis de la rosa 2026-02-13 16:25:01 -05:00
parent bc31e4389b
commit 390cbc0a6c
12 changed files with 967 additions and 4 deletions

View file

@ -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<String> = 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() {