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
51
README.md
51
README.md
|
|
@ -15,7 +15,7 @@
|
||||||
The fastest, smallest, fully autonomous AI assistant — deploy anywhere, swap anything.
|
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
|
## 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 |
|
| **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
|
||||||
| **Runtime** | `RuntimeAdapter` | Native (Mac/Linux/Pi) | Docker, WASM |
|
| **Runtime** | `RuntimeAdapter` | Native (Mac/Linux/Pi) | Docker, WASM |
|
||||||
| **Security** | `SecurityPolicy` | Sandbox + allowlists + rate limits | — |
|
| **Security** | `SecurityPolicy` | Sandbox + allowlists + rate limits | — |
|
||||||
|
| **Tunnel** | `Tunnel` | None, Cloudflare, Tailscale, ngrok, Custom | Any tunnel binary |
|
||||||
| **Heartbeat** | Engine | HEARTBEAT.md periodic tasks | — |
|
| **Heartbeat** | Engine | HEARTBEAT.md periodic tasks | — |
|
||||||
|
|
||||||
### Memory System
|
### 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_actions_per_hour`** — hard cap on tool executions (default: 20)
|
||||||
- **`max_cost_per_day_cents`** — daily cost ceiling (default: $5.00)
|
- **`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
|
#### Layer 3: Tool Sandbox
|
||||||
|
|
||||||
- **Workspace sandboxing** — can't escape workspace directory
|
- **Workspace sandboxing** — can't escape workspace directory
|
||||||
|
|
@ -298,7 +342,7 @@ interval_minutes = 30
|
||||||
```bash
|
```bash
|
||||||
cargo build # Dev build
|
cargo build # Dev build
|
||||||
cargo build --release # Release build (~3MB)
|
cargo build --release # Release build (~3MB)
|
||||||
cargo test # 629 tests
|
cargo test # 649 tests
|
||||||
cargo clippy # Lint (0 warnings)
|
cargo clippy # Lint (0 warnings)
|
||||||
|
|
||||||
# Run the SQLite vs Markdown benchmark
|
# Run the SQLite vs Markdown benchmark
|
||||||
|
|
@ -321,7 +365,8 @@ src/
|
||||||
├── providers/ # Provider trait + 22 providers
|
├── providers/ # Provider trait + 22 providers
|
||||||
├── runtime/ # RuntimeAdapter trait + Native
|
├── runtime/ # RuntimeAdapter trait + Native
|
||||||
├── security/ # Sandbox + allowlists + autonomy
|
├── 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/
|
examples/
|
||||||
├── custom_provider.rs
|
├── custom_provider.rs
|
||||||
├── custom_channel.rs
|
├── custom_channel.rs
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@ pub mod schema;
|
||||||
pub use schema::{
|
pub use schema::{
|
||||||
AutonomyConfig, ChannelsConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig,
|
AutonomyConfig, ChannelsConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig,
|
||||||
MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SlackConfig, TelegramConfig,
|
MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SlackConfig, TelegramConfig,
|
||||||
WebhookConfig,
|
TunnelConfig, WebhookConfig,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ pub struct Config {
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub memory: MemoryConfig,
|
pub memory: MemoryConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub tunnel: TunnelConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Memory ───────────────────────────────────────────────────
|
// ── 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<CloudflareTunnelConfig>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub tailscale: Option<TailscaleTunnelConfig>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub ngrok: Option<NgrokTunnelConfig>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom: Option<CustomTunnelConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NgrokTunnelConfig {
|
||||||
|
/// ngrok auth token
|
||||||
|
pub auth_token: String,
|
||||||
|
/// Optional custom domain
|
||||||
|
pub domain: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
/// Optional regex to extract public URL from command stdout
|
||||||
|
pub url_pattern: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// ── Channels ─────────────────────────────────────────────────────
|
// ── Channels ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -236,6 +305,7 @@ impl Default for Config {
|
||||||
heartbeat: HeartbeatConfig::default(),
|
heartbeat: HeartbeatConfig::default(),
|
||||||
channels_config: ChannelsConfig::default(),
|
channels_config: ChannelsConfig::default(),
|
||||||
memory: MemoryConfig::default(),
|
memory: MemoryConfig::default(),
|
||||||
|
tunnel: TunnelConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -373,6 +443,7 @@ mod tests {
|
||||||
matrix: None,
|
matrix: None,
|
||||||
},
|
},
|
||||||
memory: MemoryConfig::default(),
|
memory: MemoryConfig::default(),
|
||||||
|
tunnel: TunnelConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
|
@ -432,6 +503,7 @@ default_temperature = 0.7
|
||||||
heartbeat: HeartbeatConfig::default(),
|
heartbeat: HeartbeatConfig::default(),
|
||||||
channels_config: ChannelsConfig::default(),
|
channels_config: ChannelsConfig::default(),
|
||||||
memory: MemoryConfig::default(),
|
memory: MemoryConfig::default(),
|
||||||
|
tunnel: TunnelConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
config.save().unwrap();
|
config.save().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,28 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||||
.and_then(|w| w.secret.as_deref())
|
.and_then(|w| w.secret.as_deref())
|
||||||
.map(Arc::from);
|
.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}");
|
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!(" POST /webhook — {{\"message\": \"your prompt\"}}");
|
||||||
println!(" GET /health — health check");
|
println!(" GET /health — health check");
|
||||||
if webhook_secret.is_some() {
|
if webhook_secret.is_some() {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ mod runtime;
|
||||||
mod security;
|
mod security;
|
||||||
mod skills;
|
mod skills;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
mod tunnel;
|
||||||
|
|
||||||
use config::Config;
|
use config::Config;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ pub fn run_wizard() -> Result<Config> {
|
||||||
heartbeat: HeartbeatConfig::default(),
|
heartbeat: HeartbeatConfig::default(),
|
||||||
channels_config,
|
channels_config,
|
||||||
memory: MemoryConfig::default(), // SQLite + auto-save by default
|
memory: MemoryConfig::default(), // SQLite + auto-save by default
|
||||||
|
tunnel: crate::config::TunnelConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
|
|
|
||||||
111
src/tunnel/cloudflare.rs
Normal file
111
src/tunnel/cloudflare.rs
Normal file
|
|
@ -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<String> {
|
||||||
|
// cloudflared tunnel --no-autoupdate run --token <TOKEN> --url http://localhost:<port>
|
||||||
|
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<String> {
|
||||||
|
// 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/tunnel/custom.rs
Normal file
145
src/tunnel/custom.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
url_pattern: Option<String>,
|
||||||
|
proc: SharedProcess,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CustomTunnel {
|
||||||
|
pub fn new(
|
||||||
|
start_command: String,
|
||||||
|
health_url: Option<String>,
|
||||||
|
url_pattern: Option<String>,
|
||||||
|
) -> 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<String> {
|
||||||
|
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<String> {
|
||||||
|
self.proc
|
||||||
|
.try_lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|g| g.as_ref().map(|tp| tp.public_url.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
316
src/tunnel/mod.rs
Normal file
316
src/tunnel/mod.rs
Normal file
|
|
@ -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<String>;
|
||||||
|
|
||||||
|
/// 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<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<Mutex<Option<TunnelProcess>>>;
|
||||||
|
|
||||||
|
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<Option<Box<dyn Tunnel>>> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/tunnel/ngrok.rs
Normal file
121
src/tunnel/ngrok.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
proc: SharedProcess,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NgrokTunnel {
|
||||||
|
pub fn new(auth_token: String, domain: Option<String>) -> 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<String> {
|
||||||
|
// Set auth token
|
||||||
|
Command::new("ngrok")
|
||||||
|
.args(["config", "add-authtoken", &self.auth_token])
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Build command: ngrok http <port> [--domain <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<String> {
|
||||||
|
self.proc
|
||||||
|
.try_lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|g| g.as_ref().map(|tp| tp.public_url.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/tunnel/none.rs
Normal file
28
src/tunnel/none.rs
Normal file
|
|
@ -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<String> {
|
||||||
|
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<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
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