Skip to main content

reddb_server/server/
tls.rs

1//! HTTP TLS termination for the embedded HTTP server.
2//!
3//! The HTTP server terminates TLS with `tokio-rustls` and serves both
4//! HTTP/2 and HTTP/1.1 through the shared async hyper edge.
5//!
6//! Capabilities:
7//!   * PEM cert / key load from disk.
8//!   * Optional mTLS — when a client-CA bundle is configured, every
9//!     handshake must present a cert that chains to it.
10//!   * Auto-generated self-signed cert for dev (gated by
11//!     `RED_HTTP_TLS_DEV=1`) — refuses without that env knob.
12//!   * SHA256 fingerprint logged at boot so operators can pin the cert
13//!     out-of-band.
14//!   * TLS 1.2 + 1.3 only (rustls default; older protocols are not
15//!     compiled in). Cipher suites = rustls defaults (FS-only,
16//!     no anonymous, no exportables).
17
18use std::io::{self, BufReader, Write};
19use std::net::TcpStream;
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22
23use rustls::pki_types::CertificateDer;
24use rustls::server::WebPkiClientVerifier;
25use rustls::{RootCertStore, ServerConfig, ServerConnection, StreamOwned};
26
27/// Configuration for HTTP TLS termination.
28#[derive(Debug, Clone)]
29pub struct HttpTlsConfig {
30    /// Path to PEM-encoded server certificate chain.
31    pub cert_path: PathBuf,
32    /// Path to PEM-encoded private key (PKCS#8 or RSA).
33    pub key_path: PathBuf,
34    /// Optional path to PEM CA bundle. When set, mTLS is required and
35    /// every client must present a cert that chains to a CA in this
36    /// bundle. When `None`, plain server-side TLS is used (no client
37    /// auth — same as the public `https://` web).
38    pub client_ca_path: Option<PathBuf>,
39}
40
41/// Build a sync rustls `ServerConfig`. Installs the ring crypto
42/// provider (idempotent — set_default-style; already done by the wire
43/// path, but safe to repeat).
44pub fn build_server_config(
45    config: &HttpTlsConfig,
46) -> Result<Arc<ServerConfig>, Box<dyn std::error::Error>> {
47    let _ = rustls::crypto::ring::default_provider().install_default();
48
49    let cert_pem = std::fs::read(&config.cert_path)
50        .map_err(|err| format!("read TLS cert {}: {err}", config.cert_path.display()))?;
51    let key_pem = std::fs::read(&config.key_path)
52        .map_err(|err| format!("read TLS key {}: {err}", config.key_path.display()))?;
53
54    let certs = rustls_pemfile::certs(&mut BufReader::new(&cert_pem[..]))
55        .collect::<Result<Vec<_>, _>>()
56        .map_err(|err| format!("decode cert PEM: {err}"))?;
57    if certs.is_empty() {
58        return Err("TLS cert PEM contained no certificates".into());
59    }
60    let key = rustls_pemfile::private_key(&mut BufReader::new(&key_pem[..]))
61        .map_err(|err| format!("decode key PEM: {err}"))?
62        .ok_or("TLS key PEM contained no private key")?;
63
64    // Log SHA256 fingerprint (DER-encoded leaf) for out-of-band pinning.
65    let fingerprint = sha256_fingerprint_hex(&certs[0]);
66    tracing::info!(
67        target: "reddb::http_tls",
68        cert = %config.cert_path.display(),
69        sha256 = %fingerprint,
70        "HTTP TLS certificate loaded"
71    );
72
73    let builder = ServerConfig::builder();
74    let mut server_config = if let Some(ca_path) = &config.client_ca_path {
75        let ca_pem = std::fs::read(ca_path)
76            .map_err(|err| format!("read mTLS client CA {}: {err}", ca_path.display()))?;
77        let mut roots = RootCertStore::empty();
78        let ca_certs: Vec<CertificateDer<'static>> =
79            rustls_pemfile::certs(&mut BufReader::new(&ca_pem[..]))
80                .collect::<Result<Vec<_>, _>>()
81                .map_err(|err| format!("decode mTLS client CA PEM: {err}"))?;
82        if ca_certs.is_empty() {
83            return Err("mTLS client CA PEM contained no certificates".into());
84        }
85        for cert in ca_certs {
86            roots.add(cert)?;
87        }
88        let verifier = WebPkiClientVerifier::builder(Arc::new(roots))
89            .build()
90            .map_err(|err| format!("build mTLS client verifier: {err}"))?;
91        tracing::info!(
92            target: "reddb::http_tls",
93            ca = %ca_path.display(),
94            "HTTP mTLS enabled — clients must present a cert chaining to this CA"
95        );
96        builder
97            .with_client_cert_verifier(verifier)
98            .with_single_cert(certs, key)
99            .map_err(|err| format!("install TLS cert/key: {err}"))?
100    } else {
101        builder
102            .with_no_client_auth()
103            .with_single_cert(certs, key)
104            .map_err(|err| format!("install TLS cert/key: {err}"))?
105    };
106
107    // Prefer HTTP/2 while retaining HTTP/1.1 compatibility. The async hyper
108    // edge supports both protocols; omitting `h2` here silently forced every
109    // production HTTPS client back to HTTP/1.1.
110    server_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
111
112    Ok(Arc::new(server_config))
113}
114
115/// Derive a self-signed dev certificate when `--http-tls-bind` is set
116/// without an explicit cert/key. Gated by `RED_HTTP_TLS_DEV=1`; refuses
117/// to auto-generate in any other context (refuses prod by default).
118///
119/// Writes `http-tls-cert.pem` + `http-tls-key.pem` into `dir`. Returns
120/// the populated `HttpTlsConfig` pointing at the freshly-written files.
121pub fn auto_generate_dev_cert(dir: &Path) -> Result<HttpTlsConfig, Box<dyn std::error::Error>> {
122    let dev_flag = std::env::var("RED_HTTP_TLS_DEV").unwrap_or_default();
123    if !matches!(dev_flag.as_str(), "1" | "true" | "yes" | "on") {
124        return Err(
125            "refusing to auto-generate HTTP TLS cert: set RED_HTTP_TLS_DEV=1 to opt into self-signed dev certs"
126                .into(),
127        );
128    }
129
130    let cert_path = dir.join("http-tls-cert.pem");
131    let key_path = dir.join("http-tls-key.pem");
132
133    if cert_path.exists() && key_path.exists() {
134        tracing::info!(
135            target: "reddb::http_tls",
136            cert = %cert_path.display(),
137            "HTTP TLS dev: reusing existing self-signed cert"
138        );
139        return Ok(HttpTlsConfig {
140            cert_path,
141            key_path,
142            client_ca_path: None,
143        });
144    }
145
146    let (cert_pem, key_pem) = generate_self_signed("localhost")?;
147    std::fs::create_dir_all(dir)?;
148    std::fs::write(&cert_path, &cert_pem)?;
149    std::fs::write(&key_path, &key_pem)?;
150    #[cfg(unix)]
151    {
152        use std::os::unix::fs::PermissionsExt;
153        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))?;
154    }
155    tracing::warn!(
156        target: "reddb::http_tls",
157        cert = %cert_path.display(),
158        "HTTP TLS dev: generated SELF-SIGNED cert (NOT FOR PRODUCTION)"
159    );
160    Ok(HttpTlsConfig {
161        cert_path,
162        key_path,
163        client_ca_path: None,
164    })
165}
166
167/// HTTP dev cert: CN `"RedDB HTTP {hostname}"` with **no** organization.
168/// Shares the rcgen block with the wire edge via the parameterized
169/// [`crate::wire::tls::generate_self_signed_dev_cert`] (issue #1055) — the
170/// HTTP cert deliberately passes `org = None` so it does not carry
171/// `O=RedDB`.
172fn generate_self_signed(hostname: &str) -> Result<(String, String), Box<dyn std::error::Error>> {
173    crate::wire::tls::generate_self_signed_dev_cert(hostname, "RedDB HTTP", None)
174}
175
176fn sha256_fingerprint_hex(cert: &CertificateDer<'_>) -> String {
177    let digest = crate::crypto::sha256(cert.as_ref());
178    let mut out = String::with_capacity(64 + 31);
179    for (i, byte) in digest.iter().enumerate() {
180        if i > 0 {
181            // ':'-separated pairs match `openssl x509 -fingerprint`
182            // output so operators can copy-paste for pinning.
183            out.push(':');
184        }
185        let _ = std::fmt::Write::write_fmt(&mut out, format_args!("{:02x}", byte));
186    }
187    out
188}
189
190/// Wrap a sync TcpStream in a TLS server connection. Performs the
191/// handshake as part of stream construction. Returns a stream that
192/// transparently encrypts on write / decrypts on read.
193pub fn accept_tls(
194    config: Arc<ServerConfig>,
195    tcp: TcpStream,
196) -> io::Result<StreamOwned<ServerConnection, TcpStream>> {
197    let conn = ServerConnection::new(config)
198        .map_err(|err| io::Error::other(format!("rustls server: {err}")))?;
199    let mut stream = StreamOwned::new(conn, tcp);
200    // Force the handshake now so any failure surfaces here (and not on
201    // the first read inside the request parser).
202    let _ = stream.flush();
203    Ok(stream)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    /// Tests that mutate the process-global `RED_HTTP_TLS_DEV` env var
211    /// must serialize to avoid trampling each other under cargo's
212    /// default parallel test runner.
213    fn env_lock() -> &'static std::sync::Mutex<()> {
214        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
215        LOCK.get_or_init(|| std::sync::Mutex::new(()))
216    }
217
218    #[test]
219    fn fingerprint_format() {
220        // 32 zero bytes → 32 ":" -separated lowercase hex pairs.
221        let cert = CertificateDer::from(vec![0u8; 8]);
222        let fp = sha256_fingerprint_hex(&cert);
223        // 32 bytes hex = 64 chars, plus 31 colons = 95 total.
224        assert_eq!(fp.len(), 64 + 31);
225        assert!(fp.chars().all(|c| c == ':' || c.is_ascii_hexdigit()));
226    }
227
228    #[test]
229    fn auto_generate_refuses_without_dev_flag() {
230        let _g = env_lock().lock();
231        let dir = std::env::temp_dir().join(format!(
232            "reddb-http-tls-test-{}-{}",
233            std::process::id(),
234            std::time::SystemTime::now()
235                .duration_since(std::time::UNIX_EPOCH)
236                .unwrap()
237                .as_nanos()
238        ));
239        // Make sure flag is unset.
240        unsafe {
241            std::env::remove_var("RED_HTTP_TLS_DEV");
242        }
243        let err = auto_generate_dev_cert(&dir).unwrap_err();
244        assert!(err.to_string().contains("RED_HTTP_TLS_DEV"));
245    }
246
247    #[test]
248    fn auto_generate_with_dev_flag_writes_cert() {
249        let _g = env_lock().lock();
250        let dir = std::env::temp_dir().join(format!(
251            "reddb-http-tls-dev-{}-{}",
252            std::process::id(),
253            std::time::SystemTime::now()
254                .duration_since(std::time::UNIX_EPOCH)
255                .unwrap()
256                .as_nanos()
257        ));
258        unsafe {
259            std::env::set_var("RED_HTTP_TLS_DEV", "1");
260        }
261        let cfg = auto_generate_dev_cert(&dir).expect("should generate");
262        assert!(cfg.cert_path.exists());
263        assert!(cfg.key_path.exists());
264        let server_config = build_server_config(&cfg).expect("TLS config");
265        assert_eq!(
266            server_config.alpn_protocols,
267            vec![b"h2".to_vec(), b"http/1.1".to_vec()]
268        );
269        unsafe {
270            std::env::remove_var("RED_HTTP_TLS_DEV");
271        }
272        let _ = std::fs::remove_dir_all(&dir);
273    }
274}