1use std::net::SocketAddr;
39use std::sync::Arc;
40use std::time::Duration;
41
42use axum::body::Bytes;
43use axum::extract::{ConnectInfo, State};
44use axum::http::{header, HeaderMap, Method, StatusCode, Uri};
45use axum::response::{IntoResponse, Response};
46use axum::Router;
47use serde_json::{json, Value};
48use tokio::sync::{mpsc, oneshot};
49use tokio_util::sync::CancellationToken;
50
51use super::Event;
52
53pub const ACK_TIMEOUT: Duration = Duration::from_secs(30);
56
57const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
59
60pub const DEFAULT_KIND: &str = "http";
62
63#[derive(Debug, Clone)]
65pub struct HttpSourceConfig {
66 pub bind: String,
69 pub token: Option<String>,
72 pub kind: String,
74 pub meta: Value,
78}
79
80impl HttpSourceConfig {
81 pub fn new(bind: impl Into<String>) -> Self {
83 Self {
84 bind: bind.into(),
85 token: None,
86 kind: DEFAULT_KIND.to_string(),
87 meta: Value::Object(Default::default()),
88 }
89 }
90
91 pub fn token(mut self, token: impl Into<String>) -> Self {
92 self.token = Some(token.into());
93 self
94 }
95
96 pub fn kind(mut self, kind: impl Into<String>) -> Self {
97 self.kind = kind.into();
98 self
99 }
100
101 pub fn meta(mut self, meta: Value) -> Self {
102 self.meta = meta;
103 self
104 }
105}
106
107#[derive(Debug)]
109pub struct HttpSourceHandle {
110 pub addr: SocketAddr,
113 task: tokio::task::JoinHandle<()>,
114 cancel: CancellationToken,
115}
116
117impl HttpSourceHandle {
118 pub async fn shutdown(self) {
120 self.cancel.cancel();
121 match tokio::time::timeout(SHUTDOWN_TIMEOUT, self.task).await {
122 Ok(Ok(())) => tracing::info!("http source: listener shut down"),
123 Ok(Err(e)) => tracing::error!(error = %e, "http source: listener task join error"),
124 Err(_) => tracing::warn!(
125 timeout_secs = SHUTDOWN_TIMEOUT.as_secs(),
126 "http source: listener did not stop in time; abandoned"
127 ),
128 }
129 }
130}
131
132struct Inner {
133 tx: mpsc::Sender<Event>,
134 token: Option<String>,
135 kind: String,
136 meta: Value,
137 allowed_hosts: Vec<String>,
138}
139
140pub async fn start(
143 config: HttpSourceConfig,
144 tx: mpsc::Sender<Event>,
145) -> Result<HttpSourceHandle, String> {
146 let listener = tokio::net::TcpListener::bind(&config.bind)
147 .await
148 .map_err(|e| format!("http source: bind {}: {e}", config.bind))?;
149 let addr = listener
150 .local_addr()
151 .map_err(|e| format!("http source: local_addr: {e}"))?;
152
153 let mut allowed_hosts = vec![
154 "localhost".to_string(),
155 "127.0.0.1".to_string(),
156 "::1".to_string(),
157 ];
158 if !addr.ip().is_loopback() {
159 allowed_hosts.push(addr.ip().to_string());
160 }
161
162 let inner = Arc::new(Inner {
163 tx,
164 token: config.token,
165 kind: config.kind,
166 meta: if config.meta.is_object() {
167 config.meta
168 } else {
169 Value::Object(Default::default())
170 },
171 allowed_hosts,
172 });
173 let app = Router::new().fallback(handle).with_state(inner);
174
175 let cancel = CancellationToken::new();
176 let stop = cancel.clone();
177 let task = tokio::spawn(async move {
178 let server = axum::serve(
179 listener,
180 app.into_make_service_with_connect_info::<SocketAddr>(),
181 )
182 .with_graceful_shutdown(async move { stop.cancelled().await });
183 if let Err(e) = server.await {
184 tracing::error!(error = %e, "http source: listener ended with error");
185 }
186 });
187 tracing::info!(%addr, "http source: listening");
188 Ok(HttpSourceHandle { addr, task, cancel })
189}
190
191pub fn host_only(value: &str) -> String {
194 let value = value.trim();
195 if let Some(rest) = value.strip_prefix('[') {
196 if let Some(end) = rest.find(']') {
197 return rest[..end].to_string();
198 }
199 }
200 match value.rsplit_once(':') {
201 Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => host.to_string(),
202 _ => value.to_string(),
203 }
204}
205
206fn origin_host(value: &str) -> Option<String> {
208 let rest = value.trim().split_once("://")?.1;
209 let authority = rest.split('/').next()?;
210 Some(host_only(authority))
211}
212
213fn reply(status: u16, body: Value) -> Response {
214 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
215 (status, axum::Json(body)).into_response()
216}
217
218fn answer(value: Value) -> Response {
221 if let Some(obj) = value.as_object() {
222 if obj.contains_key("status") || obj.contains_key("body") {
223 let status = obj
224 .get("status")
225 .and_then(Value::as_u64)
226 .and_then(|s| u16::try_from(s).ok())
227 .unwrap_or(200);
228 let body = obj.get("body").cloned().unwrap_or(Value::Null);
229 return reply(status, body);
230 }
231 }
232 reply(200, value)
233}
234
235async fn handle(
236 State(inner): State<Arc<Inner>>,
237 ConnectInfo(remote): ConnectInfo<SocketAddr>,
238 method: Method,
239 uri: Uri,
240 headers: HeaderMap,
241 body: Bytes,
242) -> Response {
243 let host = headers
244 .get(header::HOST)
245 .and_then(|v| v.to_str().ok())
246 .map(host_only);
247 let host_ok = host
248 .as_ref()
249 .map(|h| inner.allowed_hosts.iter().any(|a| a == h))
250 .unwrap_or(false);
251 if !host_ok {
252 return reply(403, json!({ "error": "host not allowed" }));
253 }
254 if let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
255 let origin_ok = origin_host(origin)
256 .map(|h| inner.allowed_hosts.iter().any(|a| a == &h))
257 .unwrap_or(false);
258 if !origin_ok {
259 return reply(403, json!({ "error": "origin not allowed" }));
260 }
261 }
262 if let Some(expected) = &inner.token {
263 let presented = headers
264 .get(header::AUTHORIZATION)
265 .and_then(|v| v.to_str().ok())
266 .and_then(|v| v.strip_prefix("Bearer "))
267 .map(str::trim);
268 if presented != Some(expected.as_str()) {
269 return reply(401, json!({ "error": "token required" }));
270 }
271 }
272
273 let body_value = if body.is_empty() {
274 Value::Null
275 } else {
276 serde_json::from_slice(&body)
277 .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&body).into_owned()))
278 };
279 let payload = json!({
280 "method": method.as_str(),
281 "path": uri.path(),
282 "query": uri.query(),
283 "body": body_value,
284 });
285 let mut meta = inner.meta.clone();
286 if let Some(obj) = meta.as_object_mut() {
287 obj.insert("remote".to_string(), Value::String(remote.to_string()));
288 }
289
290 let id = uuid::Uuid::new_v4().to_string();
291 let (ack_tx, ack_rx) = oneshot::channel();
292 let event = Event {
293 kind: inner.kind.clone(),
294 id: id.clone(),
295 payload,
296 meta,
297 ack_tx: Some(ack_tx),
298 };
299 if let Err(e) = inner.tx.send(event).await {
300 tracing::error!(error = %e, id = %id, "http source: bus channel closed; rejecting request");
301 return reply(503, json!({ "error": "bus channel closed" }));
302 }
303 match tokio::time::timeout(ACK_TIMEOUT, ack_rx).await {
304 Ok(Ok(Ok(value))) => answer(value),
305 Ok(Ok(Err(e))) => {
306 tracing::error!(id = %id, error = %e, "http source: handler returned error");
307 reply(500, json!({ "error": e.to_string() }))
308 }
309 Ok(Err(e)) => {
310 tracing::error!(id = %id, error = %e, "http source: ack receiver dropped");
311 reply(500, json!({ "error": "ack dropped" }))
312 }
313 Err(_) => {
314 tracing::error!(id = %id, timeout_secs = ACK_TIMEOUT.as_secs(), "http source: handler timeout");
315 reply(504, json!({ "error": "handler timeout" }))
316 }
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn host_only_drops_a_port_and_ipv6_brackets() {
326 assert_eq!(host_only("localhost:7788"), "localhost");
327 assert_eq!(host_only("127.0.0.1"), "127.0.0.1");
328 assert_eq!(host_only("[::1]:7788"), "::1");
329 assert_eq!(host_only("[::1]"), "::1");
330 assert_eq!(host_only("evil.example"), "evil.example");
331 }
332
333 #[test]
334 fn origin_host_reads_the_authority() {
335 assert_eq!(
336 origin_host("http://localhost:3000").as_deref(),
337 Some("localhost")
338 );
339 assert_eq!(
340 origin_host("https://evil.example/x").as_deref(),
341 Some("evil.example")
342 );
343 assert_eq!(origin_host("null"), None);
344 }
345
346 async fn echoing() -> (HttpSourceHandle, tokio::task::JoinHandle<()>) {
349 let (tx, mut rx) = mpsc::channel::<Event>(8);
350 let handle = start(HttpSourceConfig::new("127.0.0.1:0").token("t0k3n"), tx)
351 .await
352 .expect("bind");
353 let pump = tokio::spawn(async move {
354 while let Some(mut ev) = rx.recv().await {
355 let ack = ev.ack_tx.take().expect("ack channel");
356 let has_remote = ev.meta.get("remote").is_some();
357 let _ = ack.send(Ok(json!({
358 "status": 201,
359 "body": { "echo": ev.payload, "kind": ev.kind, "remote": has_remote },
360 })));
361 }
362 });
363 (handle, pump)
364 }
365
366 #[tokio::test]
367 async fn a_request_with_the_token_becomes_an_event_and_the_answer_the_response() {
368 let (handle, pump) = echoing().await;
369 let url = format!(
370 "http://127.0.0.1:{}/jobs/x/runs?limit=3",
371 handle.addr.port()
372 );
373 let res = reqwest::Client::new()
374 .post(&url)
375 .bearer_auth("t0k3n")
376 .header(header::CONTENT_TYPE, "application/json")
377 .body(r#"{"by":"test"}"#)
378 .send()
379 .await
380 .expect("request");
381 assert_eq!(res.status().as_u16(), 201);
382 let body: Value = res.json().await.expect("json");
383 assert_eq!(body["kind"], "http");
384 assert_eq!(body["remote"], true);
385 assert_eq!(body["echo"]["method"], "POST");
386 assert_eq!(body["echo"]["path"], "/jobs/x/runs");
387 assert_eq!(body["echo"]["query"], "limit=3");
388 assert_eq!(body["echo"]["body"]["by"], "test");
389 handle.shutdown().await;
390 pump.abort();
391 }
392
393 #[tokio::test]
394 async fn without_the_token_nothing_reaches_the_bus() {
395 let (handle, pump) = echoing().await;
396 let url = format!("http://127.0.0.1:{}/jobs", handle.addr.port());
397 let res = reqwest::get(&url).await.expect("request");
398 assert_eq!(res.status().as_u16(), 401);
399 let res = reqwest::Client::new()
400 .get(&url)
401 .bearer_auth("wrong")
402 .send()
403 .await
404 .expect("request");
405 assert_eq!(res.status().as_u16(), 401);
406 handle.shutdown().await;
407 pump.abort();
408 }
409
410 #[tokio::test]
411 async fn a_foreign_host_or_origin_is_refused_before_the_token_is_read() {
412 let (handle, pump) = echoing().await;
413 let url = format!("http://127.0.0.1:{}/jobs", handle.addr.port());
414 let client = reqwest::Client::new();
415 let res = client
416 .get(&url)
417 .header(header::HOST, "evil.example")
418 .send()
419 .await
420 .expect("request");
421 assert_eq!(res.status().as_u16(), 403);
422 let res = client
423 .get(&url)
424 .bearer_auth("t0k3n")
425 .header(header::ORIGIN, "http://evil.example")
426 .send()
427 .await
428 .expect("request");
429 assert_eq!(res.status().as_u16(), 403);
430 let res = client
431 .get(&url)
432 .bearer_auth("t0k3n")
433 .header(header::ORIGIN, "http://localhost:5173")
434 .send()
435 .await
436 .expect("request");
437 assert_eq!(res.status().as_u16(), 201);
438 handle.shutdown().await;
439 pump.abort();
440 }
441}