Skip to main content

manabrew_server/
config.rs

1const DEFAULT_CAPTURE_MAX_GB: u64 = 20;
2/// Fixed rather than ephemeral: a client that learns it from config rather
3/// than from mDNS needs it to survive a restart.
4const DEFAULT_ART_PORT: u16 = 9528;
5
6const BYTES_PER_GB: u64 = 1024 * 1024 * 1024;
7
8pub use crate::protocol::IceServer as TransportIceServer;
9
10/// Reads `MANABREW_ICE_SERVERS`: a url list, or a JSON array of `RTCIceServer`.
11pub fn parse_ice_servers(raw: &str) -> Vec<TransportIceServer> {
12    let raw = raw.trim();
13    if raw.is_empty() {
14        return Vec::new();
15    }
16    if raw.starts_with('[') {
17        return match serde_json::from_str::<Vec<TransportIceServer>>(raw) {
18            Ok(servers) => servers.into_iter().filter(|s| !s.urls.is_empty()).collect(),
19            Err(error) => {
20                tracing::error!(%error, "MANABREW_ICE_SERVERS is not valid JSON; ignoring it");
21                Vec::new()
22            }
23        };
24    }
25    let urls: Vec<String> = raw
26        .split([',', ' ', '\t', '\n'])
27        .map(str::trim)
28        .filter(|url| !url.is_empty())
29        .map(str::to_string)
30        .collect();
31    if urls.is_empty() {
32        Vec::new()
33    } else {
34        vec![TransportIceServer {
35            urls,
36            username: None,
37            credential: None,
38        }]
39    }
40}
41
42pub struct ServerConfig {
43    pub host: String,
44    pub port: u16,
45    pub health_port: u16,
46    pub max_rooms: usize,
47    pub server_key: String,
48    pub official_key: Option<String>,
49    pub events_dir: Option<String>,
50    pub capture_dir: Option<String>,
51    pub capture_max_gb: u64,
52    pub deck_hub_enabled: bool,
53    /// The hub takes the analytics feed and, with `DECK_HUB`, deck-play
54    /// evidence. Env names keep the older `DECK_PLAYS` spelling.
55    pub hub_url: Option<String>,
56    pub hub_token: Option<String>,
57    pub hub_jwks_url: Option<String>,
58    /// Opt-in. Off, the relay never sends a roster.
59    pub direct_transport: bool,
60    /// ICE servers handed to the browser plane. See [`parse_ice_servers`].
61    pub ice_servers: Vec<TransportIceServer>,
62    /// Where this relay keeps card art. Set it and the relay serves
63    /// `/scryfall-img/` for everyone on the network, which is the point of
64    /// running one: nobody else has to hold 8GB of images.
65    pub art_dir: Option<String>,
66    pub art_port: u16,
67    /// An absolute url, for a deployment behind a proxy where the port this
68    /// binds is not the one a client reaches. On a LAN nobody sets this: the
69    /// mDNS record already carries the port and the client already knows the
70    /// host it connected to.
71    pub art_base_url: Option<String>,
72    /// Answer mDNS, so four desktops on a network find this box without anyone
73    /// typing its address.
74    pub lan_advertise: bool,
75}
76
77impl ServerConfig {
78    pub fn from_env() -> Self {
79        ServerConfig {
80            host: std::env::var("FORGE_HOST").unwrap_or_else(|_| "0.0.0.0".into()),
81            port: std::env::var("FORGE_PORT")
82                .ok()
83                .and_then(|p| p.parse().ok())
84                .unwrap_or(9443),
85            health_port: std::env::var("FORGE_HEALTH_PORT")
86                .ok()
87                .and_then(|p| p.parse().ok())
88                .unwrap_or(9444),
89            max_rooms: std::env::var("FORGE_MAX_ROOMS")
90                .ok()
91                .and_then(|r| r.parse().ok())
92                .unwrap_or(100),
93            server_key: std::env::var("MANABREW_SERVER_KEY").unwrap_or_else(|_| "forge".into()),
94            official_key: std::env::var("SECRET_MANABREW_KEY")
95                .ok()
96                .filter(|key| !key.is_empty()),
97            events_dir: std::env::var("MANABREW_EVENTS_DIR")
98                .ok()
99                .filter(|dir| !dir.is_empty()),
100            capture_dir: std::env::var("MANABREW_GAME_CAPTURE_DIR")
101                .ok()
102                .filter(|dir| !dir.is_empty()),
103            capture_max_gb: std::env::var("MANABREW_GAME_CAPTURE_MAX_GB")
104                .ok()
105                .and_then(|gb| gb.parse().ok())
106                .unwrap_or(DEFAULT_CAPTURE_MAX_GB),
107            deck_hub_enabled: std::env::var("DECK_HUB").ok().is_some_and(|value| {
108                matches!(
109                    value.to_ascii_lowercase().as_str(),
110                    "1" | "true" | "yes" | "on"
111                )
112            }),
113            hub_url: std::env::var("MANABREW_HUB_DECK_PLAYS_URL")
114                .ok()
115                .filter(|url| !url.is_empty()),
116            hub_token: std::env::var("MANABREW_HUB_DECK_PLAYS_TOKEN")
117                .ok()
118                .filter(|token| !token.is_empty()),
119            hub_jwks_url: std::env::var("MANABREW_HUB_JWKS_URL")
120                .ok()
121                .filter(|url| !url.is_empty()),
122            direct_transport: std::env::var("MANABREW_DIRECT_TRANSPORT")
123                .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")),
124            ice_servers: std::env::var("MANABREW_ICE_SERVERS")
125                .ok()
126                .map(|raw| parse_ice_servers(&raw))
127                .unwrap_or_default(),
128            art_dir: std::env::var("MANABREW_ART_DIR")
129                .ok()
130                .filter(|dir| !dir.is_empty()),
131            art_port: std::env::var("MANABREW_ART_PORT")
132                .ok()
133                .and_then(|port| port.parse().ok())
134                .unwrap_or(DEFAULT_ART_PORT),
135            art_base_url: std::env::var("MANABREW_ART_BASE_URL")
136                .ok()
137                .filter(|url| !url.is_empty()),
138            lan_advertise: std::env::var("MANABREW_LAN_ADVERTISE")
139                .map(|value| {
140                    matches!(
141                        value.to_ascii_lowercase().as_str(),
142                        "1" | "true" | "yes" | "on"
143                    )
144                })
145                .unwrap_or(false),
146        }
147    }
148
149    pub fn capture_max_bytes(&self) -> u64 {
150        self.capture_max_gb.saturating_mul(BYTES_PER_GB)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn a_plain_url_list_becomes_one_server() {
160        let parsed = parse_ice_servers("stun:a.example.org:19302, stun:b.example.org");
161        assert_eq!(parsed.len(), 1);
162        assert_eq!(
163            parsed[0].urls,
164            vec!["stun:a.example.org:19302", "stun:b.example.org"]
165        );
166        assert!(parsed[0].username.is_none());
167    }
168
169    #[test]
170    fn json_carries_turn_credentials() {
171        let parsed = parse_ice_servers(
172            r#"[{"urls":["turn:t.example.org"],"username":"u","credential":"p"}]"#,
173        );
174        assert_eq!(parsed.len(), 1);
175        assert_eq!(parsed[0].username.as_deref(), Some("u"));
176        assert_eq!(parsed[0].credential.as_deref(), Some("p"));
177    }
178
179    #[test]
180    fn anything_unparseable_yields_no_servers_rather_than_a_panic() {
181        assert!(parse_ice_servers("").is_empty());
182        assert!(parse_ice_servers("   ").is_empty());
183        assert!(parse_ice_servers("[not json").is_empty());
184        assert!(parse_ice_servers(r#"[{"username":"u"}]"#).is_empty());
185        assert!(parse_ice_servers(r#"[{"urls":[]}]"#).is_empty());
186    }
187}