Skip to main content

isb_apps/monitor/
probe.rs

1//! The probes: one HTTP(S) request or TCP connect, under a deadline, held
2//! to [`net`]'s address policy.
3//!
4//! A URL a member typed is resolved here, every address it resolves to is
5//! checked, and the connection goes to a checked address (never re-resolved),
6//! exactly as for notification channels. An app's own endpoint (a replica,
7//! a published port), which isb found by reference rather than a member
8//! typing an address, is dialled directly with `connect_to`.
9//!
10//! Errors name the host, never the full URL: a query string may hold a
11//! token.
12
13use std::io::{Read, Write};
14use std::net::{SocketAddr, TcpStream};
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use crate::net::{self, Net, Target};
19
20/// The most of a response body read (keywords are looked for in it).
21pub const MAX_BODY: usize = 256 * 1024;
22/// Redirects followed at most, when following.
23pub const MAX_REDIRECTS: usize = 5;
24
25/// One HTTP check.
26#[derive(Clone)]
27pub struct HttpProbe {
28    pub url: String,
29    /// `GET` or `HEAD`.
30    pub method: String,
31    pub headers: Vec<(String, String)>,
32    pub timeout: Duration,
33    pub follow_redirects: bool,
34    pub allow_private: bool,
35    /// Dial this address instead of resolving the URL's host: an app's own
36    /// endpoint, exempt from the address policy. The URL's host still goes
37    /// in the `Host` header.
38    pub connect_to: Option<SocketAddr>,
39    pub tls: Arc<rustls::ClientConfig>,
40}
41
42/// What an HTTP check got back.
43#[derive(Debug, Clone, Default)]
44pub struct HttpAnswer {
45    pub status: u16,
46    pub location: Option<String>,
47    /// The start of the body (at most [`MAX_BODY`]), de-chunked.
48    pub body: Vec<u8>,
49    pub latency: Duration,
50    /// The server certificate's expiry, unix seconds (HTTPS).
51    pub cert_expires: Option<u64>,
52    /// The URL that answered, after redirects, without its query.
53    pub final_url: String,
54}
55
56/// A URL without its query and fragment, for messages.
57pub fn display_url(url: &str) -> String {
58    let u = url.split(['?', '#']).next().unwrap_or_default();
59    u.to_string()
60}
61
62/// Request `p.url`, following redirects when asked.
63pub fn http(p: &HttpProbe) -> Result<HttpAnswer, String> {
64    let deadline = Instant::now() + p.timeout;
65    let started = Instant::now();
66    let mut url = p.url.clone();
67    for hop in 0..=MAX_REDIRECTS {
68        let t = net::parse_url(&url).map_err(|e| format!("URL: {e}"))?;
69        // A redirect goes wherever it says, under the policy again.
70        let connect_to = if hop == 0 { p.connect_to } else { None };
71        let mut a = once(p, &t, connect_to, deadline)?;
72        a.final_url = display_url(&url);
73        let redirect = (300..400).contains(&a.status) && a.status != 304;
74        match (&a.location, redirect && p.follow_redirects) {
75            (Some(loc), true) if hop < MAX_REDIRECTS => url = join(&t, loc),
76            _ => {
77                a.latency = started.elapsed();
78                return Ok(a);
79            }
80        }
81    }
82    Err(format!("more than {MAX_REDIRECTS} redirects"))
83}
84
85/// Resolve a redirect's `Location` against the URL that sent it.
86pub fn join(base: &Target, loc: &str) -> String {
87    let l = loc.trim();
88    if l.starts_with("http://") || l.starts_with("https://") {
89        return l.to_string();
90    }
91    let scheme = if base.https { "https" } else { "http" };
92    let default = if base.https { 443 } else { 80 };
93    let host = if base.host.contains(':') {
94        format!("[{}]", base.host)
95    } else {
96        base.host.clone()
97    };
98    let authority = if base.port == default {
99        host
100    } else {
101        format!("{host}:{}", base.port)
102    };
103    if let Some(rest) = l.strip_prefix("//") {
104        return format!("{scheme}://{rest}");
105    }
106    if l.starts_with('/') {
107        return format!("{scheme}://{authority}{l}");
108    }
109    let dir = base.path.split('?').next().unwrap_or("/");
110    let dir = &dir[..dir.rfind('/').map(|i| i + 1).unwrap_or(1)];
111    format!("{scheme}://{authority}{dir}{l}")
112}
113
114fn left(deadline: Instant) -> Result<Duration, String> {
115    let d = deadline.saturating_duration_since(Instant::now());
116    if d.is_zero() {
117        Err("timed out".into())
118    } else {
119        Ok(d.max(Duration::from_millis(1)))
120    }
121}
122
123/// Connect to `host:port`, under the policy, within the deadline.
124pub fn connect(
125    host: &str,
126    port: u16,
127    allow_private: bool,
128    deadline: Instant,
129) -> Result<TcpStream, String> {
130    let addrs = net::resolve(host, port, allow_private).map_err(|e| e.message)?;
131    let mut last = None;
132    for a in addrs {
133        match dial(a, deadline) {
134            Ok(s) => {
135                let peer = s.peer_addr().map_err(|e| format!("{host}: {e}"))?;
136                net::check_ip(peer.ip(), allow_private)
137                    .map_err(|why| format!("refusing {host}: {why}"))?;
138                return Ok(s);
139            }
140            Err(e) => last = Some(e),
141        }
142    }
143    Err(format!(
144        "cannot connect to {host}:{port}: {}",
145        last.unwrap_or_default()
146    ))
147}
148
149/// Connect to one address within the deadline.
150pub fn dial(a: SocketAddr, deadline: Instant) -> Result<TcpStream, String> {
151    let s = TcpStream::connect_timeout(&a, left(deadline)?).map_err(|e| {
152        if e.kind() == std::io::ErrorKind::TimedOut || e.kind() == std::io::ErrorKind::WouldBlock {
153            "timed out connecting".to_string()
154        } else {
155            e.to_string()
156        }
157    })?;
158    s.set_nodelay(true).ok();
159    Ok(s)
160}
161
162/// A TCP check: connect, then hang up. The time it took.
163pub fn tcp(
164    host: &str,
165    port: u16,
166    timeout: Duration,
167    allow_private: bool,
168) -> Result<Duration, String> {
169    let started = Instant::now();
170    let s = connect(host, port, allow_private, started + timeout)?;
171    drop(s);
172    Ok(started.elapsed())
173}
174
175fn host_header(t: &Target) -> String {
176    let h = if t.host.contains(':') {
177        format!("[{}]", t.host)
178    } else {
179        t.host.clone()
180    };
181    let default = if t.https { 443 } else { 80 };
182    if t.port == default {
183        h
184    } else {
185        format!("{h}:{}", t.port)
186    }
187}
188
189fn request_head(p: &HttpProbe, t: &Target) -> Result<String, String> {
190    let mut head = format!(
191        "{} {} HTTP/1.1\r\nHost: {}\r\nUser-Agent: isb-monitor/{}\r\nAccept: */*\r\nConnection: close\r\n",
192        p.method,
193        t.path,
194        host_header(t),
195        env!("CARGO_PKG_VERSION"),
196    );
197    for (k, v) in &p.headers {
198        if k.contains(['\r', '\n', ':']) || v.contains(['\r', '\n']) {
199            return Err(format!("a bad header {k:?}"));
200        }
201        head.push_str(&format!("{k}: {v}\r\n"));
202    }
203    head.push_str("\r\n");
204    Ok(head)
205}
206
207/// One request, no redirects.
208fn once(
209    p: &HttpProbe,
210    t: &Target,
211    connect_to: Option<SocketAddr>,
212    deadline: Instant,
213) -> Result<HttpAnswer, String> {
214    let head = request_head(p, t)?;
215    let tcp = match connect_to {
216        Some(a) => dial(a, deadline).map_err(|e| format!("cannot connect to {a}: {e}"))?,
217        None => connect(&t.host, t.port, p.allow_private, deadline)?,
218    };
219    let head_only = p.method == "HEAD";
220    if !t.https {
221        let sock = s_clone(&tcp)?;
222        let mut s = tcp;
223        let raw = exchange(&mut s, &sock, head.as_bytes(), deadline, &t.host)?;
224        return parse(&raw, head_only);
225    }
226    let net = Net {
227        allow_private: p.allow_private,
228        tls: p.tls.clone(),
229    };
230    let mut s = net::tls(&net, &t.host, tcp).map_err(|e| e.message)?;
231    let sock = s_clone(&s.sock)?;
232    while s.conn.is_handshaking() {
233        sock.set_read_timeout(Some(left(deadline)?)).ok();
234        s.conn
235            .complete_io(&mut s.sock)
236            .map_err(|e| tls_error(&t.host, &e))?;
237    }
238    let cert_expires = s
239        .conn
240        .peer_certificates()
241        .and_then(|c| c.first())
242        .and_then(|c| cert_not_after(c.as_ref()));
243    let raw = exchange(&mut s, &sock, head.as_bytes(), deadline, &t.host)?;
244    let mut a = parse(&raw, head_only)?;
245    a.cert_expires = cert_expires;
246    Ok(a)
247}
248
249fn s_clone(s: &TcpStream) -> Result<TcpStream, String> {
250    s.try_clone().map_err(|e| e.to_string())
251}
252
253fn tls_error(host: &str, e: &std::io::Error) -> String {
254    let m = e.to_string();
255    if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut {
256        format!("{host}: timed out in the TLS handshake")
257    } else {
258        format!("TLS to {host}: {m}")
259    }
260}
261
262/// Write the request and read the answer until it is complete, the peer
263/// closes, the body cap is reached or the deadline passes.
264fn exchange<S: Read + Write>(
265    s: &mut S,
266    sock: &TcpStream,
267    head: &[u8],
268    deadline: Instant,
269    host: &str,
270) -> Result<Vec<u8>, String> {
271    let io = |e: std::io::Error| {
272        if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut {
273            format!("{host}: timed out waiting for an answer")
274        } else {
275            format!("{host}: {e}")
276        }
277    };
278    sock.set_write_timeout(Some(left(deadline)?)).ok();
279    s.write_all(head).map_err(io)?;
280    s.flush().map_err(io)?;
281    let mut out = Vec::new();
282    let mut buf = [0u8; 16384];
283    loop {
284        sock.set_read_timeout(Some(
285            left(deadline).map_err(|_| format!("{host}: timed out waiting for an answer"))?,
286        ))
287        .ok();
288        match s.read(&mut buf) {
289            Ok(0) => break,
290            Ok(n) => {
291                out.extend_from_slice(&buf[..n]);
292                if out.len() >= MAX_BODY + 16384 || complete(&out) {
293                    break;
294                }
295            }
296            Err(_) if header_end(&out).is_some() => break,
297            Err(e) => return Err(io(e)),
298        }
299    }
300    if out.is_empty() {
301        return Err(format!("{host}: closed the connection without answering"));
302    }
303    Ok(out)
304}
305
306fn header_end(b: &[u8]) -> Option<usize> {
307    b.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
308}
309
310/// Headers and the whole body (by Content-Length, or a chunked body's
311/// last chunk) are in.
312fn complete(b: &[u8]) -> bool {
313    let Some(end) = header_end(b) else {
314        return false;
315    };
316    let mut headers = [httparse::EMPTY_HEADER; 64];
317    let mut r = httparse::Response::new(&mut headers);
318    if r.parse(b).is_err() {
319        return false;
320    }
321    let header = |name: &str| {
322        r.headers
323            .iter()
324            .find(|h| h.name.eq_ignore_ascii_case(name))
325            .and_then(|h| std::str::from_utf8(h.value).ok())
326            .map(|v| v.trim().to_ascii_lowercase())
327    };
328    if header("transfer-encoding").is_some_and(|v| v.contains("chunked")) {
329        return b[end..].ends_with(b"0\r\n\r\n");
330    }
331    match header("content-length").and_then(|v| v.parse::<usize>().ok()) {
332        Some(n) => b.len() >= end + n,
333        None => false,
334    }
335}
336
337/// Parse a raw answer: status, `Location`, body (de-chunked, capped).
338pub fn parse(raw: &[u8], head_only: bool) -> Result<HttpAnswer, String> {
339    let mut headers = [httparse::EMPTY_HEADER; 64];
340    let mut r = httparse::Response::new(&mut headers);
341    let end = match r.parse(raw) {
342        Ok(httparse::Status::Complete(n)) => n,
343        Ok(httparse::Status::Partial) => return Err("an incomplete HTTP answer".into()),
344        Err(e) => return Err(format!("not an HTTP answer: {e}")),
345    };
346    let header = |name: &str| {
347        r.headers
348            .iter()
349            .find(|h| h.name.eq_ignore_ascii_case(name))
350            .and_then(|h| std::str::from_utf8(h.value).ok())
351            .map(|v| v.trim().to_string())
352    };
353    let chunked =
354        header("transfer-encoding").is_some_and(|v| v.to_ascii_lowercase().contains("chunked"));
355    let raw_body = if head_only { &[][..] } else { &raw[end..] };
356    let mut body = if chunked {
357        dechunk(raw_body)
358    } else {
359        raw_body.to_vec()
360    };
361    body.truncate(MAX_BODY);
362    Ok(HttpAnswer {
363        status: r.code.unwrap_or(0),
364        location: header("location"),
365        body,
366        ..Default::default()
367    })
368}
369
370/// A chunked body's data, as far as it goes.
371pub fn dechunk(mut b: &[u8]) -> Vec<u8> {
372    let mut out = Vec::new();
373    while let Some(i) = b.windows(2).position(|w| w == b"\r\n") {
374        let size = std::str::from_utf8(&b[..i])
375            .ok()
376            .and_then(|s| usize::from_str_radix(s.split(';').next()?.trim(), 16).ok());
377        let Some(n) = size else { break };
378        if n == 0 {
379            break;
380        }
381        let start = i + 2;
382        let stop = (start + n).min(b.len());
383        out.extend_from_slice(&b[start..stop]);
384        if start + n + 2 > b.len() {
385            break;
386        }
387        b = &b[start + n + 2..];
388    }
389    out
390}
391
392/// The `notAfter` of a DER X.509 certificate, unix seconds. Walks the
393/// structure by hand: Certificate ::= SEQUENCE { tbsCertificate SEQUENCE
394/// { \[0\] version OPTIONAL, serial, signature, issuer, validity SEQUENCE {
395/// notBefore, notAfter }, ... }, ... }.
396pub fn cert_not_after(der: &[u8]) -> Option<u64> {
397    let (_, cert, _) = tlv(der)?;
398    let (_, tbs, _) = tlv(cert)?;
399    let mut rest = tbs;
400    let (tag, _, r) = tlv(rest)?;
401    if tag == 0xa0 {
402        rest = r; // the version
403    }
404    for _ in 0..3 {
405        rest = tlv(rest)?.2; // serial, signature algorithm, issuer
406    }
407    let (tag, validity, _) = tlv(rest)?;
408    if tag != 0x30 {
409        return None;
410    }
411    let (_, _, r) = tlv(validity)?; // notBefore
412    let (tag, t, _) = tlv(r)?;
413    let s = std::str::from_utf8(t).ok()?;
414    match tag {
415        0x17 => asn1_time(s, true),
416        0x18 => asn1_time(s, false),
417        _ => None,
418    }
419}
420
421/// One DER element: (tag, contents, what follows).
422fn tlv(b: &[u8]) -> Option<(u8, &[u8], &[u8])> {
423    let tag = *b.first()?;
424    let first = *b.get(1)?;
425    let (len, hdr) = if first < 0x80 {
426        (first as usize, 2)
427    } else {
428        let n = (first & 0x7f) as usize;
429        if n == 0 || n > 4 {
430            return None;
431        }
432        let mut len = 0usize;
433        for i in 0..n {
434            len = (len << 8) | *b.get(2 + i)? as usize;
435        }
436        (len, 2 + n)
437    };
438    let end = hdr.checked_add(len)?;
439    (end <= b.len()).then(|| (tag, &b[hdr..end], &b[end..]))
440}
441
442/// `YYMMDDHHMMSSZ` (UTCTime) or `YYYYMMDDHHMMSSZ` (GeneralizedTime).
443fn asn1_time(s: &str, utc: bool) -> Option<u64> {
444    let s = s.strip_suffix('Z')?;
445    let (year, rest) = if utc {
446        let y: i64 = s.get(..2)?.parse().ok()?;
447        (if y >= 50 { 1900 + y } else { 2000 + y }, s.get(2..)?)
448    } else {
449        (s.get(..4)?.parse().ok()?, s.get(4..)?)
450    };
451    let n = |i: usize| -> Option<i64> { rest.get(i..i + 2)?.parse().ok() };
452    let (mo, d, h, mi, se) = (n(0)?, n(2)?, n(4)?, n(6)?, n(8)?);
453    let days = days_from_civil(year, mo, d);
454    let t = days * 86400 + h * 3600 + mi * 60 + se;
455    u64::try_from(t).ok()
456}
457
458/// Days since 1970-01-01 of a proleptic Gregorian date.
459pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
460    let y = if m <= 2 { y - 1 } else { y };
461    let era = y.div_euclid(400);
462    let yoe = y - era * 400;
463    let mp = (m + 9) % 12;
464    let doy = (153 * mp + 2) / 5 + d - 1;
465    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
466    era * 146_097 + doe - 719_468
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use std::net::TcpListener;
473
474    fn probe(url: &str) -> HttpProbe {
475        HttpProbe {
476            url: url.into(),
477            method: "GET".into(),
478            headers: vec![("X-Token".into(), "t0k".into())],
479            timeout: Duration::from_secs(3),
480            follow_redirects: false,
481            allow_private: true,
482            connect_to: None,
483            tls: net::default_tls(),
484        }
485    }
486
487    /// Answer each connection with the next canned response; return the
488    /// requests.
489    fn server(answers: Vec<String>) -> (u16, std::thread::JoinHandle<Vec<String>>) {
490        let l = TcpListener::bind("127.0.0.1:0").unwrap();
491        let port = l.local_addr().unwrap().port();
492        let h = std::thread::spawn(move || {
493            let mut got = Vec::new();
494            for a in answers {
495                let (mut s, _) = l.accept().unwrap();
496                let mut buf = vec![0u8; 8192];
497                let n = s.read(&mut buf).unwrap();
498                got.push(String::from_utf8_lossy(&buf[..n]).to_string());
499                s.write_all(a.as_bytes()).unwrap();
500            }
501            got
502        });
503        (port, h)
504    }
505
506    #[test]
507    fn get_with_headers_and_a_chunked_body() {
508        let (port, h) = server(vec![
509            "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"
510                .into(),
511        ]);
512        let a = http(&probe(&format!("http://127.0.0.1:{port}/health?k=1"))).unwrap();
513        assert_eq!(a.status, 200);
514        assert_eq!(a.body, b"hello world");
515        assert_eq!(a.final_url, format!("http://127.0.0.1:{port}/health"));
516        let req = &h.join().unwrap()[0];
517        assert!(req.starts_with("GET /health?k=1 HTTP/1.1\r\n"), "{req}");
518        assert!(req.contains("X-Token: t0k\r\n"), "{req}");
519    }
520
521    #[test]
522    fn redirects_are_followed_only_when_asked() {
523        let (port, h) = server(vec![
524            "HTTP/1.1 302 Found\r\nLocation: /login\r\nContent-Length: 0\r\n\r\n".into(),
525            "HTTP/1.1 302 Found\r\nLocation: /login\r\nContent-Length: 0\r\n\r\n".into(),
526            "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok".into(),
527        ]);
528        let url = format!("http://127.0.0.1:{port}/");
529        let a = http(&probe(&url)).unwrap();
530        assert_eq!((a.status, a.location.as_deref()), (302, Some("/login")));
531        let mut p = probe(&url);
532        p.follow_redirects = true;
533        let a = http(&p).unwrap();
534        assert_eq!((a.status, a.body.as_slice()), (200, &b"ok"[..]));
535        assert!(a.final_url.ends_with("/login"));
536        h.join().unwrap();
537    }
538
539    #[test]
540    fn the_address_policy_holds_unless_dialled_by_reference() {
541        let (port, h) = server(vec!["HTTP/1.1 204 No Content\r\n\r\n".into()]);
542        let mut p = probe(&format!("http://127.0.0.1:{port}/"));
543        p.allow_private = false;
544        let e = http(&p).unwrap_err();
545        assert!(e.contains("refusing 127.0.0.1"), "{e}");
546        // By reference (an app's own endpoint), the Host stays the name.
547        let mut p = probe("http://shop.example.com/");
548        p.allow_private = false;
549        p.connect_to = Some(format!("127.0.0.1:{port}").parse().unwrap());
550        assert_eq!(http(&p).unwrap().status, 204);
551        assert!(h.join().unwrap()[0].contains("Host: shop.example.com\r\n"));
552        // TCP too.
553        assert!(tcp("127.0.0.1", port, Duration::from_secs(1), false).is_err());
554    }
555
556    #[test]
557    fn timeouts_and_refusals() {
558        // Accepts, never answers.
559        let l = TcpListener::bind("127.0.0.1:0").unwrap();
560        let port = l.local_addr().unwrap().port();
561        let mut p = probe(&format!("http://127.0.0.1:{port}/"));
562        p.timeout = Duration::from_millis(300);
563        let started = Instant::now();
564        let e = http(&p).unwrap_err();
565        assert!(e.contains("timed out"), "{e}");
566        assert!(started.elapsed() < Duration::from_secs(2));
567        drop(l);
568        let e = http(&probe(&format!("http://127.0.0.1:{port}/"))).unwrap_err();
569        assert!(e.contains("cannot connect"), "{e}");
570        let e = tcp("127.0.0.1", port, Duration::from_secs(1), true).unwrap_err();
571        assert!(e.contains("cannot connect"), "{e}");
572    }
573
574    #[test]
575    fn redirect_targets() {
576        let t = net::parse_url("https://a.example.com:8443/x/y?q").unwrap();
577        assert_eq!(join(&t, "/z"), "https://a.example.com:8443/z");
578        assert_eq!(join(&t, "z"), "https://a.example.com:8443/x/z");
579        assert_eq!(join(&t, "http://b.example/"), "http://b.example/");
580        assert_eq!(join(&t, "//c.example/p"), "https://c.example/p");
581        assert_eq!(display_url("https://a/b?token=1#f"), "https://a/b");
582    }
583
584    #[test]
585    fn certificate_expiry() {
586        let mut params = rcgen::CertificateParams::new(vec!["shop.example.com".into()]).unwrap();
587        params.not_after = rcgen::date_time_ymd(2031, 7, 9);
588        let key = rcgen::KeyPair::generate().unwrap();
589        let cert = params.self_signed(&key).unwrap();
590        let want = days_from_civil(2031, 7, 9) as u64 * 86400;
591        assert_eq!(cert_not_after(cert.der().as_ref()), Some(want));
592        // GeneralizedTime past 2049.
593        params.not_after = rcgen::date_time_ymd(2051, 1, 2);
594        let cert = params.self_signed(&key).unwrap();
595        assert_eq!(
596            cert_not_after(cert.der().as_ref()),
597            Some(days_from_civil(2051, 1, 2) as u64 * 86400)
598        );
599        assert_eq!(cert_not_after(b"\x30\x03\x02\x01\x01"), None);
600        assert_eq!(cert_not_after(&[]), None);
601        assert_eq!(days_from_civil(1970, 1, 1), 0);
602        assert_eq!(days_from_civil(2000, 3, 1), 11017);
603    }
604}