proper productionize project
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user