Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f58ebc6
feat(redis): add validated shared connection config
rickcrawford Jul 19, 2026
1a6dc43
fix(redis): harden validation and error redaction
rickcrawford Jul 19, 2026
6cd8163
fix(redis): preserve secure DSN semantics in blocking store
rickcrawford Jul 19, 2026
c409f2d
feat(config): compile secure Redis L2 connections
rickcrawford Jul 19, 2026
20e4ec1
fix(config): redact Redis L2 configuration
rickcrawford Jul 19, 2026
a7c8397
docs(redis): add secure L2 setup and operations guide
rickcrawford Jul 19, 2026
62ce4e8
fix(docs): correct Redis L2 degradation behavior
rickcrawford Jul 19, 2026
3977f8e
chore(redis): register metrics and refresh generated docs
rickcrawford Jul 19, 2026
6f61563
fix(redis): classify blocking TLS handshake failures
rickcrawford Jul 19, 2026
e1b7e69
test(redis): verify auth database and mutual TLS
rickcrawford Jul 19, 2026
549a332
fix(redis): distinguish TLS from transport errors
rickcrawford Jul 19, 2026
6c0bf02
test(config): validate secure Redis example in clean checkout
rickcrawford Jul 19, 2026
59061ea
fix(redis): reuse compiled connection snapshot
rickcrawford Jul 19, 2026
73984bd
test(redis): prove secure live command semantics
rickcrawford Jul 19, 2026
469a6c2
test(cli): remove admin fixture socket race
rickcrawford Jul 19, 2026
996f867
docs(redis): refresh generated guidance and notices
rickcrawford Jul 19, 2026
9d919d4
fix(ci): satisfy Rust 1.97 byte slice lint
rickcrawford Jul 19, 2026
e960a8a
test(config): construct secure Redis example in clean checkout
rickcrawford Jul 19, 2026
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
15 changes: 12 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,21 @@ jobs:

# These tests are ignored in the default local lane because they require
# a redis-server executable. CI installs Redis above and invokes them
# explicitly so the Lua scripts, delete fencing, NOSCRIPT reload, and
# connection recovery are verified against a real server on every PR.
- name: Redis compression state (Lua fencing + recovery)
# explicitly so secure DSN semantics, Lua scripts, delete fencing,
# NOSCRIPT reload, and connection recovery are verified against real
# server processes on every PR.
- name: Redis live state (secure DSN + Lua fencing + recovery)
if: steps.filter.outputs.code == 'true'
run: |
redis-server --version
tls_probe_output="$(redis-server --port 0 --tls-port 1 2>&1 || true)"
if ! grep -q 'No tls-cert-file configured' <<<"${tls_probe_output}"; then
echo "::error::redis-server lacks the TLS support required by secure Redis tests"
exit 1
fi
unset tls_probe_output
cargo test -p sbproxy-platform --test redis_secure -- \
--ignored --nocapture --test-threads=1
cargo test -p sbproxy-core --locked --lib \
'compression_store::redis::tests::live_redis_' -- \
--ignored --test-threads=1
Expand Down
17 changes: 12 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ tokio-util = { version = "0.7", features = ["rt"] }
# dep so sbproxy-modules can drive XREAD directly for feature feeds (WAF
# rule subscription) without going through the KVStore facade.
# `streams` enables the XREAD typed reply parser (StreamReadReply).
redis = { version = "0.28.1", default-features = false, features = ["tokio-comp", "streams"] }
redis = { version = "0.31.0", default-features = false, features = ["tokio-comp", "streams"] }

# Bloom filter
bloomfilter = "1"
Expand Down
7 changes: 7 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,13 @@ Crates: ciborium, ciborium-io, ciborium-ll.

===========================================================================

backon
Copyright the backon contributors
Licensed under the Apache License, Version 2.0
https://github.com/Xuanwo/backon

===========================================================================

The following crates are pulled in only by the embedded model-host
engine (mistral.rs, behind the off-by-default `embedded` cargo feature)
and are licensed under the Apache License, Version 2.0. Where a crate is
Expand Down
1 change: 1 addition & 0 deletions crates/sbproxy-config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ tempfile = "3"
schemars = { workspace = true }

[dev-dependencies]
rcgen = { workspace = true }
serde_json = { workspace = true }
tempfile = "3"
tokio = { workspace = true }
80 changes: 63 additions & 17 deletions crates/sbproxy-config/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
//! performance-optimized `CompiledConfig` / `CompiledOrigin` types that
//! the proxy runtime works with.

use std::fs::File;
use std::io::Read;
use std::sync::Arc;

use anyhow::{Context, Result};
Expand All @@ -14,18 +16,22 @@ use sbproxy_platform::messenger::redis::RedisMessengerConfig;
use sbproxy_platform::messenger::{
GcpPubSubMessenger, MemoryMessenger, Messenger, RedisMessenger, SqsMessenger,
};
use sbproxy_platform::storage::{KVStore, RedisConfig, RedisKVStore};
use sbproxy_platform::storage::{
KVStore, RedisConfig, RedisKVStore, RedisTlsConfig, ValidatedRedisConnection,
};
use smallvec::SmallVec;

use crate::snapshot::{CompiledConfig, CompiledOrigin};
use crate::types::{ConfigFile, L2CacheConfig, MessengerSettings, RawOriginConfig};
use crate::types::{ConfigFile, L2CacheConfig, L2CacheParams, MessengerSettings, RawOriginConfig};

const MAX_REDIS_TLS_FILE_BYTES: u64 = 1_048_576;

/// Extract the Redis host:port pair from a DSN like `redis://host:6379/0`.
///
/// Accepts either a bare `host:port` form (as used by the raw RESP client)
/// or a `redis://[user[:pass]@]host:port[/db]` URL. The database index is
/// ignored since the single-connection RESP client does not issue SELECT.
fn parse_redis_addr(dsn: &str) -> Result<String> {
fn parse_redis_messenger_addr(dsn: &str) -> Result<String> {
let s = dsn.trim();
let without_scheme = s
.strip_prefix("redis://")
Expand Down Expand Up @@ -56,22 +62,62 @@ fn parse_redis_addr(dsn: &str) -> Result<String> {
}
}

fn read_redis_tls_file(path: &str, error: &'static str) -> Result<Vec<u8>> {
let file = File::open(path).map_err(|_| anyhow::anyhow!(error))?;
let mut bytes = Vec::new();
file.take(MAX_REDIS_TLS_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|_| anyhow::anyhow!(error))?;
if bytes.is_empty() || bytes.len() as u64 > MAX_REDIS_TLS_FILE_BYTES {
return Err(anyhow::anyhow!(error));
}
Ok(bytes)
}

fn read_optional_redis_tls_file(
path: Option<&str>,
error: &'static str,
) -> Result<Option<Vec<u8>>> {
path.map(|path| read_redis_tls_file(path, error))
.transpose()
}

/// Compile the Redis connection shared by the blocking L2 store and async
/// compression state without opening a network connection.
///
/// # Errors
///
/// Returns a static, redacted error when the DSN, TLS field combination, file
/// contents, or client identity is invalid.
pub fn build_l2_redis_connection(params: &L2CacheParams) -> Result<ValidatedRedisConnection> {
let tls = RedisTlsConfig {
root_cert: read_optional_redis_tls_file(
params.ca_file.as_deref(),
"invalid Redis ca_file configuration",
)?,
client_cert: read_optional_redis_tls_file(
params.cert_file.as_deref(),
"invalid Redis cert_file configuration",
)?,
client_key: read_optional_redis_tls_file(
params.key_file.as_deref(),
"invalid Redis key_file configuration",
)?,
};
ValidatedRedisConnection::new(&params.dsn, tls)
}

/// Build a concrete `KVStore` for the given L2 cache config.
///
/// # Errors
///
/// Returns an error if the configured `driver` is not recognized, or if
/// the `redis` driver is selected and its DSN cannot be parsed into a
/// `host:port` address.
/// Returns an error if the configured `driver` is not recognized or if the
/// Redis connection and TLS configuration is invalid.
pub fn build_l2_store(cfg: &L2CacheConfig) -> Result<Arc<dyn KVStore>> {
match cfg.driver.as_str() {
"redis" => {
let addr = parse_redis_addr(&cfg.params.dsn)
.with_context(|| format!("invalid redis DSN '{}'", cfg.params.dsn))?;
Ok(Arc::new(RedisKVStore::new(RedisConfig {
addr,
..RedisConfig::default()
})))
let connection = build_l2_redis_connection(&cfg.params)?;
Ok(Arc::new(RedisKVStore::new(RedisConfig::new(connection))))
}
other => anyhow::bail!("unsupported l2_cache driver: '{}'", other),
}
Expand Down Expand Up @@ -115,7 +161,7 @@ pub fn build_messenger(settings: &MessengerSettings) -> Result<Arc<dyn Messenger
.get("dsn")
.cloned()
.unwrap_or_else(|| "redis://127.0.0.1:6379".to_string());
let addr = parse_redis_addr(&dsn)
let addr = parse_redis_messenger_addr(&dsn)
.with_context(|| format!("invalid redis messenger DSN '{}'", dsn))?;
Ok(Arc::new(RedisMessenger::new(RedisMessengerConfig { addr })))
}
Expand Down Expand Up @@ -1130,10 +1176,10 @@ pub fn compile_config(yaml: &str) -> Result<CompiledConfig> {
}
}

// Instantiate the L2 cache backend (Redis) if configured. The store is
// created lazily, so this call just records the target address without
// opening a connection yet. Any concrete failure surfaces the first
// time a request tries to use it.
// Instantiate the L2 cache backend (Redis) if configured. DSN semantics,
// TLS field combinations, and local PEM material are validated here at
// startup. Only the network connection is lazy; reachability, TLS handshake,
// authentication, and database-selection failures surface on first use.
let l2_store = match &config_file.proxy.l2_cache {
Some(cfg) => Some(build_l2_store(cfg)?),
None => None,
Expand Down
29 changes: 26 additions & 3 deletions crates/sbproxy-config/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2150,12 +2150,35 @@ pub struct L2CacheConfig {
///
/// Kept separate from `L2CacheConfig` so future drivers can add fields
/// (auth, pool size) without churning the parent struct.
#[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)]
#[derive(Clone, Deserialize, Serialize, Default, schemars::JsonSchema)]
pub struct L2CacheParams {
/// Connection DSN. For `redis` drivers this is a `redis://host:port[/db]`
/// URL. Only the host:port portion is parsed today; the DB index is ignored.
/// Redis connection DSN. Supports `redis://`, `rediss://`, credentials,
/// bracketed IPv6 addresses, and a non-negative logical database.
#[serde(default)]
pub dsn: String,
/// Optional path to PEM-encoded Redis trust anchors for a private CA.
#[serde(default)]
pub ca_file: Option<String>,
/// Optional path to a PEM-encoded Redis client certificate chain.
/// Must be configured together with `key_file` and requires `rediss://`.
#[serde(default)]
pub cert_file: Option<String>,
/// Optional path to the PEM-encoded Redis client private key.
/// Must be configured together with `cert_file` and requires `rediss://`.
#[serde(default)]
pub key_file: Option<String>,
}

impl std::fmt::Debug for L2CacheParams {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("L2CacheParams")
.field("dsn_configured", &!self.dsn.is_empty())
.field("ca_file_configured", &self.ca_file.is_some())
.field("cert_file_configured", &self.cert_file.is_some())
.field("key_file_configured", &self.key_file.is_some())
.finish()
}
}

// --- Cache Reserve Config ---
Expand Down
Loading
Loading