This commit is contained in:
Mateusz Gruszczyński committed 2026-10-31 14:36:07 +01:00
1 parent fc81ecafd6
commit dc57aa7089
89 files changed
+5290 -1551

No files matched your search

+425 -18
View File
@@ -7,6 +7,118 @@ use figment::{
use ipnet::IpNet;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct StorageConfig {
#[serde(default = "default_storage_backend")]
pub backend: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub shared: bool,
#[serde(default)]
pub bucket: Option<String>,
#[serde(default = "default_s3_region")]
pub region: String,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub prefix: String,
#[serde(default)]
pub path_style: bool,
#[serde(default)]
pub allow_http: bool,
#[serde(default)]
pub access_key_id: Option<String>,
#[serde(default)]
pub secret_access_key: Option<String>,
#[serde(default)]
pub session_token: Option<String>,
#[serde(default = "default_storage_request_timeout_secs")]
pub request_timeout_secs: u64,
#[serde(default = "default_storage_connect_timeout_secs")]
pub connect_timeout_secs: u64,
#[serde(default = "default_storage_max_retries")]
pub max_retries: usize,
#[serde(default = "default_storage_retry_timeout_secs")]
pub retry_timeout_secs: u64,
#[serde(default)]
pub server_side_encryption: Option<String>,
#[serde(default)]
pub kms_key_id: Option<String>,
}
impl Default for StorageConfig {
fn default() -> Self {
Self {
backend: default_storage_backend(),
path: None,
shared: false,
bucket: None,
region: default_s3_region(),
endpoint: None,
prefix: String::new(),
path_style: false,
allow_http: false,
access_key_id: None,
secret_access_key: None,
session_token: None,
request_timeout_secs: default_storage_request_timeout_secs(),
connect_timeout_secs: default_storage_connect_timeout_secs(),
max_retries: default_storage_max_retries(),
retry_timeout_secs: default_storage_retry_timeout_secs(),
server_side_encryption: None,
kms_key_id: None,
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct SearchConfig {
#[serde(default = "default_search_backend")]
pub backend: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub urls: Vec<String>,
#[serde(default = "default_search_index_prefix")]
pub index_prefix: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub allow_insecure_http: bool,
#[serde(default = "default_search_request_timeout_secs")]
pub request_timeout_secs: u64,
#[serde(default = "default_search_worker_interval_secs")]
pub worker_interval_secs: u64,
#[serde(default = "default_search_worker_batch_size")]
pub worker_batch_size: usize,
#[serde(default = "default_search_worker_max_attempts")]
pub worker_max_attempts: u32,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
backend: default_search_backend(),
path: None,
urls: Vec::new(),
index_prefix: default_search_index_prefix(),
username: None,
password: None,
required: false,
allow_insecure_http: false,
request_timeout_secs: default_search_request_timeout_secs(),
worker_interval_secs: default_search_worker_interval_secs(),
worker_batch_size: default_search_worker_batch_size(),
worker_max_attempts: default_search_worker_max_attempts(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct AppConfig {
@@ -80,10 +192,10 @@ pub struct AppConfig {
pub cluster_mode: bool,
#[serde(default)]
pub cluster_shared_data_dir: bool,
pub storage: StorageConfig,
#[serde(default = "default_search_index_backend")]
pub search_index_backend: String,
#[serde(default)]
pub search: SearchConfig,
#[serde(default = "default_session_timeout_hours")]
pub session_timeout_hours: u64,
@@ -211,6 +323,46 @@ fn default_data_dir() -> String {
"/data".to_string()
}
fn default_s3_region() -> String {
"us-east-1".to_string()
}
fn default_storage_request_timeout_secs() -> u64 {
30
}
fn default_storage_connect_timeout_secs() -> u64 {
5
}
fn default_storage_max_retries() -> usize {
3
}
fn default_storage_retry_timeout_secs() -> u64 {
60
}
fn default_search_index_prefix() -> String {
"rustbox".to_string()
}
fn default_search_request_timeout_secs() -> u64 {
10
}
fn default_search_worker_interval_secs() -> u64 {
2
}
fn default_search_worker_batch_size() -> usize {
100
}
fn default_search_worker_max_attempts() -> u32 {
12
}
fn default_log_level() -> String {
"info".to_string()
}
@@ -223,8 +375,12 @@ fn default_session_backend() -> String {
"database".to_string()
}
fn default_search_index_backend() -> String {
"tantivy".to_string()
fn default_storage_backend() -> String {
"filesystem".to_string()
}
fn default_search_backend() -> String {
"local_tantivy".to_string()
}
fn default_session_timeout_hours() -> u64 {
@@ -305,8 +461,8 @@ impl Default for AppConfig {
redis_password: None,
redis_sentinel_password: None,
cluster_mode: false,
cluster_shared_data_dir: false,
search_index_backend: default_search_index_backend(),
storage: StorageConfig::default(),
search: SearchConfig::default(),
session_timeout_hours: default_session_timeout_hours(),
remember_me_enabled: default_remember_me_enabled(),
remember_me_days: default_remember_me_days(),
@@ -450,7 +606,43 @@ impl AppConfig {
figment = figment.merge(Toml::file(path));
}
figment.merge(Env::raw()).extract()
let figment = figment.merge(Env::raw().split("__"));
let data_dir_explicit = figment.find_value("data_dir").is_ok();
let mut config: Self = figment.extract()?;
config.apply_cluster_data_dir_default(data_dir_explicit);
Ok(config)
}
fn apply_cluster_data_dir_default(&mut self, data_dir_explicit: bool) {
if self.cluster_mode && !data_dir_explicit {
self.data_dir.clear();
}
}
pub fn effective_storage_backend(&self) -> String {
self.storage.backend.trim().to_ascii_lowercase()
}
pub fn effective_search_backend(&self) -> String {
self.search.backend.trim().to_ascii_lowercase()
}
pub fn effective_storage_path(&self) -> &str {
self.storage
.path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(&self.data_dir)
}
pub fn effective_search_path(&self) -> &str {
self.search
.path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(&self.data_dir)
}
pub fn validate(&self) -> Result<(), String> {
@@ -493,10 +685,7 @@ impl AppConfig {
if self.max_attachment_size_mb > self.max_message_size_mb {
return Err("MAX_ATTACHMENT_SIZE_MB cannot exceed MAX_MESSAGE_SIZE_MB".to_string());
}
if !matches!(
self.database_backend.as_str(),
"sqlite" | "mysql" | "pgsql" | "postgres" | "postgresql"
) {
if !matches!(self.database_backend.as_str(), "sqlite" | "mysql" | "pgsql") {
return Err("DATABASE_BACKEND must be one of: sqlite, mysql, pgsql".to_string());
}
if self.database_backend != "sqlite"
@@ -530,8 +719,133 @@ impl AppConfig {
return Err("Redis sessions require REDIS_URL or REDIS_SENTINEL_NODES + REDIS_SENTINEL_MASTER".to_string());
}
}
if !matches!(self.search_index_backend.as_str(), "tantivy" | "disabled") {
return Err("SEARCH_INDEX_BACKEND must be one of: tantivy, disabled".to_string());
let storage_backend = self.effective_storage_backend();
if !matches!(storage_backend.as_str(), "filesystem" | "s3") {
return Err("storage.backend must be one of: filesystem, s3".to_string());
}
if storage_backend == "filesystem" {
let path = self.effective_storage_path();
if path.trim().is_empty() {
return Err(
"storage.path/data_dir cannot be empty for filesystem storage".to_string(),
);
}
if self.cluster_mode && !std::path::Path::new(path).is_absolute() {
return Err("cluster filesystem storage.path must be an absolute path".to_string());
}
} else {
if self
.storage
.bucket
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none()
{
return Err("storage.bucket is required when storage.backend=s3".to_string());
}
if self.storage.region.trim().is_empty() {
return Err("storage.region cannot be empty".to_string());
}
if let Some(endpoint) = self
.storage
.endpoint
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
let url = url::Url::parse(endpoint)
.map_err(|e| format!("invalid storage.endpoint: {e}"))?;
if url.scheme() != "https" && !(self.storage.allow_http && url.scheme() == "http") {
return Err(
"storage.endpoint must use HTTPS unless storage.allow_http=true"
.to_string(),
);
}
}
}
if self.storage.request_timeout_secs == 0
|| self.storage.connect_timeout_secs == 0
|| self.storage.retry_timeout_secs == 0
{
return Err("storage timeouts must be greater than zero".to_string());
}
if let Some(mode) = self
.storage
.server_side_encryption
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
if !matches!(mode, "AES256" | "aws:kms") {
return Err("storage.server_side_encryption must be AES256 or aws:kms".to_string());
}
if mode == "aws:kms"
&& self
.storage
.kms_key_id
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none()
{
return Err("storage.kms_key_id is required for aws:kms encryption".to_string());
}
}
let search_backend = self.effective_search_backend();
if !matches!(
search_backend.as_str(),
"local_tantivy" | "disabled" | "opensearch"
) {
return Err(
"search.backend must be one of: disabled, local_tantivy, opensearch".to_string(),
);
}
if search_backend == "local_tantivy" {
let path = self.effective_search_path();
if path.trim().is_empty() {
return Err("search.path/data_dir cannot be empty for local_tantivy".to_string());
}
}
if search_backend == "opensearch" {
if self.search.urls.is_empty() {
return Err("search.urls must contain at least one URL for opensearch".to_string());
}
for raw in &self.search.urls {
let parsed = url::Url::parse(raw.trim())
.map_err(|e| format!("invalid OpenSearch URL '{raw}': {e}"))?;
if parsed.scheme() != "https"
&& !(self.search.allow_insecure_http && parsed.scheme() == "http")
{
return Err(
"OpenSearch URLs must use HTTPS unless search.allow_insecure_http=true"
.to_string(),
);
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("OpenSearch credentials must not be embedded in URLs".to_string());
}
}
let prefix = self.search.index_prefix.as_str();
if prefix.is_empty()
|| !prefix
.chars()
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|| !prefix
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_'))
{
return Err("search.index_prefix must start with a lowercase ASCII letter/digit and contain only lowercase ASCII letters, digits, '-' or '_'".to_string());
}
}
if self.search.request_timeout_secs == 0
|| self.search.worker_interval_secs == 0
|| self.search.worker_batch_size == 0
|| self.search.worker_max_attempts == 0
{
return Err("search timeout/worker settings must be greater than zero".to_string());
}
if self.cluster_mode && self.database_backend == "sqlite" {
return Err(
@@ -539,15 +853,17 @@ impl AppConfig {
.to_string(),
);
}
if self.cluster_mode && !self.cluster_shared_data_dir {
let storage_cluster_safe =
storage_backend == "s3" || (storage_backend == "filesystem" && self.storage.shared);
if self.cluster_mode && !storage_cluster_safe {
return Err(
"CLUSTER_MODE requires CLUSTER_SHARED_DATA_DIR=true after data_dir is mounted as shared storage on every node; draft attachments are stored below data_dir"
"CLUSTER_MODE requires cluster-safe blob storage: storage.backend=s3 or storage.backend=filesystem with storage.shared=true"
.to_string(),
);
}
if self.cluster_mode && self.search_index_backend != "disabled" {
if self.cluster_mode && search_backend == "local_tantivy" {
return Err(
"CLUSTER_MODE requires SEARCH_INDEX_BACKEND=disabled; the built-in Tantivy index is local and does not coordinate writers across nodes"
"CLUSTER_MODE forbids search.backend=local_tantivy; use disabled or an external indexer"
.to_string(),
);
}
@@ -669,6 +985,8 @@ mod tests {
assert!(config.redis_sentinel_master.is_none());
assert!(config.redis_sentinel_password.is_none());
assert!(!config.cluster_mode);
assert_eq!(config.storage.backend, "filesystem");
assert_eq!(config.search.backend, "local_tantivy");
assert_eq!(config.session_timeout_hours, 24);
assert!(config.remember_me_enabled);
assert_eq!(config.remember_me_days, 30);
@@ -869,6 +1187,95 @@ mod tests {
);
}
#[test]
fn single_node_pgsql_can_still_use_data_dir_backends() {
let config = AppConfig {
imap_host: Some("imap.example.com".to_string()),
database_backend: "pgsql".to_string(),
database_url: Some("postgres://rustbox:secret@db/rustbox".to_string()),
session_encryption_key: Some("shared-key".to_string()),
data_dir: "/srv/rustbox".to_string(),
..AppConfig::default()
};
assert!(config.validate().is_ok());
assert_eq!(config.effective_storage_backend(), "filesystem");
assert_eq!(config.effective_search_backend(), "local_tantivy");
}
#[test]
fn cluster_mode_disables_implicit_data_dir_but_preserves_explicit_value() {
let mut implicit = AppConfig {
cluster_mode: true,
data_dir: "/data".to_string(),
..AppConfig::default()
};
implicit.apply_cluster_data_dir_default(false);
assert!(implicit.data_dir.is_empty());
let mut explicit = AppConfig {
cluster_mode: true,
data_dir: "/mnt/shared-rustbox".to_string(),
..AppConfig::default()
};
explicit.apply_cluster_data_dir_default(true);
assert_eq!(explicit.data_dir, "/mnt/shared-rustbox");
}
#[test]
fn cluster_with_s3_and_opensearch_does_not_require_data_dir() {
let mut config = AppConfig {
imap_host: Some("imap.example.com".to_string()),
database_backend: "pgsql".to_string(),
database_url: Some("postgres://rustbox:secret@db/rustbox".to_string()),
session_encryption_key: Some("shared-key".to_string()),
cluster_mode: true,
pgp_enabled: false,
data_dir: String::new(),
..AppConfig::default()
};
config.storage.backend = "s3".to_string();
config.storage.bucket = Some("rustbox".to_string());
config.storage.endpoint = Some("https://s3.example.com".to_string());
config.search.backend = "opensearch".to_string();
config.search.urls = vec!["https://search.example.com".to_string()];
assert!(config.validate().is_ok());
}
#[test]
fn cluster_accepts_shared_posix_storage_without_s3() {
let mut config = AppConfig {
imap_host: Some("imap.example.com".to_string()),
database_backend: "mysql".to_string(),
database_url: Some("mysql://rustbox:secret@db/rustbox".to_string()),
session_encryption_key: Some("shared-key".to_string()),
cluster_mode: true,
pgp_enabled: false,
..AppConfig::default()
};
config.storage.backend = "filesystem".to_string();
config.storage.path = Some("/mnt/rustbox-shared".to_string());
config.storage.shared = true;
config.search.backend = "disabled".to_string();
assert!(config.validate().is_ok());
}
#[test]
fn pre_0_2_backend_aliases_are_rejected() {
let mut config = AppConfig {
imap_host: Some("imap.example.com".to_string()),
..AppConfig::default()
};
config.search.backend = "tantivy".to_string();
assert!(config.validate().unwrap_err().contains("search.backend"));
config.search.backend = "local_tantivy".to_string();
for backend in ["postgres", "postgresql"] {
config.database_backend = backend.to_string();
assert!(config.validate().unwrap_err().contains("DATABASE_BACKEND"));
}
}
#[test]
fn cluster_mode_rejects_local_sqlite_even_with_redis_sessions() {
let config = AppConfig {