Skip to main content

rusmes_server/
bootstrap.rs

1//! Server bootstrap: configuration-driven construction of authentication and
2//! storage backends, plus PID-file lifecycle management.
3//!
4//! This module isolates the conversion logic between the configuration crate
5//! ([`rusmes_config`]) and the runtime factory APIs exposed by [`rusmes_auth`]
6//! and [`rusmes_storage`]. The bridging here exists because the on-disk
7//! configuration intentionally exposes a smaller, user-friendly surface than
8//! the full backend configuration types — the helpers below fill in sensible
9//! defaults for the fields that the configuration does not surface.
10//!
11//! The PID-file helpers cooperate with the signal-handling loop in `main.rs`:
12//! [`PidFile::write`] creates the file (overwriting any stale entry) and
13//! [`PidFile::cleanup`] removes it on graceful shutdown.
14
15use anyhow::{Context, Result};
16use rusmes_auth::backends::ldap::LdapConfig;
17use rusmes_auth::backends::oauth2::{OAuth2Config, OidcProvider};
18use rusmes_auth::backends::sql::SqlConfig;
19use rusmes_auth::file::HashAlgorithm;
20use rusmes_auth::{AuthBackend, AuthBackendKind, FileBackendConfig};
21use rusmes_config::{
22    AuthConfig as CfgAuthConfig, LdapAuthConfig, OAuth2AuthConfig, ServerConfig, SqlAuthConfig,
23    StorageConfig as CfgStorageConfig,
24};
25use rusmes_storage::{build_storage, BackendKind, StorageBackend};
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29// ---------------------------------------------------------------------------
30// Auth-backend bridging
31// ---------------------------------------------------------------------------
32
33/// Translate a [`CfgAuthConfig`] from the on-disk configuration into the
34/// matching [`AuthBackendKind`] understood by `rusmes-auth`.
35///
36/// The on-disk configuration intentionally exposes a smaller subset of fields
37/// than the runtime backend configurations. Missing fields are filled in from
38/// each backend's `Default` implementation, which provides production-safe
39/// defaults (e.g. `max_connections = 10` for SQL, sensible LDAP timeouts).
40pub fn auth_kind_from_config(cfg: &CfgAuthConfig) -> AuthBackendKind {
41    match cfg {
42        CfgAuthConfig::File { config } => {
43            // The on-disk hash_algorithm string is best-effort: an unrecognised
44            // value falls back to the default (bcrypt) with a warning, so a
45            // typo never blocks startup of an otherwise valid configuration.
46            let algorithm = match HashAlgorithm::from_config_str(&config.hash_algorithm) {
47                Ok(a) => a,
48                Err(e) => {
49                    tracing::warn!(
50                        "[auth.file] {}; falling back to bcrypt for new password writes",
51                        e
52                    );
53                    HashAlgorithm::default()
54                }
55            };
56            AuthBackendKind::File(FileBackendConfig {
57                path: config.path.clone(),
58                hash_algorithm: algorithm,
59            })
60        }
61        CfgAuthConfig::Sql { config } => AuthBackendKind::Sql(sql_config_from(config)),
62        CfgAuthConfig::Ldap { config } => AuthBackendKind::Ldap(ldap_config_from(config)),
63        CfgAuthConfig::OAuth2 { config } => AuthBackendKind::OAuth2(oauth2_config_from(config)),
64    }
65}
66
67/// Build an authentication backend from configuration, defaulting to a
68/// `FileAuthBackend` rooted in `<runtime_dir>/passwd` when the configuration
69/// omits the `[auth]` section.
70///
71/// The fallback is intentional: it keeps existing single-host installs that
72/// rely on the implicit file backend working, while routing the on-disk store
73/// through the runtime-dir convention so it never lands in an unexpected place.
74pub async fn build_auth_backend(cfg: &ServerConfig) -> Result<Arc<dyn AuthBackend>> {
75    match &cfg.auth {
76        Some(auth_cfg) => {
77            let kind = auth_kind_from_config(auth_cfg);
78            log_auth_backend_kind(auth_cfg);
79            kind.build()
80                .await
81                .context("failed to construct authentication backend from configuration")
82        }
83        None => {
84            let default_path = format!("{}/passwd", cfg.runtime_dir);
85            tracing::warn!(
86                "[auth] section missing — defaulting to file backend at {}",
87                default_path
88            );
89            AuthBackendKind::File(FileBackendConfig {
90                path: default_path,
91                hash_algorithm: HashAlgorithm::default(),
92            })
93            .build()
94            .await
95            .context("failed to construct default file authentication backend")
96        }
97    }
98}
99
100fn log_auth_backend_kind(cfg: &CfgAuthConfig) {
101    match cfg {
102        CfgAuthConfig::File { config } => {
103            tracing::info!("Using file authentication backend at {}", config.path);
104        }
105        CfgAuthConfig::Sql { config } => {
106            tracing::info!(
107                "Using SQL authentication backend at {}",
108                redact_database_url(&config.connection_string)
109            );
110        }
111        CfgAuthConfig::Ldap { config } => {
112            tracing::info!(
113                "Using LDAP authentication backend at {} (base_dn={})",
114                config.url,
115                config.base_dn
116            );
117        }
118        CfgAuthConfig::OAuth2 { config } => {
119            tracing::info!(
120                "Using OAuth2 authentication backend (client_id={}, token_url={})",
121                config.client_id,
122                config.token_url
123            );
124        }
125    }
126}
127
128fn sql_config_from(cfg: &SqlAuthConfig) -> SqlConfig {
129    let defaults = SqlConfig::default();
130    let password_query = if cfg.query.trim().is_empty() {
131        defaults.password_query
132    } else {
133        cfg.query.clone()
134    };
135    SqlConfig {
136        database_url: cfg.connection_string.clone(),
137        password_query,
138        ..defaults
139    }
140}
141
142fn ldap_config_from(cfg: &LdapAuthConfig) -> LdapConfig {
143    let bind_dn = if cfg.bind_dn.is_empty() {
144        None
145    } else {
146        Some(cfg.bind_dn.clone())
147    };
148    let bind_password = if cfg.bind_password.is_empty() {
149        None
150    } else {
151        Some(cfg.bind_password.clone())
152    };
153    LdapConfig {
154        server_url: cfg.url.clone(),
155        base_dn: cfg.base_dn.clone(),
156        user_filter: cfg.user_filter.clone(),
157        bind_dn,
158        bind_password,
159        ..LdapConfig::default()
160    }
161}
162
163fn oauth2_config_from(cfg: &OAuth2AuthConfig) -> OAuth2Config {
164    // The on-disk configuration exposes the issuer-agnostic OAuth2 fields, so
165    // we always materialise a `Generic` provider. Operators that want
166    // tenant-specific Google / Microsoft validation can extend the config in a
167    // follow-up release.
168    let provider = OidcProvider::Generic {
169        issuer_url: cfg.authorization_url.clone(),
170        client_id: cfg.client_id.clone(),
171        client_secret: cfg.client_secret.clone(),
172        jwks_url: cfg.token_url.clone(),
173    };
174    OAuth2Config {
175        provider,
176        ..OAuth2Config::default()
177    }
178}
179
180fn redact_database_url(raw: &str) -> String {
181    // Strip embedded credentials (`scheme://user:pass@host/...`) before
182    // emitting to logs. Keeps the scheme + host portion intact for operators.
183    if let Some(scheme_end) = raw.find("://") {
184        let (scheme, rest) = raw.split_at(scheme_end + 3);
185        if let Some(at_pos) = rest.find('@') {
186            return format!("{}***@{}", scheme, &rest[at_pos + 1..]);
187        }
188    }
189    raw.to_string()
190}
191
192// ---------------------------------------------------------------------------
193// Storage-backend bridging
194// ---------------------------------------------------------------------------
195
196/// Translate a [`CfgStorageConfig`] from the on-disk configuration into the
197/// matching [`BackendKind`] understood by `rusmes-storage`.
198pub fn storage_kind_from_config(cfg: &CfgStorageConfig) -> BackendKind {
199    match cfg {
200        CfgStorageConfig::Filesystem { path } => BackendKind::Filesystem { path: path.clone() },
201        CfgStorageConfig::Postgres { connection_string } => BackendKind::Postgres {
202            connection_string: connection_string.clone(),
203        },
204        CfgStorageConfig::AmateRS {
205            endpoints,
206            replication_factor,
207        } => BackendKind::Amaters {
208            endpoints: endpoints.clone(),
209            replication_factor: *replication_factor,
210        },
211    }
212}
213
214/// Construct a storage backend from configuration. Wraps
215/// [`rusmes_storage::build_storage`] with a [`anyhow::Context`] tag for the
216/// configured backend kind so startup errors are easy to trace.
217pub async fn build_storage_backend(cfg: &CfgStorageConfig) -> Result<Arc<dyn StorageBackend>> {
218    let kind = storage_kind_from_config(cfg);
219    let label = backend_label(&kind);
220    build_storage(&kind)
221        .await
222        .with_context(|| format!("failed to construct storage backend ({})", label))
223}
224
225fn backend_label(kind: &BackendKind) -> &'static str {
226    match kind {
227        BackendKind::Filesystem { .. } => "filesystem",
228        BackendKind::Sqlite { .. } => "sqlite",
229        BackendKind::Postgres { .. } => "postgres",
230        BackendKind::Amaters { .. } => "amaters",
231    }
232}
233
234// ---------------------------------------------------------------------------
235// PID file lifecycle
236// ---------------------------------------------------------------------------
237
238/// PID file marker conventionally placed under `<runtime_dir>/rusmes.pid`.
239///
240/// The handle is deliberately small: callers create it with [`PidFile::write`]
241/// during startup and call [`PidFile::cleanup`] from the graceful-shutdown
242/// path. Silent-removal failures are logged at WARN level; they should not
243/// abort shutdown.
244#[derive(Debug, Clone)]
245pub struct PidFile {
246    path: PathBuf,
247}
248
249impl PidFile {
250    /// Compute the canonical PID-file path inside `runtime_dir`.
251    pub fn path_in(runtime_dir: impl AsRef<Path>) -> PathBuf {
252        runtime_dir.as_ref().join("rusmes.pid")
253    }
254
255    /// Write the current process PID to `<runtime_dir>/rusmes.pid`, creating
256    /// the runtime directory (and any intermediate parents) if needed.
257    ///
258    /// If a stale file already exists (from a previous crashed process) it is
259    /// overwritten. The write is performed atomically via `tokio::fs::write`.
260    pub async fn write(runtime_dir: impl AsRef<Path>) -> Result<Self> {
261        let runtime_dir = runtime_dir.as_ref();
262        if !runtime_dir.exists() {
263            tokio::fs::create_dir_all(runtime_dir)
264                .await
265                .with_context(|| {
266                    format!(
267                        "failed to create runtime_dir at {} for PID file",
268                        runtime_dir.display()
269                    )
270                })?;
271        }
272        let path = Self::path_in(runtime_dir);
273        let pid = std::process::id();
274        tokio::fs::write(&path, format!("{}\n", pid))
275            .await
276            .with_context(|| format!("failed to write PID file at {}", path.display()))?;
277        tracing::info!("Wrote PID {} to {}", pid, path.display());
278        Ok(Self { path })
279    }
280
281    /// Path to the PID file managed by this handle.
282    pub fn path(&self) -> &Path {
283        &self.path
284    }
285
286    /// Remove the PID file. Errors are logged but never returned — shutdown
287    /// must remain best-effort.
288    pub async fn cleanup(&self) {
289        if let Err(e) = tokio::fs::remove_file(&self.path).await {
290            if e.kind() != std::io::ErrorKind::NotFound {
291                tracing::warn!(
292                    "failed to remove PID file at {}: {}",
293                    self.path.display(),
294                    e
295                );
296            }
297        } else {
298            tracing::info!("Removed PID file at {}", self.path.display());
299        }
300    }
301}
302
303// ---------------------------------------------------------------------------
304// Configuration validation entry point
305// ---------------------------------------------------------------------------
306
307/// Load and validate a configuration file without opening any sockets.
308///
309/// Used by both the `--check-config` flag and the regular startup path. A
310/// successful return guarantees that:
311/// - The file parses as TOML or YAML.
312/// - All schema-level invariants pass (`ServerConfig::validate`).
313/// - The configured backend kinds for `[auth]` and `[storage]` map onto known
314///   `AuthBackendKind` / `BackendKind` variants.
315///
316/// Network-level reachability checks (e.g. opening an LDAP connection) are
317/// intentionally out of scope.
318pub fn load_and_validate(path: impl AsRef<Path>) -> Result<ServerConfig> {
319    let path = path.as_ref();
320    let cfg = ServerConfig::from_file(path)
321        .with_context(|| format!("failed to load configuration from {}", path.display()))?;
322
323    // Round-trip through the bridging helpers to catch any silent variant
324    // mismatch at validation time rather than at startup.
325    if let Some(ref auth_cfg) = cfg.auth {
326        let _ = auth_kind_from_config(auth_cfg);
327    }
328    let _ = storage_kind_from_config(&cfg.storage);
329
330    Ok(cfg)
331}
332
333// ---------------------------------------------------------------------------
334// Tests
335// ---------------------------------------------------------------------------
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use rusmes_config::FileAuthConfig;
341
342    fn write_minimal_config(path: &Path, runtime_dir: &str) {
343        let body = format!(
344            r#"domain = "example.com"
345postmaster = "postmaster@example.com"
346runtime_dir = "{}"
347
348[smtp]
349host = "0.0.0.0"
350port = 2525
351max_message_size = "10MB"
352require_auth = false
353enable_starttls = false
354
355[storage]
356backend = "filesystem"
357path = "{}/mail"
358
359[[processors]]
360name = "root"
361state = "root"
362
363[[processors.mailets]]
364matcher = "All"
365mailet = "LocalDelivery"
366"#,
367            runtime_dir, runtime_dir
368        );
369        std::fs::write(path, body).expect("write tmp config");
370    }
371
372    #[test]
373    fn auth_kind_from_file_config_round_trips() {
374        let cfg = CfgAuthConfig::File {
375            config: FileAuthConfig {
376                path: "/etc/rusmes/passwd".to_string(),
377                hash_algorithm: "bcrypt".to_string(),
378            },
379        };
380        match auth_kind_from_config(&cfg) {
381            AuthBackendKind::File(file_cfg) => {
382                assert_eq!(file_cfg.path, "/etc/rusmes/passwd");
383                assert_eq!(file_cfg.hash_algorithm, HashAlgorithm::Bcrypt);
384            }
385            _ => panic!("expected AuthBackendKind::File"),
386        }
387    }
388
389    #[test]
390    fn auth_kind_from_file_config_argon2_algorithm() {
391        let cfg = CfgAuthConfig::File {
392            config: FileAuthConfig {
393                path: "/etc/rusmes/passwd".to_string(),
394                hash_algorithm: "argon2id".to_string(),
395            },
396        };
397        match auth_kind_from_config(&cfg) {
398            AuthBackendKind::File(file_cfg) => {
399                assert_eq!(file_cfg.hash_algorithm, HashAlgorithm::Argon2);
400            }
401            _ => panic!("expected AuthBackendKind::File"),
402        }
403    }
404
405    #[test]
406    fn auth_kind_from_file_config_unknown_algorithm_falls_back_to_bcrypt() {
407        let cfg = CfgAuthConfig::File {
408            config: FileAuthConfig {
409                path: "/etc/rusmes/passwd".to_string(),
410                hash_algorithm: "scrypt".to_string(),
411            },
412        };
413        match auth_kind_from_config(&cfg) {
414            AuthBackendKind::File(file_cfg) => {
415                assert_eq!(file_cfg.hash_algorithm, HashAlgorithm::Bcrypt);
416            }
417            _ => panic!("expected AuthBackendKind::File"),
418        }
419    }
420
421    #[test]
422    fn auth_kind_from_sql_config_preserves_url() {
423        let cfg = CfgAuthConfig::Sql {
424            config: SqlAuthConfig {
425                connection_string: "postgres://user:pw@db/auth".to_string(),
426                query: "SELECT password FROM users WHERE name = ?".to_string(),
427            },
428        };
429        match auth_kind_from_config(&cfg) {
430            AuthBackendKind::Sql(sql_cfg) => {
431                assert_eq!(sql_cfg.database_url, "postgres://user:pw@db/auth");
432                assert_eq!(
433                    sql_cfg.password_query,
434                    "SELECT password FROM users WHERE name = ?"
435                );
436                // Defaults preserved for fields not in the on-disk schema.
437                assert!(sql_cfg.max_connections > 0);
438            }
439            _ => panic!("expected AuthBackendKind::Sql"),
440        }
441    }
442
443    #[test]
444    fn auth_kind_from_ldap_config_promotes_blank_to_none() {
445        let cfg = CfgAuthConfig::Ldap {
446            config: LdapAuthConfig {
447                url: "ldaps://ldap.example.com:636".to_string(),
448                base_dn: "dc=example,dc=com".to_string(),
449                bind_dn: String::new(),
450                bind_password: String::new(),
451                user_filter: "(uid={username})".to_string(),
452            },
453        };
454        match auth_kind_from_config(&cfg) {
455            AuthBackendKind::Ldap(ldap_cfg) => {
456                assert_eq!(ldap_cfg.server_url, "ldaps://ldap.example.com:636");
457                assert!(ldap_cfg.bind_dn.is_none());
458                assert!(ldap_cfg.bind_password.is_none());
459            }
460            _ => panic!("expected AuthBackendKind::Ldap"),
461        }
462    }
463
464    #[test]
465    fn auth_kind_from_oauth2_config_uses_generic_provider() {
466        let cfg = CfgAuthConfig::OAuth2 {
467            config: OAuth2AuthConfig {
468                client_id: "rusmes".to_string(),
469                client_secret: "secret".to_string(),
470                token_url: "https://auth.example/.well-known/jwks.json".to_string(),
471                authorization_url: "https://auth.example".to_string(),
472            },
473        };
474        match auth_kind_from_config(&cfg) {
475            AuthBackendKind::OAuth2(oauth_cfg) => match oauth_cfg.provider {
476                OidcProvider::Generic {
477                    issuer_url,
478                    client_id,
479                    client_secret,
480                    jwks_url,
481                } => {
482                    assert_eq!(issuer_url, "https://auth.example");
483                    assert_eq!(client_id, "rusmes");
484                    assert_eq!(client_secret, "secret");
485                    assert_eq!(jwks_url, "https://auth.example/.well-known/jwks.json");
486                }
487                other => panic!("expected Generic provider, got {:?}", other),
488            },
489            _ => panic!("expected AuthBackendKind::OAuth2"),
490        }
491    }
492
493    #[test]
494    fn storage_kind_from_filesystem_config() {
495        let cfg = CfgStorageConfig::Filesystem {
496            path: "/var/mail".to_string(),
497        };
498        match storage_kind_from_config(&cfg) {
499            BackendKind::Filesystem { path } => assert_eq!(path, "/var/mail"),
500            _ => panic!("expected BackendKind::Filesystem"),
501        }
502    }
503
504    #[test]
505    fn storage_kind_from_postgres_config() {
506        let cfg = CfgStorageConfig::Postgres {
507            connection_string: "postgres://user:pw@db/mail".to_string(),
508        };
509        match storage_kind_from_config(&cfg) {
510            BackendKind::Postgres { connection_string } => {
511                assert_eq!(connection_string, "postgres://user:pw@db/mail");
512            }
513            _ => panic!("expected BackendKind::Postgres"),
514        }
515    }
516
517    #[test]
518    fn storage_kind_from_amaters_config() {
519        let cfg = CfgStorageConfig::AmateRS {
520            endpoints: vec!["a:1".to_string(), "b:2".to_string()],
521            replication_factor: 3,
522        };
523        match storage_kind_from_config(&cfg) {
524            BackendKind::Amaters {
525                endpoints,
526                replication_factor,
527            } => {
528                assert_eq!(endpoints.len(), 2);
529                assert_eq!(replication_factor, 3);
530            }
531            _ => panic!("expected BackendKind::Amaters"),
532        }
533    }
534
535    #[test]
536    fn redacts_credentials_from_database_url() {
537        assert_eq!(
538            redact_database_url("postgres://alice:secret@db.example/auth"),
539            "postgres://***@db.example/auth"
540        );
541        assert_eq!(
542            redact_database_url("sqlite:///tmp/auth.db"),
543            "sqlite:///tmp/auth.db"
544        );
545    }
546
547    #[tokio::test]
548    async fn pid_file_round_trip_inside_tempdir() {
549        let dir = tempfile::tempdir().expect("tempdir");
550        let pid_file = PidFile::write(dir.path()).await.expect("write pid file");
551        let path = pid_file.path().to_path_buf();
552        assert!(path.exists(), "PID file must be created");
553        let contents = tokio::fs::read_to_string(&path).await.expect("read");
554        let pid: u32 = contents.trim().parse().expect("parse pid");
555        assert_eq!(pid, std::process::id());
556        pid_file.cleanup().await;
557        assert!(!path.exists(), "PID file must be removed after cleanup");
558    }
559
560    #[tokio::test]
561    async fn pid_file_creates_runtime_dir_if_missing() {
562        let dir = tempfile::tempdir().expect("tempdir");
563        let nested = dir.path().join("nested/runtime");
564        let pid_file = PidFile::write(&nested).await.expect("write pid file");
565        assert!(nested.exists(), "runtime_dir must be created");
566        assert!(pid_file.path().exists(), "PID file must be created");
567        pid_file.cleanup().await;
568    }
569
570    #[tokio::test]
571    async fn pid_file_cleanup_silent_on_missing_file() {
572        let dir = tempfile::tempdir().expect("tempdir");
573        let pid_file = PidFile {
574            path: dir.path().join("rusmes.pid"),
575        };
576        // No write — cleanup must not panic or error.
577        pid_file.cleanup().await;
578    }
579
580    #[test]
581    fn load_and_validate_accepts_minimal_config() {
582        let dir = tempfile::tempdir().expect("tempdir");
583        let runtime_dir = dir.path().to_string_lossy().to_string();
584        let cfg_path = dir.path().join("rusmes.toml");
585        write_minimal_config(&cfg_path, &runtime_dir);
586        let cfg = load_and_validate(&cfg_path).expect("config must validate");
587        assert_eq!(cfg.domain, "example.com");
588        assert_eq!(cfg.runtime_dir, runtime_dir);
589    }
590
591    #[test]
592    fn load_and_validate_rejects_missing_file() {
593        let dir = tempfile::tempdir().expect("tempdir");
594        let bogus = dir.path().join("does-not-exist.toml");
595        let err = load_and_validate(&bogus).expect_err("must fail");
596        let msg = format!("{:#}", err);
597        assert!(msg.contains("does-not-exist.toml"), "msg = {msg}");
598    }
599
600    #[test]
601    fn load_and_validate_rejects_invalid_port() {
602        let dir = tempfile::tempdir().expect("tempdir");
603        let runtime_dir = dir.path().to_string_lossy().to_string();
604        let cfg_path = dir.path().join("rusmes.toml");
605        let body = format!(
606            r#"domain = "example.com"
607postmaster = "postmaster@example.com"
608runtime_dir = "{}"
609
610[smtp]
611host = "0.0.0.0"
612port = 0
613max_message_size = "10MB"
614require_auth = false
615enable_starttls = false
616
617[storage]
618backend = "filesystem"
619path = "{}/mail"
620
621[[processors]]
622name = "root"
623state = "root"
624
625[[processors.mailets]]
626matcher = "All"
627mailet = "LocalDelivery"
628"#,
629            runtime_dir, runtime_dir
630        );
631        std::fs::write(&cfg_path, body).expect("write");
632        let err = load_and_validate(&cfg_path).expect_err("invalid port must fail");
633        let msg = format!("{:#}", err);
634        assert!(
635            msg.to_lowercase().contains("port") || msg.to_lowercase().contains("0"),
636            "expected port-related error, got: {msg}"
637        );
638    }
639}