proper productionize project

This commit is contained in:
2026-07-15 23:54:53 -07:00
parent b8850c87a4
commit edb6d2b5b4
46 changed files with 11346 additions and 1560 deletions
+360
View File
@@ -0,0 +1,360 @@
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};
/// 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 {
font_scale: 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,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<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 {
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"));
}
}