gate4agent_wake/
routine.rs1use std::time::Duration;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RoutineError {
14 BadUrl,
16 BadBearer,
18 Transport(String),
20 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 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
49pub 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
64pub 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
74pub 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}