ryu_webhook_ingress/lib.rs
1//! Webhook ingress — the swappable public-reachability seam (P6a of the
2//! unified-tool-gateway epic, #479). Extracted from `apps/core` into its own
3//! crate (program §3/§4 W3); the kernel couplings are inverted through
4//! [`WebhookIngressHost`], so this crate has ZERO dependency on `apps/core`.
5//!
6//! Composio triggers are **webhook-delivered**: there is no event-pull API, so a
7//! local Core bound to `127.0.0.1` never receives them. This crate is the
8//! swappable seam that gives Core a publicly-reachable URL pointed at its existing
9//! handler (`POST /api/composio/webhook`, reached via the host), so a trigger
10//! fires unchanged. Public webhook *routes* stay kernel-ingress in Core (program
11//! §5) and forward into this engine.
12//!
13//! Core vs Gateway (CLAUDE.md §1): exposing a tunnel + deciding which backend runs
14//! is *what runs* → **Core**. There is no policy here.
15//!
16//! "Nothing hardcoded" (CLAUDE.md §1): the backend is a swappable [`Ingress`]
17//! enum selected by the `webhook.ingress.backend` pref, with an
18//! `RYU_WEBHOOK_INGRESS_URL` env override for the BYO (OwnRelay) case. The default
19//! is the managed [`IngressKind::RyuRelay`].
20//!
21//! Backend dispatch uses native `async fn` trait methods (not object-safe) + a
22//! closed [`Ingress`] enum match-dispatched — no `async-trait`, no `dyn`. See
23//! [`tunnels`]. (The host seam is the one `dyn`/`async-trait` boundary.)
24
25mod dispatch;
26mod host;
27mod ryu_relay;
28mod tunnels;
29mod win_process;
30
31pub use dispatch::{
32 deliver_inbound, deliver_workflow_webhook, first_http_delivery, last_delivery, record_delivery,
33 timestamp_fresh, workflow_webhook_path, InboundOutcome, WorkflowWebhookOutcome,
34};
35pub use host::{set_global_host, WebhookIngressHost, WorkflowWebhookSecret};
36pub use ryu_relay::{ensure_relay_started, relay_inbound_url};
37pub use tunnels::{
38 CloudflaredSource, Ingress, OwnRelaySource, RyuRelaySource, TailscaleFunnelSource,
39 OWN_RELAY_URL_ENV, WEBHOOK_PATH,
40};
41
42use std::str::FromStr;
43use std::sync::RwLock;
44
45use anyhow::{bail, Result};
46use serde::{Deserialize, Serialize};
47
48/// The pref key selecting the active ingress backend (`webhook.ingress.backend`).
49pub const INGRESS_BACKEND_PREF: &str = "webhook.ingress.backend";
50
51/// The pref key holding the BYO public base URL (the OwnRelay fallback when the
52/// `RYU_WEBHOOK_INGRESS_URL` env override is absent).
53pub const INGRESS_URL_PREF: &str = "webhook.ingress.url";
54
55/// The four ingress backends. Serializes kebab-case so the wire form and the pref
56/// value round-trip (`ryu-relay` / `tailscale-funnel` / `cloudflared` /
57/// `own-relay`).
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum IngressKind {
61 RyuRelay,
62 TailscaleFunnel,
63 Cloudflared,
64 OwnRelay,
65}
66
67impl IngressKind {
68 /// The default backend on a fresh install: the managed RyuRelay push.
69 pub const DEFAULT: IngressKind = IngressKind::RyuRelay;
70
71 /// Every kind, for selector listings.
72 pub const ALL: [IngressKind; 4] = [
73 IngressKind::RyuRelay,
74 IngressKind::TailscaleFunnel,
75 IngressKind::Cloudflared,
76 IngressKind::OwnRelay,
77 ];
78
79 /// The kebab-case wire form (also the pref value). Kept in lockstep with the
80 /// serde derive and [`FromStr`] so the parse + serialize paths never drift.
81 pub fn as_str(&self) -> &'static str {
82 match self {
83 IngressKind::RyuRelay => "ryu-relay",
84 IngressKind::TailscaleFunnel => "tailscale-funnel",
85 IngressKind::Cloudflared => "cloudflared",
86 IngressKind::OwnRelay => "own-relay",
87 }
88 }
89}
90
91impl FromStr for IngressKind {
92 type Err = anyhow::Error;
93 fn from_str(s: &str) -> Result<Self> {
94 match s.trim().to_ascii_lowercase().as_str() {
95 "ryu-relay" | "ryurelay" => Ok(IngressKind::RyuRelay),
96 "tailscale-funnel" | "tailscalefunnel" | "funnel" => Ok(IngressKind::TailscaleFunnel),
97 "cloudflared" => Ok(IngressKind::Cloudflared),
98 "own-relay" | "ownrelay" => Ok(IngressKind::OwnRelay),
99 other => bail!("unknown webhook ingress backend `{other}`"),
100 }
101 }
102}
103
104/// The webhook-ingress trait every backend implements. Native `async fn` (not
105/// object-safe) → stored via the closed [`Ingress`] enum, never `dyn`.
106pub trait WebhookIngress {
107 /// Which backend this is.
108 fn kind(&self) -> IngressKind;
109 /// Start (or adopt) the backend so webhooks can arrive. May be a no-op (the
110 /// OwnRelay case) or error gracefully when the backing infra is absent.
111 async fn start(&self) -> Result<()>;
112 /// The public URL Composio should POST to (ends in [`WEBHOOK_PATH`]).
113 async fn public_url(&self) -> Result<String>;
114}
115
116/// Process-global public URL, set by `main.rs` (and re-settable, so a later
117/// rebuild can update it). A re-settable lock (not `OnceLock`) keeps the
118/// `set_public_url`/`public_url` round-trip stable under cargo's parallel test
119/// runner.
120static PUBLIC_URL: RwLock<Option<String>> = RwLock::new(None);
121
122/// Publish the resolved public ingress URL for `GET /api/webhook-ingress/status`.
123pub fn set_public_url(url: Option<String>) {
124 if let Ok(mut guard) = PUBLIC_URL.write() {
125 *guard = url;
126 }
127}
128
129/// The current public ingress URL, if one has been resolved.
130pub fn public_url() -> Option<String> {
131 PUBLIC_URL.read().ok().and_then(|g| g.clone())
132}
133
134/// The resolved public **origin** base URL (no webhook path) — but ONLY when the
135/// active ingress is a true reverse-proxy origin that forwards *every* path to
136/// Core. `None` otherwise.
137///
138/// The webhook registry (`GET /api/webhooks`) uses this to build a per-endpoint
139/// URL (`base + /api/workflows/<id>/webhook`, …) — the fix for the desktop
140/// showing a `localhost` URL for a workflow webhook.
141///
142/// The discriminator is whether [`public_url`] is [`WEBHOOK_PATH`]-suffixed:
143/// - **Tunnel backends** (Cloudflared / TailscaleFunnel / OwnRelay) publish
144/// `<origin>/api/composio/webhook`. Stripping the suffix yields a real origin
145/// that forwards every path, so `base + <any-path>` is directly reachable.
146/// - **Managed RyuRelay** publishes a relay-ingress endpoint
147/// (`…/api/composio-relay/ingress/<token>`) that is NOT path-composable —
148/// appending `/api/workflows/<id>/webhook` would produce a dead URL. So this
149/// returns `None` for the relay, and the registry advertises `null` for the
150/// per-path (workflow) URLs (they are genuinely not path-addressable until the
151/// server emits the generic inbound frame — see [`dispatch`] + the server
152/// handoff). The relay's own composio ingress URL is still surfaced verbatim
153/// via [`public_url`].
154pub fn public_base_url() -> Option<String> {
155 let u = public_url()?;
156 let base = u.strip_suffix(WEBHOOK_PATH)?;
157 Some(base.trim_end_matches('/').to_owned())
158}
159
160/// The configured backend kind, resolved from (1) the `RYU_WEBHOOK_INGRESS_URL`
161/// env override ⇒ [`IngressKind::OwnRelay`], else (2) the `backend_pref`
162/// (`webhook.ingress.backend`), else (3) the [`IngressKind::DEFAULT`]
163/// (`RyuRelay`). Shared by [`from_prefs`], the backend selector, and the status
164/// handler so they never disagree.
165///
166/// `backend_pref` is the raw `webhook.ingress.backend` pref value (Core reads it;
167/// this crate is `PreferencesStore`-free — the primitive must not know the store).
168pub fn configured_kind(backend_pref: Option<&str>) -> IngressKind {
169 let env_url = std::env::var(OWN_RELAY_URL_ENV)
170 .ok()
171 .map(|v| v.trim().to_owned())
172 .filter(|v| !v.is_empty());
173 if env_url.is_some() {
174 return IngressKind::OwnRelay;
175 }
176 match backend_pref {
177 Some(raw) => IngressKind::from_str(raw).unwrap_or(IngressKind::DEFAULT),
178 None => IngressKind::DEFAULT,
179 }
180}
181
182/// Build the configured [`Ingress`] from the resolved pref values + the local
183/// server URL.
184///
185/// Precedence (acceptance #1): the `RYU_WEBHOOK_INGRESS_URL` env override wins
186/// (OwnRelay), else `backend_pref` (`webhook.ingress.backend`), else the default
187/// (`RyuRelay`). `url_pref` is the `webhook.ingress.url` pref (the OwnRelay
188/// fallback base). `server_url` is Core's own reachable base
189/// (`http://host:port`), used to derive the Funnel/Cloudflared target port.
190pub fn from_prefs(backend_pref: Option<&str>, url_pref: Option<&str>, server_url: &str) -> Ingress {
191 let kind = configured_kind(backend_pref);
192 let port = port_from_url(server_url).unwrap_or(7980);
193 match kind {
194 IngressKind::RyuRelay => Ingress::RyuRelay(RyuRelaySource::new()),
195 IngressKind::TailscaleFunnel => Ingress::TailscaleFunnel(TailscaleFunnelSource::new(port)),
196 IngressKind::Cloudflared => Ingress::Cloudflared(CloudflaredSource::new(port)),
197 IngressKind::OwnRelay => {
198 // OwnRelay base: env override (read inside OwnRelaySource::new) → the
199 // `webhook.ingress.url` pref. There is deliberately NO `server_url`
200 // fallback: the loopback bind addr is not publicly reachable, so
201 // substituting it would let an unconfigured OwnRelay report a green
202 // `up:true` with a `http://127.0.0.1:7980/...` URL Composio can never
203 // reach. An empty base makes `start()`/`public_url()` error and `up`
204 // read `false` until a real public URL is configured.
205 let pref_base = url_pref
206 .map(|s| s.trim().to_owned())
207 .filter(|s| !s.is_empty())
208 .unwrap_or_default();
209 Ingress::OwnRelay(OwnRelaySource::new(pref_base))
210 }
211 }
212}
213
214/// Extract the port from a `scheme://host:port[/...]` URL. Returns `None` when no
215/// explicit port is present (the caller defaults to Core's 7980).
216fn port_from_url(url: &str) -> Option<u16> {
217 let after_scheme = url.split("://").nth(1).unwrap_or(url);
218 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
219 // Strip an IPv6 literal's brackets so the `:port` split below is unambiguous.
220 let authority = authority.rsplit(']').next().unwrap_or(authority);
221 authority.rsplit(':').next().and_then(|p| p.parse().ok())
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn kind_serde_kebab_round_trips() {
230 for kind in IngressKind::ALL {
231 let json = serde_json::to_value(kind).unwrap();
232 let s = json.as_str().unwrap().to_owned();
233 // The serde wire form equals as_str() and parses back via FromStr.
234 assert_eq!(s, kind.as_str());
235 let back: IngressKind = serde_json::from_value(json).unwrap();
236 assert_eq!(back, kind);
237 assert_eq!(IngressKind::from_str(&s).unwrap(), kind);
238 }
239 }
240
241 #[test]
242 fn kind_serde_wire_forms_are_kebab() {
243 assert_eq!(
244 serde_json::to_value(IngressKind::RyuRelay).unwrap(),
245 serde_json::json!("ryu-relay")
246 );
247 assert_eq!(
248 serde_json::to_value(IngressKind::TailscaleFunnel).unwrap(),
249 serde_json::json!("tailscale-funnel")
250 );
251 assert_eq!(
252 serde_json::to_value(IngressKind::OwnRelay).unwrap(),
253 serde_json::json!("own-relay")
254 );
255 }
256
257 #[test]
258 fn from_str_unknown_errors() {
259 assert!(IngressKind::from_str("nope").is_err());
260 }
261
262 /// Serializes the tests that mutate the process-global `PUBLIC_URL` (cargo
263 /// runs them on parallel threads in one process; without this a sibling's
264 /// `set_public_url` could flip the value mid-assertion).
265 static PUBLIC_URL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
266
267 #[test]
268 fn public_url_global_round_trips() {
269 let _guard = PUBLIC_URL_LOCK.lock().unwrap_or_else(|e| e.into_inner());
270 // Re-settable (not OnceLock): two sets both take effect.
271 set_public_url(Some("https://a.example/api/composio/webhook".to_owned()));
272 assert_eq!(
273 public_url().as_deref(),
274 Some("https://a.example/api/composio/webhook")
275 );
276 set_public_url(Some("https://b.example/api/composio/webhook".to_owned()));
277 assert_eq!(
278 public_url().as_deref(),
279 Some("https://b.example/api/composio/webhook")
280 );
281 set_public_url(None);
282 assert!(public_url().is_none());
283 }
284
285 #[test]
286 fn public_base_url_only_for_true_origins() {
287 let _guard = PUBLIC_URL_LOCK.lock().unwrap_or_else(|e| e.into_inner());
288 // A WEBHOOK_PATH-suffixed URL (tunnel/OwnRelay) yields a composable origin.
289 set_public_url(Some("https://x.example/api/composio/webhook".to_owned()));
290 assert_eq!(public_base_url().as_deref(), Some("https://x.example"));
291 // A relay-ingress URL (RyuRelay) is NOT path-composable → None (never a
292 // fabricated dead URL like `<ingress>/api/workflows/<id>/webhook`).
293 set_public_url(Some(
294 "https://s.example/api/composio-relay/ingress/tok123".to_owned(),
295 ));
296 assert!(public_base_url().is_none());
297 // No ingress up → None.
298 set_public_url(None);
299 assert!(public_base_url().is_none());
300 }
301
302 #[test]
303 fn port_from_url_parses() {
304 assert_eq!(port_from_url("http://127.0.0.1:7980"), Some(7980));
305 assert_eq!(port_from_url("http://localhost:3000/api"), Some(3000));
306 assert_eq!(port_from_url("https://[::1]:7980"), Some(7980));
307 assert_eq!(port_from_url("http://example.com"), None);
308 }
309
310 // ── from_prefs branches (acceptance #1) ──────────────────────────────────
311 //
312 // Pref values are passed as plain `Option<&str>` (Core reads the store; this
313 // crate is `PreferencesStore`-free). Env-override branch is exercised inline.
314 //
315 // `OWN_RELAY_URL_ENV` is process-global; cargo runs these tests as parallel
316 // threads in one process. Without serialization, `env_override_forces_own_relay`
317 // can `set_var` the override while a sibling `from_prefs_*` test reads it,
318 // flipping its branch to OwnRelay and failing. All three env-sensitive tests
319 // acquire ENV_LOCK for their full duration so the env mutation is serialized
320 // against the readers (an `is_err()` guard alone cannot win a concurrent race).
321 // `unwrap_or_else(|e| e.into_inner())` recovers a poisoned lock so a single
322 // failing assertion doesn't cascade into the other two and mask the real one.
323 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
324
325 #[tokio::test]
326 async fn from_prefs_defaults_to_ryu_relay() {
327 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
328 // No env override, no pref → RyuRelay (the headline AC). Guard on the env
329 // being unset so a CI machine with it set doesn't flip the branch.
330 if std::env::var(OWN_RELAY_URL_ENV).is_err() {
331 let ing = from_prefs(None, None, "http://127.0.0.1:7980");
332 assert_eq!(ing.kind(), IngressKind::RyuRelay);
333 }
334 }
335
336 #[tokio::test]
337 async fn from_prefs_honours_pref() {
338 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
339 if std::env::var(OWN_RELAY_URL_ENV).is_err() {
340 let ing = from_prefs(Some("tailscale-funnel"), None, "http://127.0.0.1:7980");
341 assert_eq!(ing.kind(), IngressKind::TailscaleFunnel);
342
343 let ing = from_prefs(
344 Some("own-relay"),
345 Some("https://relay.example.com"),
346 "http://127.0.0.1:7980",
347 );
348 assert_eq!(ing.kind(), IngressKind::OwnRelay);
349 assert_eq!(
350 ing.public_url().await.unwrap(),
351 "https://relay.example.com/api/composio/webhook"
352 );
353 }
354 }
355
356 #[tokio::test]
357 async fn env_override_forces_own_relay() {
358 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
359 // Set the env override for this test only, then clear it. env is
360 // process-global; ENV_LOCK serializes this mutation against the sibling
361 // from_prefs_* readers (edition 2021, so set_var/remove_var are safe).
362 std::env::set_var(OWN_RELAY_URL_ENV, "https://ovr.example.com");
363 // Even with a conflicting pref, the env override wins.
364 let kind = configured_kind(Some("cloudflared"));
365 let ing = from_prefs(Some("cloudflared"), None, "http://127.0.0.1:7980");
366 std::env::remove_var(OWN_RELAY_URL_ENV);
367 assert_eq!(kind, IngressKind::OwnRelay);
368 assert_eq!(ing.kind(), IngressKind::OwnRelay);
369 assert_eq!(
370 ing.public_url().await.unwrap(),
371 "https://ovr.example.com/api/composio/webhook"
372 );
373 }
374}