first commit
RustBox CI / rustbox (push) Waiting to run

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 15:24:59 +01:00
commit bc5fbd6ba9
180 files changed
+63750

No files matched your search

+538
View File
@@ -0,0 +1,538 @@
use std::net::IpAddr;
use figment::{
Figment,
providers::{Env, Format, Toml},
};
use ipnet::IpNet;
use serde::Deserialize;
/// Application configuration loaded via figment.
///
/// Layers (lowest to highest priority):
/// 1. Serde defaults
/// 2. Optional TOML (`RUSTBOX_CONFIG`, default `/etc/rustbox/rustbox.toml`)
/// 3. Environment variables (flat, e.g. `PORT`, `IMAP_HOST`)
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct AppConfig {
/// Bind address for the HTTP server.
#[serde(default = "default_host")]
pub host: String,
/// Port for the HTTP server.
#[serde(default = "default_port")]
pub port: u16,
/// IMAP server hostname. Required at startup.
#[serde(default)]
pub imap_host: Option<String>,
/// IMAP server port.
#[serde(default = "default_imap_port")]
pub imap_port: u16,
/// SMTP server hostname. When unset or blank, IMAP host is used.
#[serde(default)]
pub smtp_host: Option<String>,
/// SMTP server port.
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
/// Whether TLS is enabled for mail connections.
#[serde(default = "default_tls_enabled")]
pub tls_enabled: bool,
/// Directory for persistent data storage.
#[serde(default = "default_data_dir")]
pub data_dir: String,
/// Session timeout in hours.
#[serde(default = "default_session_timeout_hours")]
pub session_timeout_hours: u64,
/// Whether users may request a persistent "remember me" session.
#[serde(default = "default_remember_me_enabled")]
pub remember_me_enabled: bool,
/// Lifetime of a remembered session in days.
#[serde(default = "default_remember_me_days")]
pub remember_me_days: u64,
/// Environment the application is running in (development, production)
#[serde(default = "default_environment")]
pub environment: String,
/// Optional base path prefix (e.g. "/mail") for serving behind a reverse proxy subpath.
#[serde(default)]
pub base_path: Option<String>,
/// ManageSieve host. When unset, the authenticated account's IMAP host is used.
#[serde(default)]
pub sieve_host: Option<String>,
/// ManageSieve TCP port.
#[serde(default = "default_sieve_port")]
pub sieve_port: u16,
/// ManageSieve transport: `starttls`, `implicit` or `none`.
#[serde(default = "default_sieve_tls_mode")]
pub sieve_tls_mode: String,
/// Explicit opt-in for sending reusable credentials over a cleartext Sieve connection.
#[serde(default)]
pub sieve_allow_insecure_auth: bool,
/// Network timeout for ManageSieve connect/read/write operations.
#[serde(default = "default_sieve_timeout_secs")]
pub sieve_timeout_secs: u64,
/// Comma-separated list of trusted proxy IPs or CIDR ranges that are allowed to set X-Forwarded-For.
#[serde(default)]
pub trusted_proxies: Option<String>,
/// Administrator-defined extra headers added to every outgoing message.
/// Each entry uses `Header-Name: value`. Structural/MIME headers are rejected.
#[serde(default)]
pub outgoing_headers: Vec<String>,
/// Enable Web Key Directory lookups for OpenPGP recipient keys.
#[serde(default = "default_pgp_wkd_enabled")]
pub pgp_wkd_enabled: bool,
/// Allow optional HKP-over-HTTPS fallback for OpenPGP discovery.
#[serde(default)]
pub pgp_hkp_enabled: bool,
/// HKP-compatible HTTPS endpoint used when the fallback is enabled.
#[serde(default = "default_pgp_hkp_url")]
pub pgp_hkp_url: String,
/// Timeout for WKD/HKP network operations.
#[serde(default = "default_pgp_network_timeout_secs")]
pub pgp_network_timeout_secs: u64,
/// Maximum accepted OpenPGP key response size.
#[serde(default = "default_pgp_max_key_bytes")]
pub pgp_max_key_bytes: usize,
/// Allow authenticated users to export private OpenPGP key material.
/// Disabled by default; public-key export is always available.
#[serde(default)]
pub pgp_allow_private_export: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustedProxy {
Ip(IpAddr),
Cidr(IpNet),
}
impl TrustedProxy {
pub fn contains(&self, ip: &IpAddr) -> bool {
match self {
Self::Ip(trusted_ip) => trusted_ip == ip,
Self::Cidr(network) => network.contains(ip),
}
}
}
fn default_host() -> String {
"0.0.0.0".to_string()
}
fn default_port() -> u16 {
3001
}
fn default_imap_port() -> u16 {
993
}
fn default_smtp_port() -> u16 {
587
}
fn default_tls_enabled() -> bool {
true
}
fn default_data_dir() -> String {
"/data".to_string()
}
fn default_session_timeout_hours() -> u64 {
24
}
fn default_remember_me_enabled() -> bool {
true
}
fn default_remember_me_days() -> u64 {
30
}
fn default_environment() -> String {
"development".to_string()
}
fn default_sieve_port() -> u16 {
4190
}
fn default_sieve_tls_mode() -> String {
"starttls".to_string()
}
fn default_sieve_timeout_secs() -> u64 {
10
}
fn default_pgp_wkd_enabled() -> bool {
true
}
fn default_pgp_hkp_url() -> String {
"https://keys.openpgp.org".to_string()
}
fn default_pgp_network_timeout_secs() -> u64 {
10
}
fn default_pgp_max_key_bytes() -> usize {
1024 * 1024
}
impl AppConfig {
/// Use the root path when no reverse-proxy prefix is configured.
pub fn normalized_base_path(&self) -> &str {
self.base_path
.as_deref()
.filter(|path| !path.is_empty())
.unwrap_or("/")
}
/// Parse the `trusted_proxies` field into IP or CIDR entries.
/// Logs warnings for unparseable entries.
pub fn parsed_trusted_proxies(&self) -> Vec<TrustedProxy> {
let Some(ref proxies) = self.trusted_proxies else {
return vec![];
};
proxies
.split(',')
.filter_map(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(ip) = trimmed.parse::<IpAddr>() {
return Some(TrustedProxy::Ip(ip));
}
match trimmed.parse::<IpNet>() {
Ok(network) => Some(TrustedProxy::Cidr(network)),
Err(e) => {
tracing::warn!(entry = trimmed, error = %e, "ignoring unparseable trusted proxy");
None
}
}
})
.collect()
}
pub fn parsed_outgoing_headers(&self) -> Result<Vec<(String, String)>, String> {
const RESERVED: &[&str] = &[
"from",
"sender",
"to",
"cc",
"bcc",
"reply-to",
"subject",
"date",
"message-id",
"in-reply-to",
"references",
"mime-version",
"content-type",
"content-transfer-encoding",
"content-disposition",
];
let mut parsed = Vec::new();
for entry in &self.outgoing_headers {
let (raw_name, raw_value) = entry.split_once(':').ok_or_else(|| {
format!("Invalid outgoing header '{entry}': expected 'Name: value'")
})?;
let name = raw_name.trim();
let value = raw_value.trim();
if name.is_empty()
|| !name
.bytes()
.all(|byte| (33..=126).contains(&byte) && byte != b':')
{
return Err(format!("Invalid outgoing header name: {name}"));
}
if RESERVED
.iter()
.any(|reserved| name.eq_ignore_ascii_case(reserved))
{
return Err(format!(
"Outgoing header '{name}' is managed by RustBox and cannot be overridden"
));
}
if value.contains('\r') || value.contains('\n') {
return Err(format!("Outgoing header '{name}' contains a line break"));
}
if value.is_empty() {
return Err(format!("Outgoing header '{name}' has an empty value"));
}
parsed.push((name.to_string(), value.to_string()));
}
Ok(parsed)
}
/// Load configuration by layering serde defaults, optional TOML, and environment variables.
///
/// Environment variables are read without a prefix and mapped directly to
/// struct fields via case-insensitive matching (e.g. `IMAP_HOST` → `imap_host`).
#[allow(clippy::result_large_err)]
pub fn load() -> Result<Self, figment::Error> {
let path = std::env::var("RUSTBOX_CONFIG")
.unwrap_or_else(|_| "/etc/rustbox/rustbox.toml".to_string());
let mut figment = Figment::new();
if std::path::Path::new(&path).is_file() {
figment = figment.merge(Toml::file(path));
}
// Keep Oxi-compatible flat environment names (IMAP_HOST, SMTP_HOST, ...).
// Environment variables intentionally override the TOML file.
figment.merge(Env::raw()).extract()
}
pub fn validate(&self) -> Result<(), String> {
if self
.imap_host
.as_deref()
.map(|host| host.trim().is_empty())
.unwrap_or(true)
{
return Err("IMAP_HOST / imap_host must be configured".to_string());
}
if self.port == 0 || self.imap_port == 0 || self.smtp_port == 0 || self.sieve_port == 0 {
return Err("network ports must be greater than zero".to_string());
}
if self.session_timeout_hours == 0 || self.session_timeout_hours > 24 * 365 {
return Err("SESSION_TIMEOUT_HOURS must be between 1 and 8760".to_string());
}
if self.remember_me_days == 0 || self.remember_me_days > 365 {
return Err("REMEMBER_ME_DAYS must be between 1 and 365".to_string());
}
if !matches!(
self.sieve_tls_mode.as_str(),
"starttls" | "implicit" | "none"
) {
return Err("SIEVE_TLS_MODE must be one of: starttls, implicit, none".to_string());
}
if self.sieve_tls_mode == "none" && !self.sieve_allow_insecure_auth {
tracing::warn!(
"ManageSieve plaintext transport selected; authentication will remain blocked until SIEVE_ALLOW_INSECURE_AUTH=true"
);
}
self.parsed_outgoing_headers()?;
if self.pgp_network_timeout_secs == 0 || self.pgp_network_timeout_secs > 60 {
return Err("PGP_NETWORK_TIMEOUT_SECS must be between 1 and 60".to_string());
}
if self.pgp_max_key_bytes < 16 * 1024 || self.pgp_max_key_bytes > 4 * 1024 * 1024 {
return Err("PGP_MAX_KEY_BYTES must be between 16384 and 4194304".to_string());
}
if self.pgp_hkp_enabled {
let url = url::Url::parse(&self.pgp_hkp_url)
.map_err(|e| format!("invalid PGP_HKP_URL: {e}"))?;
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
return Err("PGP_HKP_URL must be a credential-free HTTPS URL".to_string());
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_values_load_correctly() {
// With no env vars set, all defaults should apply.
// We use figment with no providers (only serde defaults).
let config: AppConfig = Figment::new().extract().expect("defaults should load");
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 3001);
assert!(config.imap_host.is_none());
assert_eq!(config.imap_port, 993);
assert!(config.smtp_host.is_none());
assert_eq!(config.smtp_port, 587);
assert!(config.tls_enabled);
assert_eq!(config.data_dir, "/data");
assert_eq!(config.session_timeout_hours, 24);
assert!(config.remember_me_enabled);
assert_eq!(config.remember_me_days, 30);
assert_eq!(config.environment, "development");
assert!(config.sieve_host.is_none());
assert_eq!(config.sieve_port, 4190);
assert_eq!(config.sieve_tls_mode, "starttls");
assert!(!config.sieve_allow_insecure_auth);
assert_eq!(config.sieve_timeout_secs, 10);
assert!(config.trusted_proxies.is_none());
assert!(config.outgoing_headers.is_empty());
assert!(!config.pgp_allow_private_export);
}
#[test]
fn env_var_overrides_work() {
// Simulate env overrides via figment's tuple provider
// to verify all fields are overridable.
let config: AppConfig = Figment::new()
.merge(("host", "127.0.0.1"))
.merge(("port", 8080u16))
.merge(("imap_host", "mail.example.com"))
.merge(("imap_port", 143u16))
.merge(("smtp_host", "smtp.example.com"))
.merge(("smtp_port", 465u16))
.merge(("tls_enabled", false))
.merge(("data_dir", "/var/rustbox"))
.merge(("session_timeout_hours", 48u64))
.merge(("remember_me_enabled", false))
.merge(("remember_me_days", 14u64))
.merge(("environment", "production"))
.merge(("sieve_host", "sieve.example.com"))
.merge(("sieve_port", 4191u16))
.merge(("sieve_tls_mode", "implicit"))
.merge(("sieve_allow_insecure_auth", true))
.merge(("sieve_timeout_secs", 15u64))
.merge(("trusted_proxies", "127.0.0.1,::1"))
.extract()
.expect("overrides should load");
assert_eq!(config.host, "127.0.0.1");
assert_eq!(config.port, 8080);
assert_eq!(config.imap_host.as_deref(), Some("mail.example.com"));
assert_eq!(config.imap_port, 143);
assert_eq!(config.smtp_host.as_deref(), Some("smtp.example.com"));
assert_eq!(config.smtp_port, 465);
assert!(!config.tls_enabled);
assert_eq!(config.data_dir, "/var/rustbox");
assert_eq!(config.session_timeout_hours, 48);
assert!(!config.remember_me_enabled);
assert_eq!(config.remember_me_days, 14);
assert_eq!(config.environment, "production");
assert_eq!(config.sieve_host.as_deref(), Some("sieve.example.com"));
assert_eq!(config.sieve_port, 4191);
assert_eq!(config.sieve_tls_mode, "implicit");
assert!(config.sieve_allow_insecure_auth);
assert_eq!(config.sieve_timeout_secs, 15);
assert_eq!(config.trusted_proxies.as_deref(), Some("127.0.0.1,::1"));
}
#[test]
fn real_env_vars_override_multi_word_fields() {
// Verify that actual environment variables with underscores
// (e.g. IMAP_HOST) correctly map to struct fields (imap_host).
// SAFETY: This test runs single-threaded (--test-threads=1)
// and cleans up env vars after use.
unsafe {
std::env::set_var("IMAP_HOST", "test-imap.example.com");
std::env::set_var("DATA_DIR", "/tmp/oxi-test");
}
let config = AppConfig::load().expect("load with env vars should succeed");
assert_eq!(config.imap_host.as_deref(), Some("test-imap.example.com"));
assert_eq!(config.data_dir, "/tmp/oxi-test");
// Clean up
unsafe {
std::env::remove_var("IMAP_HOST");
std::env::remove_var("DATA_DIR");
}
}
#[test]
fn outgoing_headers_are_parsed_and_structural_headers_rejected() {
let config = AppConfig {
outgoing_headers: vec![
"X-Company-Mailer: RustBox".to_string(),
"X-Environment: production".to_string(),
],
..Figment::new().extract().expect("defaults should load")
};
assert_eq!(
config.parsed_outgoing_headers().unwrap(),
vec![
("X-Company-Mailer".to_string(), "RustBox".to_string()),
("X-Environment".to_string(), "production".to_string()),
]
);
let reserved = AppConfig {
outgoing_headers: vec!["Subject: injected".to_string()],
..Figment::new().extract().expect("defaults should load")
};
assert!(reserved.parsed_outgoing_headers().is_err());
let injected = AppConfig {
outgoing_headers: vec!["X-Test: ok\r\nBcc: hidden@example.com".to_string()],
..Figment::new().extract().expect("defaults should load")
};
assert!(injected.parsed_outgoing_headers().is_err());
}
#[test]
fn parsed_trusted_proxies_accepts_individual_ip() {
let config = AppConfig {
trusted_proxies: Some("127.0.0.1".to_string()),
..Figment::new().extract().expect("defaults should load")
};
assert_eq!(
config.parsed_trusted_proxies(),
vec![TrustedProxy::Ip("127.0.0.1".parse().unwrap())]
);
}
#[test]
fn parsed_trusted_proxies_matches_cidr() {
let config = AppConfig {
trusted_proxies: Some("10.0.0.0/8".to_string()),
..Figment::new().extract().expect("defaults should load")
};
let parsed = config.parsed_trusted_proxies();
assert_eq!(parsed.len(), 1);
assert!(parsed[0].contains(&"10.1.2.3".parse().unwrap()));
}
#[test]
fn parsed_trusted_proxies_does_not_match_outside_cidr() {
let config = AppConfig {
trusted_proxies: Some("10.0.0.0/8".to_string()),
..Figment::new().extract().expect("defaults should load")
};
let parsed = config.parsed_trusted_proxies();
assert_eq!(parsed.len(), 1);
assert!(!parsed[0].contains(&"192.168.1.1".parse().unwrap()));
}
#[test]
fn parsed_trusted_proxies_ignores_invalid_entries() {
let config = AppConfig {
trusted_proxies: Some("bad-entry,127.0.0.1,10.0.0.0/8/extra".to_string()),
..Figment::new().extract().expect("defaults should load")
};
assert_eq!(
config.parsed_trusted_proxies(),
vec![TrustedProxy::Ip("127.0.0.1".parse().unwrap())]
);
}
}