Skip to main content

edgeguard/
tls.rs

1//! TLS termination via `rustls` + `tokio-rustls`.
2//!
3//! axum 0.7 has no built-in TLS, so we run a small accept loop: take a TCP connection,
4//! complete the rustls handshake, then hand the encrypted stream to hyper, serving the same
5//! axum [`Router`] the plaintext path uses. Certificates come either from PEM files
6//! ([`load_server_config`]) or from ACME (which writes those same files; see [`crate::acme`]).
7
8use std::fs::File;
9use std::io::BufReader;
10use std::net::SocketAddr;
11use std::sync::Arc;
12use std::time::Duration;
13
14use anyhow::{Context, Result};
15use axum::http::{HeaderValue, Request, StatusCode};
16use axum::response::IntoResponse;
17use axum::Router;
18use hyper::body::Incoming;
19use hyper_util::rt::{TokioExecutor, TokioIo};
20use hyper_util::server::conn::auto::Builder as ConnBuilder;
21use rustls::pki_types::{CertificateDer, PrivateKeyDer};
22use rustls::ServerConfig;
23use tokio::net::TcpListener;
24use tokio::sync::watch;
25use tokio_rustls::TlsAcceptor;
26use tower::{Service, ServiceExt};
27use tracing::{debug, info, warn};
28
29/// Install a process-wide default crypto provider (ring). Idempotent and best-effort: if a
30/// provider is already installed (e.g. by the JWKS HTTP client) this is a no-op.
31///
32/// `ring` is the only provider linked (see the rustls entry in Cargo.toml), so rustls would
33/// resolve it from crate features anyway. Installing it explicitly keeps that independent of
34/// the dependency graph: a future dep that re-enables `rustls/aws_lc_rs` would otherwise make
35/// every feature-based `CryptoProvider` lookup ambiguous — and those lookups panic.
36pub fn init_crypto() {
37    let _ = rustls::crypto::ring::default_provider().install_default();
38}
39
40/// Build a rustls [`ServerConfig`] from a PEM certificate chain and private key. Uses an
41/// explicit ring provider so it doesn't depend on which provider happens to be the process
42/// default. Advertises HTTP/1.1 via ALPN (the proxy speaks HTTP/1.1 upstream).
43pub fn load_server_config(cert_path: &str, key_path: &str) -> Result<Arc<ServerConfig>> {
44    let certs = load_certs(cert_path)?;
45    let key = load_key(key_path)?;
46
47    let mut config =
48        ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
49            .with_safe_default_protocol_versions()
50            .context("selecting TLS protocol versions")?
51            .with_no_client_auth()
52            .with_single_cert(certs, key)
53            .context("building rustls ServerConfig (does the key match the certificate?)")?;
54    config.alpn_protocols = vec![b"http/1.1".to_vec()];
55    Ok(Arc::new(config))
56}
57
58fn load_certs(path: &str) -> Result<Vec<CertificateDer<'static>>> {
59    let file = File::open(path).with_context(|| format!("opening certificate file {path}"))?;
60    let mut reader = BufReader::new(file);
61    let certs = rustls_pemfile::certs(&mut reader)
62        .collect::<Result<Vec<_>, _>>()
63        .with_context(|| format!("parsing certificates from {path}"))?;
64    anyhow::ensure!(!certs.is_empty(), "no certificates found in {path}");
65    Ok(certs)
66}
67
68fn load_key(path: &str) -> Result<PrivateKeyDer<'static>> {
69    let file = File::open(path).with_context(|| format!("opening private key file {path}"))?;
70    let mut reader = BufReader::new(file);
71    rustls_pemfile::private_key(&mut reader)
72        .with_context(|| format!("parsing private key from {path}"))?
73        .with_context(|| format!("no private key found in {path}"))
74}
75
76/// Serve `app` over TLS on `listener` until `shutdown` flips true. Each connection is
77/// handshaked and served on its own task, so a slow handshake can't block new accepts and a
78/// graceful shutdown stops accepting while letting the listener drop.
79pub async fn serve(
80    listener: TcpListener,
81    config: Arc<ServerConfig>,
82    app: Router,
83    mut shutdown: watch::Receiver<bool>,
84) -> Result<()> {
85    let acceptor = TlsAcceptor::from(config);
86    // `into_make_service_with_connect_info` injects `ConnectInfo(peer)` per connection, which
87    // the proxy handler relies on for client-IP resolution.
88    let mut make_service = app.into_make_service_with_connect_info::<SocketAddr>();
89
90    info!(listen = %listener.local_addr().map(|a| a.to_string()).unwrap_or_default(), "TLS listener up");
91
92    loop {
93        let (stream, peer) = tokio::select! {
94            _ = shutdown.changed() => {
95                if *shutdown.borrow() { break; }
96                continue;
97            }
98            accepted = listener.accept() => match accepted {
99                Ok(v) => v,
100                Err(e) => { warn!(error = %e, "TLS accept error"); continue; }
101            },
102        };
103
104        let acceptor = acceptor.clone();
105        // Connection-scoped tower service carrying this peer's ConnectInfo.
106        let tower_service = unwrap_infallible(make_service.call(peer).await);
107
108        tokio::spawn(async move {
109            // Bound the handshake so a client that never completes it can't pin a task/socket
110            // indefinitely (this runs before any auth/rate-limit checks).
111            let tls_stream = match tokio::time::timeout(
112                Duration::from_secs(10),
113                acceptor.accept(stream),
114            )
115            .await
116            {
117                Ok(Ok(s)) => s,
118                Ok(Err(e)) => {
119                    debug!(error = %e, %peer, "TLS handshake failed");
120                    return;
121                }
122                Err(_) => {
123                    debug!(%peer, "TLS handshake timed out");
124                    return;
125                }
126            };
127            let io = TokioIo::new(tls_stream);
128            let hyper_service = hyper::service::service_fn(move |request: Request<Incoming>| {
129                tower_service.clone().oneshot(request)
130            });
131            if let Err(e) = ConnBuilder::new(TokioExecutor::new())
132                .serve_connection_with_upgrades(io, hyper_service)
133                .await
134            {
135                debug!(error = %e, %peer, "error serving TLS connection");
136            }
137        });
138    }
139    Ok(())
140}
141
142/// The ACME HTTP-01 challenge prefix. The redirect listener answers `404` here instead of
143/// redirecting: a CA validating a challenge must read the token over plain HTTP, and bouncing it
144/// to a port whose certificate is the very thing being issued would deadlock issuance. Nothing on
145/// this path is served by the redirect listener itself — `crate::acme` binds `:80` for the
146/// duration of an order and the redirect listener starts only after it has released the port.
147const ACME_CHALLENGE_PREFIX: &str = "/.well-known/acme-challenge/";
148
149/// Decide the `Location` for an HTTP request that should be served over HTTPS.
150///
151/// Returns `None` when the request must not be redirected — an absent, malformed, or non-allowed
152/// `Host`. That case matters: `Host` is attacker-controlled, so reflecting it unchecked turns
153/// this listener into an open redirect that borrows the site's name to send visitors elsewhere.
154/// A security proxy must not ship the very hole it exists to close, so a host is only reflected
155/// after it passes a syntactic check and, when `allowed` is non-empty, an allow-list check.
156///
157/// `tls_port` is appended when it is not 443, so a proxy on `:8443` redirects to `:8443` rather
158/// than to a port nothing is listening on.
159pub fn redirect_location(
160    host_header: Option<&str>,
161    path_and_query: &str,
162    tls_port: u16,
163    allowed: &[String],
164) -> Option<String> {
165    let host = host_header?;
166    let bare = split_authority(host)?;
167    if !allowed.is_empty() && !allowed.iter().any(|a| a.eq_ignore_ascii_case(bare)) {
168        return None;
169    }
170    Some(if tls_port == 443 {
171        format!("https://{bare}{path_and_query}")
172    } else {
173        format!("https://{bare}:{tls_port}{path_and_query}")
174    })
175}
176
177/// Split a `Host` header into its host part, rejecting anything that is not a bare authority.
178///
179/// The whole header is validated, not just the part before the first colon. Taking
180/// `host.split(':').next()` alone would quietly *repair* a malformed authority instead of
181/// refusing it: `attacker.example:443@victim.example` would reduce to `attacker.example` and
182/// produce a redirect, when a header that is not a valid authority has to be a `400`. A port is
183/// only stripped once the remainder is confirmed to be digits, so userinfo, a second colon, or
184/// any other junk fails the whole header rather than one component of it.
185fn split_authority(host: &str) -> Option<&str> {
186    // `None` = no port at all; `Some(p)` = a colon was present and `p` is everything after it,
187    // which must then be a non-empty run of digits. Collapsing those two cases is what let
188    // `host.example:` (a colon with nothing after it) through.
189    let (bare, port) = match host.rfind(']') {
190        // IPv6 literal: everything through the closing bracket is the host, and only `:<port>`
191        // may follow it — any other trailing text is not an authority.
192        Some(close) => {
193            let rest = &host[close + 1..];
194            match rest.strip_prefix(':') {
195                Some(p) => (&host[..=close], Some(p)),
196                None if rest.is_empty() => (&host[..=close], None),
197                None => return None,
198            }
199        }
200        None => match host.split_once(':') {
201            Some((h, p)) => (h, Some(p)),
202            None => (host, None),
203        },
204    };
205    if let Some(digits) = port {
206        if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
207            return None;
208        }
209    }
210    is_valid_host(bare).then_some(bare)
211}
212
213/// Accept only what can appear in an authority: hostname characters, or a bracketed IPv6 literal.
214/// This is deliberately strict — it is the gate that keeps a header like
215/// `Host: evil.example/@` or one carrying CR/LF out of a `Location` we sign our name to.
216fn is_valid_host(host: &str) -> bool {
217    if host.is_empty() || host.len() > 253 {
218        return false;
219    }
220    if let Some(inner) = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
221        return !inner.is_empty() && inner.parse::<std::net::Ipv6Addr>().is_ok();
222    }
223    // No leading/trailing dot or hyphen, and nothing outside the LDH set.
224    !host.starts_with(['.', '-'])
225        && !host.ends_with(['.', '-'])
226        && host
227            .bytes()
228            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
229}
230
231/// The redirect listener's router: one catch-all that answers every method and path.
232fn redirect_router(tls_port: u16, status: StatusCode, allowed: Vec<String>) -> Router {
233    let allowed = Arc::new(allowed);
234    Router::new().fallback(move |req: Request<axum::body::Body>| {
235        let allowed = Arc::clone(&allowed);
236        async move {
237            let path_and_query = req
238                .uri()
239                .path_and_query()
240                .map(|pq| pq.as_str())
241                .unwrap_or("/");
242
243            if path_and_query.starts_with(ACME_CHALLENGE_PREFIX) {
244                return (StatusCode::NOT_FOUND, "not found\n").into_response();
245            }
246
247            let host = req
248                .headers()
249                .get(axum::http::header::HOST)
250                .and_then(|h| h.to_str().ok());
251
252            match redirect_location(host, path_and_query, tls_port, &allowed) {
253                Some(location) => match HeaderValue::from_str(&location) {
254                    Ok(value) => (status, [(axum::http::header::LOCATION, value)]).into_response(),
255                    Err(_) => (StatusCode::BAD_REQUEST, "bad host\n").into_response(),
256                },
257                None => (
258                    StatusCode::BAD_REQUEST,
259                    "this port serves only an HTTPS redirect; send a valid Host header\n",
260                )
261                    .into_response(),
262            }
263        }
264    })
265}
266
267/// Validate a configured `tls.redirect_status`.
268///
269/// Called from startup *before* the plaintext listener binds, because the only other check used
270/// to run inside the spawned serving task: a `redirect_status = 200` failed there, the task
271/// logged a warning and ended, and the proxy carried on serving HTTPS with nothing on the
272/// redirect port — so a typo turned into "connection refused" for every bare-hostname visitor
273/// rather than a refusal to start. [`serve_redirect`] still calls it as a guard.
274pub fn parse_redirect_status(status: u16) -> Result<StatusCode> {
275    let parsed = StatusCode::from_u16(status)
276        .with_context(|| format!("invalid tls.redirect_status {status}"))?;
277    // NOT `is_redirection()`, which is true for the whole 3xx class and so accepts codes that do
278    // not navigate: `304 Not Modified` is a cache validator, `305`/`306` are deprecated, and
279    // `300 Multiple Choices` needs a body to choose from. A listener answering `304` with a
280    // `Location` leaves the client sitting on plaintext — the exact failure this feature exists
281    // to prevent, arrived at through a config value we accepted.
282    anyhow::ensure!(
283        matches!(status, 301 | 302 | 303 | 307 | 308),
284        "tls.redirect_status must be 301, 302, 303, 307 or 308 (got {parsed}); \
285         308 preserves the method and body, 301 is the older browser-facing convention"
286    );
287    Ok(parsed)
288}
289
290/// Serve plain-HTTP redirects to HTTPS on `listener` until `shutdown` flips.
291///
292/// This exists because the common failure is not "the operator refused TLS" — it is a user typing
293/// a bare hostname, the browser trying `:80` first, and the request either failing or being
294/// answered in plaintext by the app. Terminating TLS only helps if plaintext traffic actually
295/// arrives at it.
296///
297/// `status` should be 308 to preserve the method and body of a non-GET request, or 301 for the
298/// classic browser-facing behaviour; [`crate::config::TlsCfg`] documents the choice.
299pub async fn serve_redirect(
300    listener: TcpListener,
301    tls_port: u16,
302    status: u16,
303    allowed: Vec<String>,
304    shutdown: watch::Receiver<bool>,
305) -> Result<()> {
306    let status = parse_redirect_status(status)?;
307
308    info!(
309        listen = %listener.local_addr().map(|a| a.to_string()).unwrap_or_default(),
310        to_port = tls_port,
311        %status,
312        "HTTP→HTTPS redirect listener up"
313    );
314
315    axum::serve(
316        listener,
317        redirect_router(tls_port, status, allowed).into_make_service(),
318    )
319    .with_graceful_shutdown(async move {
320        let mut shutdown = shutdown;
321        while shutdown.changed().await.is_ok() {
322            if *shutdown.borrow() {
323                break;
324            }
325        }
326    })
327    .await
328    .context("HTTP→HTTPS redirect server error")
329}
330
331fn unwrap_infallible<T>(result: Result<T, std::convert::Infallible>) -> T {
332    match result {
333        Ok(value) => value,
334        Err(never) => match never {},
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn load_server_config_errors_on_missing_files() {
344        assert!(load_server_config("/no/such/cert.pem", "/no/such/key.pem").is_err());
345    }
346
347    fn loc(host: Option<&str>, pq: &str, port: u16) -> Option<String> {
348        redirect_location(host, pq, port, &[])
349    }
350
351    #[test]
352    fn redirects_preserving_path_and_query() {
353        assert_eq!(
354            loc(Some("app.example.com"), "/a/b?x=1&y=2", 443).as_deref(),
355            Some("https://app.example.com/a/b?x=1&y=2")
356        );
357    }
358
359    #[test]
360    fn rewrites_to_the_tls_port_when_it_is_not_443() {
361        // The client connected to :80; the certificate is served on :8443, so sending them back
362        // to the default port would point them at nothing.
363        assert_eq!(
364            loc(Some("localhost:80"), "/", 8443).as_deref(),
365            Some("https://localhost:8443/")
366        );
367    }
368
369    #[test]
370    fn strips_the_plaintext_port_from_the_host_header() {
371        assert_eq!(
372            loc(Some("app.example.com:80"), "/", 443).as_deref(),
373            Some("https://app.example.com/")
374        );
375    }
376
377    #[test]
378    fn keeps_ipv6_literals_bracketed() {
379        assert_eq!(
380            loc(Some("[::1]:80"), "/health", 8443).as_deref(),
381            Some("https://[::1]:8443/health")
382        );
383        assert_eq!(loc(Some("[not-an-ip]"), "/", 443), None);
384    }
385
386    #[test]
387    fn refuses_a_missing_or_malformed_host() {
388        // Each of these would otherwise end up verbatim in a `Location` header sent under this
389        // site's name — the open-redirect / header-injection case this gate exists for.
390        assert_eq!(loc(None, "/", 443), None);
391        assert_eq!(loc(Some(""), "/", 443), None);
392        assert_eq!(loc(Some("evil.example/@good.example"), "/", 443), None);
393        assert_eq!(loc(Some("host\r\nX-Injected: 1"), "/", 443), None);
394        assert_eq!(loc(Some("has space"), "/", 443), None);
395        assert_eq!(loc(Some("user@evil.example"), "/", 443), None);
396        // The whole authority is validated, not just the part before the first colon. Splitting
397        // on ':' alone would reduce this to "attacker.example" and redirect, silently repairing
398        // a malformed header instead of refusing it — and parsing a Host differently from the
399        // hop in front of us is how host-confusion bugs start.
400        assert_eq!(
401            loc(Some("attacker.example:443@victim.example"), "/", 443),
402            None
403        );
404        assert_eq!(loc(Some("host.example:80:81"), "/", 443), None);
405        assert_eq!(loc(Some("host.example:"), "/", 443), None);
406        assert_eq!(loc(Some("host.example:notaport"), "/", 443), None);
407        assert_eq!(loc(Some("[::1]:junk"), "/", 443), None);
408        assert_eq!(loc(Some(".leading-dot"), "/", 443), None);
409        assert_eq!(loc(Some(&"a".repeat(254)), "/", 443), None);
410    }
411
412    #[test]
413    fn allow_list_pins_the_redirect_target() {
414        let allowed = vec!["app.example.com".to_string()];
415        assert!(redirect_location(Some("app.example.com"), "/", 443, &allowed).is_some());
416        // Syntactically fine, but not a name we serve — so we do not lend our domain to it.
417        assert_eq!(
418            redirect_location(Some("attacker.example"), "/", 443, &allowed),
419            None
420        );
421        // Host matching is case-insensitive, per RFC 3986.
422        assert!(redirect_location(Some("APP.Example.com:80"), "/", 443, &allowed).is_some());
423    }
424
425    #[tokio::test]
426    async fn redirect_router_answers_end_to_end() {
427        use axum::body::Body;
428        use tower::ServiceExt;
429
430        let app = redirect_router(8443, StatusCode::PERMANENT_REDIRECT, vec![]);
431
432        let res = app
433            .clone()
434            .oneshot(
435                Request::builder()
436                    .method("POST")
437                    .uri("/submit?a=1")
438                    .header("host", "localhost")
439                    .body(Body::empty())
440                    .unwrap(),
441            )
442            .await
443            .unwrap();
444        // 308 rather than 301/302 so the POST is replayed as a POST over TLS.
445        assert_eq!(res.status(), StatusCode::PERMANENT_REDIRECT);
446        assert_eq!(
447            res.headers().get("location").unwrap(),
448            "https://localhost:8443/submit?a=1"
449        );
450
451        // The ACME challenge path must not be redirected: HTTP-01 validation reads it in
452        // plaintext, and bouncing it to the port whose certificate is being issued would
453        // deadlock the order.
454        let res = app
455            .clone()
456            .oneshot(
457                Request::builder()
458                    .uri("/.well-known/acme-challenge/tok")
459                    .header("host", "localhost")
460                    .body(Body::empty())
461                    .unwrap(),
462            )
463            .await
464            .unwrap();
465        assert_eq!(res.status(), StatusCode::NOT_FOUND);
466        assert!(res.headers().get("location").is_none());
467
468        // No Host at all (HTTP/1.0) gets a 400, not a redirect to nowhere.
469        let res = app
470            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
471            .await
472            .unwrap();
473        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
474    }
475
476    #[test]
477    fn redirect_status_accepts_only_navigational_redirects() {
478        for ok in [301, 302, 303, 307, 308] {
479            assert!(parse_redirect_status(ok).is_ok(), "{ok} should be accepted");
480        }
481        // Startup calls this before binding, so these must be errors, not a task that dies.
482        for bad in [200, 404, 0] {
483            assert!(
484                parse_redirect_status(bad).is_err(),
485                "{bad} should be rejected"
486            );
487        }
488        // 3xx, but not navigation: 304 is a cache validator, 305/306 are deprecated, and 300
489        // needs a body to choose from. Answering any of them with a `Location` leaves the client
490        // on plaintext, so `is_redirection()` is the wrong test.
491        for not_navigational in [300, 304, 305, 306] {
492            assert!(
493                parse_redirect_status(not_navigational).is_err(),
494                "{not_navigational} is 3xx but does not navigate; it must be rejected"
495            );
496        }
497    }
498
499    #[tokio::test]
500    async fn serve_redirect_rejects_a_non_3xx_status() {
501        let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
502        let (_tx, rx) = watch::channel(false);
503        // A typo'd `redirect_status = 200` must fail loudly at startup rather than answering
504        // every plaintext request with an empty 200 that looks like the app.
505        assert!(serve_redirect(listener, 443, 200, vec![], rx)
506            .await
507            .is_err());
508    }
509}