diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c785c4a..945798e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 4.3.0 + +* Add `sentinel-auth` feature + +## 4.2.3 + +* Add `NotFound` error kind variant +* Use `NotFound` errors when casting `nil` server responses to non-nullable types + ## 4.2.2 * Remove some unnecessary async locks diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 130eb871..8d5ec684 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,27 @@ This document gives some background on how the library is structured and how to If you'd like to contribute to any of the above features feel free to reach out +### Next Major Release + +The next major release (5.0.0) will include the following: + +* RESP3 support (this will result in breaking changes to nearly all response types due to value attributes being added) +* Move several global config options to the `RedisConfig` struct. Currently there are some use cases where current global options would work better if they were client-specific +* Improved error types and messages +* Remove or collapse several compile-time features. For example, the `sentinel-auth` feature will become the default interface, etc. +* Replace some configuration struct locks with `ArcSwap` +* Collapse the different pool types to one pool type that can dynamically scale while supporting client use via the `Deref` trait. +* Switch from `Arc` to `ArcStr` +* Publish benchmarks and run them during CI. The closest thing to that currently is in the [pipeline_test](bin/pipeline_test) module. +* Lots of code cleanup and refactoring. + +In addition, in 5.1.0 the [streams](https://redis.io/topics/streams-intro) interface will be added. This will likely include the following: + +* A lower level interface to use the X* interface directly. +* An optional, higher level client interface to manage the strange ways that stream subscriptions can interact with reconnect/retry. This will likely look a lot like a Kafka client. + +Finally, the 5.2.0 release will add [client tracking](https://redis.io/topics/client-side-caching) support built into the client (behind a new feature). + ## Design This section covers some useful design considerations and assumptions that went into this module. diff --git a/Cargo.toml b/Cargo.toml index 1cfbb078..e3418894 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fred" -version = "4.2.3" +version = "4.3.0" authors = ["Alec Embke "] edition = "2018" description = "An async Redis client for Rust built on Futures and Tokio." @@ -77,6 +77,7 @@ network-logs = [] custom-reconnect-errors = [] monitor = ["nom"] sentinel-client = [] +sentinel-auth = [] # Testing Features sentinel-tests = [] # a testing feature to randomly stop, restart, and rebalance the cluster while tests are running diff --git a/README.md b/README.md index a283a07e..66bf8449 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ When a client is initialized it will generate a unique client name with a prefix | custom-reconnect-errors | | Enable an interface for callers to customize the types of errors that should automatically trigger reconnection logic. | | monitor | | Enable an interface for running the `MONITOR` command. | | sentinel-client | | Enable an interface for communicating directly with Sentinel nodes. This is not necessary to use normal Redis clients behind a sentinel layer. | +| sentinel-auth | | Enable an interface for using different authentication credentials to sentinel nodes. | ## Environment Variables @@ -136,7 +137,7 @@ To use the [Redis Sentinel](https://redis.io/topics/sentinel) interface callers The client will automatically update these values in place as sentinel nodes change whenever connections to the primary Redis server close. Callers can inspect these changes with the `client_config` function on any `RedisClient` that uses the sentinel interface. -Note: Sentinel connections will use the same authentication and TLS configuration options as the connections to the Redis servers. +Note: Sentinel connections will use the same TLS configuration options as the connections to the Redis servers. By default connections will also use the same authentication credentials as well unless the `sentinel-auth` feature is enabled. Callers can also use the `sentinel-client` feature to communicate directly with Sentinel nodes. diff --git a/examples/sentinel.rs b/examples/sentinel.rs index 96ccd3d4..20bf3e31 100644 --- a/examples/sentinel.rs +++ b/examples/sentinel.rs @@ -13,8 +13,14 @@ async fn main() -> Result<(), RedisError> { ("localhost".into(), 26380), ("localhost".into(), 26381), ], + // note: by default sentinel nodes use the same authentication settings as the redis servers, however + // callers can also use the `sentinel-auth` feature to use different credentials to sentinel nodes + #[cfg(feature = "sentinel-auth")] + username: None, + #[cfg(feature = "sentinel-auth")] + password: None, }, - // sentinels should use the same TLS and authentication settings as the Redis servers + // sentinels should use the same TLS settings as the Redis servers ..Default::default() }; diff --git a/src/modules/types.rs b/src/modules/types.rs index 495aaffb..3129a099 100644 --- a/src/modules/types.rs +++ b/src/modules/types.rs @@ -667,6 +667,15 @@ pub enum ServerConfig { hosts: Vec<(String, u16)>, /// The service name for primary/main instances. service_name: String, + + /// An optional ACL username for the client to use when authenticating. + #[cfg(feature = "sentinel-auth")] + #[cfg_attr(docsrs, doc(cfg(feature = "sentinel-auth")))] + username: Option, + /// An optional password for the client to use when authenticating. + #[cfg(feature = "sentinel-auth")] + #[cfg_attr(docsrs, doc(cfg(feature = "sentinel-auth")))] + password: Option, }, } @@ -711,6 +720,10 @@ impl ServerConfig { ServerConfig::Sentinel { hosts: hosts.drain(..).map(|(h, p)| (h.into(), p)).collect(), service_name: service_name.into(), + #[cfg(feature = "sentinel-auth")] + username: None, + #[cfg(feature = "sentinel-auth")] + password: None, } } @@ -749,7 +762,7 @@ impl ServerConfig { } } - /// Read the first host + /// Read the server hosts or sentinel hosts if using the sentinel interface. pub fn hosts(&self) -> Vec<(&str, u16)> { match *self { ServerConfig::Centralized { ref host, port } => vec![(host.as_str(), port)], diff --git a/src/multiplexer/sentinel.rs b/src/multiplexer/sentinel.rs index 925a0a9c..017538f6 100644 --- a/src/multiplexer/sentinel.rs +++ b/src/multiplexer/sentinel.rs @@ -3,7 +3,8 @@ use crate::globals::globals; use crate::modules::inner::RedisClientInner; use crate::modules::types::ClientState; use crate::multiplexer::{utils, CloseTx, Connections, Counters, SentCommand}; -use crate::protocol::connection::{self, RedisTransport}; +use crate::protocol::codec::RedisCodec; +use crate::protocol::connection::{self, authenticate, FramedTcp, FramedTls, RedisTransport}; use crate::protocol::types::{RedisCommand, RedisCommandKind}; use crate::protocol::utils as protocol_utils; use crate::types::Resolve; @@ -14,7 +15,12 @@ use std::collections::VecDeque; use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::sync::Arc; +use tokio::net::TcpStream; use tokio::sync::RwLock as AsyncRwLock; +use tokio_util::codec::Framed; + +#[cfg(feature = "enable-tls")] +use crate::protocol::tls; /// The amount of time to wait when trying to connect to the redis server. /// @@ -45,21 +51,71 @@ macro_rules! stry ( } ); -fn read_sentinel_hosts(inner: &Arc) -> Result<(String, Vec<(String, u16)>), RedisError> { - if let ServerConfig::Sentinel { - ref hosts, - ref service_name, - } = inner.config.read().server - { - Ok((service_name.to_owned(), hosts.to_vec())) - } else { - Err(RedisError::new( +#[cfg(feature = "sentinel-auth")] +fn read_sentinel_auth(inner: &Arc) -> Result<(Option, Option), RedisError> { + match inner.config.read().server { + ServerConfig::Sentinel { + ref username, + ref password, + .. + } => Ok((username.clone(), password.clone())), + _ => Err(RedisError::new( RedisErrorKind::Config, - "Expected sentinel server config.", - )) + "Expected sentinel server configuration.", + )), } } +#[cfg(not(feature = "sentinel-auth"))] +fn read_sentinel_auth(inner: &Arc) -> Result<(Option, Option), RedisError> { + let guard = inner.config.read(); + Ok((guard.username.clone(), guard.password.clone())) +} + +// TODO clean this up in the next major release by breaking up the connection functions +#[cfg(feature = "enable-tls")] +pub async fn create_authenticated_connection_tls( + addr: &SocketAddr, + domain: &str, + inner: &Arc, +) -> Result { + let server = format!("{}:{}", addr.ip().to_string(), addr.port()); + let codec = RedisCodec::new(inner, server); + let client_name = inner.client_name(); + let (username, password) = read_sentinel_auth(inner)?; + + let socket = TcpStream::connect(addr).await?; + let tls_stream = tls::create_tls_connector(&inner.config)?; + let socket = tls_stream.connect(domain, socket).await?; + let framed = authenticate(Framed::new(socket, codec), &client_name, username, password).await?; + + Ok(framed) +} + +#[cfg(not(feature = "enable-tls"))] +pub(crate) async fn create_authenticated_connection_tls( + addr: &SocketAddr, + _domain: &str, + inner: &Arc, +) -> Result { + create_authenticated_connection(addr, inner).await +} + +pub async fn create_authenticated_connection( + addr: &SocketAddr, + inner: &Arc, +) -> Result { + let server = format!("{}:{}", addr.ip().to_string(), addr.port()); + let codec = RedisCodec::new(inner, server); + let client_name = inner.client_name(); + let (username, password) = read_sentinel_auth(inner)?; + + let socket = TcpStream::connect(addr).await?; + let framed = authenticate(Framed::new(socket, codec), &client_name, username, password).await?; + + Ok(framed) +} + async fn connect_to_server( inner: &Arc, host: &str, @@ -69,12 +125,12 @@ async fn connect_to_server( let uses_tls = inner.config.read().uses_tls(); let transport = if uses_tls { - let transport_ft = connection::create_authenticated_connection_tls(addr, host, inner); + let transport_ft = create_authenticated_connection_tls(addr, host, inner); let transport = stry!(client_utils::apply_timeout(transport_ft, timeout).await); RedisTransport::Tls(transport) } else { - let transport_ft = connection::create_authenticated_connection(addr, inner); + let transport_ft = create_authenticated_connection(addr, inner); let transport = stry!(client_utils::apply_timeout(transport_ft, timeout).await); RedisTransport::Tcp(transport) @@ -123,7 +179,7 @@ pub async fn connect_to_sentinel( async fn discover_primary_node( inner: &Arc, ) -> Result<(String, RedisTransport, String, SocketAddr), RedisError> { - let (name, hosts) = read_sentinel_hosts(inner)?; + let (hosts, name) = client_utils::read_sentinel_host(inner)?; let timeout = globals().sentinel_connection_timeout_ms() as u64; for (idx, (sentinel_host, port)) in hosts.into_iter().enumerate() { diff --git a/src/utils.rs b/src/utils.rs index aed46606..8acd6d90 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -717,48 +717,61 @@ pub async fn read_connection_ids(inner: &Arc) -> Option) -> Result<(Vec<(String, u16)>, String), RedisError> { + match inner.config.read().server { + #[cfg(not(feature = "sentinel-auth"))] + ServerConfig::Sentinel { + ref hosts, + ref service_name, + } => Ok((hosts.clone(), service_name.to_owned())), + #[cfg(feature = "sentinel-auth")] + ServerConfig::Sentinel { + ref hosts, + ref service_name, + .. + } => Ok((hosts.clone(), service_name.to_owned())), + _ => Err(RedisError::new(RedisErrorKind::Config, "Expected sentinel config.")), + } +} + pub async fn update_sentinel_nodes(inner: &Arc) -> Result<(), RedisError> { - let old_config = inner.config.read().clone(); - - if let ServerConfig::Sentinel { hosts, service_name } = old_config.server { - let timeout = globals().sentinel_connection_timeout_ms() as u64; - - for (host, port) in hosts.into_iter() { - _debug!(inner, "Updating sentinel nodes from {}:{}...", host, port); - - let transport = match sentinel::connect_to_sentinel(inner, &host, port, timeout).await { - Ok(transport) => transport, - Err(e) => { - _warn!( - inner, - "Failed to connect to sentinel {}:{} with error: {:?}", - host, - port, - e - ); - continue; - } - }; - if let Err(e) = sentinel::update_sentinel_nodes(inner, transport, &service_name).await { + let (hosts, service_name) = read_sentinel_host(inner)?; + let timeout = globals().sentinel_connection_timeout_ms() as u64; + + for (host, port) in hosts.into_iter() { + _debug!(inner, "Updating sentinel nodes from {}:{}...", host, port); + + let transport = match sentinel::connect_to_sentinel(inner, &host, port, timeout).await { + Ok(transport) => transport, + Err(e) => { _warn!( inner, - "Failed to read sentinel nodes from {}:{} with error: {:?}", + "Failed to connect to sentinel {}:{} with error: {:?}", host, port, e ); - } else { - return Ok(()); + continue; } + }; + if let Err(e) = sentinel::update_sentinel_nodes(inner, transport, &service_name).await { + _warn!( + inner, + "Failed to read sentinel nodes from {}:{} with error: {:?}", + host, + port, + e + ); + } else { + return Ok(()); } - - Err(RedisError::new( - RedisErrorKind::Sentinel, - "Failed to read sentinel nodes from any known sentinel.", - )) - } else { - Err(RedisError::new(RedisErrorKind::Config, "Expected sentinel config.")) } + + Err(RedisError::new( + RedisErrorKind::Sentinel, + "Failed to read sentinel nodes from any known sentinel.", + )) } pub fn check_empty_keys(keys: &MultipleKeys) -> Result<(), RedisError> { diff --git a/tests/integration/utils.rs b/tests/integration/utils.rs index 5f6eded0..9dc2ff66 100644 --- a/tests/integration/utils.rs +++ b/tests/integration/utils.rs @@ -44,6 +44,10 @@ where ("127.0.0.1".into(), 26381), ], service_name: "redis-sentinel-main".into(), + #[cfg(feature = "sentinel-auth")] + username: None, + #[cfg(feature = "sentinel-auth")] + password: None, }, pipeline, ..Default::default() diff --git a/tests/run_all.sh b/tests/run_all.sh index eeddb249..79ddf5c9 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -17,4 +17,6 @@ cargo test --release --lib --tests --features \ -- --test-threads=1 echo "Testing with sentinel interface..." -cargo test --release --features sentinel-tests --lib --tests -- --test-threads=1 \ No newline at end of file +cargo test --release --features "sentinel-tests" --lib --tests -- --test-threads=1 +echo "Testing with sentinel interface and sentinel auth..." +cargo test --release --features "sentinel-tests sentinel-auth" --lib --tests -- --test-threads=1 \ No newline at end of file diff --git a/tests/run_sentinel.sh b/tests/run_sentinel.sh index 83d318d3..8abee01f 100755 --- a/tests/run_sentinel.sh +++ b/tests/run_sentinel.sh @@ -1,3 +1,4 @@ #!/bin/bash -cargo test --release --features sentinel-tests --lib --tests -- --test-threads=1 -- "$@" \ No newline at end of file +cargo test --release --features "sentinel-tests" --lib --tests -- --test-threads=1 -- "$@" +cargo test --release --features "sentinel-tests sentinel-auth" --lib --tests -- --test-threads=1 -- "$@" \ No newline at end of file