const DEFAULT_CAPTURE_MAX_GB: u64 = 20;
const DEFAULT_ART_PORT: u16 = 9528;
const BYTES_PER_GB: u64 = 1024 * 1024 * 1024;
pub use crate::protocol::IceServer as TransportIceServer;
pub fn parse_ice_servers(raw: &str) -> Vec<TransportIceServer> {
let raw = raw.trim();
if raw.is_empty() {
return Vec::new();
}
if raw.starts_with('[') {
return match serde_json::from_str::<Vec<TransportIceServer>>(raw) {
Ok(servers) => servers.into_iter().filter(|s| !s.urls.is_empty()).collect(),
Err(error) => {
tracing::error!(%error, "MANABREW_ICE_SERVERS is not valid JSON; ignoring it");
Vec::new()
}
};
}
let urls: Vec<String> = raw
.split([',', ' ', '\t', '\n'])
.map(str::trim)
.filter(|url| !url.is_empty())
.map(str::to_string)
.collect();
if urls.is_empty() {
Vec::new()
} else {
vec![TransportIceServer {
urls,
username: None,
credential: None,
}]
}
}
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub health_port: u16,
pub max_rooms: usize,
pub server_key: String,
pub official_key: Option<String>,
pub events_dir: Option<String>,
pub capture_dir: Option<String>,
pub capture_max_gb: u64,
pub deck_hub_enabled: bool,
pub hub_deck_plays_url: Option<String>,
pub hub_deck_plays_token: Option<String>,
pub hub_jwks_url: Option<String>,
pub direct_transport: bool,
pub ice_servers: Vec<TransportIceServer>,
pub art_dir: Option<String>,
pub art_port: u16,
pub art_base_url: Option<String>,
pub lan_advertise: bool,
}
impl ServerConfig {
pub fn from_env() -> Self {
ServerConfig {
host: std::env::var("FORGE_HOST").unwrap_or_else(|_| "0.0.0.0".into()),
port: std::env::var("FORGE_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(9443),
health_port: std::env::var("FORGE_HEALTH_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(9444),
max_rooms: std::env::var("FORGE_MAX_ROOMS")
.ok()
.and_then(|r| r.parse().ok())
.unwrap_or(100),
server_key: std::env::var("MANABREW_SERVER_KEY").unwrap_or_else(|_| "forge".into()),
official_key: std::env::var("SECRET_MANABREW_KEY")
.ok()
.filter(|key| !key.is_empty()),
events_dir: std::env::var("MANABREW_EVENTS_DIR")
.ok()
.filter(|dir| !dir.is_empty()),
capture_dir: std::env::var("MANABREW_GAME_CAPTURE_DIR")
.ok()
.filter(|dir| !dir.is_empty()),
capture_max_gb: std::env::var("MANABREW_GAME_CAPTURE_MAX_GB")
.ok()
.and_then(|gb| gb.parse().ok())
.unwrap_or(DEFAULT_CAPTURE_MAX_GB),
deck_hub_enabled: std::env::var("DECK_HUB").ok().is_some_and(|value| {
matches!(
value.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}),
hub_deck_plays_url: std::env::var("MANABREW_HUB_DECK_PLAYS_URL")
.ok()
.filter(|url| !url.is_empty()),
hub_deck_plays_token: std::env::var("MANABREW_HUB_DECK_PLAYS_TOKEN")
.ok()
.filter(|token| !token.is_empty()),
hub_jwks_url: std::env::var("MANABREW_HUB_JWKS_URL")
.ok()
.filter(|url| !url.is_empty()),
direct_transport: std::env::var("MANABREW_DIRECT_TRANSPORT")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")),
ice_servers: std::env::var("MANABREW_ICE_SERVERS")
.ok()
.map(|raw| parse_ice_servers(&raw))
.unwrap_or_default(),
art_dir: std::env::var("MANABREW_ART_DIR")
.ok()
.filter(|dir| !dir.is_empty()),
art_port: std::env::var("MANABREW_ART_PORT")
.ok()
.and_then(|port| port.parse().ok())
.unwrap_or(DEFAULT_ART_PORT),
art_base_url: std::env::var("MANABREW_ART_BASE_URL")
.ok()
.filter(|url| !url.is_empty()),
lan_advertise: std::env::var("MANABREW_LAN_ADVERTISE")
.map(|value| {
matches!(
value.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false),
}
}
pub fn capture_max_bytes(&self) -> u64 {
self.capture_max_gb.saturating_mul(BYTES_PER_GB)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_url_list_becomes_one_server() {
let parsed = parse_ice_servers("stun:a.example.org:19302, stun:b.example.org");
assert_eq!(parsed.len(), 1);
assert_eq!(
parsed[0].urls,
vec!["stun:a.example.org:19302", "stun:b.example.org"]
);
assert!(parsed[0].username.is_none());
}
#[test]
fn json_carries_turn_credentials() {
let parsed = parse_ice_servers(
r#"[{"urls":["turn:t.example.org"],"username":"u","credential":"p"}]"#,
);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].username.as_deref(), Some("u"));
assert_eq!(parsed[0].credential.as_deref(), Some("p"));
}
#[test]
fn anything_unparseable_yields_no_servers_rather_than_a_panic() {
assert!(parse_ice_servers("").is_empty());
assert!(parse_ice_servers(" ").is_empty());
assert!(parse_ice_servers("[not json").is_empty());
assert!(parse_ice_servers(r#"[{"username":"u"}]"#).is_empty());
assert!(parse_ice_servers(r#"[{"urls":[]}]"#).is_empty());
}
}