mod dispatch;
mod host;
mod ryu_relay;
mod tunnels;
mod win_process;
pub use dispatch::{
deliver_inbound, deliver_workflow_webhook, last_delivery, record_delivery, timestamp_fresh,
workflow_webhook_path, InboundOutcome, WorkflowWebhookOutcome,
};
pub use host::{set_global_host, WebhookIngressHost, WorkflowWebhookSecret};
pub use ryu_relay::relay_inbound_url;
pub use tunnels::{
CloudflaredSource, Ingress, OwnRelaySource, RyuRelaySource, TailscaleFunnelSource,
OWN_RELAY_URL_ENV, WEBHOOK_PATH,
};
use std::str::FromStr;
use std::sync::RwLock;
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
pub const INGRESS_BACKEND_PREF: &str = "webhook.ingress.backend";
pub const INGRESS_URL_PREF: &str = "webhook.ingress.url";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IngressKind {
RyuRelay,
TailscaleFunnel,
Cloudflared,
OwnRelay,
}
impl IngressKind {
pub const DEFAULT: IngressKind = IngressKind::RyuRelay;
pub const ALL: [IngressKind; 4] = [
IngressKind::RyuRelay,
IngressKind::TailscaleFunnel,
IngressKind::Cloudflared,
IngressKind::OwnRelay,
];
pub fn as_str(&self) -> &'static str {
match self {
IngressKind::RyuRelay => "ryu-relay",
IngressKind::TailscaleFunnel => "tailscale-funnel",
IngressKind::Cloudflared => "cloudflared",
IngressKind::OwnRelay => "own-relay",
}
}
}
impl FromStr for IngressKind {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"ryu-relay" | "ryurelay" => Ok(IngressKind::RyuRelay),
"tailscale-funnel" | "tailscalefunnel" | "funnel" => Ok(IngressKind::TailscaleFunnel),
"cloudflared" => Ok(IngressKind::Cloudflared),
"own-relay" | "ownrelay" => Ok(IngressKind::OwnRelay),
other => bail!("unknown webhook ingress backend `{other}`"),
}
}
}
pub trait WebhookIngress {
fn kind(&self) -> IngressKind;
async fn start(&self) -> Result<()>;
async fn public_url(&self) -> Result<String>;
}
static PUBLIC_URL: RwLock<Option<String>> = RwLock::new(None);
pub fn set_public_url(url: Option<String>) {
if let Ok(mut guard) = PUBLIC_URL.write() {
*guard = url;
}
}
pub fn public_url() -> Option<String> {
PUBLIC_URL.read().ok().and_then(|g| g.clone())
}
pub fn public_base_url() -> Option<String> {
let u = public_url()?;
let base = u.strip_suffix(WEBHOOK_PATH)?;
Some(base.trim_end_matches('/').to_owned())
}
pub fn configured_kind(backend_pref: Option<&str>) -> IngressKind {
let env_url = std::env::var(OWN_RELAY_URL_ENV)
.ok()
.map(|v| v.trim().to_owned())
.filter(|v| !v.is_empty());
if env_url.is_some() {
return IngressKind::OwnRelay;
}
match backend_pref {
Some(raw) => IngressKind::from_str(raw).unwrap_or(IngressKind::DEFAULT),
None => IngressKind::DEFAULT,
}
}
pub fn from_prefs(backend_pref: Option<&str>, url_pref: Option<&str>, server_url: &str) -> Ingress {
let kind = configured_kind(backend_pref);
let port = port_from_url(server_url).unwrap_or(7980);
match kind {
IngressKind::RyuRelay => Ingress::RyuRelay(RyuRelaySource::new()),
IngressKind::TailscaleFunnel => Ingress::TailscaleFunnel(TailscaleFunnelSource::new(port)),
IngressKind::Cloudflared => Ingress::Cloudflared(CloudflaredSource::new(port)),
IngressKind::OwnRelay => {
let pref_base = url_pref
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.unwrap_or_default();
Ingress::OwnRelay(OwnRelaySource::new(pref_base))
}
}
}
fn port_from_url(url: &str) -> Option<u16> {
let after_scheme = url.split("://").nth(1).unwrap_or(url);
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
let authority = authority.rsplit(']').next().unwrap_or(authority);
authority.rsplit(':').next().and_then(|p| p.parse().ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_serde_kebab_round_trips() {
for kind in IngressKind::ALL {
let json = serde_json::to_value(kind).unwrap();
let s = json.as_str().unwrap().to_owned();
assert_eq!(s, kind.as_str());
let back: IngressKind = serde_json::from_value(json).unwrap();
assert_eq!(back, kind);
assert_eq!(IngressKind::from_str(&s).unwrap(), kind);
}
}
#[test]
fn kind_serde_wire_forms_are_kebab() {
assert_eq!(
serde_json::to_value(IngressKind::RyuRelay).unwrap(),
serde_json::json!("ryu-relay")
);
assert_eq!(
serde_json::to_value(IngressKind::TailscaleFunnel).unwrap(),
serde_json::json!("tailscale-funnel")
);
assert_eq!(
serde_json::to_value(IngressKind::OwnRelay).unwrap(),
serde_json::json!("own-relay")
);
}
#[test]
fn from_str_unknown_errors() {
assert!(IngressKind::from_str("nope").is_err());
}
static PUBLIC_URL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn public_url_global_round_trips() {
let _guard = PUBLIC_URL_LOCK.lock().unwrap_or_else(|e| e.into_inner());
set_public_url(Some("https://a.example/api/composio/webhook".to_owned()));
assert_eq!(
public_url().as_deref(),
Some("https://a.example/api/composio/webhook")
);
set_public_url(Some("https://b.example/api/composio/webhook".to_owned()));
assert_eq!(
public_url().as_deref(),
Some("https://b.example/api/composio/webhook")
);
set_public_url(None);
assert!(public_url().is_none());
}
#[test]
fn public_base_url_only_for_true_origins() {
let _guard = PUBLIC_URL_LOCK.lock().unwrap_or_else(|e| e.into_inner());
set_public_url(Some("https://x.example/api/composio/webhook".to_owned()));
assert_eq!(public_base_url().as_deref(), Some("https://x.example"));
set_public_url(Some(
"https://s.example/api/composio-relay/ingress/tok123".to_owned(),
));
assert!(public_base_url().is_none());
set_public_url(None);
assert!(public_base_url().is_none());
}
#[test]
fn port_from_url_parses() {
assert_eq!(port_from_url("http://127.0.0.1:7980"), Some(7980));
assert_eq!(port_from_url("http://localhost:3000/api"), Some(3000));
assert_eq!(port_from_url("https://[::1]:7980"), Some(7980));
assert_eq!(port_from_url("http://example.com"), None);
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[tokio::test]
async fn from_prefs_defaults_to_ryu_relay() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
if std::env::var(OWN_RELAY_URL_ENV).is_err() {
let ing = from_prefs(None, None, "http://127.0.0.1:7980");
assert_eq!(ing.kind(), IngressKind::RyuRelay);
}
}
#[tokio::test]
async fn from_prefs_honours_pref() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
if std::env::var(OWN_RELAY_URL_ENV).is_err() {
let ing = from_prefs(Some("tailscale-funnel"), None, "http://127.0.0.1:7980");
assert_eq!(ing.kind(), IngressKind::TailscaleFunnel);
let ing = from_prefs(
Some("own-relay"),
Some("https://relay.example.com"),
"http://127.0.0.1:7980",
);
assert_eq!(ing.kind(), IngressKind::OwnRelay);
assert_eq!(
ing.public_url().await.unwrap(),
"https://relay.example.com/api/composio/webhook"
);
}
}
#[tokio::test]
async fn env_override_forces_own_relay() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var(OWN_RELAY_URL_ENV, "https://ovr.example.com");
let kind = configured_kind(Some("cloudflared"));
let ing = from_prefs(Some("cloudflared"), None, "http://127.0.0.1:7980");
std::env::remove_var(OWN_RELAY_URL_ENV);
assert_eq!(kind, IngressKind::OwnRelay);
assert_eq!(ing.kind(), IngressKind::OwnRelay);
assert_eq!(
ing.public_url().await.unwrap(),
"https://ovr.example.com/api/composio/webhook"
);
}
}