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
8 changes: 7 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ ember binds to `127.0.0.1` by default. if you expose it to a network:

- use a firewall to restrict access to trusted clients
- consider running behind a reverse proxy with TLS termination
- ember does not currently support authentication (planned for future releases)
- use `--requirepass` for password authentication (redis-compatible AUTH command)
- enable ACL for per-user access control with `ACL SETUSER` commands
- TLS is available via `--tls-port`, with optional mTLS for client certificates

### per-ip rate limiting

ember does not implement per-ip brute-force protection natively. when exposing the server to untrusted networks, use a reverse proxy (nginx, caddy, haproxy) or firewall rules to rate-limit connection attempts.

### memory limits

Expand Down
12 changes: 12 additions & 0 deletions crates/ember-core/src/keyspace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,10 @@ pub struct KeyspaceStats {
pub keys_evicted: u64,
/// Cumulative count of write commands rejected due to memory limits.
pub oom_rejections: u64,
/// Cumulative count of successful key lookups.
pub keyspace_hits: u64,
/// Cumulative count of key lookups that found no key (expired or absent).
pub keyspace_misses: u64,
}

/// Number of random keys to sample when looking for an eviction candidate.
Expand Down Expand Up @@ -377,6 +381,10 @@ pub struct Keyspace {
evicted_total: u64,
/// Cumulative count of write rejections due to memory limits.
oom_rejections: u64,
/// Cumulative count of successful key lookups.
keyspace_hits: u64,
/// Cumulative count of key lookups that found no key (expired or absent).
keyspace_misses: u64,
/// When set, large values are dropped on a background thread instead
/// of inline on the shard thread. See [`crate::dropper`].
drop_handle: Option<DropHandle>,
Expand Down Expand Up @@ -412,6 +420,8 @@ impl Keyspace {
expired_total: 0,
evicted_total: 0,
oom_rejections: 0,
keyspace_hits: 0,
keyspace_misses: 0,
drop_handle: None,
next_version: 0,
versions: AHashMap::new(),
Expand Down Expand Up @@ -1106,6 +1116,8 @@ impl Keyspace {
keys_expired: self.expired_total,
keys_evicted: self.evicted_total,
oom_rejections: self.oom_rejections,
keyspace_hits: self.keyspace_hits,
keyspace_misses: self.keyspace_misses,
}
}

Expand Down
7 changes: 6 additions & 1 deletion crates/ember-core/src/keyspace/string.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,21 @@ impl Keyspace {
return match &e.value {
Value::String(_) => {
e.touch(self.track_access);
self.keyspace_hits += 1;
Ok(Some(e.value.clone()))
}
_ => Err(WrongType),
};
}
None => return Ok(None),
None => {
self.keyspace_misses += 1;
return Ok(None);
}
};
if expired {
self.remove_expired_entry(key);
}
self.keyspace_misses += 1;
Ok(None)
}

Expand Down
21 changes: 17 additions & 4 deletions crates/ember-server/src/connection/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2719,6 +2719,8 @@ async fn render_info(engine: &Engine, ctx: &Arc<ServerContext>, section: Option<
keys_expired: 0,
keys_evicted: 0,
oom_rejections: 0,
keyspace_hits: 0,
keyspace_misses: 0,
};
for r in &responses {
if let ShardResponse::Stats(s) = r {
Expand All @@ -2728,6 +2730,8 @@ async fn render_info(engine: &Engine, ctx: &Arc<ServerContext>, section: Option<
total.keys_expired += s.keys_expired;
total.keys_evicted += s.keys_evicted;
total.oom_rejections += s.oom_rejections;
total.keyspace_hits += s.keyspace_hits;
total.keyspace_misses += s.keyspace_misses;
}
}
Some(total)
Expand All @@ -2747,6 +2751,13 @@ async fn render_info(engine: &Engine, ctx: &Arc<ServerContext>, section: Option<
out.push_str(&format!("process_id:{}\r\n", std::process::id()));
out.push_str(&format!("uptime_in_seconds:{uptime}\r\n"));
out.push_str(&format!("shard_count:{}\r\n", ctx.shard_count));
out.push_str(&format!("tcp_port:{}\r\n", ctx.bind_addr.port()));
out.push_str("hz:10\r\n");
if let Some(ref path) = ctx.config_path {
out.push_str(&format!("config_file:{}\r\n", path.display()));
} else {
out.push_str("config_file:\r\n");
}
out.push_str("\r\n");
}

Expand Down Expand Up @@ -2788,11 +2799,11 @@ async fn render_info(engine: &Engine, ctx: &Arc<ServerContext>, section: Option<
}

if want("PERSISTENCE") {
let last_save = ctx.last_save_timestamp.load(std::sync::atomic::Ordering::Relaxed);
out.push_str("# Persistence\r\n");
out.push_str(&format!(
"aof_enabled:{}\r\n",
if ctx.aof_enabled { 1 } else { 0 }
));
out.push_str(&format!("aof_enabled:{}\r\n", if ctx.aof_enabled { 1 } else { 0 }));
out.push_str("aof_last_bgrewrite_status:ok\r\n");
out.push_str(&format!("rdb_last_save_time:{last_save}\r\n"));
out.push_str("\r\n");
}

Expand All @@ -2806,6 +2817,8 @@ async fn render_info(engine: &Engine, ctx: &Arc<ServerContext>, section: Option<
out.push_str(&format!("expired_keys:{}\r\n", stats.keys_expired));
out.push_str(&format!("evicted_keys:{}\r\n", stats.keys_evicted));
out.push_str(&format!("oom_rejections:{}\r\n", stats.oom_rejections));
out.push_str(&format!("keyspace_hits:{}\r\n", stats.keyspace_hits));
out.push_str(&format!("keyspace_misses:{}\r\n", stats.keyspace_misses));
}
out.push_str("\r\n");
}
Expand Down
51 changes: 51 additions & 0 deletions crates/ember-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ struct Args {
/// --cluster-auth-pass). the file contents are trimmed of trailing whitespace.
#[arg(long, env = "EMBER_CLUSTER_AUTH_PASS_FILE")]
cluster_auth_pass_file: Option<std::path::PathBuf>,

/// perform a health check against the running server and exit.
/// connects to the configured port, sends PING, expects PONG.
/// exits with code 0 on success, 1 on failure.
#[arg(long)]
healthcheck: bool,
}

/// Applies CLI overrides to an `EmberConfig`. Only `Some` values from the
Expand Down Expand Up @@ -409,6 +415,34 @@ fn build_persistence_config(
})
}

/// Sends PING to the running server and returns 0 on success, 1 on failure.
fn healthcheck(host: &str, port: u16) -> i32 {
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;

let addr = format!("{host}:{port}");
let mut stream = match TcpStream::connect(&addr) {
Ok(s) => s,
Err(_) => return 1,
};
if stream.set_read_timeout(Some(Duration::from_secs(2))).is_err() {
return 1;
}
if stream.write_all(b"*1\r\n$4\r\nPING\r\n").is_err() {
return 1;
}
let mut buf = [0u8; 7];
if stream.read_exact(&mut buf).is_err() {
return 1;
}
if &buf == b"+PONG\r\n" {
0
} else {
1
}
}

#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
Expand All @@ -420,6 +454,23 @@ async fn main() {

let args = Args::parse();

// --healthcheck: ping the running server and exit
if args.healthcheck {
// build config to find the correct port; ignore errors and fall back to default
let mut cfg = match &args.config {
Some(path) => EmberConfig::from_file(path).unwrap_or_default(),
None => EmberConfig::default(),
};
apply_args(&mut cfg, &args);
let port = cfg.port;
let host = if cfg.bind == "0.0.0.0" || cfg.bind == "::" {
"127.0.0.1".to_string()
} else {
cfg.bind.clone()
};
std::process::exit(healthcheck(&host, port));
}

// --config-template: dump defaults and exit
if args.config_template {
let cfg = EmberConfig::default();
Expand Down
6 changes: 6 additions & 0 deletions crates/ember-server/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ pub fn spawn_stats_poller(engine: Engine, ctx: Arc<ServerContext>, poll_interval
keys_expired: 0,
keys_evicted: 0,
oom_rejections: 0,
keyspace_hits: 0,
keyspace_misses: 0,
};
for r in &responses {
if let ShardResponse::Stats(stats) = r {
Expand All @@ -247,6 +249,8 @@ pub fn spawn_stats_poller(engine: Engine, ctx: Arc<ServerContext>, poll_interval
total.keys_expired += stats.keys_expired;
total.keys_evicted += stats.keys_evicted;
total.oom_rejections += stats.oom_rejections;
total.keyspace_hits += stats.keyspace_hits;
total.keyspace_misses += stats.keyspace_misses;
}
}

Expand All @@ -255,6 +259,8 @@ pub fn spawn_stats_poller(engine: Engine, ctx: Arc<ServerContext>, poll_interval
gauge!("ember_keys_expired_total").set(total.keys_expired as f64);
gauge!("ember_keys_evicted_total").set(total.keys_evicted as f64);
gauge!("ember_oom_rejections_total").set(total.oom_rejections as f64);
gauge!("ember_keyspace_hits_total").set(total.keyspace_hits as f64);
gauge!("ember_keyspace_misses_total").set(total.keyspace_misses as f64);

// update atomic for /health endpoint
ctx.memory_used_bytes.store(total.used_bytes as u64);
Expand Down
6 changes: 6 additions & 0 deletions crates/ember-server/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,9 @@ pub async fn run_concurrent(
info!("gRPC listening on {grpc_addr}");
let server = tonic::transport::Server::builder()
.concurrency_limit_per_connection(256)
.http2_keepalive_interval(Some(Duration::from_secs(30)))
.http2_keepalive_timeout(Some(Duration::from_secs(10)))
.tcp_keepalive(Some(Duration::from_secs(60)))
.add_service(svc.into_service())
.serve(grpc_addr);
Some(tokio::spawn(async move {
Expand Down Expand Up @@ -634,6 +637,9 @@ pub async fn run_threaded(
info!("gRPC listening on {grpc_addr}");
let server = tonic::transport::Server::builder()
.concurrency_limit_per_connection(256)
.http2_keepalive_interval(Some(Duration::from_secs(30)))
.http2_keepalive_timeout(Some(Duration::from_secs(10)))
.tcp_keepalive(Some(Duration::from_secs(60)))
.add_service(svc.into_service())
.serve(grpc_addr);
Some(tokio::spawn(async move {
Expand Down
Loading