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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ a low-latency, memory-efficient, distributed cache written in Rust. designed to
- **hashes** — HSET, HGET, HGETALL, HDEL, HEXISTS, HLEN, HINCRBY, HKEYS, HVALS, HMGET
- **sets** — SADD, SREM, SMEMBERS, SISMEMBER, SCARD
- **key commands** — DEL, EXISTS, EXPIRE, TTL, PEXPIRE, PTTL, PERSIST, TYPE, SCAN, KEYS, RENAME
- **server commands** — PING, ECHO, INFO, DBSIZE, FLUSHDB, BGSAVE, BGREWRITEAOF
- **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)
- **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
- **concurrent mode** — experimental DashMap-backed keyspace for lock-free GET/SET (2x faster than Redis)
Expand Down Expand Up @@ -108,6 +110,7 @@ redis-cli FLUSHDB # => OK
| `--slowlog-log-slower-than` | 10000 | log commands slower than N microseconds (-1 disables) |
| `--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 |

## build & development

Expand Down Expand Up @@ -184,7 +187,7 @@ contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
| 4 | clustering (raft, gossip, slots, migration) | ✅ complete |
| 5 | developer experience (observability, CLI, clients) | 🚧 in progress |

**current**: 83 commands, 695 tests, ~22k lines of code
**current**: 85 commands, 701 tests, ~23k lines of code

## security

Expand All @@ -193,7 +196,7 @@ see [SECURITY.md](SECURITY.md) for:
- security considerations for deployment
- recommended configuration

**note**: ember does not currently support authentication. always run behind a firewall or in a trusted network.
**note**: use `--requirepass` to enable authentication. protected mode is active by default when no password is set, rejecting non-loopback connections on public binds.

## license

Expand Down
92 changes: 92 additions & 0 deletions crates/ember-protocol/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,17 @@ pub enum Command {
/// PUBSUB NUMPAT. Returns the number of active pattern subscriptions.
PubSubNumPat,

/// AUTH \[username\] password. Authenticate the connection.
Auth {
/// Username for ACL-style auth. None for legacy AUTH.
username: Option<String>,
/// The password to validate.
password: String,
},

/// QUIT. Requests the server to close the connection.
Quit,

/// A command we don't recognize (yet).
Unknown(String),
}
Expand Down Expand Up @@ -432,6 +443,8 @@ impl Command {
Command::PubSubChannels { .. } => "pubsub",
Command::PubSubNumSub { .. } => "pubsub",
Command::PubSubNumPat => "pubsub",
Command::Auth { .. } => "auth",
Command::Quit => "quit",
Command::Unknown(_) => "unknown",
}
}
Expand Down Expand Up @@ -526,6 +539,8 @@ impl Command {
"PUNSUBSCRIBE" => parse_punsubscribe(&frames[1..]),
"PUBLISH" => parse_publish(&frames[1..]),
"PUBSUB" => parse_pubsub(&frames[1..]),
"AUTH" => parse_auth(&frames[1..]),
"QUIT" => parse_quit(&frames[1..]),
_ => Ok(Command::Unknown(name)),
}
}
Expand Down Expand Up @@ -1657,6 +1672,34 @@ fn parse_pubsub(args: &[Frame]) -> Result<Command, ProtocolError> {
}
}

fn parse_auth(args: &[Frame]) -> Result<Command, ProtocolError> {
match args.len() {
1 => {
let password = extract_string(&args[0])?;
Ok(Command::Auth {
username: None,
password,
})
}
2 => {
let username = extract_string(&args[0])?;
let password = extract_string(&args[1])?;
Ok(Command::Auth {
username: Some(username),
password,
})
}
_ => Err(ProtocolError::WrongArity("AUTH".into())),
}
}

fn parse_quit(args: &[Frame]) -> Result<Command, ProtocolError> {
if !args.is_empty() {
return Err(ProtocolError::WrongArity("QUIT".into()));
}
Ok(Command::Quit)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -3764,4 +3807,53 @@ mod tests {
let err = Command::from_frame(cmd(&["RENAME", "only"])).unwrap_err();
assert!(matches!(err, ProtocolError::WrongArity(_)));
}

// --- AUTH ---

#[test]
fn auth_legacy() {
assert_eq!(
Command::from_frame(cmd(&["AUTH", "secret"])).unwrap(),
Command::Auth {
username: None,
password: "secret".into()
},
);
}

#[test]
fn auth_with_username() {
assert_eq!(
Command::from_frame(cmd(&["AUTH", "default", "secret"])).unwrap(),
Command::Auth {
username: Some("default".into()),
password: "secret".into()
},
);
}

#[test]
fn auth_no_args() {
let err = Command::from_frame(cmd(&["AUTH"])).unwrap_err();
assert!(matches!(err, ProtocolError::WrongArity(_)));
}

#[test]
fn auth_too_many_args() {
let err = Command::from_frame(cmd(&["AUTH", "a", "b", "c"])).unwrap_err();
assert!(matches!(err, ProtocolError::WrongArity(_)));
}

// --- QUIT ---

#[test]
fn quit_basic() {
assert_eq!(Command::from_frame(cmd(&["QUIT"])).unwrap(), Command::Quit,);
}

#[test]
fn quit_wrong_arity() {
let err = Command::from_frame(cmd(&["QUIT", "extra"])).unwrap_err();
assert!(matches!(err, ProtocolError::WrongArity(_)));
}
}
63 changes: 59 additions & 4 deletions crates/ember-server/src/concurrent_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ use ember_protocol::{parse_frame, Command, Frame, SetExpire};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;

use crate::connection_common::{BUF_CAPACITY, IDLE_TIMEOUT, MAX_BUF_SIZE};
use crate::connection_common::{
is_allowed_before_auth, is_auth_frame, try_auth, BUF_CAPACITY, IDLE_TIMEOUT, MAX_BUF_SIZE,
};
use crate::pubsub::PubSubManager;
use crate::server::ServerContext;
use crate::slowlog::SlowLog;
Expand All @@ -38,6 +40,8 @@ pub async fn handle(
) -> Result<(), Box<dyn std::error::Error>> {
stream.set_nodelay(true)?;

let mut authenticated = ctx.requirepass.is_none();

let mut buf = BytesMut::with_capacity(BUF_CAPACITY);
let mut out = BytesMut::with_capacity(BUF_CAPACITY);

Expand All @@ -62,8 +66,27 @@ pub async fn handle(
match parse_frame(&buf) {
Ok(Some((frame, consumed))) => {
let _ = buf.split_to(consumed);
let response = process(frame, &keyspace, &engine, ctx, slow_log, pubsub).await;
response.serialize(&mut out);

if !authenticated {
if is_auth_frame(&frame) {
let (response, success) = try_auth(frame, ctx);
response.serialize(&mut out);
if success {
authenticated = true;
}
} else if is_allowed_before_auth(&frame) {
let response =
process(frame, &keyspace, &engine, ctx, slow_log, pubsub).await;
response.serialize(&mut out);
} else {
Frame::Error("NOAUTH Authentication required.".into())
.serialize(&mut out);
}
} else {
let response =
process(frame, &keyspace, &engine, ctx, slow_log, pubsub).await;
response.serialize(&mut out);
}
}
Ok(None) => break,
Err(e) => {
Expand Down Expand Up @@ -99,7 +122,7 @@ async fn process(
None
};

let response = execute_concurrent(cmd, keyspace, engine, pubsub).await;
let response = execute_concurrent(cmd, keyspace, engine, ctx, pubsub).await;
ctx.commands_processed.fetch_add(1, Ordering::Relaxed);

if let Some(start) = start {
Expand All @@ -122,6 +145,7 @@ async fn execute_concurrent(
cmd: Command,
keyspace: &Arc<ConcurrentKeyspace>,
_engine: &Engine,
ctx: &Arc<ServerContext>,
pubsub: &Arc<PubSubManager>,
) -> Frame {
match cmd {
Expand Down Expand Up @@ -233,6 +257,37 @@ async fn execute_concurrent(
Frame::Error("ERR pub/sub not supported in concurrent mode yet".into())
}

// AUTH on an already-authenticated connection (re-auth)
Command::Auth { username, password } => match &ctx.requirepass {
None => Frame::Error(
"ERR Client sent AUTH, but no password is set. \
Did you mean ACL SETUSER with >password?"
.into(),
),
Some(expected) => {
if let Some(ref user) = username {
if user != "default" {
return Frame::Error(
"WRONGPASS invalid username-password pair \
or user is disabled."
.into(),
);
}
}
if password == *expected {
Frame::Simple("OK".into())
} else {
Frame::Error(
"WRONGPASS invalid username-password pair \
or user is disabled."
.into(),
)
}
}
},

Command::Quit => Frame::Simple("OK".into()),

// For unsupported commands, return an error
Command::Unknown(name) => Frame::Error(format!("ERR unknown command '{name}'")),

Expand Down
61 changes: 60 additions & 1 deletion crates/ember-server/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::broadcast;

use crate::connection_common::{BUF_CAPACITY, IDLE_TIMEOUT, MAX_BUF_SIZE};
use crate::connection_common::{
is_allowed_before_auth, is_auth_frame, try_auth, BUF_CAPACITY, IDLE_TIMEOUT, MAX_BUF_SIZE,
};
use crate::pubsub::{PubMessage, PubSubManager};
use crate::server::ServerContext;
use crate::slowlog::SlowLog;
Expand All @@ -39,6 +41,9 @@ pub async fn handle(
// and we already batch responses from pipelining into a single write
stream.set_nodelay(true)?;

// per-connection auth state. auto-authenticated when no password is set.
let mut authenticated = ctx.requirepass.is_none();

let mut buf = BytesMut::with_capacity(BUF_CAPACITY);
let mut out = BytesMut::with_capacity(BUF_CAPACITY);

Expand Down Expand Up @@ -82,6 +87,29 @@ pub async fn handle(
}
}

// when not yet authenticated, process frames serially so that an
// AUTH command in a pipeline takes effect for subsequent frames
if !authenticated {
for frame in frames {
if is_auth_frame(&frame) {
let (response, success) = try_auth(frame, ctx);
response.serialize(&mut out);
if success {
authenticated = true;
}
} else if is_allowed_before_auth(&frame) {
let response = process(frame, &engine, ctx, slow_log, pubsub).await;
response.serialize(&mut out);
} else {
Frame::Error("NOAUTH Authentication required.".into()).serialize(&mut out);
}
}
if !out.is_empty() {
stream.write_all(&out).await?;
}
continue;
}

// check if any frame is a subscribe command — if so, we need
// to enter subscriber mode which changes the connection loop
let enter_sub = frames.iter().any(is_subscribe_frame);
Expand Down Expand Up @@ -1414,6 +1442,37 @@ async fn execute(
Frame::Error("ERR subscribe commands should not reach execute".into())
}

// AUTH on an already-authenticated connection (re-auth)
Command::Auth { username, password } => match &ctx.requirepass {
None => Frame::Error(
"ERR Client sent AUTH, but no password is set. \
Did you mean ACL SETUSER with >password?"
.into(),
),
Some(expected) => {
if let Some(ref user) = username {
if user != "default" {
return Frame::Error(
"WRONGPASS invalid username-password pair \
or user is disabled."
.into(),
);
}
}
if password == *expected {
Frame::Simple("OK".into())
} else {
Frame::Error(
"WRONGPASS invalid username-password pair \
or user is disabled."
.into(),
)
}
}
},

Command::Quit => Frame::Simple("OK".into()),

Command::Unknown(name) => Frame::Error(format!("ERR unknown command '{name}'")),
}
}
Expand Down
Loading
Loading