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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ REDIS_PASSWORD=__SECRET_HEX_16__
# prod: REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
# Redis Cluster: REDIS_URL=redis-cluster://:${REDIS_PASSWORD}@redis-0:6379?node=redis-1:6379
# (see deploy/helm/think-watch/README.md, "Redis Cluster")
# TLS (managed Redis services usually require it): the rediss:// scheme,
# rediss-cluster:// for a cluster. The certificate is checked against the
# public CAs and the host name in the URL.
# REDIS_URL=rediss://:${REDIS_PASSWORD}@my-cache.example.com:6379
# A Redis whose certificate a private CA signed: a PEM file with that CA,
# then trusted alone (see deploy/helm/think-watch/README.md, "Redis over TLS")
# REDIS_CA_CERT=/etc/thinkwatch/redis-ca.crt

# --- Application ---
JWT_SECRET=__SECRET_HEX_32__
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ target.
invalidation reached one node only. Every key a script touches now shares a
hash tag, pattern deletes scan every node, and the Helm chart's README
describes a `redis-cluster://` URL.
- **Redis over TLS.** The server was built without TLS for Redis: a
`rediss://` URL was used as plain TCP, so against a Redis that requires
TLS — ElastiCache with in-transit encryption, Upstash, Azure Cache for
Redis and Redis Cloud among them — the server did not start
(`Failed to connect to Redis: Protocol Error: Expected string.`).
`rediss://` and `rediss-cluster://` URLs now connect over TLS and check
the certificate against the system's CAs, as upstream HTTPS does. For a
Redis whose certificate a private CA signed, `REDIS_CA_CERT` names a PEM
file with that CA, which is then trusted alone; the Helm chart sets it
from a Secret given in `redis.caSecret`. The chart's README describes
both.

## [3.1.0] — 2026-10-05

Expand Down
6 changes: 6 additions & 0 deletions Cargo.lock

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

9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,14 @@ rust_decimal = { version = "1", features = ["serde", "serde-with-str"] }
clickhouse = { version = "0.13", features = ["time", "chrono"] }

# Redis
fred = { version = "10", features = ["subscriber-client", "i-scripts"] }
# `enable-rustls` gives fred its rustls connector, for `rediss://` URLs.
# The connector itself is built in `think_watch_common::redis_config`
# with the same TLS stack the HTTP client uses (below): rustls on
# aws-lc-rs, the platform's roots through rustls-platform-verifier. No
# OpenSSL anywhere, so the static musl image needs nothing new.
fred = { version = "10", features = ["subscriber-client", "i-scripts", "enable-rustls"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
rustls-platform-verifier = "0.6"

# Auth
# Crypto-critical crates are pinned with `=` so a silent minor-release
Expand Down
2 changes: 2 additions & 0 deletions crates/common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ tw-bedrock = { workspace = true }
axum = { workspace = true }
sqlx = { workspace = true }
fred = { workspace = true }
rustls = { workspace = true }
rustls-platform-verifier = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
Expand Down
23 changes: 23 additions & 0 deletions crates/common/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ use serde::Deserialize;
pub struct AppConfig {
pub database_url: String,
pub redis_url: String,
/// `REDIS_CA_CERT`: a PEM file with the CA certificate(s) to trust
/// for a `rediss://` `REDIS_URL`, in place of the system's roots —
/// for a Redis whose certificate a private CA signed. See
/// [`crate::redis_config`].
pub redis_ca_cert: Option<std::path::PathBuf>,
pub jwt_secret: String,
pub encryption_key: String,
pub server_host: String,
Expand Down Expand Up @@ -51,6 +56,9 @@ impl AppConfig {
.map_err(|_| anyhow::anyhow!("DATABASE_URL environment variable is required"))?,
redis_url: std::env::var("REDIS_URL")
.map_err(|_| anyhow::anyhow!("REDIS_URL environment variable is required"))?,
redis_ca_cert: std::env::var_os("REDIS_CA_CERT")
.filter(|s| !s.is_empty())
.map(Into::into),
jwt_secret: std::env::var("JWT_SECRET")
.map_err(|_| anyhow::anyhow!("JWT_SECRET environment variable is required"))?,
encryption_key: std::env::var("ENCRYPTION_KEY")
Expand Down Expand Up @@ -134,6 +142,13 @@ impl AppConfig {
}
}

if self.redis_ca_cert.is_some() && !crate::redis_config::uses_tls(&self.redis_url) {
tracing::warn!(
"REDIS_CA_CERT is set, but REDIS_URL does not use TLS — the connection is \
unencrypted and the certificate unused; use a rediss:// URL"
);
}

// ClickHouse auth warning
if self.clickhouse_url.is_some() && self.clickhouse_password.is_none() {
tracing::warn!(
Expand All @@ -144,6 +159,13 @@ impl AppConfig {
Ok(())
}

/// The fred config every Redis client of the server is built from:
/// `REDIS_URL`, with TLS when its scheme is `rediss` and
/// `REDIS_CA_CERT`'s roots when set.
pub fn redis_config(&self) -> anyhow::Result<fred::types::config::Config> {
crate::redis_config::client_config(&self.redis_url, self.redis_ca_cert.as_deref())
}

pub fn gateway_addr(&self) -> String {
format!("{}:{}", self.server_host, self.gateway_port)
}
Expand All @@ -157,6 +179,7 @@ impl AppConfig {
Self {
database_url: "postgres://test".into(),
redis_url: "redis://test".into(),
redis_ca_cert: None,
jwt_secret: jwt_secret.into(),
encryption_key: encryption_key.into(),
server_host: "0.0.0.0".into(),
Expand Down
1 change: 1 addition & 0 deletions crates/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub mod crypto; // AES-256-GCM envelope for secrets at rest
pub mod fixed_window;
pub mod json_secret; // `{"$enc": ...}` — a secret nested inside a JSONB column
pub mod pii; // BlobRedactor — at-rest body redaction shared by gateway + mcp-gateway
pub mod redis_config; // the fred client config for REDIS_URL, with TLS for rediss://
pub mod redis_keys; // pattern deletes that work on one node and on a Redis Cluster
pub mod tasks; // supervised_spawn — panic-isolated background tasks
pub mod validation;
194 changes: 194 additions & 0 deletions crates/common/src/redis_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//! The fred client config for a Redis URL, TLS included.
//!
//! Every Redis client the server builds — the command client and the
//! three Pub/Sub subscribers — goes through [`client_config`], so a
//! `rediss://` URL means the same thing for all of them.
//!
//! A `rediss` / `valkeys` scheme (with or without `-cluster` /
//! `-sentinel`) turns TLS on. The connector is built here rather than
//! left to fred, which would build its own on `ClientConfig::builder()`:
//! that picks the process-wide rustls crypto provider, and this binary
//! compiles in two (aws-lc-rs for reqwest 0.13, ring for the reqwest
//! 0.12 under openidconnect), so rustls cannot choose one and panics.
//! Here the provider is named — aws-lc-rs, as reqwest uses — and the
//! server certificate is checked the way the HTTP client checks an
//! upstream's: against the platform's roots, through
//! rustls-platform-verifier. Managed Redis services (ElastiCache,
//! Upstash, Azure Cache, Redis Cloud) present certificates from public
//! CAs, which those roots cover.
//!
//! A self-hosted Redis whose certificate a private CA signed needs that
//! CA: `REDIS_CA_CERT` names a PEM file holding it (one certificate or
//! several), and then only those certificates are trusted — the same as
//! `redis-cli --cacert`.

use std::path::Path;
use std::sync::Arc;

use anyhow::Context;
use fred::types::config::{Config, TlsConfig, TlsConnector, TlsHostMapping};
use rustls::pki_types::CertificateDer;
use rustls::pki_types::pem::PemObject;

/// The fred config for `url`, with a TLS connector when its scheme asks
/// for TLS. `ca_cert` is the PEM file of `REDIS_CA_CERT`; it only
/// matters for a TLS URL.
///
/// Errors never quote `url`: it carries the Redis password.
pub fn client_config(url: &str, ca_cert: Option<&Path>) -> anyhow::Result<Config> {
let mut parsed = url::Url::parse(url).context("REDIS_URL is not a valid URL")?;
// Hand fred the plain-TCP twin of a TLS scheme, so that it parses
// the URL without building a connector of its own (see the module
// docs), and attach ours.
let tls = match plain_twin(parsed.scheme()) {
Some(plain) => {
parsed
.set_scheme(&plain)
.map_err(|()| anyhow::anyhow!("REDIS_URL has an unusable scheme"))?;
true
}
None => false,
};
let mut config = Config::from_url(parsed.as_str()).context("REDIS_URL is not usable")?;
if tls {
config.tls = Some(TlsConfig {
connector: tls_connector(ca_cert)?,
// A cluster node is reached at the address it announces, and
// its certificate must name that address (host name or IP).
hostnames: TlsHostMapping::None,
});
}
Ok(config)
}

/// Whether `url` asks for TLS, by the same rule fred applies.
pub fn uses_tls(url: &str) -> bool {
url::Url::parse(url).is_ok_and(|u| plain_twin(u.scheme()).is_some())
}

/// `rediss` → `redis`, `rediss-cluster` → `redis-cluster`, `valkeys` →
/// `valkey`, …; `None` for a scheme without TLS.
fn plain_twin(scheme: &str) -> Option<String> {
[("rediss", "redis"), ("valkeys", "valkey")]
.into_iter()
.find_map(|(tls, plain)| {
scheme
.strip_prefix(tls)
.map(|rest| format!("{plain}{rest}"))
})
}

fn tls_connector(ca_cert: Option<&Path>) -> anyhow::Result<TlsConnector> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let builder = rustls::ClientConfig::builder_with_provider(provider.clone())
.with_safe_default_protocol_versions()
.context("Redis TLS: no usable protocol version")?;
let config = match ca_cert {
Some(path) => builder.with_root_certificates(private_roots(path)?),
None => {
let verifier = rustls_platform_verifier::Verifier::new(provider)
.context("Redis TLS: could not load the system's CA certificates")?;
// `dangerous()` is only rustls's door to a verifier of one's
// own; this one verifies fully (chain and host name), as it
// does inside reqwest.
builder
.dangerous()
.with_custom_certificate_verifier(Arc::new(verifier))
}
};
Ok(config.with_no_client_auth().into())
}

/// The certificates of `REDIS_CA_CERT`, as the only roots trusted.
fn private_roots(path: &Path) -> anyhow::Result<rustls::RootCertStore> {
let shown = path.display();
let mut roots = rustls::RootCertStore::empty();
for cert in CertificateDer::pem_file_iter(path)
.with_context(|| format!("REDIS_CA_CERT: cannot read {shown}"))?
{
let cert = cert.with_context(|| format!("REDIS_CA_CERT: {shown} is not valid PEM"))?;
roots
.add(cert)
.with_context(|| format!("REDIS_CA_CERT: {shown} holds an unusable certificate"))?;
}
anyhow::ensure!(
!roots.is_empty(),
"REDIS_CA_CERT: {shown} holds no certificate"
);
Ok(roots)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn plain_urls_have_no_tls() {
for url in [
"redis://:pw@localhost:6379/1",
"redis-cluster://:pw@redis-0:6379?node=redis-1:6379",
"valkey://localhost",
] {
let config = client_config(url, None).unwrap();
assert!(config.tls.is_none(), "{url}");
assert!(!uses_tls(url), "{url}");
}
}

#[test]
fn tls_urls_get_our_connector_and_keep_everything_else() {
let config = client_config("rediss://user:p%40ss@cache.example.com:6380/3", None).unwrap();
assert!(config.uses_rustls());
assert_eq!(config.username.as_deref(), Some("user"));
assert_eq!(config.password.as_deref(), Some("p@ss"));
assert_eq!(config.database, Some(3));
let fred::types::config::ServerConfig::Centralized { server } = &config.server else {
panic!("not centralized: {:?}", config.server);
};
assert_eq!((&*server.host, server.port), ("cache.example.com", 6380));

let config = client_config(
"rediss-cluster://:pw@redis-0:6379?node=redis-1:6379&node=redis-2:6379",
None,
)
.unwrap();
assert!(config.uses_rustls());
assert!(config.server.is_clustered());
assert_eq!(config.server.hosts().len(), 3);

for url in [
"valkeys://localhost",
"rediss-sentinel://localhost:26379/0?sentinelServiceName=m",
] {
assert!(client_config(url, None).unwrap().uses_rustls(), "{url}");
assert!(uses_tls(url), "{url}");
}
}

#[test]
fn a_ca_file_without_certificates_is_refused() {
let dir = std::env::temp_dir().join(format!("tw-redis-ca-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let empty = dir.join("empty.pem");
std::fs::write(&empty, "not a certificate\n").unwrap();
let err = client_config("rediss://localhost", Some(&empty)).unwrap_err();
assert!(
format!("{err:#}").contains("holds no certificate"),
"{err:#}"
);

let missing = dir.join("missing.pem");
let err = client_config("rediss://localhost", Some(&missing)).unwrap_err();
assert!(format!("{err:#}").contains("cannot read"), "{err:#}");
std::fs::remove_dir_all(&dir).unwrap();

// A plain URL never reads it.
assert!(client_config("redis://localhost", Some(&missing)).is_ok());
}

#[test]
fn errors_do_not_quote_the_url() {
let err = client_config("rediss://:hunter2@", None).unwrap_err();
assert!(!format!("{err:#}").contains("hunter2"), "{err:#}");
}
}
6 changes: 3 additions & 3 deletions crates/server/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,14 +221,14 @@ pub fn install_cb_listener(state: &AppState) {
/// pool whenever any instance flips a setting.
pub async fn spawn_config_subscriber(state: &AppState) -> anyhow::Result<()> {
// Multi-instance config sync (`system_settings.value` updates → Pub/Sub).
let sub_main = fred::types::config::Config::from_url(&state.config.redis_url)?;
let sub_main = state.config.redis_config()?;
let sub_main_redis: fred::clients::SubscriberClient =
Builder::from_config(sub_main).build_subscriber_client()?;
sub_main_redis.init().await?;
dynamic_config::spawn_config_subscriber(sub_main_redis, state.dynamic_config.clone());

// Hot-reload the per-state arc-swap handles on the same channel.
let sub_filters_cfg = fred::types::config::Config::from_url(&state.config.redis_url)?;
let sub_filters_cfg = state.config.redis_config()?;
let sub_filters: fred::clients::SubscriberClient =
Builder::from_config(sub_filters_cfg).build_subscriber_client()?;
sub_filters.init().await?;
Expand Down Expand Up @@ -342,7 +342,7 @@ pub async fn spawn_config_subscriber(state: &AppState) -> anyhow::Result<()> {
// replica's subscriber rebuilds its local `ArcSwap<ModelRouter>`.
// Without this, multi-instance deployments had per-replica stale
// routers between CRUD time and the next process restart.
let sub_router_cfg = fred::types::config::Config::from_url(&state.config.redis_url)?;
let sub_router_cfg = state.config.redis_config()?;
let sub_router: fred::clients::SubscriberClient =
Builder::from_config(sub_router_cfg).build_subscriber_client()?;
sub_router.init().await?;
Expand Down
15 changes: 10 additions & 5 deletions crates/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,19 @@ async fn main() -> anyhow::Result<()> {
std::process::exit(1);
}

let redis_config = Config::from_url(&config.redis_url).map_err(|e| {
tracing::error!("Invalid REDIS_URL: {e}");
anyhow::anyhow!("Invalid REDIS_URL")
})?;
let redis_config = match config.redis_config() {
Ok(c) => c,
Err(e) => {
tracing::error!("Invalid Redis configuration: {e:#}");
std::process::exit(1);
}
};
let redis = Builder::from_config(redis_config).build()?;
if let Err(e) = redis.init().await {
tracing::error!("Failed to connect to Redis: {e}");
tracing::error!("Check REDIS_URL and ensure Redis is running");
tracing::error!(
"Check REDIS_URL (and REDIS_CA_CERT for a rediss:// URL) and ensure Redis is running"
);
std::process::exit(1);
}
tracing::info!("Redis connected");
Expand Down
Loading
Loading