Skip to main content

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}