ironclaw 0.22.0

Secure personal AI assistant that protects your data and expands its capabilities on the fly
Documentation
use crate::config::helpers::optional_env;
use crate::error::ConfigError;
use crate::settings::Settings;

/// Tunnel configuration for exposing the agent to the internet.
///
/// Used by channels and tools that need public webhook endpoints.
/// The tunnel URL is shared across all channels (Telegram, Slack, etc.).
///
/// Two modes:
/// - **Static URL** (`TUNNEL_URL`): set the public URL directly (manual tunnel)
/// - **Managed provider** (`TUNNEL_PROVIDER`): lifecycle-managed tunnel process
///
/// When a managed provider is configured _and_ no static URL is set,
/// the gateway starts the tunnel on boot and populates `public_url`.
#[derive(Debug, Clone, Default)]
pub struct TunnelConfig {
    /// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io").
    /// Set statically via `TUNNEL_URL` or populated at runtime by a managed tunnel.
    pub public_url: Option<String>,
    /// Provider configuration for lifecycle-managed tunnels.
    /// `None` when using a static URL or no tunnel at all.
    pub provider: Option<crate::tunnel::TunnelProviderConfig>,
}

impl TunnelConfig {
    pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
        let public_url = optional_env("TUNNEL_URL")?
            .or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty()));

        if let Some(ref url) = public_url
            && !url.starts_with("https://")
        {
            return Err(ConfigError::InvalidValue {
                key: "TUNNEL_URL".to_string(),
                message: "must start with https:// (webhooks require HTTPS)".to_string(),
            });
        }

        // Resolve managed tunnel provider config.
        // Priority: env var > settings > default (none).
        let provider_name = optional_env("TUNNEL_PROVIDER")?
            .or_else(|| settings.tunnel.provider.clone())
            .unwrap_or_default();

        let provider = if provider_name.is_empty() || provider_name == "none" {
            None
        } else {
            Some(crate::tunnel::TunnelProviderConfig {
                provider: provider_name.clone(),
                cloudflare: optional_env("TUNNEL_CF_TOKEN")?
                    .or_else(|| settings.tunnel.cf_token.clone())
                    .map(|token| crate::tunnel::CloudflareTunnelConfig { token }),
                tailscale: Some(crate::tunnel::TailscaleTunnelConfig {
                    funnel: optional_env("TUNNEL_TS_FUNNEL")?
                        .map(|s| s == "true" || s == "1")
                        .unwrap_or(settings.tunnel.ts_funnel),
                    hostname: optional_env("TUNNEL_TS_HOSTNAME")?
                        .or_else(|| settings.tunnel.ts_hostname.clone()),
                }),
                ngrok: {
                    let ngrok_domain = optional_env("TUNNEL_NGROK_DOMAIN")?
                        .or_else(|| settings.tunnel.ngrok_domain.clone());
                    optional_env("TUNNEL_NGROK_TOKEN")?
                        .or_else(|| settings.tunnel.ngrok_token.clone())
                        .map(|auth_token| crate::tunnel::NgrokTunnelConfig {
                            auth_token,
                            domain: ngrok_domain,
                        })
                },
                custom: {
                    let health_url = optional_env("TUNNEL_CUSTOM_HEALTH_URL")?
                        .or_else(|| settings.tunnel.custom_health_url.clone());
                    let url_pattern = optional_env("TUNNEL_CUSTOM_URL_PATTERN")?
                        .or_else(|| settings.tunnel.custom_url_pattern.clone());
                    optional_env("TUNNEL_CUSTOM_COMMAND")?
                        .or_else(|| settings.tunnel.custom_command.clone())
                        .map(|start_command| crate::tunnel::CustomTunnelConfig {
                            start_command,
                            health_url,
                            url_pattern,
                        })
                },
            })
        };

        Ok(Self {
            public_url,
            provider,
        })
    }

    /// Check if a tunnel is configured (static URL or managed provider).
    pub fn is_enabled(&self) -> bool {
        self.public_url.is_some() || self.provider.is_some()
    }

    /// Get the webhook URL for a given path.
    pub fn webhook_url(&self, path: &str) -> Option<String> {
        self.public_url.as_ref().map(|base| {
            let base = base.trim_end_matches('/');
            let path = path.trim_start_matches('/');
            format!("{}/{}", base, path)
        })
    }
}

#[cfg(test)]
mod tests {
    use crate::config::tunnel::TunnelConfig;
    use crate::tunnel::{
        CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TailscaleTunnelConfig,
        TunnelProviderConfig,
    };

    // ── Default ─────────────────────────────────────────────────────

    #[test]
    fn default_is_disabled() {
        let cfg = TunnelConfig::default();
        assert!(cfg.public_url.is_none());
        assert!(cfg.provider.is_none());
        assert!(!cfg.is_enabled());
    }

    // ── is_enabled ──────────────────────────────────────────────────

    #[test]
    fn is_enabled_with_static_url() {
        let cfg = TunnelConfig {
            public_url: Some("https://tunnel.example.com".to_string()),
            provider: None,
        };
        assert!(cfg.is_enabled());
    }

    #[test]
    fn is_enabled_with_provider() {
        let cfg = TunnelConfig {
            public_url: None,
            provider: Some(TunnelProviderConfig {
                provider: "cloudflare".to_string(),
                cloudflare: Some(CloudflareTunnelConfig {
                    token: "cf-tok".to_string(),
                }),
                tailscale: None,
                ngrok: None,
                custom: None,
            }),
        };
        assert!(cfg.is_enabled());
    }

    #[test]
    fn is_enabled_with_both() {
        let cfg = TunnelConfig {
            public_url: Some("https://example.com".to_string()),
            provider: Some(TunnelProviderConfig {
                provider: "ngrok".to_string(),
                cloudflare: None,
                tailscale: None,
                ngrok: Some(NgrokTunnelConfig {
                    auth_token: "ngrok-tok".to_string(),
                    domain: None,
                }),
                custom: None,
            }),
        };
        assert!(cfg.is_enabled());
    }

    // ── webhook_url ─────────────────────────────────────────────────

    #[test]
    fn webhook_url_none_when_no_public_url() {
        let cfg = TunnelConfig::default();
        assert!(cfg.webhook_url("/hook").is_none());
    }

    #[test]
    fn webhook_url_basic() {
        let cfg = TunnelConfig {
            public_url: Some("https://abc.ngrok.io".to_string()),
            provider: None,
        };
        assert_eq!(
            cfg.webhook_url("/webhook/telegram"),
            Some("https://abc.ngrok.io/webhook/telegram".to_string())
        );
    }

    #[test]
    fn webhook_url_trims_trailing_slash_on_base() {
        let cfg = TunnelConfig {
            public_url: Some("https://abc.ngrok.io/".to_string()),
            provider: None,
        };
        assert_eq!(
            cfg.webhook_url("/hook"),
            Some("https://abc.ngrok.io/hook".to_string())
        );
    }

    #[test]
    fn webhook_url_trims_leading_slash_on_path() {
        let cfg = TunnelConfig {
            public_url: Some("https://abc.ngrok.io".to_string()),
            provider: None,
        };
        // Path without leading slash should also work
        assert_eq!(
            cfg.webhook_url("hook"),
            Some("https://abc.ngrok.io/hook".to_string())
        );
    }

    #[test]
    fn webhook_url_double_slash_normalization() {
        let cfg = TunnelConfig {
            public_url: Some("https://abc.ngrok.io/".to_string()),
            provider: None,
        };
        // Both base trailing and path leading slashes trimmed
        assert_eq!(
            cfg.webhook_url("/api/webhook"),
            Some("https://abc.ngrok.io/api/webhook".to_string())
        );
    }

    #[test]
    fn webhook_url_empty_path() {
        let cfg = TunnelConfig {
            public_url: Some("https://abc.ngrok.io".to_string()),
            provider: None,
        };
        assert_eq!(
            cfg.webhook_url(""),
            Some("https://abc.ngrok.io/".to_string())
        );
    }

    // ── TunnelProviderConfig field coverage ─────────────────────────

    #[test]
    fn provider_config_cloudflare() {
        let p = TunnelProviderConfig {
            provider: "cloudflare".to_string(),
            cloudflare: Some(CloudflareTunnelConfig {
                token: "cf-secret".to_string(),
            }),
            tailscale: None,
            ngrok: None,
            custom: None,
        };
        assert_eq!(p.provider, "cloudflare");
        assert_eq!(p.cloudflare.as_ref().unwrap().token, "cf-secret");
    }

    #[test]
    fn provider_config_tailscale() {
        let ts = TailscaleTunnelConfig {
            funnel: true,
            hostname: Some("my-host".to_string()),
        };
        assert!(ts.funnel);
        assert_eq!(ts.hostname.as_deref(), Some("my-host"));
    }

    #[test]
    fn provider_config_tailscale_defaults() {
        let ts = TailscaleTunnelConfig::default();
        assert!(!ts.funnel);
        assert!(ts.hostname.is_none());
    }

    #[test]
    fn provider_config_ngrok() {
        let ng = NgrokTunnelConfig {
            auth_token: "ng-tok".to_string(),
            domain: Some("custom.ngrok.dev".to_string()),
        };
        assert_eq!(ng.auth_token, "ng-tok");
        assert_eq!(ng.domain.as_deref(), Some("custom.ngrok.dev"));
    }

    #[test]
    fn provider_config_ngrok_defaults() {
        let ng = NgrokTunnelConfig::default();
        assert!(ng.auth_token.is_empty());
        assert!(ng.domain.is_none());
    }

    #[test]
    fn provider_config_custom() {
        let c = CustomTunnelConfig {
            start_command: "bore local {port}".to_string(),
            health_url: Some("http://localhost:8080/health".to_string()),
            url_pattern: Some("https://bore.pub".to_string()),
        };
        assert_eq!(c.start_command, "bore local {port}");
        assert!(c.health_url.is_some());
        assert!(c.url_pattern.is_some());
    }

    #[test]
    fn provider_config_custom_defaults() {
        let c = CustomTunnelConfig::default();
        assert!(c.start_command.is_empty());
        assert!(c.health_url.is_none());
        assert!(c.url_pattern.is_none());
    }

    #[test]
    fn cloudflare_config_defaults() {
        let cf = CloudflareTunnelConfig::default();
        assert!(cf.token.is_empty());
    }
}