Skip to main content

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}