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
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ metrics-exporter-prometheus = { version = "0.16", features = ["http-listener"] }
# benchmarking
criterion = { version = "0.5", features = ["html_reports", "async_tokio"] }

# tls
tokio-rustls = "0.26"
rustls = { version = "0.23", default-features = false, features = ["std", "tls12"] }
rustls-pemfile = "2"

# internal crates (version required for crates.io publishing)
emberkv-core = { version = "0.3.0", path = "crates/ember-core" }
ember-protocol = { version = "0.3.0", path = "crates/ember-protocol" }
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ a low-latency, memory-efficient, distributed cache written in Rust. designed to
- **server commands** — PING, ECHO, INFO, DBSIZE, FLUSHDB, BGSAVE, BGREWRITEAOF, AUTH, QUIT
- **pub/sub** — SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PUBLISH, plus PUBSUB introspection
- **authentication** — `--requirepass` for redis-compatible AUTH (legacy and username/password forms)
- **tls support** — redis-compatible TLS on a separate port, with optional mTLS for client certificates
- **protected mode** — rejects non-loopback connections when no password is set on public binds
- **observability** — prometheus metrics (`--metrics-port`), enriched INFO with 6 sections, SLOWLOG command
- **sharded engine** — shared-nothing, thread-per-core design with no cross-shard locking
Expand Down Expand Up @@ -46,6 +47,10 @@ cargo build --release

# concurrent mode (experimental, 2x faster for GET/SET)
./target/release/ember-server --concurrent

# with TLS (runs alongside plain TCP)
./target/release/ember-server --tls-port 6380 \
--tls-cert-file cert.pem --tls-key-file key.pem
```

```bash
Expand Down Expand Up @@ -92,6 +97,11 @@ redis-cli SREM tags fast # => (integer) 1
redis-cli SCAN 0 MATCH "user:*" COUNT 100
redis-cli DBSIZE # => (integer) 6
redis-cli FLUSHDB # => OK

# TLS connection
redis-cli -p 6380 --tls --insecure PING
# or with cert verification
redis-cli -p 6380 --tls --cacert cert.pem PING
```

## configuration
Expand All @@ -111,6 +121,11 @@ redis-cli FLUSHDB # => OK
| `--slowlog-max-len` | 128 | max entries in slow log ring buffer |
| `--concurrent` | false | use DashMap-backed keyspace (experimental, faster GET/SET) |
| `--requirepass` | — | require AUTH with this password before running commands |
| `--tls-port` | — | port for TLS connections (enables TLS when set) |
| `--tls-cert-file` | — | path to server certificate (PEM) |
| `--tls-key-file` | — | path to server private key (PEM) |
| `--tls-ca-cert-file` | — | path to CA certificate for client verification |
| `--tls-auth-clients` | no | require client certificates (`yes` or `no`) |

## build & development

Expand Down
12 changes: 6 additions & 6 deletions crates/ember-core/src/keyspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1758,12 +1758,6 @@ impl Default for Keyspace {
}
}

/// Glob-style pattern matching for SCAN's MATCH option.
///
/// Supports:
/// - `*` matches any sequence of characters (including empty)
/// - `?` matches exactly one character
/// - `[abc]` matches one character from the set
/// Formats a float value matching Redis behavior.
///
/// Uses up to 17 significant digits and strips unnecessary trailing zeros,
Expand All @@ -1786,6 +1780,12 @@ fn format_float(val: f64) -> String {
}
}

/// Glob-style pattern matching for SCAN's MATCH option.
///
/// Supports:
/// - `*` matches any sequence of characters (including empty)
/// - `?` matches exactly one character
/// - `[abc]` matches one character from the set
/// - `[^abc]` or `[!abc]` matches one character NOT in the set
///
/// Uses an iterative two-pointer algorithm with backtracking for O(n*m)
Expand Down
6 changes: 6 additions & 0 deletions crates/ember-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ tracing-subscriber = { workspace = true }
clap = { workspace = true }
metrics = { workspace = true }
metrics-exporter-prometheus = { workspace = true }
thiserror = { workspace = true }
futures = "0.3"
dashmap = "6"

# tls
tokio-rustls = { workspace = true }
rustls = { workspace = true }
rustls-pemfile = { workspace = true }

# optional: better multi-threaded allocation performance
tikv-jemallocator = { version = "0.6", optional = true }
17 changes: 10 additions & 7 deletions crates/ember-server/src/concurrent_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ use std::time::{Duration, Instant};
use bytes::BytesMut;
use ember_core::{ConcurrentKeyspace, Engine, TtlResult};
use ember_protocol::{parse_frame, Command, Frame, SetExpire};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};

use crate::connection_common::{
is_allowed_before_auth, is_auth_frame, try_auth, BUF_CAPACITY, IDLE_TIMEOUT, MAX_BUF_SIZE,
Expand All @@ -30,16 +29,20 @@ use crate::server::ServerContext;
use crate::slowlog::SlowLog;

/// Handles a connection using the concurrent keyspace for GET/SET.
pub async fn handle(
mut stream: TcpStream,
///
/// Generic over the stream type to support both plain TCP and TLS connections.
/// Callers should set TCP_NODELAY on the underlying socket before calling.
pub async fn handle<S>(
mut stream: S,
keyspace: Arc<ConcurrentKeyspace>,
engine: Engine, // fallback for complex commands
ctx: &Arc<ServerContext>,
slow_log: &Arc<SlowLog>,
pubsub: &Arc<PubSubManager>,
) -> Result<(), Box<dyn std::error::Error>> {
stream.set_nodelay(true)?;

) -> Result<(), Box<dyn std::error::Error>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let mut authenticated = ctx.requirepass.is_none();

let mut buf = BytesMut::with_capacity(BUF_CAPACITY);
Expand Down
30 changes: 17 additions & 13 deletions crates/ember-server/src/connection.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Per-connection handler for sharded engine mode.
//!
//! Reads RESP3 frames from a TCP stream, routes them through the
//! Reads RESP3 frames from a TCP/TLS stream, routes them through the
//! sharded engine, and writes responses back. Supports pipelining
//! by dispatching multiple commands concurrently to shards using
//! `join_all` for parallel execution.
Expand All @@ -14,8 +14,7 @@ use bytes::{Bytes, BytesMut};
use ember_core::{Engine, KeyspaceStats, ShardRequest, ShardResponse, TtlResult, Value};
use ember_protocol::{parse_frame, Command, Frame, SetExpire};
use futures::future::join_all;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::sync::broadcast;

use crate::connection_common::{
Expand All @@ -30,17 +29,19 @@ use crate::slowlog::SlowLog;
/// Reads data into a buffer, parses complete frames, dispatches commands
/// through the engine, and writes serialized responses back. The loop
/// exits when the client disconnects or a protocol error occurs.
pub async fn handle(
mut stream: TcpStream,
///
/// Generic over the stream type to support both plain TCP and TLS connections.
/// Callers should set TCP_NODELAY on the underlying socket before calling.
pub async fn handle<S>(
mut stream: S,
engine: Engine,
ctx: &Arc<ServerContext>,
slow_log: &Arc<SlowLog>,
pubsub: &Arc<PubSubManager>,
) -> Result<(), Box<dyn std::error::Error>> {
// disable Nagle's algorithm — cache servers need low-latency writes,
// and we already batch responses from pipelining into a single write
stream.set_nodelay(true)?;

) -> Result<(), Box<dyn std::error::Error>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
// per-connection auth state. auto-authenticated when no password is set.
let mut authenticated = ctx.requirepass.is_none();

Expand Down Expand Up @@ -173,13 +174,16 @@ fn is_subscribe_frame(frame: &Frame) -> bool {
/// PSUBSCRIBE, PUNSUBSCRIBE, and PING. All other commands return an error.
/// Returns to the caller when all subscriptions are removed or the client
/// disconnects.
async fn handle_subscriber_mode(
stream: &mut TcpStream,
async fn handle_subscriber_mode<S>(
stream: &mut S,
buf: &mut BytesMut,
out: &mut BytesMut,
pubsub: &Arc<PubSubManager>,
initial_frames: Vec<Frame>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
// track subscriptions: channel/pattern -> receiver
let mut channel_rxs: HashMap<String, broadcast::Receiver<PubMessage>> = HashMap::new();
let mut pattern_rxs: HashMap<String, broadcast::Receiver<PubMessage>> = HashMap::new();
Expand Down
68 changes: 68 additions & 0 deletions crates/ember-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod metrics;
mod pubsub;
mod server;
mod slowlog;
mod tls;

use std::net::SocketAddr;
use std::path::PathBuf;
Expand Down Expand Up @@ -81,6 +82,28 @@ struct Args {
/// when set, connections must authenticate before executing any data commands.
#[arg(long)]
requirepass: Option<String>,

// -- TLS options (matching redis) --
/// port for TLS connections. when set, enables TLS alongside plain TCP
#[arg(long)]
tls_port: Option<u16>,

/// path to server certificate file (PEM format)
#[arg(long)]
tls_cert_file: Option<String>,

/// path to server private key file (PEM format)
#[arg(long)]
tls_key_file: Option<String>,

/// path to CA certificate for client verification (enables mTLS)
#[arg(long)]
tls_ca_cert_file: Option<String>,

/// require client certificates when CA cert is configured.
/// accepts: yes, no. default: no
#[arg(long, default_value = "no")]
tls_auth_clients: String,
}

#[tokio::main]
Expand Down Expand Up @@ -191,6 +214,49 @@ async fn main() {
info!("authentication enabled (requirepass set)");
}

// build TLS config if --tls-port is set
let tls_config = if let Some(tls_port) = args.tls_port {
let cert_file = args.tls_cert_file.unwrap_or_else(|| {
eprintln!("--tls-port requires --tls-cert-file and --tls-key-file");
std::process::exit(1);
});
let key_file = args.tls_key_file.unwrap_or_else(|| {
eprintln!("--tls-port requires --tls-cert-file and --tls-key-file");
std::process::exit(1);
});

let auth_clients = match args.tls_auth_clients.to_lowercase().as_str() {
"yes" | "true" | "1" => true,
"no" | "false" | "0" => false,
_ => {
eprintln!("--tls-auth-clients must be 'yes' or 'no'");
std::process::exit(1);
}
};

let tls_addr: SocketAddr = format!("{}:{}", args.host, tls_port)
.parse()
.expect("invalid TLS bind address");

info!(
tls_port = tls_port,
cert = %cert_file,
"TLS enabled"
);

Some((
tls_addr,
tls::TlsConfig {
cert_file,
key_file,
ca_cert_file: args.tls_ca_cert_file,
auth_clients,
},
))
} else {
None
};

let result = if args.concurrent {
server::run_concurrent(
addr,
Expand All @@ -202,6 +268,7 @@ async fn main() {
args.metrics_port.is_some(),
slowlog_config,
args.requirepass,
tls_config,
)
.await
} else {
Expand All @@ -213,6 +280,7 @@ async fn main() {
args.metrics_port.is_some(),
slowlog_config,
args.requirepass,
tls_config,
)
.await
};
Expand Down
Loading