use config::{Config, ConfigError, File, FileFormat}; use serde::Deserialize; use std::error::Error; use std::net::{Ipv4Addr, Ipv6Addr}; use std::path::Path; use std::{fmt, fs}; #[derive(Debug)] pub enum AppConfigError { Io(std::io::Error), Config(ConfigError), } impl fmt::Display for AppConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(err) => write!(f, "failed to access config file: {err}"), Self::Config(err) => write!(f, "failed to load config: {err}"), } } } impl Error for AppConfigError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::Io(err) => Some(err), Self::Config(err) => Some(err), } } } impl From for AppConfigError { fn from(err: std::io::Error) -> Self { Self::Io(err) } } impl From for AppConfigError { fn from(err: ConfigError) -> Self { Self::Config(err) } } pub const DEFAULT_CONFIG_TOML: &str = r#"[network] # IP address to bind to host = "0.0.0.0" # hostv6 = "::" # TCP and UDP port can be the same # HTTP port tcp_port = 8080 # WebRTC ICE/Media UDP multiplexing port udp_port = 8080 # Public IPv4 address advertised by ICE when behind NAT (forward udp_port to this server). # external_ip = "203.0.113.1" # STUN servers used when external_ip is absent; set [] to disable discovery. # stun_servers = ["stun:stun.l.google.com:19302"] # Optional native HTTPS (omit this section to keep plain HTTP behind a proxy). # [network.tls] # cert_path = "certs/server.pem" # key_path = "certs/server-key.pem" # names = ["localhost", "127.0.0.1"] [database] # DSN for database # SQLite url = "sqlite://oxspeak.db" # PostgreSQL # url = "postgresql://user:passwd@localhost:5432/db_name" # MySQL # url = "mysql://user:passwd@localhost:3306/db_name" [media] root = "media" [jwt] secret = "changeme" # Duration in seconds duration = 86400 # 1 day refresh_duration = 1296000 # 15 days "#; #[derive(Debug, Clone, Deserialize)] pub struct AppConfig { pub network: NetworkConfig, pub database: DatabaseConfig, pub jwt: JwtConfig, #[serde(default)] pub media: MediaConfig, } #[derive(Debug, Clone, Deserialize)] pub struct MediaConfig { #[serde(default = "default_media_root")] pub root: String, } impl Default for MediaConfig { fn default() -> Self { Self { root: default_media_root(), } } } fn default_media_root() -> String { "media".to_string() } #[derive(Debug, Clone, Deserialize)] pub struct NetworkConfig { pub host: Ipv4Addr, pub hostv6: Option, pub tcp_port: u16, pub udp_port: u16, #[serde(default)] pub external_ip: Option, #[serde(default = "default_stun_servers")] pub stun_servers: Vec, #[serde(default)] pub tls: Option, } fn default_stun_servers() -> Vec { vec!["stun:stun.l.google.com:19302".to_string()] } #[derive(Debug, Clone, Deserialize)] pub struct TlsConfig { pub cert_path: std::path::PathBuf, pub key_path: std::path::PathBuf, pub names: Vec, } #[derive(Clone, Deserialize)] pub struct DatabaseConfig { pub url: String, } impl fmt::Debug for DatabaseConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("DatabaseConfig") .field("url", &"") .finish() } } #[derive(Debug, Clone, Deserialize)] pub struct JwtConfig { pub secret: String, pub duration: u64, pub refresh_duration: u64, } impl AppConfig { pub fn file_exists() -> bool { Path::new("config.toml").exists() } pub fn load() -> Result { let settings = Config::builder() .add_source(File::new("config.toml", FileFormat::Toml)) .build()?; Ok(settings.try_deserialize()?) } pub fn gen_config() -> Result<(), AppConfigError> { if !Self::file_exists() { fs::write("config.toml", DEFAULT_CONFIG_TOML)?; } Ok(()) } pub fn load_or_generate() -> Result { if !Self::file_exists() { Self::gen_config()?; tracing::info!(config_path = "config.toml", "Generated"); } Self::load() } } #[cfg(test)] mod tests { use super::{AppConfig, DEFAULT_CONFIG_TOML}; use std::net::Ipv4Addr; #[test] fn external_ip_is_optional_and_accepts_public_ipv4() { let default: AppConfig = toml::from_str(DEFAULT_CONFIG_TOML).unwrap(); assert_eq!(default.network.external_ip, None); assert_eq!(default.network.stun_servers, vec!["stun:stun.l.google.com:19302"]); let configured = DEFAULT_CONFIG_TOML.replace( "# external_ip = \"203.0.113.1\"", "external_ip = \"203.0.113.1\"", ); let config: AppConfig = toml::from_str(&configured).unwrap(); assert_eq!(config.network.external_ip, Some(Ipv4Addr::new(203, 0, 113, 1))); } #[test] fn stun_servers_can_be_disabled_or_replaced() { for (value, expected) in [ ("[]", vec![]), ("[\"stun:example.org:3478\"]", vec!["stun:example.org:3478"]), ] { let text = DEFAULT_CONFIG_TOML.replace( "# stun_servers = [\"stun:stun.l.google.com:19302\"]", &format!("stun_servers = {value}"), ); let config: AppConfig = toml::from_str(&text).unwrap(); assert_eq!(config.network.stun_servers, expected); } } }