Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions crates/ember-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
25 changes: 24 additions & 1 deletion crates/ember-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,37 @@ 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 |
|------|---------|-------------|
| `-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

Expand Down
28 changes: 20 additions & 8 deletions crates/ember-cli/src/bench_conn.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Self> {
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<Self> {
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),
Expand Down
23 changes: 16 additions & 7 deletions crates/ember-cli/src/benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
Expand All @@ -64,14 +66,17 @@ 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(
args: &BenchmarkArgs,
host: &str,
port: u16,
password: Option<&str>,
tls: Option<&TlsClientConfig>,
) -> ExitCode {
// print header
println!();
Expand All @@ -88,15 +93,15 @@ 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()
{
return ExitCode::FAILURE;
}
}
"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()
{
Expand All @@ -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()
{
Expand Down Expand Up @@ -144,6 +149,7 @@ async fn run_workload(
host: &str,
port: u16,
password: Option<&str>,
tls: Option<&TlsClientConfig>,
label: &str,
kind: WorkloadKind,
) -> Result<(), ()> {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand All @@ -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());
Expand Down
4 changes: 3 additions & 1 deletion crates/ember-cli/src/cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
Expand All @@ -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!(
Expand Down
28 changes: 19 additions & 9 deletions crates/ember-cli/src/connection.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -43,19 +44,27 @@ 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,
}

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<Self, ConnectionError> {
let stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect((host, port)))
/// 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<Self, ConnectionError> {
let stream = tokio::time::timeout(CONNECT_TIMEOUT, tls::connect(host, port, tls))
.await
.map_err(|_| ConnectionError::Timeout)?
.map_err(ConnectionError::Io)?;
Expand Down Expand Up @@ -105,7 +114,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
Expand All @@ -115,7 +124,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;
}

Expand Down
Loading