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
168/// HTTP dev cert: CN `"RedDB HTTP {hostname}"` with **no** organization.
169/// Shares the rcgen block with the wire edge via the parameterized
170/// [`crate::wire::tls::generate_self_signed_dev_cert`] (issue #1055) — the
171/// HTTP cert deliberately passes `org = None` so it does not carry
172/// `O=RedDB`.
173fn generate_self_signed(hostname: &str) -> Result<(String, String), Box<dyn std::error::Error>> {
174    crate::wire::tls::generate_self_signed_dev_cert(hostname, "RedDB HTTP", None)
175}
176
177fn sha256_fingerprint_hex(cert: &CertificateDer<'_>) -> String {
178    let digest = crate::crypto::sha256(cert.as_ref());
179    let mut out = String::with_capacity(64 + 31);
180    for (i, byte) in digest.iter().enumerate() {
181        if i > 0 {
182            // ':'-separated pairs match `openssl x509 -fingerprint`
183            // output so operators can copy-paste for pinning.
184            out.push(':');
185        }
186        let _ = std::fmt::Write::write_fmt(&mut out, format_args!("{:02x}", byte));
187    }
188    out
189}
190
191/// Wrap a sync TcpStream in a TLS server connection. Performs the
192/// handshake as part of stream construction. Returns a stream that
193/// transparently encrypts on write / decrypts on read.
194pub fn accept_tls(
195    config: Arc<ServerConfig>,
196    tcp: TcpStream,
197) -> io::Result<StreamOwned<ServerConnection, TcpStream>> {
198    let conn = ServerConnection::new(config)
199        .map_err(|err| io::Error::other(format!("rustls server: {err}")))?;
200    let mut stream = StreamOwned::new(conn, tcp);
201    // Force the handshake now so any failure surfaces here (and not on
202    // the first read inside the request parser).
203    let _ = stream.flush();
204    Ok(stream)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    /// Tests that mutate the process-global `RED_HTTP_TLS_DEV` env var
212    /// must serialize to avoid trampling each other under cargo's
213    /// default parallel test runner.
214    fn env_lock() -> &'static std::sync::Mutex<()> {
215        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
216        LOCK.get_or_init(|| std::sync::Mutex::new(()))
217    }
218
219    #[test]
220    fn fingerprint_format() {
221        // 32 zero bytes → 32 ":" -separated lowercase hex pairs.
222        let cert = CertificateDer::from(vec![0u8; 8]);
223        let fp = sha256_fingerprint_hex(&cert);
224        // 32 bytes hex = 64 chars, plus 31 colons = 95 total.
225        assert_eq!(fp.len(), 64 + 31);
226        assert!(fp.chars().all(|c| c == ':' || c.is_ascii_hexdigit()));
227    }
228
229    #[test]
230    fn auto_generate_refuses_without_dev_flag() {
231        let _g = env_lock().lock();
232        let dir = std::env::temp_dir().join(format!(
233            "reddb-http-tls-test-{}-{}",
234            std::process::id(),
235            std::time::SystemTime::now()
236                .duration_since(std::time::UNIX_EPOCH)
237                .unwrap()
238                .as_nanos()
239        ));
240        // Make sure flag is unset.
241        unsafe {
242            std::env::remove_var("RED_HTTP_TLS_DEV");
243        }
244        let err = auto_generate_dev_cert(&dir).unwrap_err();
245        assert!(err.to_string().contains("RED_HTTP_TLS_DEV"));
246    }
247
248    #[test]
249    fn auto_generate_with_dev_flag_writes_cert() {
250        let _g = env_lock().lock();
251        let dir = std::env::temp_dir().join(format!(
252            "reddb-http-tls-dev-{}-{}",
253            std::process::id(),
254            std::time::SystemTime::now()
255                .duration_since(std::time::UNIX_EPOCH)
256                .unwrap()
257                .as_nanos()
258        ));
259        unsafe {
260            std::env::set_var("RED_HTTP_TLS_DEV", "1");
261        }
262        let cfg = auto_generate_dev_cert(&dir).expect("should generate");
263        assert!(cfg.cert_path.exists());
264        assert!(cfg.key_path.exists());
265        unsafe {
266            std::env::remove_var("RED_HTTP_TLS_DEV");
267        }
268        let _ = std::fs::remove_dir_all(&dir);
269    }
270}