1use std::io::{self, Read, Write};
23use std::net::{IpAddr, SocketAddr, TcpStream};
24use std::sync::Arc;
25use std::time::Duration;
26
27use rustls::pki_types::ServerName;
28use rustls::{ClientConfig, ClientConnection, StreamOwned};
29
30mod body;
31
32use body::Framing;
33pub use body::MAX_BODY;
34
35use crate::decide::{self, Forbidden};
36use crate::tls::{Roots, TlsError};
37use crate::upstream::Resolve;
38
39const MAX_HEAD: usize = 16 * 1024;
41
42const MAX_RESPONSE_HEADERS: usize = 64;
44
45#[derive(Debug, thiserror::Error)]
47#[non_exhaustive]
48pub enum PostError {
49 #[error("{0}")]
51 Url(String),
52 #[error("header `{0}` has a character a header cannot carry")]
54 Header(String),
55 #[error("{host} resolves to {addr}, {}, which audit delivery never reaches", why.describe())]
57 Address {
58 host: String,
60 addr: IpAddr,
62 why: Forbidden,
64 },
65 #[error("{host} resolves to {addr}, not a loopback address: use https")]
67 PlainOffMachine {
68 host: String,
70 addr: IpAddr,
72 },
73 #[error("cannot reach {0}")]
75 Unreachable(String),
76 #[error("TLS: {0}")]
78 Tls(String),
79 #[error("{0}")]
81 Io(#[from] io::Error),
82 #[error("the answer is not an HTTP response: {0}")]
84 Response(String),
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum Scheme {
90 Http,
92 Https,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Endpoint {
99 pub scheme: Scheme,
101 pub host: String,
103 pub port: u16,
105 pub path: String,
107}
108
109impl Endpoint {
110 pub fn parse(url: &str) -> Result<Self, PostError> {
114 let bad = |why: &str| PostError::Url(format!("`{url}`: {why}"));
115 if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
116 return Err(bad("a URL has no spaces or control characters"));
117 }
118 let (scheme, rest) = if let Some(rest) = url.strip_prefix("https://") {
119 (Scheme::Https, rest)
120 } else if let Some(rest) = url.strip_prefix("http://") {
121 (Scheme::Http, rest)
122 } else {
123 return Err(bad("only https:// (or http:// to this machine) is sent to"));
124 };
125 if rest.contains('#') {
126 return Err(bad("a fragment is never sent"));
127 }
128 let (authority, path) = match rest.find(['/', '?']) {
129 Some(at) if rest[at..].starts_with('/') => (&rest[..at], rest[at..].to_owned()),
130 Some(at) => (&rest[..at], format!("/{}", &rest[at..])),
131 None => (rest, "/".to_owned()),
132 };
133 if authority.contains('@') {
134 return Err(bad("credentials go in a header, never in the URL"));
135 }
136 let default = match scheme {
137 Scheme::Http => 80,
138 Scheme::Https => 443,
139 };
140 let (host, port) = split_port(authority, default).ok_or_else(|| bad("no valid host"))?;
141 if decide::metadata_name(host.trim_end_matches('.')) {
142 return Err(bad("a cloud metadata service is never sent to"));
143 }
144 let endpoint = Self {
145 scheme,
146 host,
147 port,
148 path,
149 };
150 if scheme == Scheme::Http && !endpoint.names_loopback() {
151 return Err(bad(
152 "plain http goes only to this machine (localhost, 127.0.0.1, [::1]); use https",
153 ));
154 }
155 Ok(endpoint)
156 }
157
158 fn names_loopback(&self) -> bool {
159 self.host.eq_ignore_ascii_case("localhost")
160 || self.host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
161 }
162
163 fn authority(&self) -> String {
165 let host = if self.host.contains(':') {
166 format!("[{}]", self.host)
167 } else {
168 self.host.clone()
169 };
170 let default = match self.scheme {
171 Scheme::Http => 80,
172 Scheme::Https => 443,
173 };
174 if self.port == default {
175 host
176 } else {
177 format!("{host}:{}", self.port)
178 }
179 }
180}
181
182fn split_port(authority: &str, default: u16) -> Option<(String, u16)> {
183 let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
184 let (host, after) = rest.split_once(']')?;
185 host.parse::<std::net::Ipv6Addr>().ok()?;
186 (host, after.strip_prefix(':'))
187 } else {
188 match authority.split_once(':') {
189 Some((host, port)) => (host, Some(port)),
190 None => (authority, None),
191 }
192 };
193 let valid = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':');
194 if host.is_empty() || !host.chars().all(valid) {
195 return None;
196 }
197 let port = match port {
198 Some(p) => p.parse::<u16>().ok().filter(|p| *p != 0)?,
199 None => default,
200 };
201 Some((host.to_ascii_lowercase(), port))
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct Answer {
207 pub status: u16,
209 pub retry_after_s: Option<u64>,
211 pub body: Option<Vec<u8>>,
215}
216
217impl Answer {
218 #[must_use]
220 pub fn accepted(&self) -> bool {
221 (200..300).contains(&self.status)
222 }
223
224 #[must_use]
228 pub fn retryable(&self) -> bool {
229 matches!(self.status, 408 | 429)
230 || (500..600).contains(&self.status) && !matches!(self.status, 501 | 505)
231 }
232}
233
234pub struct Poster {
237 tls: Arc<ClientConfig>,
238 resolver: Box<dyn Resolve>,
239}
240
241impl std::fmt::Debug for Poster {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 f.debug_struct("Poster").finish_non_exhaustive()
244 }
245}
246
247impl Poster {
248 pub fn new(roots: &Roots, resolver: Box<dyn Resolve>) -> Result<Self, TlsError> {
250 let provider = Arc::new(rustls::crypto::ring::default_provider());
251 Ok(Self {
252 tls: Arc::new(crate::tls::upstream_config(roots, &provider)?),
253 resolver,
254 })
255 }
256
257 pub fn post(
261 &self,
262 endpoint: &Endpoint,
263 headers: &[(&str, &str)],
264 body: &[u8],
265 timeout: Duration,
266 ) -> Result<Answer, PostError> {
267 let request = request_head(endpoint, headers, body.len())?;
268 let tcp = self.connect(endpoint, timeout)?;
269 tcp.set_read_timeout(Some(timeout))?;
270 tcp.set_write_timeout(Some(timeout))?;
271 match endpoint.scheme {
272 Scheme::Http => exchange(tcp, &request, body),
273 Scheme::Https => {
274 let name = ServerName::try_from(endpoint.host.clone()).map_err(|_| {
275 PostError::Url(format!("{} is not a server name", endpoint.host))
276 })?;
277 let conn = ClientConnection::new(self.tls.clone(), name)
278 .map_err(|e| PostError::Tls(e.to_string()))?;
279 exchange(StreamOwned::new(conn, tcp), &request, body)
280 }
281 }
282 }
283
284 fn connect(&self, endpoint: &Endpoint, timeout: Duration) -> Result<TcpStream, PostError> {
285 let host = &endpoint.host;
286 let addrs: Vec<SocketAddr> = match host.parse::<IpAddr>() {
287 Ok(ip) => vec![SocketAddr::new(ip, endpoint.port)],
288 Err(_) => self
289 .resolver
290 .resolve(host, endpoint.port)
291 .map_err(|e| PostError::Unreachable(format!("{host}: {e}")))?,
292 };
293 if addrs.is_empty() {
294 return Err(PostError::Unreachable(format!("{host}: no addresses")));
295 }
296 for addr in &addrs {
298 check_address(endpoint, addr.ip())?;
299 }
300 let mut last = None;
301 for addr in &addrs {
302 match TcpStream::connect_timeout(addr, timeout) {
303 Ok(stream) => return Ok(stream),
304 Err(e) => last = Some(e),
305 }
306 }
307 Err(PostError::Unreachable(match last {
308 Some(e) => format!("{host}:{}: {e}", endpoint.port),
309 None => format!("{host}:{}", endpoint.port),
310 }))
311 }
312}
313
314fn check_address(endpoint: &Endpoint, ip: IpAddr) -> Result<(), PostError> {
317 match decide::forbidden(ip) {
318 Some(why @ (Forbidden::LinkLocal | Forbidden::Metadata | Forbidden::NotUnicast)) => {
319 return Err(PostError::Address {
320 host: endpoint.host.clone(),
321 addr: ip,
322 why,
323 });
324 }
325 Some(Forbidden::Loopback) => return Ok(()),
326 _ => {}
327 }
328 if endpoint.scheme == Scheme::Http {
329 return Err(PostError::PlainOffMachine {
330 host: endpoint.host.clone(),
331 addr: ip,
332 });
333 }
334 Ok(())
335}
336
337fn request_head(
339 endpoint: &Endpoint,
340 headers: &[(&str, &str)],
341 length: usize,
342) -> Result<Vec<u8>, PostError> {
343 let mut head = format!(
344 "POST {} HTTP/1.1\r\nHost: {}\r\nContent-Length: {length}\r\nConnection: close\r\n",
345 endpoint.path,
346 endpoint.authority()
347 );
348 for (name, value) in headers {
349 let token = |c: char| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c);
350 let framing = ["host", "content-length", "connection", "transfer-encoding"];
351 if name.is_empty()
352 || !name.chars().all(token)
353 || framing.iter().any(|f| name.eq_ignore_ascii_case(f))
354 || value.chars().any(|c| c.is_control() && c != '\t')
355 {
356 return Err(PostError::Header((*name).to_owned()));
357 }
358 head.push_str(name);
359 head.push_str(": ");
360 head.push_str(value);
361 head.push_str("\r\n");
362 }
363 head.push_str("\r\n");
364 Ok(head.into_bytes())
365}
366
367pub(crate) fn exchange(
370 mut stream: impl Read + Write,
371 head: &[u8],
372 body: &[u8],
373) -> Result<Answer, PostError> {
374 stream.write_all(head)?;
375 stream.write_all(body)?;
376 stream.flush()?;
377 let mut buf = Vec::with_capacity(1024);
378 let mut chunk = [0u8; 2048];
379 loop {
380 if let Some((mut answer, end, framing)) = parse_head(&buf)? {
381 if answer.accepted() {
382 let after = buf.split_off(end);
383 answer.body = body::read(&mut stream, after, framing);
384 }
385 return Ok(answer);
386 }
387 if buf.len() >= MAX_HEAD {
388 return Err(PostError::Response(format!(
389 "no end of head in {MAX_HEAD} bytes"
390 )));
391 }
392 let n = stream.read(&mut chunk)?;
393 if n == 0 {
394 return Err(PostError::Response(
395 "closed before the head ended".to_owned(),
396 ));
397 }
398 buf.extend_from_slice(&chunk[..n]);
399 }
400}
401
402fn parse_head(buf: &[u8]) -> Result<Option<(Answer, usize, Framing)>, PostError> {
405 let mut headers = [httparse::EMPTY_HEADER; MAX_RESPONSE_HEADERS];
406 let mut response = httparse::Response::new(&mut headers);
407 match response.parse(buf) {
408 Ok(httparse::Status::Partial) => Ok(None),
409 Ok(httparse::Status::Complete(end)) => {
410 let status = response
411 .code
412 .ok_or_else(|| PostError::Response("no status".to_owned()))?;
413 let value = |name: &str| {
414 response
415 .headers
416 .iter()
417 .find(|h| h.name.eq_ignore_ascii_case(name))
418 .and_then(|h| std::str::from_utf8(h.value).ok())
419 .map(str::trim)
420 };
421 let retry_after_s = value("retry-after").and_then(|v| v.parse::<u64>().ok());
422 let chunked = value("transfer-encoding")
423 .is_some_and(|v| v.to_ascii_lowercase().ends_with("chunked"));
424 let framing = match (status, chunked, value("content-length")) {
427 (204 | 304, _, _) => Framing::Empty,
428 (_, true, _) => Framing::Chunked,
429 (_, false, Some(length)) => length
430 .parse::<usize>()
431 .map_or(Framing::Length(usize::MAX), Framing::Length),
432 (_, false, None) => Framing::Close,
433 };
434 let answer = Answer {
435 status,
436 retry_after_s,
437 body: None,
438 };
439 Ok(Some((answer, end, framing)))
440 }
441 Err(e) => Err(PostError::Response(e.to_string())),
442 }
443}
444
445#[cfg(test)]
446mod tests;