use std::process::Stdio;
use std::sync::RwLock;
use std::time::Duration;
use anyhow::{anyhow, bail, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use super::host::host;
use super::{IngressKind, WebhookIngress};
use crate::win_process::NoWindow;
pub const WEBHOOK_PATH: &str = "/api/composio/webhook";
fn join_webhook(base: &str) -> String {
format!("{}{}", base.trim_end_matches('/'), WEBHOOK_PATH)
}
#[derive(Clone, Debug)]
pub struct OwnRelaySource {
pub base_url: String,
}
pub const OWN_RELAY_URL_ENV: &str = "RYU_WEBHOOK_INGRESS_URL";
impl OwnRelaySource {
pub fn new(fallback_base: impl Into<String>) -> Self {
let env_base = std::env::var(OWN_RELAY_URL_ENV)
.ok()
.map(|v| v.trim().to_owned())
.filter(|v| !v.is_empty());
Self {
base_url: env_base.unwrap_or_else(|| fallback_base.into()),
}
}
}
impl WebhookIngress for OwnRelaySource {
fn kind(&self) -> IngressKind {
IngressKind::OwnRelay
}
async fn start(&self) -> Result<()> {
if self.base_url.trim().is_empty() {
bail!(
"own-relay ingress: no public URL set (env {OWN_RELAY_URL_ENV} \
or the webhook.ingress.url pref)"
);
}
Ok(())
}
async fn public_url(&self) -> Result<String> {
let base = self.base_url.trim();
if base.is_empty() {
bail!(
"own-relay ingress: no public URL set (env {OWN_RELAY_URL_ENV} \
or the webhook.ingress.url pref)"
);
}
Ok(join_webhook(base))
}
}
#[derive(Clone, Debug)]
pub struct TailscaleFunnelSource {
pub port: u16,
}
impl TailscaleFunnelSource {
pub fn new(port: u16) -> Self {
Self { port }
}
}
impl WebhookIngress for TailscaleFunnelSource {
fn kind(&self) -> IngressKind {
IngressKind::TailscaleFunnel
}
async fn start(&self) -> Result<()> {
let url = host()?
.ensure_funnel(self.port)
.await
.map_err(|e| anyhow::anyhow!("mesh funnel not available — Phase 5 ({e})"))?;
let _ = url;
Ok(())
}
async fn public_url(&self) -> Result<String> {
match host()?.funnel_url(self.port).await {
Some(base) => Ok(join_webhook(&base)),
None => bail!("mesh funnel not available — Phase 5 (no active Funnel for this port)"),
}
}
}
#[derive(Clone, Debug)]
pub struct CloudflaredSource {
pub port: u16,
}
impl CloudflaredSource {
pub fn new(port: u16) -> Self {
Self { port }
}
}
struct CloudflaredState {
base_url: String,
#[allow(dead_code)]
child: tokio::process::Child,
}
static CLOUDFLARED: RwLock<Option<CloudflaredState>> = RwLock::new(None);
fn cloudflared_base_url() -> Option<String> {
CLOUDFLARED
.read()
.ok()
.and_then(|g| g.as_ref().map(|s| s.base_url.clone()))
}
fn extract_trycloudflare_url(line: &str) -> Option<String> {
let start = line.find("https://")?;
let rest = &line[start..];
let end = rest
.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
.unwrap_or(rest.len());
let url = rest[..end].trim_end_matches('/');
if url.ends_with(".trycloudflare.com") {
Some(url.to_owned())
} else {
None
}
}
impl WebhookIngress for CloudflaredSource {
fn kind(&self) -> IngressKind {
IngressKind::Cloudflared
}
async fn start(&self) -> Result<()> {
if cloudflared_base_url().is_some() {
return Ok(());
}
let mut child = Command::new("cloudflared")
.arg("tunnel")
.arg("--no-autoupdate")
.arg("--url")
.arg(format!("http://localhost:{}", self.port))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.no_window()
.spawn()
.map_err(|e| {
anyhow!(
"cloudflared ingress: failed to spawn `cloudflared` ({e}) — install \
cloudflared and ensure it is on PATH, or use own-relay / tailscale-funnel"
)
})?;
if let Some(out) = child.stdout.take() {
tokio::spawn(async move {
let mut lines = BufReader::new(out).lines();
while let Ok(Some(_)) = lines.next_line().await {}
});
}
let stderr = child
.stderr
.take()
.ok_or_else(|| anyhow!("cloudflared ingress: no stderr handle on child"))?;
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
let mut tx = Some(tx);
while let Ok(Some(line)) = lines.next_line().await {
if let Some(url) = extract_trycloudflare_url(&line) {
if let Some(tx) = tx.take() {
let _ = tx.send(url);
}
}
}
});
let url = tokio::time::timeout(Duration::from_secs(30), rx)
.await
.map_err(|_| {
anyhow!("cloudflared ingress: timed out waiting for the tunnel URL (is cloudflared healthy?)")
})?
.map_err(|_| {
anyhow!("cloudflared ingress: process exited before reporting a tunnel URL")
})?;
if let Ok(mut guard) = CLOUDFLARED.write() {
*guard = Some(CloudflaredState {
base_url: url,
child,
});
}
Ok(())
}
async fn public_url(&self) -> Result<String> {
match cloudflared_base_url() {
Some(base) => Ok(join_webhook(&base)),
None => bail!("cloudflared ingress: no active tunnel (call start first)"),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct RyuRelaySource;
impl RyuRelaySource {
pub fn new() -> Self {
Self
}
}
impl WebhookIngress for RyuRelaySource {
fn kind(&self) -> IngressKind {
IngressKind::RyuRelay
}
async fn start(&self) -> Result<()> {
super::ryu_relay::start().await
}
async fn public_url(&self) -> Result<String> {
super::public_url().ok_or_else(|| {
anyhow::anyhow!("ryu-relay ingress: not registered yet (login required)")
})
}
}
#[derive(Clone, Debug)]
pub enum Ingress {
RyuRelay(RyuRelaySource),
TailscaleFunnel(TailscaleFunnelSource),
Cloudflared(CloudflaredSource),
OwnRelay(OwnRelaySource),
}
impl Ingress {
pub fn kind(&self) -> IngressKind {
match self {
Ingress::RyuRelay(s) => s.kind(),
Ingress::TailscaleFunnel(s) => s.kind(),
Ingress::Cloudflared(s) => s.kind(),
Ingress::OwnRelay(s) => s.kind(),
}
}
pub async fn start(&self) -> Result<()> {
match self {
Ingress::RyuRelay(s) => s.start().await,
Ingress::TailscaleFunnel(s) => s.start().await,
Ingress::Cloudflared(s) => s.start().await,
Ingress::OwnRelay(s) => s.start().await,
}
}
pub async fn public_url(&self) -> Result<String> {
match self {
Ingress::RyuRelay(s) => s.public_url().await,
Ingress::TailscaleFunnel(s) => s.public_url().await,
Ingress::Cloudflared(s) => s.public_url().await,
Ingress::OwnRelay(s) => s.public_url().await,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn join_webhook_strips_trailing_slash() {
assert_eq!(
join_webhook("https://x.com"),
"https://x.com/api/composio/webhook"
);
assert_eq!(
join_webhook("https://x.com/"),
"https://x.com/api/composio/webhook"
);
}
#[tokio::test]
async fn own_relay_public_url_appends_path() {
let src = OwnRelaySource {
base_url: "https://relay.example.com/".to_owned(),
};
assert_eq!(
src.public_url().await.unwrap(),
"https://relay.example.com/api/composio/webhook"
);
assert_eq!(src.kind(), IngressKind::OwnRelay);
}
#[tokio::test]
async fn own_relay_empty_base_errors() {
let src = OwnRelaySource {
base_url: " ".to_owned(),
};
assert!(src.public_url().await.is_err());
assert!(src.start().await.is_err());
}
#[tokio::test]
async fn ryu_relay_kind_is_ryu_relay() {
let src = RyuRelaySource::new();
assert_eq!(src.kind(), IngressKind::RyuRelay);
}
#[test]
fn extract_trycloudflare_url_parses_banner() {
assert_eq!(
extract_trycloudflare_url(
"2024-01-01 INF | https://random-words-1234.trycloudflare.com |"
),
Some("https://random-words-1234.trycloudflare.com".to_owned())
);
assert_eq!(
extract_trycloudflare_url("https://abc.trycloudflare.com/"),
Some("https://abc.trycloudflare.com".to_owned())
);
assert_eq!(
extract_trycloudflare_url("Visit https://developers.cloudflare.com for docs"),
None
);
assert_eq!(extract_trycloudflare_url("starting tunnel"), None);
}
#[tokio::test]
async fn cloudflared_public_url_errors_without_tunnel() {
let src = CloudflaredSource::new(7980);
assert_eq!(src.kind(), IngressKind::Cloudflared);
if cloudflared_base_url().is_none() {
assert!(src.public_url().await.is_err());
}
}
#[tokio::test]
async fn cloudflared_start_errors_when_binary_absent() {
let has_binary = std::process::Command::new("cloudflared")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.no_window()
.status()
.is_ok();
if !has_binary {
let src = CloudflaredSource::new(7980);
assert!(src.start().await.is_err());
}
}
#[tokio::test]
async fn tailscale_funnel_stub_errors_when_mesh_off() {
if std::env::var("RYU_MESH_ENABLED").is_err() {
let src = TailscaleFunnelSource::new(7980);
assert_eq!(src.kind(), IngressKind::TailscaleFunnel);
assert!(src.start().await.is_err());
assert!(src.public_url().await.is_err());
}
}
#[tokio::test]
async fn enum_dispatch_routes_to_variant() {
let ing = Ingress::OwnRelay(OwnRelaySource {
base_url: "https://x.com".to_owned(),
});
assert_eq!(ing.kind(), IngressKind::OwnRelay);
assert_eq!(
ing.public_url().await.unwrap(),
"https://x.com/api/composio/webhook"
);
assert!(ing.start().await.is_ok());
let relay = Ingress::RyuRelay(RyuRelaySource::new());
assert_eq!(relay.kind(), IngressKind::RyuRelay);
}
}