1use 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
29pub fn auth_kind_from_config(cfg: &CfgAuthConfig) -> AuthBackendKind {
41 match cfg {
42 CfgAuthConfig::File { config } => {
43 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
67pub 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 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 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
192pub 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
214pub 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#[derive(Debug, Clone)]
245pub struct PidFile {
246 path: PathBuf,
247}
248
249impl PidFile {
250 pub fn path_in(runtime_dir: impl AsRef<Path>) -> PathBuf {
252 runtime_dir.as_ref().join("rusmes.pid")
253 }
254
255 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 pub fn path(&self) -> &Path {
283 &self.path
284 }
285
286 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
303pub 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 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#[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 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 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}