rpi_kickstart/https.rs
1//! A minimal HTTPS client: one request, one response, connection closed.
2//!
3//! What a board says to the outside world is a handful of small requests to
4//! known endpoints — a forecast, a push notification. Those need no
5//! connection reuse, no redirects and no content negotiation, and each of
6//! those is surface that can go wrong on a device nobody logs into. So this
7//! is deliberately the smallest client that does the job:
8//!
9//! ```ignore
10//! let client = https::Client { user_agent: "rpi-weather-station", ..https::Client::DEFAULT };
11//! let response = client
12//! .send(stack, tls_config.clone(), &https::Request::get("api.open-meteo.com", "/v1/forecast?…"))
13//! .await?;
14//! if response.status != 200 {
15//! logln!("forecast: {} -- {}", response.status_line, response.preview());
16//! }
17//! ```
18//!
19//! It does need **chunked framing**, which is the one thing on that list
20//! that is not optional: a server may answer a small body with
21//! `Transfer-Encoding: chunked` rather than a `Content-Length`, and a client
22//! that took what followed the headers as the body would hand its parser a
23//! hexadecimal length and two CRLFs along with the content.
24//!
25//! [`Client::send`](crate::https::Client::send) is where the stack meets:
26//! DNS and TCP from `embassy-net`, [`TlsStream`](crate::tls::TlsStream) over
27//! the socket, `rustls` verifying the chain, and the clock making that
28//! verification mean something — so every request fails closed until
29//! something has set the time.
30//!
31//! # The body comes back whole, and so does a refusal's
32//!
33//! A server that refuses a request explains itself in the body, and a client
34//! that discards it turns a precise diagnosis into "400". So the body is
35//! returned whatever the status, and
36//! [`Response::preview`](crate::https::Response::preview) is the excerpt
37//! made safe to put on a console.
38
39use alloc::string::String;
40use alloc::sync::Arc;
41use alloc::vec;
42use alloc::vec::Vec;
43use core::fmt::{self, Write as _};
44
45use embassy_net::dns::DnsQueryType;
46use embassy_net::tcp::TcpSocket;
47use embassy_net::{IpEndpoint, Stack};
48use embassy_time::{Duration, with_timeout};
49use embedded_io_async::{Read, Write};
50use rustls::ClientConfig;
51
52use crate::tls::{self, TlsStream};
53
54/// Longest body excerpt [`Response::preview`] returns.
55const PREVIEW: usize = 256;
56
57/// How a board's requests are made: what it calls itself, and its limits.
58/// Start from [`Client::DEFAULT`].
59#[derive(Debug, Clone, Copy)]
60pub struct Client {
61 /// The `User-Agent` sent — the board's name, so the far end's logs say
62 /// who asked.
63 pub user_agent: &'static str,
64 /// How long a whole exchange may take, DNS through the last byte.
65 /// Default 30 seconds.
66 ///
67 /// One deadline around all of it rather than one per step, because the
68 /// caller cares how long it can be kept waiting, not which stage was
69 /// slow — and the failure it bounds is a server that accepts a
70 /// connection and then says nothing.
71 pub timeout: Duration,
72 /// Bytes of response kept: status line, headers and body. Default 4 KiB.
73 ///
74 /// A response larger than this is cut short. A chunked body cut short
75 /// fails to parse, and a plain one comes back as a prefix — which is
76 /// why it is a limit to size against the largest answer expected,
77 /// generously, rather than a buffer to economize on.
78 pub response_max: usize,
79 /// TCP buffer, in bytes, per direction. Default 4 KiB.
80 pub tcp_buffer: usize,
81}
82
83impl Client {
84 /// The defaults, as a constant.
85 pub const DEFAULT: Self = Client {
86 user_agent: "rpi-kickstart",
87 timeout: Duration::from_secs(30),
88 response_max: 4 * 1024,
89 tcp_buffer: 4 * 1024,
90 };
91
92 /// Performs `request` and returns what came back, whatever its status.
93 ///
94 /// The TCP and response buffers come off the heap for the length of the
95 /// exchange.
96 pub async fn send(
97 &self,
98 stack: Stack<'_>,
99 config: Arc<ClientConfig>,
100 request: &Request<'_>,
101 ) -> Result<Response, Error> {
102 match with_timeout(self.timeout, self.exchange(stack, config, request)).await {
103 Ok(result) => result,
104 Err(_) => Err(Error::Timeout),
105 }
106 }
107
108 async fn exchange(
109 &self,
110 stack: Stack<'_>,
111 config: Arc<ClientConfig>,
112 request: &Request<'_>,
113 ) -> Result<Response, Error> {
114 let addresses = stack
115 .dns_query(request.host, DnsQueryType::A)
116 .await
117 .map_err(|_| Error::Dns)?;
118 let address = *addresses.first().ok_or(Error::NoAddress)?;
119
120 let mut rx_buffer = vec![0u8; self.tcp_buffer];
121 let mut tx_buffer = vec![0u8; self.tcp_buffer];
122 let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer);
123 socket
124 .connect(IpEndpoint::new(address, request.port))
125 .await
126 .map_err(|_| Error::Connect)?;
127
128 let mut tls = TlsStream::connect(socket, config, request.host)
129 .await
130 .map_err(Error::Tls)?;
131
132 tls.write_all(head(request, self.user_agent).as_bytes())
133 .await
134 .map_err(|_| Error::Io)?;
135 if !request.body.is_empty() {
136 tls.write_all(request.body).await.map_err(|_| Error::Io)?;
137 }
138 tls.flush().await.map_err(|_| Error::Io)?;
139
140 // Read until the peer closes: `Connection: close` bounds it, and the
141 // buffer bounds it again for an answer larger than expected.
142 let mut buffer = vec![0u8; self.response_max];
143 let mut filled = 0;
144 while filled < buffer.len() {
145 match tls.read(&mut buffer[filled..]).await {
146 Ok(0) => break,
147 Ok(n) => filled += n,
148 // What arrived before the failure is still worth parsing: a
149 // server that answers and then drops the connection has
150 // already said what it had to.
151 Err(_) if filled > 0 => break,
152 Err(_) => return Err(Error::Io),
153 }
154 }
155 parse(&buffer[..filled])
156 }
157}
158
159/// One request.
160///
161/// Headers are the caller's: [`Client::send`] adds the three every request
162/// needs (`Host`, `Connection: close`, `User-Agent`) and `Content-Length`
163/// for a body, and has no opinion about the rest.
164#[derive(Debug, Clone, Copy)]
165pub struct Request<'a> {
166 /// HTTP method, uppercase.
167 pub method: &'a str,
168 /// Host name, used for DNS, the `Host` header and TLS server-name
169 /// verification alike — the same name in all three, which is what makes
170 /// the certificate check mean anything.
171 pub host: &'a str,
172 /// TCP port; 443 unless the endpoint is somewhere unusual.
173 pub port: u16,
174 /// Request target, beginning with `/`.
175 pub path: &'a str,
176 /// Extra headers, as name/value pairs. Values must not contain CR or
177 /// LF — see `config::value::word`, which refuses any value that could
178 /// end one header and begin another.
179 pub headers: &'a [(&'a str, &'a str)],
180 /// The body. `Content-Length` is sent for anything but a `GET`.
181 pub body: &'a [u8],
182}
183
184impl<'a> Request<'a> {
185 /// A `GET` of `path` from `host` on 443, with no extra headers.
186 pub fn get(host: &'a str, path: &'a str) -> Self {
187 Request {
188 method: "GET",
189 host,
190 port: 443,
191 path,
192 headers: &[],
193 body: &[],
194 }
195 }
196}
197
198/// As much of a response as was read.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct Response {
201 /// The status code from the status line.
202 pub status: u16,
203 /// The whole status line, for logging a failure usefully.
204 pub status_line: String,
205 /// The body, unchunked if it was chunked. Empty if there was none.
206 pub body: Vec<u8>,
207}
208
209impl Response {
210 /// The start of the body, safe to print: control characters become
211 /// spaces, so a server's error text cannot rearrange the console it is
212 /// logged to, and it is cut to 256 bytes, so a server answering with a
213 /// web page cannot scroll it either.
214 pub fn preview(&self) -> String {
215 let body = &self.body[..self.body.len().min(PREVIEW)];
216 let cleaned: String = String::from_utf8_lossy(body)
217 .chars()
218 .map(|c| if c.is_control() { ' ' } else { c })
219 .collect();
220 String::from(cleaned.trim())
221 }
222}
223
224/// Why a request failed.
225#[derive(Debug)]
226pub enum Error {
227 /// The host name would not resolve — usually a lease with no usable
228 /// resolver, worth telling apart from a server that is down.
229 Dns,
230 /// The name resolved to no addresses.
231 NoAddress,
232 /// The TCP connection could not be made.
233 Connect,
234 /// The TLS layer failed — including, deliberately, certificate
235 /// rejection, and a clock nothing has set yet.
236 Tls(tls::Error),
237 /// Writing the request or reading the response failed.
238 Io,
239 /// What came back was not an HTTP response this could take apart.
240 BadResponse,
241 /// The exchange took longer than [`Client::timeout`].
242 Timeout,
243}
244
245/// One line, for the console.
246impl fmt::Display for Error {
247 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248 match self {
249 Error::Dns => f.write_str("name would not resolve"),
250 Error::NoAddress => f.write_str("name resolved to nothing"),
251 Error::Connect => f.write_str("could not connect"),
252 Error::Tls(e) => write!(f, "{e}"),
253 Error::Io => f.write_str("connection failed mid-exchange"),
254 Error::BadResponse => f.write_str("not an HTTP response"),
255 Error::Timeout => f.write_str("timed out"),
256 }
257 }
258}
259
260impl core::error::Error for Error {}
261
262/// The request line and headers.
263///
264/// `Connection: close`, so the server ends the response by closing — which
265/// is what lets the reader run to the end without tracking a length.
266fn head(request: &Request<'_>, user_agent: &str) -> String {
267 let mut text = String::new();
268 let _ = write!(
269 text,
270 "{} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nUser-Agent: {user_agent}\r\n",
271 request.method, request.path, request.host
272 );
273 if request.method != "GET" {
274 let _ = write!(text, "Content-Length: {}\r\n", request.body.len());
275 }
276 for (name, value) in request.headers {
277 let _ = write!(text, "{name}: {value}\r\n");
278 }
279 text.push_str("\r\n");
280 text
281}
282
283/// Takes a response apart: status line, then the body after the blank line,
284/// unchunked when the headers say it is chunked.
285fn parse(response: &[u8]) -> Result<Response, Error> {
286 let line_end = response
287 .iter()
288 .position(|&b| b == b'\n')
289 .ok_or(Error::BadResponse)?;
290 let line = core::str::from_utf8(&response[..line_end])
291 .map_err(|_| Error::BadResponse)?
292 .trim_end();
293
294 // `HTTP/1.1 200 OK` — the code is the second field. Anything that does
295 // not look like that is a failure, not something to guess at.
296 if !line.starts_with("HTTP/") {
297 return Err(Error::BadResponse);
298 }
299 let status: u16 = line
300 .split_whitespace()
301 .nth(1)
302 .ok_or(Error::BadResponse)?
303 .parse()
304 .map_err(|_| Error::BadResponse)?;
305
306 // A response with no blank line has no body, which for most requests is
307 // a malformed answer -- left empty, so it fails at the caller's parse
308 // rather than being guessed at here.
309 let body = match response.windows(4).position(|window| window == b"\r\n\r\n") {
310 Some(at) => {
311 let (head, body) = (&response[..at], &response[at + 4..]);
312 if is_chunked(head) {
313 dechunk(body).ok_or(Error::BadResponse)?
314 } else {
315 Vec::from(body)
316 }
317 }
318 None => Vec::new(),
319 };
320
321 Ok(Response {
322 status,
323 status_line: String::from(line),
324 body,
325 })
326}
327
328/// Whether the headers say the body is chunked.
329///
330/// Only `Transfer-Encoding`, and only for the word. A list — `gzip,
331/// chunked` — would need the *last* coding to be this, but this client
332/// sends no `Accept-Encoding`, so a server layering a content coding on top
333/// is answering something never asked; what it produces is a body that
334/// does not parse, the same visible failure as any other surprise.
335fn is_chunked(head: &[u8]) -> bool {
336 head.split(|&b| b == b'\n').any(|line| {
337 let Some(colon) = line.iter().position(|&b| b == b':') else {
338 return false;
339 };
340 line[..colon]
341 .trim_ascii()
342 .eq_ignore_ascii_case(b"transfer-encoding")
343 && line[colon + 1..]
344 .windows(7)
345 .any(|word| word.eq_ignore_ascii_case(b"chunked"))
346 })
347}
348
349/// Reassembles a chunked body.
350///
351/// A chunk is a hexadecimal length on its own line, that many bytes and a
352/// line ending; a zero length ends the body. Trailers after it are not read:
353/// nothing here asked for one, and the body is complete without them.
354///
355/// `None` for a body this cannot walk, which is deliberately also what a
356/// *truncated* one gives: a chunk claiming more bytes than arrived is what a
357/// response over [`Client::response_max`] looks like from here, and handing
358/// back the part that fit would be a prefix of the truth with nothing to say
359/// so.
360fn dechunk(mut body: &[u8]) -> Option<Vec<u8>> {
361 let mut whole = Vec::new();
362 loop {
363 let line_end = body.iter().position(|&b| b == b'\n')?;
364 let line = body[..line_end].trim_ascii();
365 // A chunk extension -- `1c4;name=value` -- is framing, not length.
366 let size = line.split(|&b| b == b';').next()?;
367 let size = usize::from_str_radix(core::str::from_utf8(size).ok()?, 16).ok()?;
368 body = body.get(line_end + 1..)?;
369
370 if size == 0 {
371 return Some(whole);
372 }
373 whole.extend_from_slice(body.get(..size)?);
374 // The line ending that closes the chunk, taken as up to two bytes
375 // rather than exactly `\r\n`, which keeps a server sending a bare LF
376 // from failing the whole request.
377 body = &body[size..];
378 while let Some((&first, rest)) = body.split_first() {
379 if first != b'\r' && first != b'\n' {
380 break;
381 }
382 body = rest;
383 if first == b'\n' {
384 break;
385 }
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn the_head_names_the_host_and_closes() {
396 let get = Request::get("api.example.com", "/v1/x?y=1");
397 assert_eq!(
398 head(&get, "board"),
399 "GET /v1/x?y=1 HTTP/1.1\r\nHost: api.example.com\r\nConnection: close\r\n\
400 User-Agent: board\r\n\r\n"
401 );
402 let post = Request {
403 method: "POST",
404 headers: &[("Authorization", "Bearer t")],
405 body: b"hello",
406 ..Request::get("ntfy.example.com", "/topic")
407 };
408 let text = head(&post, "board");
409 assert!(text.contains("Content-Length: 5\r\n"), "{text}");
410 assert!(text.ends_with("Authorization: Bearer t\r\n\r\n"), "{text}");
411 }
412
413 #[test]
414 fn a_plain_body_comes_back_as_it_arrived() {
415 let response = parse(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello").unwrap();
416 assert_eq!(response.status, 200);
417 assert_eq!(response.status_line, "HTTP/1.1 200 OK");
418 assert_eq!(response.body, b"hello");
419 }
420
421 #[test]
422 fn a_chunked_body_is_reassembled() {
423 let wire = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\
424 5\r\nhello\r\n7;ext=1\r\n, world\r\n0\r\nTrailer: x\r\n\r\n";
425 assert_eq!(parse(wire).unwrap().body, b"hello, world");
426 // A bare LF closing a chunk, and a header in another case.
427 let wire = b"HTTP/1.1 200 OK\r\ntransfer-encoding: Chunked\r\n\r\n3\nabc\n0\n\n";
428 assert_eq!(parse(wire).unwrap().body, b"abc");
429 }
430
431 #[test]
432 fn a_chunked_body_cut_short_is_refused_not_shortened() {
433 let wire = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n10\r\nonly six";
434 assert!(matches!(parse(wire), Err(Error::BadResponse)));
435 }
436
437 #[test]
438 fn what_is_not_a_response() {
439 for wire in [
440 &b""[..],
441 b"garbage\r\n",
442 b"HTTP/1.1 abc OK\r\n\r\n",
443 b"SSH-2.0-x\r\n",
444 ] {
445 assert!(matches!(parse(wire), Err(Error::BadResponse)), "{wire:?}");
446 }
447 // No blank line: a status, and no body to speak of.
448 let response = parse(b"HTTP/1.1 204 No Content\r\n").unwrap();
449 assert_eq!((response.status, response.body.len()), (204, 0));
450 }
451
452 #[test]
453 fn a_refusal_previews_safely() {
454 let mut body = b"bad\r\n\x1b[2Jtoken".to_vec();
455 body.extend(core::iter::repeat_n(b'x', 1000));
456 let response = Response {
457 status: 401,
458 status_line: String::from("HTTP/1.1 401 Unauthorized"),
459 body,
460 };
461 let preview = response.preview();
462 assert!(preview.starts_with("bad [2Jtoken"), "{preview}");
463 assert!(preview.len() <= PREVIEW);
464 }
465}