diff --git a/SECURITY.md b/SECURITY.md index cbbaa88a..95503e28 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/crates/ember-core/src/keyspace/mod.rs b/crates/ember-core/src/keyspace/mod.rs index 2ad48949..149569c6 100644 --- a/crates/ember-core/src/keyspace/mod.rs +++ b/crates/ember-core/src/keyspace/mod.rs @@ -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. @@ -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, @@ -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(), @@ -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, } } diff --git a/crates/ember-core/src/keyspace/string.rs b/crates/ember-core/src/keyspace/string.rs index f7a047a6..bc65af98 100644 --- a/crates/ember-core/src/keyspace/string.rs +++ b/crates/ember-core/src/keyspace/string.rs @@ -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) } diff --git a/crates/ember-server/src/connection/execute.rs b/crates/ember-server/src/connection/execute.rs index f394a99d..f4d919c3 100644 --- a/crates/ember-server/src/connection/execute.rs +++ b/crates/ember-server/src/connection/execute.rs @@ -2719,6 +2719,8 @@ async fn render_info(engine: &Engine, ctx: &Arc, 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 { @@ -2728,6 +2730,8 @@ async fn render_info(engine: &Engine, ctx: &Arc, 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) @@ -2747,6 +2751,13 @@ async fn render_info(engine: &Engine, ctx: &Arc, 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"); } @@ -2788,11 +2799,11 @@ async fn render_info(engine: &Engine, ctx: &Arc, 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"); } @@ -2806,6 +2817,8 @@ async fn render_info(engine: &Engine, ctx: &Arc, 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"); } diff --git a/crates/ember-server/src/main.rs b/crates/ember-server/src/main.rs index ae3f9b07..197b32c1 100644 --- a/crates/ember-server/src/main.rs +++ b/crates/ember-server/src/main.rs @@ -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, + + /// 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 @@ -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() @@ -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(); diff --git a/crates/ember-server/src/metrics.rs b/crates/ember-server/src/metrics.rs index 80dcdd1a..d6e6eb99 100644 --- a/crates/ember-server/src/metrics.rs +++ b/crates/ember-server/src/metrics.rs @@ -238,6 +238,8 @@ pub fn spawn_stats_poller(engine: Engine, ctx: Arc, 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 { @@ -247,6 +249,8 @@ pub fn spawn_stats_poller(engine: Engine, ctx: Arc, 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; } } @@ -255,6 +259,8 @@ pub fn spawn_stats_poller(engine: Engine, ctx: Arc, 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); diff --git a/crates/ember-server/src/server.rs b/crates/ember-server/src/server.rs index bd171780..0811aefb 100644 --- a/crates/ember-server/src/server.rs +++ b/crates/ember-server/src/server.rs @@ -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 { @@ -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 {