386 lines
13 KiB
Rust
386 lines
13 KiB
Rust
//! 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},
|
|
typography::FontFamilyId,
|
|
};
|
|
|
|
/// 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<String>,
|
|
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<u16>,
|
|
bind_address: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
session_ttl_hours: Option<i64>,
|
|
registration_ttl_minutes: Option<i64>,
|
|
invitation_ttl_hours: Option<i64>,
|
|
totp_issuer: Option<String>,
|
|
secure_cookies: Option<bool>,
|
|
cookiecloud_allowed_hosts: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct GiftsConfig {
|
|
refresh_interval_seconds: Option<u64>,
|
|
request_timeout_seconds: Option<u64>,
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct EmoticonsConfig {
|
|
refresh_interval_seconds: Option<u64>,
|
|
request_timeout_seconds: Option<u64>,
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct OverlayFileConfig {
|
|
theme_id: Option<OverlayThemeId>,
|
|
font_family: Option<FontFamilyId>,
|
|
font_brightness: Option<u16>,
|
|
viewer_color: Option<String>,
|
|
danmaku_color: Option<String>,
|
|
font_scale: Option<u16>,
|
|
decoration_line_weight: Option<u16>,
|
|
max_visible: Option<u8>,
|
|
collapse_after_seconds: Option<u16>,
|
|
unfold_duration_ms: Option<u16>,
|
|
motion_intensity: Option<u8>,
|
|
particle_count: Option<u8>,
|
|
particle_speed: Option<u16>,
|
|
low_performance_mode: Option<bool>,
|
|
high_value_threshold: Option<i64>,
|
|
featured_value_threshold: Option<i64>,
|
|
#[serde(default)]
|
|
events: OverlayEventsConfig,
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct OverlayEventsConfig {
|
|
danmaku: Option<bool>,
|
|
enter: Option<bool>,
|
|
gift: Option<bool>,
|
|
superchat: Option<bool>,
|
|
guard: Option<bool>,
|
|
like: Option<bool>,
|
|
share: Option<bool>,
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct LoggingConfig {
|
|
filter: Option<String>,
|
|
}
|
|
|
|
impl Config {
|
|
pub fn load() -> Result<Self, String> {
|
|
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::<Result<Vec<_>, _>>()?;
|
|
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::<IpAddr>()
|
|
.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,libilibili=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<String, String> {
|
|
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<PathBuf, String> {
|
|
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 <path>"
|
|
));
|
|
}
|
|
}
|
|
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_family: file.font_family.unwrap_or(default.font_family),
|
|
font_brightness: file.font_brightness.unwrap_or(default.font_brightness),
|
|
viewer_color: file.viewer_color.or(default.viewer_color),
|
|
danmaku_color: file.danmaku_color.or(default.danmaku_color),
|
|
font_scale: file.font_scale.unwrap_or(default.font_scale),
|
|
decoration_line_weight: file
|
|
.decoration_line_weight
|
|
.unwrap_or(default.decoration_line_weight),
|
|
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"));
|
|
}
|
|
}
|