use std::fmt;
use std::net::IpAddr;
use std::time::Duration;
use r2d2::Pool;
use redis::{Client, Script};
use tracing::warn;
use waf_core::{Acquired, BucketParams, StateStore};
mod certstore;
pub use certstore::{CertCipher, DecryptError, DistributedLock, SharedCert, SharedCertStore};
const BUCKET_LUA: &str = r#"
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
local now_override = tonumber(ARGV[5])
local now
if now_override > 0 then
now = now_override
else
local t = redis.call('TIME')
now = tonumber(t[1]) + (tonumber(t[2]) / 1000000.0)
end
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil or ts == nil then
tokens = capacity
ts = now
end
local elapsed = now - ts
if elapsed < 0 then elapsed = 0 end
tokens = math.min(capacity, tokens + (elapsed * refill))
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
if ttl > 0 then
redis.call('EXPIRE', KEYS[1], ttl)
end
return {allowed, tostring(tokens)}
"#;
const DEFAULT_POOL_SIZE: u32 = 16;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
#[derive(Clone, Copy, Debug)]
pub struct RedisStoreOptions {
pub pool_size: u32,
pub fail_open: bool,
}
impl Default for RedisStoreOptions {
fn default() -> Self {
Self { pool_size: DEFAULT_POOL_SIZE, fail_open: true }
}
}
pub struct RedisStore {
pool: Pool<Client>,
script: Script,
fail_open: bool,
cert_cipher: Option<CertCipher>,
}
impl RedisStore {
pub fn connect(url: &str) -> Result<Self, ConnectError> {
Self::connect_with_options(url, RedisStoreOptions::default())
}
pub fn connect_with_options(url: &str, options: RedisStoreOptions) -> Result<Self, ConnectError> {
let store = Self::connect_lazy_with_options(url, options)?;
store.ping().map_err(ConnectError::Ping)?;
Ok(store)
}
pub fn connect_lazy(url: &str) -> Result<Self, ConnectError> {
Self::connect_lazy_with_options(url, RedisStoreOptions::default())
}
pub fn connect_lazy_with_options(url: &str, options: RedisStoreOptions) -> Result<Self, ConnectError> {
let client = Client::open(url).map_err(ConnectError::Client)?;
let pool = Pool::builder()
.max_size(options.pool_size.max(1))
.connection_timeout(CONNECT_TIMEOUT)
.build_unchecked(client);
Ok(Self {
pool,
script: Script::new(BUCKET_LUA),
fail_open: options.fail_open,
cert_cipher: None,
})
}
pub fn with_cert_cipher(mut self, cipher: CertCipher) -> Self {
self.cert_cipher = Some(cipher);
self
}
fn ping(&self) -> redis::RedisResult<()> {
let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
redis::cmd("PING").query(&mut *conn)
}
fn ttl_secs(params: &BucketParams) -> u64 {
if params.refill_per_sec > 0.0 {
(params.capacity / params.refill_per_sec).ceil() as u64 + 1
} else {
3600
}
}
fn run(
&self,
key: &str,
cost: f64,
params: BucketParams,
now_override: f64,
) -> redis::RedisResult<(i64, String)> {
let exec = || {
let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
self.script
.key(key)
.arg(params.capacity)
.arg(params.refill_per_sec)
.arg(cost)
.arg(Self::ttl_secs(¶ms))
.arg(now_override)
.invoke::<(i64, String)>(&mut *conn)
};
match tokio::runtime::Handle::try_current() {
Ok(_) => tokio::task::block_in_place(exec),
Err(_) => exec(),
}
}
fn acquire(&self, key: &str, cost: f64, params: BucketParams, now_override: f64) -> Acquired {
match self.run(key, cost, params, now_override) {
Ok((allowed, remaining)) => Acquired {
allowed: allowed == 1,
tokens_remaining: remaining.parse().unwrap_or(0.0),
},
Err(e) if self.fail_open => {
warn!(error = %e, key = %key, "redis StateStore error — FAILING OPEN (request allowed)");
Acquired { allowed: true, tokens_remaining: params.capacity }
}
Err(e) => {
warn!(error = %e, key = %key, "redis StateStore error — FAILING CLOSED (request denied)");
Acquired { allowed: false, tokens_remaining: 0.0 }
}
}
}
pub fn try_acquire_at(&self, key: &str, cost: f64, params: BucketParams, now_secs: f64) -> Acquired {
self.acquire(key, cost, params, now_secs)
}
}
impl StateStore for RedisStore {
fn try_acquire(&self, key: &str, cost: f64, params: BucketParams) -> Acquired {
self.acquire(key, cost, params, 0.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reputation {
Unknown,
Suspicious,
Malicious,
}
impl Reputation {
fn as_wire(self) -> Option<&'static str> {
match self {
Self::Malicious => Some("malicious"),
Self::Suspicious => Some("suspicious"),
Self::Unknown => None,
}
}
fn from_wire(s: &str) -> Self {
match s {
"malicious" => Self::Malicious,
"suspicious" => Self::Suspicious,
_ => Self::Unknown,
}
}
fn rank(self) -> u8 {
match self {
Self::Unknown => 0,
Self::Suspicious => 1,
Self::Malicious => 2,
}
}
}
pub trait ReputationStore: Send + Sync {
fn reputation(&self, ip: IpAddr) -> Reputation;
}
pub trait ReputationWriter: Send + Sync {
fn apply_snapshot(&self, entries: &[(IpAddr, Reputation)], ttl: Duration) -> usize;
}
fn rep_key(ip: IpAddr) -> String {
format!("rep:{ip}")
}
impl ReputationStore for RedisStore {
fn reputation(&self, ip: IpAddr) -> Reputation {
let key = rep_key(ip);
let exec = || -> redis::RedisResult<Option<String>> {
let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
redis::cmd("GET").arg(&key).query::<Option<String>>(&mut *conn)
};
let result = match tokio::runtime::Handle::try_current() {
Ok(_) => tokio::task::block_in_place(exec),
Err(_) => exec(),
};
match result {
Ok(Some(v)) => Reputation::from_wire(&v),
Ok(None) => Reputation::Unknown,
Err(e) => {
warn!(error = %e, ip = %ip, "redis reputation lookup error — treating as Unknown");
Reputation::Unknown
}
}
}
}
fn resolve_writes(entries: &[(IpAddr, Reputation)]) -> Vec<(String, &'static str)> {
use std::collections::HashMap;
let mut strongest: HashMap<IpAddr, Reputation> = HashMap::new();
for (ip, rep) in entries {
let slot = strongest.entry(*ip).or_insert(Reputation::Unknown);
if rep.rank() > slot.rank() {
*slot = *rep;
}
}
strongest
.into_iter()
.filter_map(|(ip, rep)| rep.as_wire().map(|v| (rep_key(ip), v)))
.collect()
}
impl ReputationWriter for RedisStore {
fn apply_snapshot(&self, entries: &[(IpAddr, Reputation)], ttl: Duration) -> usize {
let writes = resolve_writes(entries);
if writes.is_empty() {
return 0;
}
let ttl_secs = ttl.as_secs().max(1);
let exec = || -> redis::RedisResult<()> {
let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
let mut pipe = redis::pipe();
for (k, v) in &writes {
pipe.cmd("SET").arg(k).arg(*v).arg("EX").arg(ttl_secs).ignore();
}
pipe.query::<()>(&mut *conn)
};
let result = match tokio::runtime::Handle::try_current() {
Ok(_) => tokio::task::block_in_place(exec),
Err(_) => exec(),
};
match result {
Ok(()) => writes.len(),
Err(e) => {
warn!(error = %e, "redis reputation feed write error — snapshot not applied this cycle");
0
}
}
}
}
fn pool_to_redis_err(e: r2d2::Error) -> redis::RedisError {
redis::RedisError::from((
redis::ErrorKind::IoError,
"redis pool unavailable",
e.to_string(),
))
}
#[derive(Debug)]
pub enum ConnectError {
Client(redis::RedisError),
Ping(redis::RedisError),
}
impl fmt::Display for ConnectError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Client(e) => write!(f, "invalid Redis URL: {e}"),
Self::Ping(e) => write!(f, "cannot reach Redis (PING failed): {e}"),
}
}
}
impl std::error::Error for ConnectError {}
#[cfg(test)]
mod tests {
use super::*;
use waf_core::StateStore;
#[test]
fn fails_open_when_redis_unreachable() {
let store = RedisStore::connect_lazy("redis://127.0.0.1:1").expect("url parses");
let params = BucketParams { capacity: 3.0, refill_per_sec: 1.0 };
for _ in 0..5 {
let a = store.try_acquire("any-key", 1.0, params);
assert!(a.allowed, "rate-limit store MUST fail open when Redis is down");
}
}
#[test]
fn fails_closed_when_configured() {
let opts = RedisStoreOptions { fail_open: false, ..Default::default() };
let store = RedisStore::connect_lazy_with_options("redis://127.0.0.1:1", opts)
.expect("url parses");
let params = BucketParams { capacity: 3.0, refill_per_sec: 1.0 };
let a = store.try_acquire("any-key", 1.0, params);
assert!(!a.allowed, "fail_open=false must DENY when Redis is down");
}
#[test]
fn rep_key_is_canonical_for_v4_and_v6() {
assert_eq!(rep_key("1.2.3.4".parse().unwrap()), "rep:1.2.3.4");
let v6: std::net::IpAddr = "2001:0DB8:0000:0000:0000:0000:0000:0001".parse().unwrap();
assert_eq!(rep_key(v6), "rep:2001:db8::1");
}
#[test]
fn reputation_is_unknown_when_redis_unreachable() {
let store = RedisStore::connect_lazy("redis://127.0.0.1:1").expect("url parses");
let ip: std::net::IpAddr = "9.9.9.9".parse().unwrap();
assert_eq!(store.reputation(ip), Reputation::Unknown);
}
#[test]
fn wire_encoding_round_trips_and_is_the_single_source_of_truth() {
for r in [Reputation::Malicious, Reputation::Suspicious] {
let w = r.as_wire().expect("has a wire form");
assert_eq!(Reputation::from_wire(w), r, "round-trip {r:?}");
}
assert_eq!(Reputation::Unknown.as_wire(), None);
assert_eq!(Reputation::from_wire(""), Reputation::Unknown);
assert_eq!(Reputation::from_wire("MALICIOUS"), Reputation::Unknown, "case-sensitive on purpose");
assert_eq!(Reputation::from_wire("bogus"), Reputation::Unknown);
}
#[test]
fn resolve_writes_dedups_to_strongest_and_drops_unknown() {
let a: IpAddr = "1.1.1.1".parse().unwrap();
let b: IpAddr = "2.2.2.2".parse().unwrap();
let c: IpAddr = "3.3.3.3".parse().unwrap();
let entries = [
(a, Reputation::Suspicious),
(b, Reputation::Suspicious),
(a, Reputation::Malicious),
(c, Reputation::Unknown),
];
let mut writes = resolve_writes(&entries);
writes.sort();
assert_eq!(
writes,
vec![
("rep:1.1.1.1".to_string(), "malicious"),
("rep:2.2.2.2".to_string(), "suspicious"),
],
"strongest verdict per IP, Unknown dropped, keyed via rep_key"
);
}
#[test]
fn apply_snapshot_with_only_unknown_writes_nothing_without_touching_redis() {
let store = RedisStore::connect_lazy("redis://127.0.0.1:1").expect("url parses");
let ip: IpAddr = "9.9.9.9".parse().unwrap();
let n = store.apply_snapshot(&[(ip, Reputation::Unknown)], Duration::from_secs(60));
assert_eq!(n, 0, "nothing to write, and no Redis round-trip attempted");
}
#[test]
fn apply_snapshot_returns_zero_when_redis_unreachable() {
let store = RedisStore::connect_lazy("redis://127.0.0.1:1").expect("url parses");
let ip: IpAddr = "203.0.113.1".parse().unwrap();
let n = store.apply_snapshot(&[(ip, Reputation::Malicious)], Duration::from_secs(60));
assert_eq!(n, 0, "write error ⇒ 0 applied, no panic");
}
#[test]
fn ttl_covers_full_refill() {
let p = BucketParams { capacity: 100.0, refill_per_sec: 10.0 };
assert_eq!(RedisStore::ttl_secs(&p), 11); let none = BucketParams { capacity: 5.0, refill_per_sec: 0.0 };
assert_eq!(RedisStore::ttl_secs(&none), 3600);
}
}