Skip to main content

gate4agent_wake/
routine.rs

1//! Routine webhook deliver channel: one JSON POST to a session's preconfigured routine URL.
2//! Moved here from mail4agent's `post_routine_json` (2026-10-13): the messenger no longer makes
3//! HTTP calls to routines; the host (hatchery node) delivers through this.
4//!
5//! One attempt, 8 seconds, no redirects. 2xx means the routine took the letter. The key goes as
6//! `Authorization: Bearer` and `X-Automation-Key`; neither the key nor the URL is ever put in an
7//! error.
8
9use std::time::Duration;
10
11/// Why a routine POST failed. `Display` never carries the URL or the key.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RoutineError {
14    /// URL refused before any socket was opened.
15    BadUrl,
16    /// Key refused (not header-safe).
17    BadBearer,
18    /// Transport failure (connect, TLS, timeout), already redacted.
19    Transport(String),
20    /// Non-2xx answer.
21    Status(u16),
22}
23
24impl std::fmt::Display for RoutineError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Self::BadUrl => f.write_str("routine url refused"),
28            Self::BadBearer => f.write_str("routine key refused"),
29            Self::Transport(t) => write!(f, "routine transport: {t}"),
30            Self::Status(s) => write!(f, "routine status {s}"),
31        }
32    }
33}
34
35impl std::error::Error for RoutineError {}
36
37impl RoutineError {
38    /// The HTTP status, when the routine answered.
39    pub fn status(&self) -> Option<u16> {
40        match self {
41            Self::Status(s) => Some(*s),
42            _ => None,
43        }
44    }
45}
46
47const MAX_URL: usize = 2048;
48
49/// `http(s)://host[...]`, no userinfo, no control characters, bounded length.
50pub fn url_ok(url: &str) -> bool {
51    if url.is_empty() || url.len() > MAX_URL || url.chars().any(char::is_control) {
52        return false;
53    }
54    let Some(after) = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://")) else { return false };
55    let authority = after.split(['/', '?', '#']).next().unwrap_or("");
56    if authority.is_empty() || authority.contains('@') || authority.contains(char::is_whitespace) {
57        return false;
58    }
59    let host = authority.rsplit_once(']').map(|(h, _)| h).unwrap_or(authority);
60    let host = host.strip_prefix('[').unwrap_or_else(|| host.split(':').next().unwrap_or(""));
61    !host.is_empty()
62}
63
64/// `Some(token)` for a usable key, `None` for blank; `Err` for an unsafe one.
65pub fn bearer_token(token: Option<&str>) -> Result<Option<&str>, RoutineError> {
66    let Some(token) = token.map(str::trim).filter(|t| !t.is_empty()) else { return Ok(None) };
67    if token.bytes().all(|b| b.is_ascii_graphic()) {
68        Ok(Some(token))
69    } else {
70        Err(RoutineError::BadBearer)
71    }
72}
73
74/// POSTs `body` once to `url` with the optional routine key.
75pub fn post_routine_json(url: &str, body: &serde_json::Value, bearer: Option<&str>) -> Result<(), RoutineError> {
76    if !url_ok(url) {
77        return Err(RoutineError::BadUrl);
78    }
79    let target = reqwest::Url::parse(url).map_err(|_| RoutineError::BadUrl)?;
80    if !matches!(target.scheme(), "http" | "https") || target.host_str().is_none() {
81        return Err(RoutineError::BadUrl);
82    }
83    let token = bearer_token(bearer)?;
84    let bytes = serde_json::to_vec(body).map_err(|e| RoutineError::Transport(e.to_string()))?;
85    let client = reqwest::blocking::Client::builder()
86        .timeout(Duration::from_secs(8))
87        .redirect(reqwest::redirect::Policy::none())
88        .http1_only()
89        .use_rustls_tls()
90        .build()
91        .map_err(|e| RoutineError::Transport(redact(e.without_url().to_string(), url, token)))?;
92    let mut req = client.post(target).header(reqwest::header::CONTENT_TYPE, "application/json");
93    if let Some(token) = token {
94        let mut auth = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| RoutineError::BadBearer)?;
95        auth.set_sensitive(true);
96        let mut key = reqwest::header::HeaderValue::from_str(token).map_err(|_| RoutineError::BadBearer)?;
97        key.set_sensitive(true);
98        req = req.header(reqwest::header::AUTHORIZATION, auth).header("x-automation-key", key);
99    }
100    let resp = req.body(bytes).send().map_err(|e| RoutineError::Transport(redact(e.without_url().to_string(), url, token)))?;
101    let status = resp.status().as_u16();
102    if (200..300).contains(&status) {
103        Ok(())
104    } else {
105        Err(RoutineError::Status(status))
106    }
107}
108
109fn redact(mut text: String, url: &str, token: Option<&str>) -> String {
110    if !url.is_empty() {
111        text = text.replace(url, "[redacted]");
112    }
113    if let Some(t) = token.filter(|t| t.len() >= 4) {
114        text = text.replace(t, "[redacted]");
115    }
116    text.chars().take(240).collect()
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::io::{Read, Write};
123
124    #[test]
125    fn urls_and_keys_are_checked_before_any_socket() {
126        assert!(url_ok("https://example.invalid/hook"));
127        assert!(url_ok("http://127.0.0.1:9/x"));
128        assert!(!url_ok("ftp://example.invalid"));
129        assert!(!url_ok("https://user@example.invalid/"));
130        assert!(!url_ok("https://"));
131        assert_eq!(bearer_token(Some("  ")), Ok(None));
132        assert_eq!(bearer_token(Some("bad key")), Err(RoutineError::BadBearer));
133        assert_eq!(post_routine_json("nope", &serde_json::json!({}), None), Err(RoutineError::BadUrl));
134    }
135
136    fn serve_once(status: &'static str) -> (String, std::thread::JoinHandle<String>) {
137        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
138        let url = format!("http://{}/routine", l.local_addr().unwrap());
139        let h = std::thread::spawn(move || {
140            let (mut s, _) = l.accept().unwrap();
141            s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
142            let mut buf = Vec::new();
143            let mut chunk = [0u8; 4096];
144            loop {
145                let n = s.read(&mut chunk).unwrap_or(0);
146                if n == 0 { break; }
147                buf.extend_from_slice(&chunk[..n]);
148                let text = String::from_utf8_lossy(&buf);
149                if let Some((head, body)) = text.split_once("\r\n\r\n") {
150                    let len = head.lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap())).unwrap_or(0);
151                    if body.len() >= len { break; }
152                }
153            }
154            let _ = write!(s, "HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n");
155            String::from_utf8_lossy(&buf).into_owned()
156        });
157        (url, h)
158    }
159
160    #[test]
161    fn posts_json_with_both_key_headers_and_maps_status() {
162        let (url, h) = serve_once("200 OK");
163        post_routine_json(&url, &serde_json::json!({"body":"hi"}), Some("k-123456")).unwrap();
164        let req = h.join().unwrap().to_ascii_lowercase();
165        assert!(req.contains("authorization: bearer k-123456"));
166        assert!(req.contains("x-automation-key: k-123456"));
167        assert!(req.contains("\"body\":\"hi\""));
168
169        let (url, h) = serve_once("403 Forbidden");
170        let err = post_routine_json(&url, &serde_json::json!({}), Some("k-123456")).unwrap_err();
171        h.join().unwrap();
172        assert_eq!(err.status(), Some(403));
173        assert!(!err.to_string().contains("k-123456"));
174    }
175}