64. Capstone: one HTTPS request, every layer, every guide, in Rust
The HTTPS guide's final chapter
traces one request through the whole series in Go. This is the Rust
version: a curl -w-style tool that performs each phase by hand, times
it, and prints which guide explains it.
// src/bin/lifecycle.rs usage: lifecycle https://example.com/
use rustls::pki_types::ServerName;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::time::timeout;
fn phase(name: &str, took: Duration, detail: &str, guide: &str) {
println!("{name:<10} {:>9.2?} {detail:<55} {guide}", took);
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let raw = std::env::args().nth(1).unwrap_or_else(|| "https://example.com/".into());
let url = reqwest::Url::parse(&raw)?;
anyhow::ensure!(url.scheme() == "https", "https URLs only");
let host = url.host_str().ok_or_else(|| anyhow::anyhow!("no host"))?.to_string();
let port = url.port_or_known_default().unwrap_or(443);
let path = match url.query() { Some(q) => format!("{}?{q}", url.path()), None => url.path().to_string() };
let total = Instant::now();
// 1. DNS: name -> addresses (Net Ch 18, HTTPS Ch 2). The OS resolver reads /etc/hosts first (Ch 15).
let t = Instant::now();
let addrs: Vec<_> = timeout(Duration::from_secs(5), tokio::net::lookup_host((host.as_str(), port))).await??.collect();
let addr = *addrs.iter().find(|a| a.is_ipv4()).or(addrs.first()).ok_or_else(|| anyhow::anyhow!("no addresses"))?;
phase("dns", t.elapsed(), &format!("{host} -> {addr} ({} addresses)", addrs.len()), "Net Ch 18 · HTTPS Ch 2 · Rust Ch 45");
// 2. TCP: three-way handshake = one round trip (Net Ch 21, HTTPS Ch 3). Measures RTT to the edge.
let t = Instant::now();
let tcp = timeout(Duration::from_secs(5), TcpStream::connect(addr)).await??;
tcp.set_nodelay(true)?;
phase("tcp", t.elapsed(), &format!("{} -> {}", tcp.local_addr()?, tcp.peer_addr()?), "Net Ch 21 · OS Ch 19 · Rust Ch 32");
// 3. TLS 1.3: one more round trip; certificate chain validated against the Mozilla root set.
let t = Instant::now();
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut cfg = rustls::ClientConfig::builder().with_root_certificates(roots).with_no_client_auth();
cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; // we speak HTTP/1.1 by hand below
let connector = tokio_rustls::TlsConnector::from(Arc::new(cfg));
let mut tls = timeout(Duration::from_secs(5), connector.connect(ServerName::try_from(host.clone())?, tcp)).await??;
let (_, conn) = tls.get_ref();
let detail = format!("{:?}, {:?}, ALPN {:?}, chain of {}",
conn.protocol_version().unwrap(), conn.negotiated_cipher_suite().unwrap().suite(),
conn.alpn_protocol().map(String::from_utf8_lossy), conn.peer_certificates().map_or(0, |c| c.len()));
phase("tls", t.elapsed(), &detail, "Sec Ch 28-29 · HTTPS Ch 4 · Rust Ch 49");
// 4. HTTP/1.1 request; time to first byte = network RTT + server (and CDN/proxy) processing.
let t = Instant::now();
let req = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: rust-guide-lifecycle/1.0\r\nAccept: */*\r\nConnection: close\r\n\r\n");
tls.write_all(req.as_bytes()).await?;
let mut reader = BufReader::new(tls);
let mut status = String::new();
timeout(Duration::from_secs(10), reader.read_line(&mut status)).await??;
phase("ttfb", t.elapsed(), status.trim(), "Net Ch 28 · HTTPS Ch 5 · Rust Ch 46");
// 5. Response headers: what the infrastructure in between left behind.
let mut interesting = Vec::new();
loop {
let mut line = String::new();
if reader.read_line(&mut line).await? == 0 || line == "\r\n" { break; }
let lower = line.to_ascii_lowercase();
for h in ["server:", "via:", "age:", "cache-control:", "cf-cache-status:", "x-cache:", "alt-svc:", "strict-transport-security:"] {
if lower.starts_with(h) { interesting.push(line.trim().to_string()); }
}
}
for h in &interesting { println!("{:<21}{h}", ""); }
println!("{:<21}(proxies, CDN cache: HTTPS Ch 8, 10, 15 · HSTS: HTTPS Ch 12 · HTTP/3 via Alt-Svc: HTTPS Ch 14)", "");
// 6. Body: read to EOF (Connection: close), bounded.
let t = Instant::now();
let mut body = Vec::new();
let mut limited = tokio::io::AsyncReadExt::take(reader, 10 << 20);
tokio::io::AsyncReadExt::read_to_end(&mut limited, &mut body).await?;
phase("body", t.elapsed(), &format!("{} bytes", body.len()), "OS Ch 14 (bytes land in a socket buffer, then your Vec)");
phase("total", total.elapsed(), "", "");
Ok(())
}
cargo run --release --bin lifecycle -- https://example.com/
dns 77.95ms example.com -> 172.66.147.243:443 (2 addresses) Net Ch 18 · HTTPS Ch 2 · Rust Ch 45
tcp 14.24ms 192.168.1.4:55293 -> 172.66.147.243:443 Net Ch 21 · OS Ch 19 · Rust Ch 32
tls 62.97ms TLSv1_3, TLS13_AES_256_GCM_SHA384, ALPN Some("http/1.1"), chain of 4 Sec Ch 28-29 · HTTPS Ch 4 · Rust Ch 49
ttfb 18.36ms HTTP/1.1 200 OK Net Ch 28 · HTTPS Ch 5 · Rust Ch 46
Server: cloudflare
Age: 12297
cf-cache-status: HIT
alt-svc: h3=":443"; ma=86400
(proxies, CDN cache: HTTPS Ch 8, 10, 15 · HSTS: HTTPS Ch 12 · HTTP/3 via Alt-Svc: HTTPS Ch 14)
body 9.04µs 589 bytes OS Ch 14 (bytes land in a socket buffer, then your Vec)
total 173.64ms
Reading your own numbers. The run above was a debug build on a home
connection, and yours will differ. Here, cf-cache-status: HIT and
Age: 12297 mean a Cloudflare edge served the page from cache, so ttfb
(18 ms) is about one round trip, the same as tcp (14 ms). The alt-svc
header advertises HTTP/3 for the next visit.
tcp≈ one RTT, andtls≈ one more RTT plus crypto. Heretlsis about four timestcp. Part of that is the unoptimized debug build doing the signature checks, so rerun with--releasebefore blaming the network. If it stays high, look at certificate chain size or a TLS terminator far from the edge.ttfbminus one RTT is server time. On a CDN hit, as above, it is close to zero. On a miss it includes the trip to the origin (HTTPS Ch 10).- The
dnsphase is often near zero on the second run because the OS or the resolver cached it (Net Ch 18). - Debug a slow phase with the tools from
HTTPS Ch 17:
digfor dns,tcpdump/ss -tifor tcp (Net Ch 31, 33), andopenssl s_clientfor tls.
Every phase in this program is one of the guides. DNS and TCP come from the networking guide. The socket and the bytes in the kernel buffer come from the OS guide. Certificate validation and AEAD record encryption come from the security guides. Status codes, headers, and caches come from the HTTPS guide. The program is about 100 lines because Rust's libraries handle each layer correctly and the types keep the layers apart.