Skip to main content

waf_proxy/
tls.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Inbound TLS termination (Phase 12).
5//!
6//! **Basic, cert-from-file termination is OPEN core** (`BOUNDARY.md` §3.2): it makes a
7//! single node self-sufficient for `https://`. Cert management *at scale* — ACME/Let's
8//! Encrypt, rotation, centralized multi-node certs, **mTLS with managed PKI** — is
9//! ENTERPRISE (governance/scale) and plugs in behind the [`TlsCertSource`] seam, the §4
10//! boundary pattern: the OPEN tier ships [`FileCertSource`]; an enterprise crate provides
11//! an at-scale impl of the SAME trait.
12//!
13//! ALPN advertises `h2` + `http/1.1` (config-driven) so an h2-capable client (e.g.
14//! gRPC-over-TLS, a later phase) negotiates HTTP/2 while an h1-only client falls back
15//! cleanly — both then flow through the SAME protocol-neutral `handle()`.
16//!
17//! **No silent downgrade**: when TLS is enabled the listener serves ONLY TLS. A required
18//! cert that cannot be loaded is a fatal boot error (`acceptor_from_config` returns `Err`,
19//! the proxy refuses to bind) — never a fallback to cleartext on the same port.
20
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
25use tokio_rustls::rustls::ServerConfig;
26use tokio_rustls::TlsAcceptor;
27
28use waf_core::TlsConfig;
29
30/// Server certificate material: a leaf-first chain + its private key, as rustls DER types.
31pub struct TlsMaterial {
32    pub cert_chain: Vec<CertificateDer<'static>>,
33    pub private_key: PrivateKeyDer<'static>,
34}
35
36/// Source of the server's TLS certificate material — the §4 boundary seam. [`FileCertSource`]
37/// is the OPEN implementation; ACME/rotation/managed-PKI are enterprise implementations of
38/// this same trait (the core depends only on the trait, never on the at-scale impl).
39pub trait TlsCertSource: Send + Sync {
40    fn load(&self) -> Result<TlsMaterial, TlsError>;
41}
42
43/// Why building the TLS terminator failed. All variants are fatal at boot (a required TLS
44/// that cannot be built must stop the proxy, never degrade to cleartext).
45#[derive(Debug)]
46pub enum TlsError {
47    Io { path: PathBuf, source: std::io::Error },
48    NoCertificates(PathBuf),
49    NoPrivateKey(PathBuf),
50    Rustls(tokio_rustls::rustls::Error),
51}
52
53impl std::fmt::Display for TlsError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::Io { path, source } =>
57                write!(f, "cannot read TLS file {}: {source}", path.display()),
58            Self::NoCertificates(p) =>
59                write!(f, "no PEM certificates found in {}", p.display()),
60            Self::NoPrivateKey(p) =>
61                write!(f, "no PEM private key found in {}", p.display()),
62            Self::Rustls(e) => write!(f, "TLS configuration error: {e}"),
63        }
64    }
65}
66
67impl std::error::Error for TlsError {}
68
69/// OPEN cert source: read a PEM certificate chain + private key from two files.
70pub struct FileCertSource {
71    cert_path: PathBuf,
72    key_path: PathBuf,
73}
74
75impl FileCertSource {
76    pub fn new(cert_path: impl Into<PathBuf>, key_path: impl Into<PathBuf>) -> Self {
77        Self { cert_path: cert_path.into(), key_path: key_path.into() }
78    }
79}
80
81impl TlsCertSource for FileCertSource {
82    fn load(&self) -> Result<TlsMaterial, TlsError> {
83        Ok(TlsMaterial {
84            cert_chain: load_certs(&self.cert_path)?,
85            private_key: load_key(&self.key_path)?,
86        })
87    }
88}
89
90fn load_certs(path: &Path) -> Result<Vec<CertificateDer<'static>>, TlsError> {
91    let data = std::fs::read(path).map_err(|source| TlsError::Io { path: path.to_path_buf(), source })?;
92    let mut reader: &[u8] = &data;
93    let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut reader)
94        .collect::<Result<_, _>>()
95        .map_err(|source| TlsError::Io { path: path.to_path_buf(), source })?;
96    if certs.is_empty() {
97        return Err(TlsError::NoCertificates(path.to_path_buf()));
98    }
99    Ok(certs)
100}
101
102fn load_key(path: &Path) -> Result<PrivateKeyDer<'static>, TlsError> {
103    let data = std::fs::read(path).map_err(|source| TlsError::Io { path: path.to_path_buf(), source })?;
104    let mut reader: &[u8] = &data;
105    rustls_pemfile::private_key(&mut reader)
106        .map_err(|source| TlsError::Io { path: path.to_path_buf(), source })?
107        .ok_or_else(|| TlsError::NoPrivateKey(path.to_path_buf()))
108}
109
110/// Build a rustls [`ServerConfig`] from a cert source + ALPN list. Uses the `ring` crypto
111/// provider explicitly (no process-global `install_default`, so multiple configs — e.g. in
112/// tests — never race over the default).
113pub fn build_server_config(source: &dyn TlsCertSource, alpn: &[String]) -> Result<ServerConfig, TlsError> {
114    let material = source.load()?;
115    let provider = Arc::new(tokio_rustls::rustls::crypto::ring::default_provider());
116    let mut cfg = ServerConfig::builder_with_provider(provider)
117        .with_safe_default_protocol_versions()
118        .map_err(TlsError::Rustls)?
119        .with_no_client_auth()
120        .with_single_cert(material.cert_chain, material.private_key)
121        .map_err(TlsError::Rustls)?;
122    cfg.alpn_protocols = alpn.iter().map(|p| p.as_bytes().to_vec()).collect();
123    Ok(cfg)
124}
125
126/// Build the [`TlsAcceptor`] with an **injected** cert source, or `Ok(None)` when TLS is
127/// off. `tls.enabled` (operator switch) and `tls.alpn` still come from config; the source
128/// only governs cert *provenance* — when `Some`, the file paths in config are ignored, so
129/// an enterprise ACME/managed-PKI source needs no `cert_path`/`key_path`. `None` falls back
130/// to the OPEN [`FileCertSource`]. An enabled-but-unbuildable terminator is `Err` → the
131/// proxy fails to bind (fatal boot, no silent cleartext fallback).
132pub fn acceptor_from_source(
133    tls: &TlsConfig,
134    source: Option<Arc<dyn TlsCertSource>>,
135) -> Result<Option<TlsAcceptor>, TlsError> {
136    if !tls.enabled {
137        return Ok(None);
138    }
139    let cfg = match source {
140        Some(s) => build_server_config(s.as_ref(), &tls.alpn)?,
141        None => build_server_config(&FileCertSource::new(&tls.cert_path, &tls.key_path), &tls.alpn)?,
142    };
143    Ok(Some(TlsAcceptor::from(Arc::new(cfg))))
144}
145
146/// Build the [`TlsAcceptor`] from config with the default OPEN [`FileCertSource`]. Thin
147/// wrapper over [`acceptor_from_source`] with no injected source.
148pub fn acceptor_from_config(tls: &TlsConfig) -> Result<Option<TlsAcceptor>, TlsError> {
149    acceptor_from_source(tls, None)
150}