//! Healthchecks.io style pings for scheduled backups. `GET /start` when a run begins, then //! `GET ` on success or `POST /fail` with the error text. The URL is admin-configured and //! trusted, so it is not SSRF-guarded. Pings never fail a backup: an error is logged and dropped. use std::time::Duration; const PING_TIMEOUT: Duration = Duration::from_secs(10); /// Longest error text sent with a failure ping. const MAX_BODY_CHARS: usize = 1000; /// Pings `/start`. pub async fn start(url: &str) { send(url, "/start", None).await; } /// Pings `` after a good run. pub async fn success(url: &str) { send(url, "", None).await; } /// Pings `/fail` with the reason. pub async fn failure(url: &str, reason: &str) { let body: String = reason.chars().take(MAX_BODY_CHARS).collect(); send(url, "/fail", Some(body)).await; } async fn send(url: &str, suffix: &str, body: Option) { let target = format!("{}{suffix}", url.trim_end_matches('/')); let client = match reqwest::Client::builder().timeout(PING_TIMEOUT).build() { Ok(client) => client, Err(e) => { tracing::warn!(error = %e, "healthcheck client could not be built"); return; } }; let request = match body { Some(text) => client.post(&target).body(text), None => client.get(&target), }; match request.send().await { Ok(res) if res.status().is_success() => {} Ok(res) => tracing::warn!(status = %res.status(), "healthcheck ping was refused"), Err(e) => tracing::warn!(error = %e, "healthcheck ping failed"), } } #[cfg(test)] mod tests { use super::*; use std::sync::{Arc, Mutex}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; /// Serves `count` requests and returns the request lines and bodies seen. async fn serve(count: usize) -> (String, Arc>>) { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); let seen = Arc::new(Mutex::new(Vec::new())); let log = Arc::clone(&seen); tokio::spawn(async move { for _ in 0..count { let (mut sock, _) = listener.accept().await.expect("accept"); let mut buf = vec![0u8; 8192]; let n = sock.read(&mut buf).await.expect("read"); let text = String::from_utf8_lossy(&buf[..n]).into_owned(); let line = text.lines().next().unwrap_or_default().to_owned(); let body = text.split("\r\n\r\n").nth(1).unwrap_or_default().to_owned(); log.lock().expect("lock").push((line, body)); sock.write_all( b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", ) .await .expect("write"); } }); (format!("http://{addr}/ping/abc/"), seen) } #[tokio::test] async fn start_success_and_failure_hit_the_expected_paths() { let (url, seen) = serve(3).await; start(&url).await; success(&url).await; failure(&url, "backup exploded").await; // Give the server task a moment to record the last request. for _ in 0..50 { if seen.lock().expect("lock").len() == 3 { break; } tokio::time::sleep(Duration::from_millis(10)).await; } let seen = seen.lock().expect("lock").clone(); assert_eq!(seen.len(), 3); assert_eq!(seen[0].0, "GET /ping/abc/start HTTP/1.1"); assert_eq!(seen[1].0, "GET /ping/abc HTTP/1.1"); assert_eq!(seen[2].0, "POST /ping/abc/fail HTTP/1.1"); assert_eq!(seen[2].1, "backup exploded"); } #[tokio::test] async fn an_unreachable_endpoint_does_not_panic() { // Nothing listens on this port: the ping logs and returns. let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); drop(listener); start(&format!("http://{addr}")).await; } }