Skip to main content

weir_client/
tls.rs

1//! Blocking mutual-TLS client connector (feature = "tls").
2//!
3//! Connects to a weir daemon's TCP+mTLS listener using the same ring
4//! provider and rustls 0.23 API that the server side uses.
5
6use std::{io, net::TcpStream, path::Path, sync::Arc, time::Duration};
7
8use rustls_pki_types::ServerName;
9use weir_core::Durability;
10
11use crate::{ClientError, WeirClient};
12
13/// TLS configuration for [`WeirClient::connect_tls`].
14///
15/// `Debug`/`Clone` are safe to derive: every field is a path, a borrowed `str`,
16/// or a `Durability` — no key material, only filesystem paths (G16).
17#[derive(Debug, Clone)]
18pub struct ClientTlsConfig<'a> {
19    /// Path to the PEM-encoded client certificate file.
20    pub client_cert: &'a Path,
21    /// Path to the PEM-encoded client private key file.
22    pub client_key: &'a Path,
23    /// Path to the PEM-encoded CA certificate used to verify the server cert.
24    pub ca_cert: &'a Path,
25    /// DNS name the server certificate must present (matched against its SAN).
26    pub server_name: &'a str,
27    /// Optional default durability tier for [`WeirClient::push_default`].
28    pub default_durability: Option<Durability>,
29}
30
31/// The TLS stream type returned by [`WeirClient::connect_tls`].
32pub type TlsStream = rustls::StreamOwned<rustls::ClientConnection, TcpStream>;
33
34impl WeirClient<TlsStream> {
35    /// Connects to a weir daemon's TCP+mTLS listener at `addr`.
36    ///
37    /// Performs a full mutual-TLS handshake: the client presents `client_cert`
38    /// (signed by the same CA the server trusts), and the server certificate is
39    /// validated against `ca_cert`. The `server_name` must match a SAN in the
40    /// server's certificate.
41    pub fn connect_tls(
42        addr: impl std::net::ToSocketAddrs,
43        cfg: ClientTlsConfig<'_>,
44    ) -> Result<Self, ClientError> {
45        // Build root store from the provided CA cert.
46        let mut roots = rustls::RootCertStore::empty();
47        for c in load_certs(cfg.ca_cert)? {
48            roots
49                .add(c)
50                .map_err(|e| ClientError::Protocol(format!("ca cert: {e}")))?;
51        }
52
53        // Load client identity.
54        let certs = load_certs(cfg.client_cert)?;
55        let key = load_key(cfg.client_key)?;
56
57        // Build rustls ClientConfig using the ring provider (matches the server).
58        let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
59            rustls::crypto::ring::default_provider(),
60        ))
61        .with_safe_default_protocol_versions()
62        .map_err(|e| ClientError::Protocol(format!("tls versions: {e}")))?
63        .with_root_certificates(roots)
64        .with_client_auth_cert(certs, key)
65        .map_err(|e| ClientError::Protocol(format!("client auth cert: {e}")))?;
66
67        // Parse and validate the server name.
68        let server_name = ServerName::try_from(cfg.server_name.to_string())
69            .map_err(|e| ClientError::Protocol(format!("server name: {e}")))?;
70
71        // Create the TLS connection object and wrap a TCP socket.
72        let conn = rustls::ClientConnection::new(Arc::new(tls_config), server_name)
73            .map_err(|e| ClientError::Protocol(format!("tls connect: {e}")))?;
74        let sock = TcpStream::connect(addr)?;
75        let stream = rustls::StreamOwned::new(conn, sock);
76
77        Ok(Self::from_parts(stream, cfg.default_durability))
78    }
79
80    /// Sets the read timeout on the underlying TCP socket. `None` (the default)
81    /// blocks indefinitely. Same opt-in availability rationale as the Unix
82    /// client's [`set_read_timeout`][WeirClient::set_read_timeout] — a wedged
83    /// daemon would otherwise block a producer forever in `read_response`.
84    pub fn set_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
85        self.stream.sock.set_read_timeout(timeout)
86    }
87
88    /// Sets the write timeout on the underlying TCP socket. `None` (the default)
89    /// blocks indefinitely. See [`set_read_timeout`][Self::set_read_timeout].
90    pub fn set_write_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
91        self.stream.sock.set_write_timeout(timeout)
92    }
93}
94
95// ── PEM helpers ────────────────────────────────────────────────────────────────
96
97fn load_certs(p: &Path) -> Result<Vec<rustls_pki_types::CertificateDer<'static>>, ClientError> {
98    use rustls_pki_types::pem::PemObject;
99    // Read the file ourselves so a missing/unreadable file stays a clean
100    // ClientError::Io; parse the PEM via rustls-pki-types (S16 — off the
101    // unmaintained rustls-pemfile).
102    let pem = std::fs::read(p)?;
103    rustls_pki_types::CertificateDer::pem_slice_iter(&pem)
104        .collect::<Result<_, _>>()
105        .map_err(|e| {
106            ClientError::Protocol(format!("invalid certificate PEM in {}: {e}", p.display()))
107        })
108}
109
110fn load_key(p: &Path) -> Result<rustls_pki_types::PrivateKeyDer<'static>, ClientError> {
111    use rustls_pki_types::pem::PemObject;
112    let pem = std::fs::read(p)?;
113    rustls_pki_types::PrivateKeyDer::from_pem_slice(&pem)
114        .map_err(|e| ClientError::Protocol(format!("no private key in file {}: {e}", p.display())))
115}
116
117// ── Tests ────────────────────────────────────────────────────────────────────
118//
119// The full mTLS round-trip THROUGH `connect_tls` against a live daemon — the
120// happy path plus the wrong-CA / expired-cert / mismatched-server_name rejection
121// cases — is covered by the non-ignored `tls_client` integration suite in
122// weir-server/tests. (`tls_listener` covers the *server's* rejection behaviour
123// with a hand-rolled rustls client; it does not exercise `connect_tls`.) These
124// unit tests cover the PEM-loader error mapping the integration suites can't
125// easily hit (G03).
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn tmp_file(label: &str, contents: &[u8]) -> std::path::PathBuf {
131        let p = std::env::temp_dir().join(format!("weir_tls_{label}_{}.pem", std::process::id()));
132        std::fs::write(&p, contents).unwrap();
133        p
134    }
135
136    #[test]
137    fn load_key_missing_file_is_io_error() {
138        let err = load_key(Path::new("/weir_no_such_key_xyzzy.pem")).unwrap_err();
139        assert!(matches!(err, ClientError::Io(_)), "{err:?}");
140    }
141
142    #[test]
143    fn load_certs_missing_file_is_io_error() {
144        let err = load_certs(Path::new("/weir_no_such_cert_xyzzy.pem")).unwrap_err();
145        assert!(matches!(err, ClientError::Io(_)), "{err:?}");
146    }
147
148    #[test]
149    fn load_key_file_without_a_key_is_protocol_error() {
150        // A readable file that contains no PEM private key → the None branch maps
151        // to a clear Protocol error rather than a panic or a silent empty key.
152        let p = tmp_file("nokey", b"not a pem private key\n");
153        let err = load_key(&p).unwrap_err();
154        match err {
155            ClientError::Protocol(msg) => assert!(msg.contains("no private key"), "{msg}"),
156            other => panic!("expected Protocol, got {other:?}"),
157        }
158        std::fs::remove_file(&p).ok();
159    }
160}