ryu_webhook_ingress/
tunnels.rs1use std::process::Stdio;
14use std::sync::RwLock;
15use std::time::Duration;
16
17use anyhow::{anyhow, bail, Result};
18use tokio::io::{AsyncBufReadExt, BufReader};
19use tokio::process::Command;
20
21use super::host::host;
22use super::{IngressKind, WebhookIngress};
23use crate::win_process::NoWindow;
24
25pub const WEBHOOK_PATH: &str = "/api/composio/webhook";
28
29fn join_webhook(base: &str) -> String {
32 format!("{}{}", base.trim_end_matches('/'), WEBHOOK_PATH)
33}
34
35#[derive(Clone, Debug)]
40pub struct OwnRelaySource {
41 pub base_url: String,
44}
45
46pub const OWN_RELAY_URL_ENV: &str = "RYU_WEBHOOK_INGRESS_URL";
48
49impl OwnRelaySource {
50 pub fn new(fallback_base: impl Into<String>) -> Self {
53 let env_base = std::env::var(OWN_RELAY_URL_ENV)
54 .ok()
55 .map(|v| v.trim().to_owned())
56 .filter(|v| !v.is_empty());
57 Self {
58 base_url: env_base.unwrap_or_else(|| fallback_base.into()),
59 }
60 }
61}
62
63impl WebhookIngress for OwnRelaySource {
64 fn kind(&self) -> IngressKind {
65 IngressKind::OwnRelay
66 }
67
68 async fn start(&self) -> Result<()> {
69 if self.base_url.trim().is_empty() {
70 bail!(
71 "own-relay ingress: no public URL set (env {OWN_RELAY_URL_ENV} \
72 or the webhook.ingress.url pref)"
73 );
74 }
75 Ok(())
76 }
77
78 async fn public_url(&self) -> Result<String> {
79 let base = self.base_url.trim();
80 if base.is_empty() {
81 bail!(
82 "own-relay ingress: no public URL set (env {OWN_RELAY_URL_ENV} \
83 or the webhook.ingress.url pref)"
84 );
85 }
86 Ok(join_webhook(base))
87 }
88}
89
90#[derive(Clone, Debug)]
96pub struct TailscaleFunnelSource {
97 pub port: u16,
99}
100
101impl TailscaleFunnelSource {
102 pub fn new(port: u16) -> Self {
103 Self { port }
104 }
105}
106
107impl WebhookIngress for TailscaleFunnelSource {
108 fn kind(&self) -> IngressKind {
109 IngressKind::TailscaleFunnel
110 }
111
112 async fn start(&self) -> Result<()> {
113 let url = host()?
117 .ensure_funnel(self.port)
118 .await
119 .map_err(|e| anyhow::anyhow!("mesh funnel not available — Phase 5 ({e})"))?;
120 let _ = url;
121 Ok(())
122 }
123
124 async fn public_url(&self) -> Result<String> {
125 match host()?.funnel_url(self.port).await {
126 Some(base) => Ok(join_webhook(&base)),
127 None => bail!("mesh funnel not available — Phase 5 (no active Funnel for this port)"),
128 }
129 }
130}
131
132#[derive(Clone, Debug)]
141pub struct CloudflaredSource {
142 pub port: u16,
144}
145
146impl CloudflaredSource {
147 pub fn new(port: u16) -> Self {
148 Self { port }
149 }
150}
151
152struct CloudflaredState {
156 base_url: String,
157 #[allow(dead_code)]
158 child: tokio::process::Child,
159}
160
161static CLOUDFLARED: RwLock<Option<CloudflaredState>> = RwLock::new(None);
162
163fn cloudflared_base_url() -> Option<String> {
165 CLOUDFLARED
166 .read()
167 .ok()
168 .and_then(|g| g.as_ref().map(|s| s.base_url.clone()))
169}
170
171fn extract_trycloudflare_url(line: &str) -> Option<String> {
175 let start = line.find("https://")?;
176 let rest = &line[start..];
177 let end = rest
178 .find(|c: char| c.is_whitespace() || c == '|' || c == '"')
179 .unwrap_or(rest.len());
180 let url = rest[..end].trim_end_matches('/');
181 if url.ends_with(".trycloudflare.com") {
182 Some(url.to_owned())
183 } else {
184 None
185 }
186}
187
188impl WebhookIngress for CloudflaredSource {
189 fn kind(&self) -> IngressKind {
190 IngressKind::Cloudflared
191 }
192
193 async fn start(&self) -> Result<()> {
194 if cloudflared_base_url().is_some() {
196 return Ok(());
197 }
198
199 let mut child = Command::new("cloudflared")
200 .arg("tunnel")
201 .arg("--no-autoupdate")
202 .arg("--url")
203 .arg(format!("http://localhost:{}", self.port))
204 .stdout(Stdio::piped())
205 .stderr(Stdio::piped())
206 .kill_on_drop(true)
207 .no_window()
208 .spawn()
209 .map_err(|e| {
210 anyhow!(
211 "cloudflared ingress: failed to spawn `cloudflared` ({e}) — install \
212 cloudflared and ensure it is on PATH, or use own-relay / tailscale-funnel"
213 )
214 })?;
215
216 if let Some(out) = child.stdout.take() {
218 tokio::spawn(async move {
219 let mut lines = BufReader::new(out).lines();
220 while let Ok(Some(_)) = lines.next_line().await {}
221 });
222 }
223
224 let stderr = child
227 .stderr
228 .take()
229 .ok_or_else(|| anyhow!("cloudflared ingress: no stderr handle on child"))?;
230 let (tx, rx) = tokio::sync::oneshot::channel::<String>();
231 tokio::spawn(async move {
232 let mut lines = BufReader::new(stderr).lines();
233 let mut tx = Some(tx);
234 while let Ok(Some(line)) = lines.next_line().await {
235 if let Some(url) = extract_trycloudflare_url(&line) {
236 if let Some(tx) = tx.take() {
237 let _ = tx.send(url);
238 }
239 }
240 }
241 });
242
243 let url = tokio::time::timeout(Duration::from_secs(30), rx)
244 .await
245 .map_err(|_| {
246 anyhow!("cloudflared ingress: timed out waiting for the tunnel URL (is cloudflared healthy?)")
247 })?
248 .map_err(|_| {
249 anyhow!("cloudflared ingress: process exited before reporting a tunnel URL")
250 })?;
251
252 if let Ok(mut guard) = CLOUDFLARED.write() {
253 *guard = Some(CloudflaredState {
254 base_url: url,
255 child,
256 });
257 }
258 Ok(())
259 }
260
261 async fn public_url(&self) -> Result<String> {
262 match cloudflared_base_url() {
263 Some(base) => Ok(join_webhook(&base)),
264 None => bail!("cloudflared ingress: no active tunnel (call start first)"),
265 }
266 }
267}
268
269#[derive(Clone, Debug, Default)]
275pub struct RyuRelaySource;
276
277impl RyuRelaySource {
278 pub fn new() -> Self {
279 Self
280 }
281}
282
283impl WebhookIngress for RyuRelaySource {
284 fn kind(&self) -> IngressKind {
285 IngressKind::RyuRelay
286 }
287
288 async fn start(&self) -> Result<()> {
289 super::ryu_relay::start().await
293 }
294
295 async fn public_url(&self) -> Result<String> {
296 super::public_url().ok_or_else(|| {
299 anyhow::anyhow!("ryu-relay ingress: not registered yet (login required)")
300 })
301 }
302}
303
304#[derive(Clone, Debug)]
306pub enum Ingress {
307 RyuRelay(RyuRelaySource),
308 TailscaleFunnel(TailscaleFunnelSource),
309 Cloudflared(CloudflaredSource),
310 OwnRelay(OwnRelaySource),
311}
312
313impl Ingress {
314 pub fn kind(&self) -> IngressKind {
316 match self {
317 Ingress::RyuRelay(s) => s.kind(),
318 Ingress::TailscaleFunnel(s) => s.kind(),
319 Ingress::Cloudflared(s) => s.kind(),
320 Ingress::OwnRelay(s) => s.kind(),
321 }
322 }
323
324 pub async fn start(&self) -> Result<()> {
326 match self {
327 Ingress::RyuRelay(s) => s.start().await,
328 Ingress::TailscaleFunnel(s) => s.start().await,
329 Ingress::Cloudflared(s) => s.start().await,
330 Ingress::OwnRelay(s) => s.start().await,
331 }
332 }
333
334 pub async fn public_url(&self) -> Result<String> {
336 match self {
337 Ingress::RyuRelay(s) => s.public_url().await,
338 Ingress::TailscaleFunnel(s) => s.public_url().await,
339 Ingress::Cloudflared(s) => s.public_url().await,
340 Ingress::OwnRelay(s) => s.public_url().await,
341 }
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn join_webhook_strips_trailing_slash() {
351 assert_eq!(
352 join_webhook("https://x.com"),
353 "https://x.com/api/composio/webhook"
354 );
355 assert_eq!(
356 join_webhook("https://x.com/"),
357 "https://x.com/api/composio/webhook"
358 );
359 }
360
361 #[tokio::test]
362 async fn own_relay_public_url_appends_path() {
363 let src = OwnRelaySource {
364 base_url: "https://relay.example.com/".to_owned(),
365 };
366 assert_eq!(
367 src.public_url().await.unwrap(),
368 "https://relay.example.com/api/composio/webhook"
369 );
370 assert_eq!(src.kind(), IngressKind::OwnRelay);
371 }
372
373 #[tokio::test]
374 async fn own_relay_empty_base_errors() {
375 let src = OwnRelaySource {
376 base_url: " ".to_owned(),
377 };
378 assert!(src.public_url().await.is_err());
379 assert!(src.start().await.is_err());
380 }
381
382 #[tokio::test]
383 async fn ryu_relay_kind_is_ryu_relay() {
384 let src = RyuRelaySource::new();
391 assert_eq!(src.kind(), IngressKind::RyuRelay);
392 }
393
394 #[test]
395 fn extract_trycloudflare_url_parses_banner() {
396 assert_eq!(
398 extract_trycloudflare_url(
399 "2024-01-01 INF | https://random-words-1234.trycloudflare.com |"
400 ),
401 Some("https://random-words-1234.trycloudflare.com".to_owned())
402 );
403 assert_eq!(
405 extract_trycloudflare_url("https://abc.trycloudflare.com/"),
406 Some("https://abc.trycloudflare.com".to_owned())
407 );
408 assert_eq!(
410 extract_trycloudflare_url("Visit https://developers.cloudflare.com for docs"),
411 None
412 );
413 assert_eq!(extract_trycloudflare_url("starting tunnel"), None);
415 }
416
417 #[tokio::test]
418 async fn cloudflared_public_url_errors_without_tunnel() {
419 let src = CloudflaredSource::new(7980);
425 assert_eq!(src.kind(), IngressKind::Cloudflared);
426 if cloudflared_base_url().is_none() {
427 assert!(src.public_url().await.is_err());
428 }
429 }
430
431 #[tokio::test]
432 async fn cloudflared_start_errors_when_binary_absent() {
433 let has_binary = std::process::Command::new("cloudflared")
437 .arg("--version")
438 .stdout(std::process::Stdio::null())
439 .stderr(std::process::Stdio::null())
440 .no_window()
441 .status()
442 .is_ok();
443 if !has_binary {
444 let src = CloudflaredSource::new(7980);
445 assert!(src.start().await.is_err());
446 }
447 }
448
449 #[tokio::test]
450 async fn tailscale_funnel_stub_errors_when_mesh_off() {
451 if std::env::var("RYU_MESH_ENABLED").is_err() {
454 let src = TailscaleFunnelSource::new(7980);
455 assert_eq!(src.kind(), IngressKind::TailscaleFunnel);
456 assert!(src.start().await.is_err());
457 assert!(src.public_url().await.is_err());
458 }
459 }
460
461 #[tokio::test]
462 async fn enum_dispatch_routes_to_variant() {
463 let ing = Ingress::OwnRelay(OwnRelaySource {
464 base_url: "https://x.com".to_owned(),
465 });
466 assert_eq!(ing.kind(), IngressKind::OwnRelay);
467 assert_eq!(
468 ing.public_url().await.unwrap(),
469 "https://x.com/api/composio/webhook"
470 );
471 assert!(ing.start().await.is_ok());
472
473 let relay = Ingress::RyuRelay(RyuRelaySource::new());
474 assert_eq!(relay.kind(), IngressKind::RyuRelay);
475 }
476}