v0.2.0
This commit is contained in:
1 parent
fc81ecafd6
commit
dc57aa7089
89 files changed
+5290
-1551
No files matched your search
+425
-18
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user