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:
parent
bc31e4389b
commit
390cbc0a6c
12 changed files with 967 additions and 4 deletions
102
src/tunnel/tailscale.rs
Normal file
102
src/tunnel/tailscale.rs
Normal file
|
|
@ -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<String>,
|
||||
proc: SharedProcess,
|
||||
}
|
||||
|
||||
impl TailscaleTunnel {
|
||||
pub fn new(funnel: bool, hostname: Option<String>) -> 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<String> {
|
||||
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 <port>
|
||||
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<String> {
|
||||
self.proc
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|tp| tp.public_url.clone()))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue