//! TOML configuration loading, validation and legacy bootstrap compatibility. //! //! Deployment-wide policy (bind address, encryption key, allowed CookieCloud //! hosts and timeouts) remains in [`Config`]. Room IDs, credentials and component //! settings become tenant-owned database records after enrollment; the legacy //! TOML fields are import inputs and must not be treated as global live state. use std::{env, fs, net::IpAddr, path::PathBuf}; use base64::{Engine, engine::general_purpose::STANDARD}; use serde::Deserialize; use sha2::{Digest, Sha256}; use crate::{ credentials::normalize_cookiecloud_host, overlay::{OverlaySettings, OverlayThemeId}, }; /// Process-level configuration. Tenant-owned room, CookieCloud and component /// settings are imported from the legacy sections once and then live in /// PostgreSQL; these values are not used as global runtime state afterwards. #[derive(Clone)] pub struct Config { pub port: u16, pub bind_address: IpAddr, pub database_url: String, pub bootstrap_password: String, pub legacy_room_id: String, pub legacy_cookiecloud_host: String, pub legacy_cookiecloud_key: String, pub legacy_cookiecloud_password: String, pub cookiecloud_allowed_hosts: Vec, pub legacy_obs_access_token: String, pub legacy_overlay_defaults: OverlaySettings, pub log_filter: String, pub gift_refresh_seconds: u64, pub gift_request_timeout_seconds: u64, pub emoticon_refresh_seconds: u64, pub emoticon_request_timeout_seconds: u64, pub data_encryption_key: [u8; 32], pub session_ttl_hours: i64, pub registration_ttl_minutes: i64, pub invitation_ttl_hours: i64, pub totp_issuer: String, pub secure_cookies: bool, pub derived_encryption_key: bool, } #[derive(Deserialize)] struct FileConfig { connection: ConnectionConfig, #[serde(default)] server: ServerConfig, database: DatabaseConfig, cookiecloud: CookieCloudConfig, admin: AdminConfig, obs: ObsConfig, #[serde(default)] security: SecurityConfig, #[serde(default)] gifts: GiftsConfig, #[serde(default)] emoticons: EmoticonsConfig, #[serde(default)] overlay: OverlayFileConfig, #[serde(default)] logging: LoggingConfig, } #[derive(Deserialize)] struct ConnectionConfig { room_id: String, } #[derive(Deserialize, Default)] struct ServerConfig { port: Option, bind_address: Option, } #[derive(Deserialize)] struct DatabaseConfig { url: String, } #[derive(Deserialize)] struct CookieCloudConfig { host: String, key: String, password: String, } #[derive(Deserialize)] struct AdminConfig { password: String, session_secret: String, } #[derive(Deserialize)] struct ObsConfig { access_token: String, } #[derive(Deserialize, Default)] struct SecurityConfig { data_encryption_key: Option, session_ttl_hours: Option, registration_ttl_minutes: Option, invitation_ttl_hours: Option, totp_issuer: Option, secure_cookies: Option, cookiecloud_allowed_hosts: Option>, } #[derive(Deserialize, Default)] struct GiftsConfig { refresh_interval_seconds: Option, request_timeout_seconds: Option, } #[derive(Deserialize, Default)] struct EmoticonsConfig { refresh_interval_seconds: Option, request_timeout_seconds: Option, } #[derive(Deserialize, Default)] struct OverlayFileConfig { theme_id: Option, font_scale: Option, max_visible: Option, collapse_after_seconds: Option, unfold_duration_ms: Option, motion_intensity: Option, particle_count: Option, particle_speed: Option, low_performance_mode: Option, high_value_threshold: Option, featured_value_threshold: Option, #[serde(default)] events: OverlayEventsConfig, } #[derive(Deserialize, Default)] struct OverlayEventsConfig { danmaku: Option, enter: Option, gift: Option, superchat: Option, guard: Option, like: Option, share: Option, } #[derive(Deserialize, Default)] struct LoggingConfig { filter: Option, } impl Config { pub fn load() -> Result { let path = config_path()?; let source = fs::read_to_string(&path) .map_err(|error| format!("Cannot read configuration {}: {error}", path.display()))?; let file: FileConfig = toml::from_str(&source) .map_err(|error| format!("Invalid TOML in {}: {error}", path.display()))?; validate_non_empty("connection.room_id", &file.connection.room_id)?; validate_non_empty("database.url", &file.database.url)?; validate_non_empty("admin.password", &file.admin.password)?; validate_non_empty("admin.session_secret", &file.admin.session_secret)?; let legacy_cookiecloud_host = normalize_cookiecloud_host(&file.cookiecloud.host)?; let cookiecloud_allowed_hosts = file .security .cookiecloud_allowed_hosts .unwrap_or_else(|| vec![legacy_cookiecloud_host.clone()]) .into_iter() .map(|host| normalize_cookiecloud_host(&host)) .collect::, _>>()?; if cookiecloud_allowed_hosts.is_empty() { return Err("security.cookiecloud_allowed_hosts must not be empty".into()); } if !cookiecloud_allowed_hosts.contains(&legacy_cookiecloud_host) { return Err( "security.cookiecloud_allowed_hosts must include cookiecloud.host for legacy import" .into(), ); } let bind_address = file .server .bind_address .as_deref() .unwrap_or("127.0.0.1") .parse::() .map_err(|_| "server.bind_address must be an IPv4 or IPv6 address".to_string())?; let (data_encryption_key, derived_encryption_key) = match file.security.data_encryption_key.as_deref() { Some(value) if !value.trim().is_empty() => (decode_key(value)?, false), _ => ( derive_key( &file.admin.session_secret, b"lxc-streamutils/data-encryption/v1", ), true, ), }; Ok(Self { port: file.server.port.unwrap_or(9719), bind_address, database_url: file.database.url, bootstrap_password: file.admin.password, legacy_room_id: file.connection.room_id, legacy_cookiecloud_host, legacy_cookiecloud_key: file.cookiecloud.key, legacy_cookiecloud_password: file.cookiecloud.password, cookiecloud_allowed_hosts, legacy_obs_access_token: file.obs.access_token, legacy_overlay_defaults: overlay_defaults(file.overlay), log_filter: file.logging.filter.unwrap_or_else(|| { "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn".into() }), gift_refresh_seconds: file .gifts .refresh_interval_seconds .unwrap_or(600) .clamp(60, 86_400), gift_request_timeout_seconds: file .gifts .request_timeout_seconds .unwrap_or(10) .clamp(2, 120), emoticon_refresh_seconds: file .emoticons .refresh_interval_seconds .unwrap_or(600) .clamp(60, 86_400), emoticon_request_timeout_seconds: file .emoticons .request_timeout_seconds .unwrap_or(10) .clamp(2, 120), data_encryption_key, session_ttl_hours: file.security.session_ttl_hours.unwrap_or(12).clamp(1, 720), registration_ttl_minutes: file .security .registration_ttl_minutes .unwrap_or(15) .clamp(5, 120), invitation_ttl_hours: file .security .invitation_ttl_hours .unwrap_or(72) .clamp(1, 8_760), totp_issuer: file .security .totp_issuer .unwrap_or_else(|| "danmaku.luoxingci.com".into()), secure_cookies: file.security.secure_cookies.unwrap_or(true), derived_encryption_key, }) } pub fn allowed_cookiecloud_host(&self, value: &str) -> Result { let normalized = normalize_cookiecloud_host(value)?; if self.cookiecloud_allowed_hosts.contains(&normalized) { Ok(normalized) } else { Err("CookieCloud host is not approved by this deployment".into()) } } pub fn default_cookiecloud_host(&self) -> &str { self.cookiecloud_allowed_hosts .first() .expect("configuration requires at least one CookieCloud host") } } fn config_path() -> Result { let mut args = env::args_os().skip(1); let mut path = PathBuf::from("config.toml"); while let Some(argument) = args.next() { if argument == "--config" { path = PathBuf::from(args.next().ok_or("--config requires a TOML path")?); } else { return Err(format!( "Unknown argument: {argument:?}; use --config " )); } } Ok(path) } fn validate_non_empty(name: &str, value: &str) -> Result<(), String> { if value.trim().is_empty() { Err(format!("{name} must not be empty")) } else { Ok(()) } } fn decode_key(value: &str) -> Result<[u8; 32], String> { let value = value.trim(); let bytes = STANDARD .decode(value) .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(value)) .map_err(|_| "security.data_encryption_key must be base64-encoded".to_string())?; bytes .try_into() .map_err(|_| "security.data_encryption_key must decode to exactly 32 bytes".to_string()) } fn derive_key(secret: &str, domain: &[u8]) -> [u8; 32] { let mut digest = Sha256::new(); digest.update(domain); digest.update([0]); digest.update(secret.as_bytes()); digest.finalize().into() } fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings { let default = OverlaySettings::default(); OverlaySettings { theme_id: file.theme_id.unwrap_or(default.theme_id), font_scale: file.font_scale.unwrap_or(default.font_scale), show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku), show_enter: file.events.enter.unwrap_or(default.show_enter), show_gift: file.events.gift.unwrap_or(default.show_gift), show_superchat: file.events.superchat.unwrap_or(default.show_superchat), show_guard: file.events.guard.unwrap_or(default.show_guard), show_like: file.events.like.unwrap_or(default.show_like), show_share: file.events.share.unwrap_or(default.show_share), max_visible: file.max_visible.unwrap_or(default.max_visible), collapse_after_seconds: file .collapse_after_seconds .unwrap_or(default.collapse_after_seconds), unfold_duration_ms: file .unfold_duration_ms .unwrap_or(default.unfold_duration_ms), motion_intensity: file.motion_intensity.unwrap_or(default.motion_intensity), particle_count: file.particle_count.unwrap_or(default.particle_count), particle_speed: file.particle_speed.unwrap_or(default.particle_speed), low_performance_mode: file .low_performance_mode .unwrap_or(default.low_performance_mode), high_value_threshold: file .high_value_threshold .unwrap_or(default.high_value_threshold), featured_value_threshold: file .featured_value_threshold .unwrap_or(default.featured_value_threshold), } .sanitize() } #[cfg(test)] mod tests { use super::*; #[test] fn accepts_exactly_32_byte_base64_key() { let value = STANDARD.encode([7_u8; 32]); assert_eq!(decode_key(&value).unwrap(), [7_u8; 32]); assert!(decode_key("too-short").is_err()); } #[test] fn domain_separates_derived_keys() { assert_ne!(derive_key("secret", b"a"), derive_key("secret", b"b")); } }