Skip to main content

reddb_server/server/
tls.rs

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