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

@ -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<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 ─────────────────────────────────────────────────────
#[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();