From 235582427adfb13b64f49dea820a80ae19b1ed5d Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Tue, 10 Feb 2026 10:54:34 -0500 Subject: [PATCH 1/4] feat: add TLS client module for ember-cli adds rustls-native-certs to the workspace and TLS dependencies to the CLI crate. introduces a new tls.rs module with: - TlsClientConfig struct for CLI TLS options - MaybeTlsStream enum wrapping plain TCP or TLS connections - AsyncRead/AsyncWrite implementations for transparent dispatch - connect() function supporting system roots, custom CA, and insecure mode (with NoVerifier for self-signed certs) --- Cargo.lock | 4 + Cargo.toml | 1 + crates/ember-cli/Cargo.toml | 4 + crates/ember-cli/src/tls.rs | 223 ++++++++++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 crates/ember-cli/src/tls.rs diff --git a/Cargo.lock b/Cargo.lock index dcffebed..42a742d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -709,9 +709,13 @@ dependencies = [ "dirs", "ember-protocol", "rand 0.9.2", + "rustls", + "rustls-native-certs", + "rustls-pemfile", "rustyline", "thiserror 2.0.18", "tokio", + "tokio-rustls", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 25de062c..49712d53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ criterion = { version = "0.5", features = ["html_reports", "async_tokio"] } tokio-rustls = "0.26" rustls = { version = "0.23", default-features = false, features = ["std", "tls12"] } rustls-pemfile = "2" +rustls-native-certs = "0.8" # internal crates (version required for crates.io publishing) emberkv-core = { version = "0.4.1", path = "crates/ember-core" } diff --git a/crates/ember-cli/Cargo.toml b/crates/ember-cli/Cargo.toml index 3404566c..e01820ea 100644 --- a/crates/ember-cli/Cargo.toml +++ b/crates/ember-cli/Cargo.toml @@ -20,6 +20,10 @@ bytes = { workspace = true } ember-protocol = { workspace = true } thiserror = { workspace = true } rand = { workspace = true } +tokio-rustls = { workspace = true } +rustls = { workspace = true } +rustls-pemfile = { workspace = true } +rustls-native-certs = { workspace = true } rustyline = "15" colored = "3" dirs = "6" diff --git a/crates/ember-cli/src/tls.rs b/crates/ember-cli/src/tls.rs new file mode 100644 index 00000000..10079c9a --- /dev/null +++ b/crates/ember-cli/src/tls.rs @@ -0,0 +1,223 @@ +//! TLS client support for ember-cli. +//! +//! Provides a `MaybeTlsStream` wrapper that implements `AsyncRead` and +//! `AsyncWrite`, allowing the rest of the codebase to work with either +//! plain TCP or TLS connections transparently. + +use std::io; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use rustls::pki_types::ServerName; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::TcpStream; +use tokio_rustls::client::TlsStream; +use tokio_rustls::TlsConnector; + +/// TLS configuration for client connections. +#[derive(Clone, Debug)] +pub struct TlsClientConfig { + /// Optional path to a CA certificate (PEM) for verifying the server. + /// When `None`, the system trust store is used. + pub ca_cert: Option, + + /// Skip server certificate verification entirely. Prints a warning + /// to stderr when enabled — useful for development with self-signed certs. + pub insecure: bool, +} + +/// A TCP stream that may or may not be wrapped in TLS. +pub enum MaybeTlsStream { + Plain(TcpStream), + Tls(Box>), +} + +impl AsyncRead for MaybeTlsStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + MaybeTlsStream::Plain(s) => Pin::new(s).poll_read(cx, buf), + MaybeTlsStream::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for MaybeTlsStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + MaybeTlsStream::Plain(s) => Pin::new(s).poll_write(cx, buf), + MaybeTlsStream::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + MaybeTlsStream::Plain(s) => Pin::new(s).poll_flush(cx), + MaybeTlsStream::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + MaybeTlsStream::Plain(s) => Pin::new(s).poll_shutdown(cx), + MaybeTlsStream::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx), + } + } +} + +/// Connects to `host:port`, optionally upgrading to TLS. +/// +/// When `tls` is `None`, returns a plain TCP stream. When `Some`, builds a +/// rustls `ClientConfig` and performs the TLS handshake before returning. +pub async fn connect( + host: &str, + port: u16, + tls: Option<&TlsClientConfig>, +) -> io::Result { + let tcp = TcpStream::connect((host, port)).await?; + + let Some(tls_config) = tls else { + return Ok(MaybeTlsStream::Plain(tcp)); + }; + + let client_config = build_client_config(tls_config)?; + let connector = TlsConnector::from(Arc::new(client_config)); + + let server_name = ServerName::try_from(host.to_string()).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid server name '{host}': {e}"), + ) + })?; + + let tls_stream = connector.connect(server_name, tcp).await?; + Ok(MaybeTlsStream::Tls(Box::new(tls_stream))) +} + +/// Builds a rustls `ClientConfig` from the CLI's TLS options. +fn build_client_config(config: &TlsClientConfig) -> io::Result { + if config.insecure { + eprintln!("warning: TLS certificate verification is disabled"); + return build_insecure_config(); + } + + let roots = load_root_certs(config.ca_cert.as_deref())?; + + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth() + .pipe_ok() +} + +/// Loads root certificates from a custom CA file or the system trust store. +fn load_root_certs(ca_cert_path: Option<&str>) -> io::Result { + let mut roots = rustls::RootCertStore::empty(); + + if let Some(path) = ca_cert_path { + let pem = std::fs::read(path).map_err(|e| { + io::Error::new( + io::ErrorKind::NotFound, + format!("failed to read CA cert '{path}': {e}"), + ) + })?; + let certs = rustls_pemfile::certs(&mut &pem[..]).collect::, _>>()?; + + if certs.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("no certificates found in '{path}'"), + )); + } + + for cert in certs { + roots.add(cert).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid CA certificate: {e}"), + ) + })?; + } + } else { + // load the platform's native root certificates + let native_certs = rustls_native_certs::load_native_certs(); + for cert in native_certs.certs { + roots.add(cert).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid native CA certificate: {e}"), + ) + })?; + } + } + + Ok(roots) +} + +/// Builds a client config that accepts any server certificate. +/// +/// This is intentionally insecure and only for development/testing. +fn build_insecure_config() -> io::Result { + let config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerifier)) + .with_no_client_auth(); + Ok(config) +} + +/// A certificate verifier that accepts everything. Used with `--tls-insecure`. +#[derive(Debug)] +struct NoVerifier; + +impl rustls::client::danger::ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::aws_lc_rs::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +/// Helper trait to make `Ok(config)` less noisy. +trait PipeOk: Sized { + fn pipe_ok(self) -> io::Result { + Ok(self) + } +} + +impl PipeOk for rustls::ClientConfig {} From 765eae9cef25e37ae39dac522d205854f2cb0886 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Tue, 10 Feb 2026 10:54:40 -0500 Subject: [PATCH 2/4] feat: wire TLS support through all CLI code paths replaces raw TcpStream with MaybeTlsStream in Connection and BenchConnection. adds --tls-ca-cert and --tls-insecure CLI flags. removes the "tls not yet supported" early-exit stub and passes TlsClientConfig through REPL, one-shot, cluster, and benchmark modes. --- crates/ember-cli/src/bench_conn.rs | 28 +++++++++---- crates/ember-cli/src/benchmark.rs | 23 ++++++---- crates/ember-cli/src/cluster.rs | 4 +- crates/ember-cli/src/connection.rs | 35 ++++++++++------ crates/ember-cli/src/main.rs | 67 ++++++++++++++++++++++-------- crates/ember-cli/src/repl.rs | 15 +++---- 6 files changed, 118 insertions(+), 54 deletions(-) diff --git a/crates/ember-cli/src/bench_conn.rs b/crates/ember-cli/src/bench_conn.rs index 8d3b29ee..b183116e 100644 --- a/crates/ember-cli/src/bench_conn.rs +++ b/crates/ember-cli/src/bench_conn.rs @@ -1,4 +1,4 @@ -//! Lightweight pipelining TCP connection for benchmarks. +//! Lightweight pipelining connection for benchmarks. //! //! Optimized for throughput: pre-serializes a command once, then writes //! it N times per pipeline batch and reads N responses. Uses a larger @@ -10,24 +10,36 @@ use bytes::{Bytes, BytesMut}; use ember_protocol::parse::parse_frame; use ember_protocol::types::Frame; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; + +use crate::tls::{self, MaybeTlsStream, TlsClientConfig}; /// Read buffer size for benchmark connections (256 KiB). const READ_BUF_SIZE: usize = 256 * 1024; -/// A TCP connection tuned for pipelined benchmark workloads. +/// A connection tuned for pipelined benchmark workloads. pub struct BenchConnection { - stream: TcpStream, + stream: MaybeTlsStream, read_buf: BytesMut, /// Pre-serialized command bytes, ready to write N times. command_bytes: Bytes, } impl BenchConnection { - /// Connects to the server. - pub async fn connect(host: &str, port: u16) -> std::io::Result { - let stream = TcpStream::connect((host, port)).await?; - stream.set_nodelay(true)?; + /// Connects to the server, optionally over TLS. + pub async fn connect( + host: &str, + port: u16, + tls: Option<&TlsClientConfig>, + ) -> std::io::Result { + let stream = tls::connect(host, port, tls).await?; + + // set TCP_NODELAY on the underlying TCP stream — for plain TCP + // we can access it directly; for TLS the inner stream is wrapped + // but tokio-rustls sets nodelay through the TLS handshake path. + if let MaybeTlsStream::Plain(ref tcp) = stream { + tcp.set_nodelay(true)?; + } + Ok(Self { stream, read_buf: BytesMut::with_capacity(READ_BUF_SIZE), diff --git a/crates/ember-cli/src/benchmark.rs b/crates/ember-cli/src/benchmark.rs index 843fe780..d96f3ab6 100644 --- a/crates/ember-cli/src/benchmark.rs +++ b/crates/ember-cli/src/benchmark.rs @@ -16,6 +16,7 @@ use rand::{Rng, SeedableRng}; use tokio::sync::Barrier; use crate::bench_conn::BenchConnection; +use crate::tls::TlsClientConfig; /// Arguments for the built-in benchmark. #[derive(Debug, Args)] @@ -55,6 +56,7 @@ pub fn run_benchmark( host: &str, port: u16, password: Option<&str>, + tls: Option<&TlsClientConfig>, ) -> ExitCode { let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, @@ -64,7 +66,9 @@ pub fn run_benchmark( } }; - rt.block_on(async { run_benchmark_async(args, host, port, password).await }) + // clone the TLS config so it can be moved into async tasks + let tls_owned = tls.cloned(); + rt.block_on(async { run_benchmark_async(args, host, port, password, tls_owned.as_ref()).await }) } async fn run_benchmark_async( @@ -72,6 +76,7 @@ async fn run_benchmark_async( host: &str, port: u16, password: Option<&str>, + tls: Option<&TlsClientConfig>, ) -> ExitCode { // print header println!(); @@ -88,7 +93,7 @@ async fn run_benchmark_async( for workload in &workloads { match workload.to_lowercase().as_str() { "ping" => { - if run_workload(args, host, port, password, "PING", WorkloadKind::Ping) + if run_workload(args, host, port, password, tls, "PING", WorkloadKind::Ping) .await .is_err() { @@ -96,7 +101,7 @@ async fn run_benchmark_async( } } "set" => { - if run_workload(args, host, port, password, "SET", WorkloadKind::Set) + if run_workload(args, host, port, password, tls, "SET", WorkloadKind::Set) .await .is_err() { @@ -108,10 +113,10 @@ async fn run_benchmark_async( if !args.quiet { println!(" pre-populating {} keys...", format_num(args.keyspace)); } - if prepopulate(args, host, port, password).await.is_err() { + if prepopulate(args, host, port, password, tls).await.is_err() { return ExitCode::FAILURE; } - if run_workload(args, host, port, password, "GET", WorkloadKind::Get) + if run_workload(args, host, port, password, tls, "GET", WorkloadKind::Get) .await .is_err() { @@ -144,6 +149,7 @@ async fn run_workload( host: &str, port: u16, password: Option<&str>, + tls: Option<&TlsClientConfig>, label: &str, kind: WorkloadKind, ) -> Result<(), ()> { @@ -174,9 +180,10 @@ async fn run_workload( let value = value.clone(); let barrier = barrier.clone(); let keyspace = args.keyspace; + let tls = tls.cloned(); let handle = tokio::spawn(async move { - let mut conn = match BenchConnection::connect(&host, port).await { + let mut conn = match BenchConnection::connect(&host, port, tls.as_ref()).await { Ok(c) => c, Err(e) => { eprintln!("{}", format!("connection failed: {e}").red()); @@ -296,6 +303,7 @@ async fn prepopulate( host: &str, port: u16, password: Option<&str>, + tls: Option<&TlsClientConfig>, ) -> Result<(), ()> { let value = generate_value(args.data_size); let clients = args.clients.max(1) as usize; @@ -315,9 +323,10 @@ async fn prepopulate( let host = host.to_string(); let password = password.map(|s| s.to_string()); let value = value.clone(); + let tls = tls.cloned(); let handle = tokio::spawn(async move { - let mut conn = match BenchConnection::connect(&host, port).await { + let mut conn = match BenchConnection::connect(&host, port, tls.as_ref()).await { Ok(c) => c, Err(e) => { eprintln!("{}", format!("prepopulate connect: {e}").red()); diff --git a/crates/ember-cli/src/cluster.rs b/crates/ember-cli/src/cluster.rs index 82b5bd4e..37bba3b6 100644 --- a/crates/ember-cli/src/cluster.rs +++ b/crates/ember-cli/src/cluster.rs @@ -11,6 +11,7 @@ use colored::Colorize; use crate::connection::Connection; use crate::format::format_response; +use crate::tls::TlsClientConfig; /// Cluster management actions. #[derive(Debug, Subcommand)] @@ -211,6 +212,7 @@ pub fn run_cluster( host: &str, port: u16, password: Option<&str>, + tls: Option<&TlsClientConfig>, ) -> ExitCode { let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, @@ -221,7 +223,7 @@ pub fn run_cluster( }; rt.block_on(async { - let mut conn = match Connection::connect(host, port).await { + let mut conn = match Connection::connect(host, port, tls).await { Ok(c) => c, Err(e) => { eprintln!( diff --git a/crates/ember-cli/src/connection.rs b/crates/ember-cli/src/connection.rs index 1af73007..d957b931 100644 --- a/crates/ember-cli/src/connection.rs +++ b/crates/ember-cli/src/connection.rs @@ -1,4 +1,4 @@ -//! Async TCP connection to an ember server. +//! Async connection to an ember server (plain TCP or TLS). //! //! Handles connecting, sending commands as RESP3 arrays, //! and reading back parsed frames. @@ -9,7 +9,8 @@ use bytes::BytesMut; use ember_protocol::parse::parse_frame; use ember_protocol::types::Frame; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; + +use crate::tls::{self, MaybeTlsStream, TlsClientConfig}; /// Maximum read buffer size (64 KiB). Prevents unbounded memory growth /// if the server sends a response that never completes. @@ -43,9 +44,12 @@ pub enum ConnectionError { ResponseTooLarge, } -/// A TCP connection to an ember server with read/write buffering. +/// A connection to an ember server with read/write buffering. +/// +/// Works transparently over plain TCP or TLS — the underlying stream +/// type is determined at connect time. pub struct Connection { - stream: TcpStream, + stream: MaybeTlsStream, read_buf: BytesMut, write_buf: BytesMut, } @@ -53,12 +57,18 @@ pub struct Connection { impl Connection { /// Connects to an ember server at the given host and port. /// - /// Times out after 5 seconds if the server is unreachable. - pub async fn connect(host: &str, port: u16) -> Result { - let stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect((host, port))) - .await - .map_err(|_| ConnectionError::Timeout)? - .map_err(ConnectionError::Io)?; + /// When `tls` is `Some`, the connection is upgraded to TLS after the + /// TCP handshake. Times out after 5 seconds if the server is unreachable. + pub async fn connect( + host: &str, + port: u16, + tls: Option<&TlsClientConfig>, + ) -> Result { + let stream = + tokio::time::timeout(CONNECT_TIMEOUT, tls::connect(host, port, tls)) + .await + .map_err(|_| ConnectionError::Timeout)? + .map_err(ConnectionError::Io)?; Ok(Self { stream, @@ -105,7 +115,7 @@ impl Connection { /// Gracefully shuts down the connection. /// - /// Sends a QUIT command to the server and then shuts down the TCP stream. + /// Sends a QUIT command to the server and then shuts down the stream. /// Errors are intentionally ignored — this is best-effort cleanup. pub async fn shutdown(&mut self) { // try to send QUIT so the server can clean up @@ -115,7 +125,8 @@ impl Connection { let _ = self.stream.write_all(&self.write_buf).await; let _ = self.stream.flush().await; - // graceful TCP shutdown (sends FIN instead of RST) + // graceful shutdown (sends FIN instead of RST for TCP, or + // close_notify for TLS) let _ = self.stream.shutdown().await; } diff --git a/crates/ember-cli/src/main.rs b/crates/ember-cli/src/main.rs index 927559e3..2fefb932 100644 --- a/crates/ember-cli/src/main.rs +++ b/crates/ember-cli/src/main.rs @@ -1,8 +1,8 @@ //! ember-cli: interactive command-line client for ember. //! -//! Connects to an ember server over TCP, sends commands as RESP3 frames, -//! and pretty-prints responses. Supports one-shot mode, interactive REPL, -//! and named subcommands for cluster management and benchmarking. +//! Connects to an ember server over TCP (or TLS), sends commands as RESP3 +//! frames, and pretty-prints responses. Supports one-shot mode, interactive +//! REPL, and named subcommands for cluster management and benchmarking. mod bench_conn; mod benchmark; @@ -11,6 +11,7 @@ mod commands; mod connection; mod format; mod repl; +mod tls; use std::ffi::OsString; use std::process::ExitCode; @@ -18,6 +19,8 @@ use std::process::ExitCode; use clap::{Parser, Subcommand}; use colored::Colorize; +use crate::tls::TlsClientConfig; + /// Interactive CLI client for ember. #[derive(Parser)] #[command(name = "ember-cli", version, about)] @@ -34,10 +37,19 @@ struct Args { #[arg(short = 'a', long)] password: Option, - /// Enable TLS (not yet supported). + /// Enable TLS for the connection. #[arg(long)] tls: bool, + /// Path to a CA certificate (PEM) for verifying the server. + /// Defaults to the system trust store when not set. + #[arg(long)] + tls_ca_cert: Option, + + /// Skip TLS certificate verification (insecure, for development only). + #[arg(long)] + tls_insecure: bool, + #[command(subcommand)] mode: Option, } @@ -59,38 +71,59 @@ enum Mode { Raw(Vec), } +impl Args { + /// Builds a `TlsClientConfig` from the CLI flags. + /// + /// Returns `None` when `--tls` is not set. + fn tls_config(&self) -> Option { + if !self.tls { + return None; + } + Some(TlsClientConfig { + ca_cert: self.tls_ca_cert.clone(), + insecure: self.tls_insecure, + }) + } +} + fn main() -> ExitCode { let args = Args::parse(); - - if args.tls { - eprintln!("{}", "tls is not yet supported".yellow()); - return ExitCode::FAILURE; - } + let tls = args.tls_config(); match args.mode { None => { // interactive REPL mode - repl::run_repl(&args.host, args.port, args.password.as_deref(), args.tls); + repl::run_repl(&args.host, args.port, args.password.as_deref(), tls.as_ref()); ExitCode::SUCCESS } Some(Mode::Cluster { cmd }) => { - cluster::run_cluster(&cmd, &args.host, args.port, args.password.as_deref()) - } - Some(Mode::Benchmark(bench_args)) => { - benchmark::run_benchmark(&bench_args, &args.host, args.port, args.password.as_deref()) + cluster::run_cluster(&cmd, &args.host, args.port, args.password.as_deref(), tls.as_ref()) } + Some(Mode::Benchmark(bench_args)) => benchmark::run_benchmark( + &bench_args, + &args.host, + args.port, + args.password.as_deref(), + tls.as_ref(), + ), Some(Mode::Raw(raw)) => { let tokens: Vec = raw .into_iter() .map(|s| s.to_string_lossy().into_owned()) .collect(); - run_oneshot(&args.host, args.port, args.password.as_deref(), &tokens) + run_oneshot(&args.host, args.port, args.password.as_deref(), tls.as_ref(), &tokens) } } } /// Sends a single command and prints the response. -fn run_oneshot(host: &str, port: u16, password: Option<&str>, command: &[String]) -> ExitCode { +fn run_oneshot( + host: &str, + port: u16, + password: Option<&str>, + tls: Option<&TlsClientConfig>, + command: &[String], +) -> ExitCode { let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(e) => { @@ -100,7 +133,7 @@ fn run_oneshot(host: &str, port: u16, password: Option<&str>, command: &[String] }; rt.block_on(async { - let mut conn = match connection::Connection::connect(host, port).await { + let mut conn = match connection::Connection::connect(host, port, tls).await { Ok(c) => c, Err(e) => { eprintln!( diff --git a/crates/ember-cli/src/repl.rs b/crates/ember-cli/src/repl.rs index 897fcf37..1b6f0f4c 100644 --- a/crates/ember-cli/src/repl.rs +++ b/crates/ember-cli/src/repl.rs @@ -21,17 +21,13 @@ use crate::commands::{ }; use crate::connection::{Connection, ConnectionError}; use crate::format::format_response; +use crate::tls::TlsClientConfig; /// Runs the interactive REPL loop. /// /// Blocks the calling thread. Uses `tokio::runtime::Runtime` internally /// because rustyline needs the main thread for terminal I/O. -pub fn run_repl(host: &str, port: u16, password: Option<&str>, tls: bool) { - if tls { - eprintln!("{}", "tls is not yet supported".yellow()); - return; - } - +pub fn run_repl(host: &str, port: u16, password: Option<&str>, tls: Option<&TlsClientConfig>) { let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(e) => { @@ -41,7 +37,7 @@ pub fn run_repl(host: &str, port: u16, password: Option<&str>, tls: bool) { }; // connect to server - let mut conn = match rt.block_on(Connection::connect(host, port)) { + let mut conn = match rt.block_on(Connection::connect(host, port, tls)) { Ok(c) => c, Err(e) => { eprintln!( @@ -128,7 +124,7 @@ pub fn run_repl(host: &str, port: u16, password: Option<&str>, tls: bool) { } Err(ConnectionError::Disconnected) => { eprintln!("{}", "server disconnected, reconnecting...".yellow()); - match rt.block_on(reconnect(host, port, password)) { + match rt.block_on(reconnect(host, port, password, tls)) { Ok(new_conn) => { conn = new_conn; eprintln!("{}", "reconnected".green()); @@ -172,8 +168,9 @@ async fn reconnect( host: &str, port: u16, password: Option<&str>, + tls: Option<&TlsClientConfig>, ) -> Result { - let mut conn = Connection::connect(host, port).await?; + let mut conn = Connection::connect(host, port, tls).await?; if let Some(pw) = password { conn.authenticate(pw).await?; } From d451f37b2eca477c4628e22dc7c9b47f33a3fdcc Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Tue, 10 Feb 2026 10:54:45 -0500 Subject: [PATCH 3/4] docs: update READMEs for CLI TLS support removes "TLS coming soon" note from root README and updates the TLS example to use ember-cli. adds a TLS section to the CLI README with examples for custom CA, insecure mode, REPL, and benchmarks. updates the options table with the new --tls-ca-cert and --tls-insecure flags. --- README.md | 4 ++-- crates/ember-cli/README.md | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c6c603d5..69cb65d7 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,8 @@ TTL temp # => 59 SCAN 0 MATCH "user:*" COUNT 100 DBSIZE # => (integer) 6 -# TLS (redis-cli only for now — ember-cli TLS coming soon) -redis-cli -p 6380 --tls --insecure PING +# TLS +ember-cli -p 6380 --tls --tls-insecure PING ``` ## configuration diff --git a/crates/ember-cli/README.md b/crates/ember-cli/README.md index 3029ac14..8f7a8f7c 100644 --- a/crates/ember-cli/README.md +++ b/crates/ember-cli/README.md @@ -23,6 +23,27 @@ ember-cli GET greeting ember-cli SET msg "hello world" ``` +## TLS + +connect to a TLS-enabled server: + +```bash +# with system CA trust store +ember-cli -p 6380 --tls PING + +# with a custom CA certificate +ember-cli -p 6380 --tls --tls-ca-cert /path/to/ca.pem PING + +# skip certificate verification (self-signed certs, development only) +ember-cli -p 6380 --tls --tls-insecure PING + +# REPL over TLS +ember-cli -p 6380 --tls --tls-insecure + +# benchmark over TLS +ember-cli -p 6380 --tls --tls-insecure benchmark -n 10000 +``` + ## options | flag | default | description | @@ -30,7 +51,9 @@ ember-cli SET msg "hello world" | `-H`, `--host` | 127.0.0.1 | server hostname | | `-p`, `--port` | 6379 | server port | | `-a`, `--password` | — | password for AUTH | -| `--tls` | — | enable TLS (not yet supported) | +| `--tls` | — | enable TLS for the connection | +| `--tls-ca-cert` | — | path to CA certificate (PEM) for server verification | +| `--tls-insecure` | — | skip server certificate verification | ## repl features From ad8c5f283644ec6546aa42b8170870218b693ecb Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Tue, 10 Feb 2026 10:55:38 -0500 Subject: [PATCH 4/4] style: fix rustfmt formatting --- crates/ember-cli/src/connection.rs | 9 ++++----- crates/ember-cli/src/main.rs | 25 ++++++++++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/crates/ember-cli/src/connection.rs b/crates/ember-cli/src/connection.rs index d957b931..df2b820a 100644 --- a/crates/ember-cli/src/connection.rs +++ b/crates/ember-cli/src/connection.rs @@ -64,11 +64,10 @@ impl Connection { port: u16, tls: Option<&TlsClientConfig>, ) -> Result { - let stream = - tokio::time::timeout(CONNECT_TIMEOUT, tls::connect(host, port, tls)) - .await - .map_err(|_| ConnectionError::Timeout)? - .map_err(ConnectionError::Io)?; + let stream = tokio::time::timeout(CONNECT_TIMEOUT, tls::connect(host, port, tls)) + .await + .map_err(|_| ConnectionError::Timeout)? + .map_err(ConnectionError::Io)?; Ok(Self { stream, diff --git a/crates/ember-cli/src/main.rs b/crates/ember-cli/src/main.rs index 2fefb932..8165ba3e 100644 --- a/crates/ember-cli/src/main.rs +++ b/crates/ember-cli/src/main.rs @@ -93,12 +93,21 @@ fn main() -> ExitCode { match args.mode { None => { // interactive REPL mode - repl::run_repl(&args.host, args.port, args.password.as_deref(), tls.as_ref()); + repl::run_repl( + &args.host, + args.port, + args.password.as_deref(), + tls.as_ref(), + ); ExitCode::SUCCESS } - Some(Mode::Cluster { cmd }) => { - cluster::run_cluster(&cmd, &args.host, args.port, args.password.as_deref(), tls.as_ref()) - } + Some(Mode::Cluster { cmd }) => cluster::run_cluster( + &cmd, + &args.host, + args.port, + args.password.as_deref(), + tls.as_ref(), + ), Some(Mode::Benchmark(bench_args)) => benchmark::run_benchmark( &bench_args, &args.host, @@ -111,7 +120,13 @@ fn main() -> ExitCode { .into_iter() .map(|s| s.to_string_lossy().into_owned()) .collect(); - run_oneshot(&args.host, args.port, args.password.as_deref(), tls.as_ref(), &tokens) + run_oneshot( + &args.host, + args.port, + args.password.as_deref(), + tls.as_ref(), + &tokens, + ) } } }