use anyhow::{Context, Result}; use serde::Deserialize; use std::path::{Path, PathBuf}; /// Root configuration loaded from config.toml #[derive(Debug, Deserialize, Clone, Default)] pub struct Config { #[serde(skip)] pub config_path: Option, #[serde(default)] pub server: ServerConfig, #[serde(default)] pub database: DatabaseConfig, #[serde(default)] pub security: SecurityConfig, #[serde(default)] pub audit: AuditConfig, #[serde(default)] pub shutdown: ShutdownConfig, } #[derive(Debug, Deserialize, Clone)] pub struct ServerConfig { /// Interface to listen on. pub host: String, /// Port to listen on. pub port: u16, /// Whether the session cookie should set the `Secure` flag. /// /// Must be `true` when the UI is served over HTTPS (or behind a TLS /// reverse proxy). Leave `false` for plain-HTTP self-hosted installs — /// browsers reject `Secure` cookies on `http://` and authentication breaks. #[serde(default)] pub cookie_secure: bool, /// Production mode: enables HSTS, requires secure cookies, and refuses /// known-insecure bind configurations. #[serde(default)] pub production: bool, } use std::fmt::Display; /// Supported database backends. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum DatabaseBackend { Sqlite, Postgres, } impl Display for DatabaseBackend { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Sqlite => write!(f, "sqlite"), Self::Postgres => write!(f, "postgres"), } } } #[derive(Debug, Deserialize, Clone)] pub struct DatabaseConfig { /// Unified database connection URL (e.g., sqlite://./auth.db or postgres://user:pass@host/db). #[serde(default)] pub url: Option, /// Legacy path to SQLite database file. #[serde(default)] pub path: Option, /// Maximum connection pool size. #[serde(default)] pub max_connections: Option, /// Minimum connection pool size. #[serde(default)] pub min_connections: Option, /// Connection timeout in seconds. #[serde(default)] pub connect_timeout_secs: Option, /// Idle connection timeout in seconds. #[serde(default)] pub idle_timeout_secs: Option, /// Maximum connection lifetime in seconds. #[serde(default)] pub max_lifetime_secs: Option, } #[derive(Debug, Deserialize, Clone)] pub struct SecurityConfig { /// Session idle timeout in hours. pub session_ttl_hours: u32, /// Session absolute lifetime in days. pub session_absolute_ttl_days: u32, /// Default API token lifetime in days. pub token_ttl_days: u32, /// Argon2id memory cost (KiB). pub argon2_memory: u32, /// Argon2id iteration count. pub argon2_iterations: u32, /// Argon2id parallelism. pub argon2_parallelism: u32, } #[derive(Debug, Deserialize, Clone)] pub struct AuditConfig { /// Whether to write events to the audit_logs table. pub enabled: bool, } // ── Defaults ──────────────────────────────────────────────────────────────── impl Default for ServerConfig { fn default() -> Self { Self { host: "127.0.0.1".to_string(), // Default to loopback for user mode safety port: 8655, // Safe default for local/self-hosted HTTP. Enable for HTTPS production. cookie_secure: false, production: false, } } } impl ServerConfig { /// Refuse insecure production deployments. /// /// TLS is typically terminated at a reverse proxy; this enforces that /// cookies/HSTS are configured as if the external surface is HTTPS. pub fn validate_production_security(&self) -> anyhow::Result<()> { if !self.production { return Ok(()); } if !self.cookie_secure { anyhow::bail!( "production mode requires server.cookie_secure = true \ (session cookies must be Secure for HTTPS deployments)" ); } Ok(()) } } impl Default for DatabaseConfig { fn default() -> Self { let default_db_path = if let Ok(home) = std::env::var("HOME") { Path::new(&home) .join(".local/share/nx9-auth/auth.db") .to_string_lossy() .into_owned() } else { "/var/lib/nx9-auth/auth.db".to_string() }; Self { url: None, path: Some(default_db_path), max_connections: None, min_connections: None, connect_timeout_secs: None, idle_timeout_secs: None, max_lifetime_secs: None, } } } impl DatabaseConfig { /// Resolve and normalize the database URL and derive the active backend. pub fn resolved_url(&self) -> Result<(String, DatabaseBackend)> { let raw = if let Some(ref url) = self.url { let trimmed = url.trim(); if !trimmed.is_empty() { trimmed.to_string() } else if let Some(ref path) = self.path { path.trim().to_string() } else { anyhow::bail!("missing database url or path configuration"); } } else if let Some(ref path) = self.path { path.trim().to_string() } else { anyhow::bail!("missing database url or path configuration"); }; if raw.starts_with("postgres://") || raw.starts_with("postgresql://") { Ok((raw, DatabaseBackend::Postgres)) } else if raw.starts_with("sqlite://") { Ok((raw, DatabaseBackend::Sqlite)) } else if self.url.is_some() && raw.contains("://") { anyhow::bail!("unknown or malformed database URL scheme in '{raw}'"); } else { // Treat plain file path as SQLite let path = resolve_home_path(&raw); let url = format!("sqlite://{path}?mode=rwc"); Ok((url, DatabaseBackend::Sqlite)) } } /// Retrieve the SQLite path for legacy file-based commands. pub fn sqlite_path(&self) -> String { if let Some(ref path) = self.path { resolve_home_path(path) } else if let Some(ref url) = self.url { if let Some(stripped) = url.strip_prefix("sqlite://") { let clean = stripped.split('?').next().unwrap_or(stripped); resolve_home_path(clean) } else { url.clone() } } else { self.default_path() } } fn default_path(&self) -> String { if let Ok(home) = std::env::var("HOME") { Path::new(&home) .join(".local/share/nx9-auth/auth.db") .to_string_lossy() .into_owned() } else { "/var/lib/nx9-auth/auth.db".to_string() } } } impl Default for SecurityConfig { fn default() -> Self { Self { session_ttl_hours: 24, session_absolute_ttl_days: 30, token_ttl_days: 365, argon2_memory: 65536, argon2_iterations: 3, argon2_parallelism: 1, } } } impl Default for AuditConfig { fn default() -> Self { Self { enabled: true } } } /// Shutdown timeout configuration. #[derive(Debug, Deserialize, Clone)] pub struct ShutdownConfig { /// Maximum time (seconds) to wait for graceful shutdown of HTTP /// connections and background workers. #[serde(default = "ShutdownConfig::default_graceful_timeout")] pub graceful_timeout_secs: u64, /// Hard timeout (seconds) after which shutdown is forced. Must be /// greater than `graceful_timeout_secs`. #[serde(default = "ShutdownConfig::default_force_timeout")] pub force_timeout_secs: u64, } impl ShutdownConfig { fn default_graceful_timeout() -> u64 { 30 } fn default_force_timeout() -> u64 { 35 } /// Validate timeout invariants at startup. pub fn validate(&self) -> anyhow::Result<()> { anyhow::ensure!( self.graceful_timeout_secs > 0, "shutdown.graceful_timeout_secs must be > 0 (got {})", self.graceful_timeout_secs ); anyhow::ensure!( self.force_timeout_secs > self.graceful_timeout_secs, "shutdown.force_timeout_secs ({}) must be > graceful_timeout_secs ({})", self.force_timeout_secs, self.graceful_timeout_secs ); Ok(()) } } impl Default for ShutdownConfig { fn default() -> Self { Self { graceful_timeout_secs: 30, force_timeout_secs: 35, } } } // ── Helpers ────────────────────────────────────────────────────────────────── fn resolve_home_path(path: &str) -> String { if let Some(stripped) = path.strip_prefix("~/") { if let Ok(home) = std::env::var("HOME") { return Path::new(&home) .join(stripped) .to_string_lossy() .into_owned(); } } path.to_string() } // ── Loading ────────────────────────────────────────────────────────────────── impl Config { /// Resolve path prefixes such as ~ to actual home directories. pub fn resolve_paths(&mut self) { if let Some(ref mut path) = self.database.path { *path = resolve_home_path(path); } if let Some(ref mut url) = self.database.url { if let Some(stripped) = url.strip_prefix("sqlite://") { let clean = resolve_home_path(stripped); *url = format!("sqlite://{clean}"); } } } /// Load and parse config from a TOML file. pub fn load(path: &Path) -> Result { let content = std::fs::read_to_string(path) .with_context(|| format!("failed to read config file: {}", path.display()))?; let mut config: Config = toml::from_str(&content) .with_context(|| format!("failed to parse config file: {}", path.display()))?; config.config_path = Some(path.to_path_buf()); config.resolve_paths(); Ok(config) } /// Load config, falling back to defaults if the file doesn't exist. /// Errors on malformed files. pub fn load_or_default(path: &Path) -> Result { let mut config = if path.exists() { Self::load(path)? } else { let mut cfg = Self::default(); cfg.resolve_paths(); cfg }; config.config_path = Some(path.to_path_buf()); Ok(config) } /// Canonical config path candidates in priority order: /// 1. ./config.toml (Current directory override) /// 2. $XDG_CONFIG_HOME/nx9-auth/config.toml or ~/.config/nx9-auth/config.toml /// 3. /etc/nx9-auth/config.toml (System-wide default) pub fn search_paths() -> Vec { let mut paths = Vec::new(); // 1. Current directory override paths.push(PathBuf::from("./config.toml")); // 2. ~/.config/nx9-auth/config.toml (or XDG_CONFIG_HOME) if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { if !xdg.is_empty() { paths.push(PathBuf::from(xdg).join("nx9-auth/config.toml")); } } else if let Ok(home) = std::env::var("HOME") { paths.push(PathBuf::from(home).join(".config/nx9-auth/config.toml")); } // 3. System-wide default paths.push(PathBuf::from("/etc/nx9-auth/config.toml")); paths } /// Default user configuration path (~/.config/nx9-auth/config.toml) pub fn default_user_config_path() -> Option { if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { if !xdg.is_empty() { return Some(PathBuf::from(xdg).join("nx9-auth/config.toml")); } } if let Ok(home) = std::env::var("HOME") { return Some(PathBuf::from(home).join(".config/nx9-auth/config.toml")); } None } /// Find and load the first existing config file from the search path. /// Returns Ok(None) if no configuration file is found in any search path. pub fn find_and_load(override_path: Option<&Path>) -> Result> { if let Some(p) = override_path { let mut config = Self::load(p)?; config.config_path = Some(p.to_path_buf()); return Ok(Some(config)); } for path in Self::search_paths() { if path.exists() { let mut config = Self::load(&path)?; config.config_path = Some(path); return Ok(Some(config)); } } Ok(None) } /// Generate default TOML content for the `init` command pub fn generate_default_toml() -> &'static str { r#"# nx9-auth configuration file [server] # Interface to bind on. Use 127.0.0.1 for local/user mode. host = "127.0.0.1" port = 8655 # Session cookie Secure flag (true only when serving over HTTPS). cookie_secure = false # Production mode: requires cookie_secure and enables HSTS. production = false [database] # Absolute or home-relative path to the SQLite database file. path = "~/.local/share/nx9-auth/auth.db" [security] # Session idle timeout in hours. session_ttl_hours = 24 # Session absolute lifetime in days. session_absolute_ttl_days = 30 # Default API token lifetime in days. token_ttl_days = 365 # Argon2id verification parameters (production strength recommended). argon2_memory = 65536 argon2_iterations = 3 argon2_parallelism = 1 [audit] # Enable structured audit logging to the database. enabled = true "# } } #[cfg(test)] mod tests { use super::*; #[test] fn test_config_defaults() { let cfg = Config::default(); assert_eq!(cfg.server.port, 8655); assert_eq!(cfg.server.host, "127.0.0.1"); assert!(!cfg.server.cookie_secure); assert!(!cfg.server.production); if std::env::var("HOME").is_ok() { assert!( cfg.database .sqlite_path() .contains(".local/share/nx9-auth/auth.db") ); } else { assert_eq!(cfg.database.sqlite_path(), "/var/lib/nx9-auth/auth.db"); } assert_eq!(cfg.security.session_ttl_hours, 24); assert_eq!(cfg.security.session_absolute_ttl_days, 30); assert_eq!(cfg.security.token_ttl_days, 365); assert!(cfg.audit.enabled); } #[test] fn test_search_paths() { let paths = Config::search_paths(); assert!(paths.iter().any(|p| p.to_str().unwrap() == "./config.toml")); assert!( paths .iter() .any(|p| p.to_str().unwrap() == "/etc/nx9-auth/config.toml") ); } }