From 90ba131ad9f3b9935df982eab19d83e3e98c5779 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 5 Jul 2026 23:16:43 -0400 Subject: [PATCH] =?UTF-8?q?fix(trunk):=20derive=20TwiML=20Stream=20URL=20f?= =?UTF-8?q?rom=20RUTSTER=5FTWILIO=5FWEBHOOK=5FBASE;=20wire=20twilio=5Fcred?= =?UTF-8?q?entials=20into=20startup=20(deploy-A=20=C2=A75.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kills the slice-5 placeholder's hardcoded ws://127.0.0.1:8080 Stream URL (routes.rs) — no CPaaS could ever dial it. The webhook now answers with wss:///twilio/media-stream derived from the operator's configured base (authority-only; https→wss, http→ws for the dev loop), or 503 when the trunk is unconfigured. config::twilio_credentials existed since slice-5 but was never called: partial/malformed RUTSTER_TWILIO_* config booted silently WebRTC-only. main.rs now fail-fasts at startup, matching every other RUTSTER_* knob. Signed-off-by: Aaron D. Lee --- crates/rutster/src/main.rs | 19 +++- crates/rutster/src/routes.rs | 149 ++++++++++++++++++++++++++---- crates/rutster/src/session_map.rs | 14 +++ 3 files changed, 163 insertions(+), 19 deletions(-) diff --git a/crates/rutster/src/main.rs b/crates/rutster/src/main.rs index a8cf3bb..9743be8 100644 --- a/crates/rutster/src/main.rs +++ b/crates/rutster/src/main.rs @@ -36,7 +36,24 @@ async fn main() { // Placeholder sender so we can build an AppState; the real sender comes // from the spawned MediaThread below. let (placeholder_tx, _placeholder_rx) = mpsc::channel(1); - let app_state = AppState::new(placeholder_tx, default_tap_url); + // Deploy slice A §5.3: config::twilio_credentials was parsed-but- + // never-called since slice-5 — wired into startup validation here. + // Partial or malformed RUTSTER_TWILIO_* config now fails the boot + // loudly instead of silently running WebRTC-only. (The credentials + // themselves stay green-zone: only webhook_base is threaded into the + // routes; TwilioCredentials' Debug redacts auth_token — ADR-0009.) + let twilio_credentials = rutster::config::twilio_credentials( + std::env::var("RUTSTER_TWILIO_ACCOUNT_SID").ok(), + std::env::var("RUTSTER_TWILIO_AUTH_TOKEN").ok(), + std::env::var("RUTSTER_TWILIO_MEDIA_BIND").ok(), + std::env::var("RUTSTER_TWILIO_WEBHOOK_BASE").ok(), + ) + .expect("RUTSTER_TWILIO_* env config invalid"); + if let Some(creds) = twilio_credentials.as_ref() { + info!(webhook_base = %creds.webhook_base, "trunk configured"); + } + let app_state = AppState::new(placeholder_tx, default_tap_url) + .with_trunk_webhook_base(twilio_credentials.as_ref().map(|c| c.webhook_base.clone())); let media_cfg = rutster::config::media_address_config( std::env::var("RUTSTER_MEDIA_BIND_IP").ok(), diff --git a/crates/rutster/src/routes.rs b/crates/rutster/src/routes.rs index 3e8ccc4..4e0a55f 100644 --- a/crates/rutster/src/routes.rs +++ b/crates/rutster/src/routes.rs @@ -242,29 +242,76 @@ pub async fn originate_trunk_call() -> Response { .into_response() } -/// POST /v1/trunk/webhook -- Twilio's inbound-call signaling webhook -/// receiver (spec §3.6 + §4.1 step 2). The handler responds with TwiML -/// instructing Twilio to Media Streams against our /twilio/media-stream -/// endpoint. The TwiML is: +/// Derive the TwiML `` URL from the operator's public webhook +/// base (`RUTSTER_TWILIO_WEBHOOK_BASE`): `https://…` → `wss://…`, +/// `http://…` → `ws://…` (dev loop), path fixed to the WS route's mount +/// point. /// -/// ```xml -/// -/// -/// -/// -/// -/// ``` +/// Authority-only by design: any path on the base is dropped, because +/// `TwilioMediaStreamsServer::router` mounts the WS route absolutely at +/// `/twilio/media-stream` — a path-prefixing edge must strip its prefix +/// before the FOB, exactly like it must for every other route. +fn stream_url_from_webhook_base(base: &url::Url) -> Result { + let ws_scheme = match base.scheme() { + "https" => "wss", + "http" => "ws", + other => { + return Err(format!( + "webhook base must be http(s); got scheme {other:?}" + )); + } + }; + let mut stream_url = base + .join("/twilio/media-stream") + .map_err(|e| format!("webhook base rejects a path join: {e}"))?; + stream_url + .set_scheme(ws_scheme) + .map_err(|()| format!("cannot rewrite scheme to {ws_scheme}"))?; + Ok(stream_url) +} + +/// POST /v1/trunk/webhook — Twilio's inbound-call signaling webhook +/// receiver (deploy slice A §5.3). Responds with TwiML instructing Twilio +/// to open a Media Streams fork against our `/twilio/media-stream` WS +/// endpoint, at the PUBLIC address derived from +/// `RUTSTER_TWILIO_WEBHOOK_BASE` — replacing the slice-5 placeholder's +/// hardcoded `ws://127.0.0.1:8080`, which no CPaaS could ever dial. /// -/// Stub T5: emits the TwiML but the URL is hardcoded loopback for now. -/// The full impl lands with the Step B refactor + dev-b's TwilioCredentials -/// env parser (`webhook_base` from RUTSTER_THIRTY_WEBHOOK_BASE). -pub async fn twilio_inbound_webhook() -> Response { - let twiml = r#" +/// 503 when the trunk is unconfigured: a webhook arriving without +/// `RUTSTER_TWILIO_*` set is an operator misconfiguration (Twilio was +/// pointed here, the binary wasn't told its public name) — failing the +/// call loudly beats returning a loopback Stream URL that dies silently +/// 10 s later. No XML escaping needed: the derived URL is +/// scheme+authority+fixed-path, no query, and `url` forbids `"` in +/// authorities. +pub async fn twilio_inbound_webhook(State(state): State) -> Response { + let Some(base) = state.trunk_webhook_base.as_ref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "trunk not configured: set the RUTSTER_TWILIO_* env vars \ + (webhook received, but there is no RUTSTER_TWILIO_WEBHOOK_BASE \ + to derive the public Stream URL from)", + ) + .into_response(); + }; + let stream_url = match stream_url_from_webhook_base(base) { + Ok(u) => u, + Err(e) => { + // Defensive: config::twilio_credentials validated the URL at + // startup, so this arm is unreachable in a correctly-booted + // binary. 500, never a panic. + tracing::error!(error = %e, "webhook base failed Stream-URL derivation"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + let twiml = format!( + r#" - + -"#; +"# + ); ( StatusCode::OK, [(header::CONTENT_TYPE, "application/xml")], @@ -332,4 +379,70 @@ mod tests { .unwrap(); assert_eq!(health.status(), StatusCode::OK); } + + #[test] + fn stream_url_derivation_maps_https_to_wss_and_keeps_authority() { + let base = url::Url::parse("https://pbx.example.com:8443").unwrap(); + let u = stream_url_from_webhook_base(&base).unwrap(); + assert_eq!(u.as_str(), "wss://pbx.example.com:8443/twilio/media-stream"); + } + + #[test] + fn stream_url_derivation_maps_http_to_ws_for_dev() { + let base = url::Url::parse("http://localhost:8080").unwrap(); + let u = stream_url_from_webhook_base(&base).unwrap(); + assert_eq!(u.as_str(), "ws://localhost:8080/twilio/media-stream"); + } + + #[test] + fn stream_url_derivation_rejects_non_http_schemes() { + let base = url::Url::parse("ftp://pbx.example.com").unwrap(); + let err = stream_url_from_webhook_base(&base).unwrap_err(); + assert!(err.contains("http"), "msg: {err}"); + } + + #[tokio::test] + async fn webhook_derives_stream_url_from_configured_base() { + let state = AppState::default() + .with_trunk_webhook_base(Some(url::Url::parse("https://pbx.example.com").unwrap())); + let app = router(state); + let resp = app + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/v1/trunk/webhook") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); + let twiml = String::from_utf8(body.to_vec()).unwrap(); + assert!( + twiml.contains(r#""#), + "twiml: {twiml}" + ); + assert!( + !twiml.contains("127.0.0.1"), + "hardcoded loopback must be gone: {twiml}" + ); + } + + #[tokio::test] + async fn webhook_503_when_trunk_unconfigured() { + // Default AppState: trunk_webhook_base = None (no RUTSTER_TWILIO_*). + let app = router(AppState::default()); + let resp = app + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/v1/trunk/webhook") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } } diff --git a/crates/rutster/src/session_map.rs b/crates/rutster/src/session_map.rs index 7c7265a..f29f8d0 100644 --- a/crates/rutster/src/session_map.rs +++ b/crates/rutster/src/session_map.rs @@ -24,6 +24,11 @@ use crate::media_thread::{MediaCmd, MediaThread}; pub struct AppState { pub cmd_tx: mpsc::Sender, pub default_tap_url: url::Url, + /// Public base URL the CPaaS calls back on + /// (`RUTSTER_TWILIO_WEBHOOK_BASE`). `None` = trunk unconfigured — the + /// webhook answers 503 instead of minting a TwiML Stream URL nobody + /// can dial (deploy slice A §5.3). + pub trunk_webhook_base: Option, } impl AppState { @@ -37,9 +42,18 @@ impl AppState { Self { cmd_tx, default_tap_url, + trunk_webhook_base: None, } } + /// Thread the operator's public webhook base (from + /// `config::twilio_credentials`) into the routes. Builder-style so the + /// two-arg `new` keeps its many existing call sites. + pub fn with_trunk_webhook_base(mut self, base: Option) -> Self { + self.trunk_webhook_base = base; + self + } + /// Ask the media thread to create a fresh `RtcSession`, returning its id. pub async fn create_session( &self,