1use std::collections::BTreeMap;
9use std::net::SocketAddr;
10use std::num::{NonZeroU64, NonZeroUsize};
11use std::path::PathBuf;
12use std::time::Duration;
13
14use serde::Deserialize;
15
16use super::listen::Limits;
17use super::packed::Packed;
18use super::route::Site;
19use crate::client::Address;
20use crate::client::network::{Remote, Trust};
21
22const STUDIO_STORE: &str = "studio";
26const HEAD_CAP: usize = 16 * 1024;
28const BODY_CAP: usize = 1024 * 1024;
30const READ_TIMEOUT: Duration = Duration::from_secs(10);
32
33#[derive(Debug, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Config {
37 #[serde(default)]
39 pub children: Vec<super::children::Config>,
40 pub listen: SocketAddr,
42 pub origin: String,
44 pub files: PathBuf,
46 pub stores: BTreeMap<String, Store>,
49 #[serde(default = "timeout")]
51 pub timeout_ms: NonZeroU64,
52 #[serde(default = "connections")]
54 pub max_connections: NonZeroUsize,
55 #[serde(default)]
59 pub lys: Option<Lys>,
60 pub assistant: Option<Assistant>,
62}
63
64#[derive(Clone, Debug, Deserialize)]
71#[serde(untagged)]
72pub enum Store {
73 Socket(PathBuf),
74 Network(NetworkStore),
75}
76
77#[derive(Clone, Debug, Deserialize)]
80pub struct NetworkStore {
81 #[serde(default)]
82 pub endpoint: Option<String>,
83 #[serde(default)]
84 pub server_name: Option<String>,
85 #[serde(flatten)]
88 pub other: BTreeMap<String, serde_json::Value>,
89}
90
91impl NetworkStore {
92 fn check(&self, name: &str) -> Result<(), String> {
93 if let Some(field) = self.other.keys().next() {
94 return Err(format!(
95 "door_store_field_unknown: store {name}: {field:?} is not a network store field; a network store names endpoint and server_name and is reached only over verified TLS"
96 ));
97 }
98 let endpoint = self.endpoint.as_deref().unwrap_or_default();
99 if !crate::client::network::endpoint_parses(endpoint) {
100 return Err(format!(
101 "door_store_endpoint_invalid: store {name}: endpoint {endpoint:?} is not <host>:<port> with a port from 1 to 65535"
102 ));
103 }
104 let server_name = self.server_name.as_deref().unwrap_or_default();
105 if server_name.is_empty() {
106 return Err(format!(
107 "door_store_server_name_missing: store {name}: a network store names the server_name its TLS certificate is verified for"
108 ));
109 }
110 crate::client::network::name(server_name)
111 .map(drop)
112 .map_err(|why| format!("door_store_server_name_invalid: store {name}: {why}"))
113 }
114}
115
116#[derive(Clone, Debug, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct Assistant {
120 pub socket: PathBuf,
122 #[serde(default = "assistant_frame")]
124 pub frame_bytes: NonZeroUsize,
125 #[serde(default = "assistant_person")]
127 pub max_per_person: NonZeroUsize,
128 #[serde(default = "assistant_store")]
130 pub max_per_store: NonZeroUsize,
131 #[serde(default = "assistant_frame")]
133 pub events_after_bytes: NonZeroUsize,
134}
135
136const fn assistant_frame() -> NonZeroUsize {
137 NonZeroUsize::MIN.saturating_add(262_143)
138}
139const fn assistant_person() -> NonZeroUsize {
140 NonZeroUsize::MIN.saturating_add(1)
141}
142const fn assistant_store() -> NonZeroUsize {
143 NonZeroUsize::MIN.saturating_add(31)
144}
145
146#[derive(Debug, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct Lys {
150 pub issuer: String,
152 pub client_id: String,
154 pub secret_file: PathBuf,
156 pub redirect: String,
158}
159
160const fn timeout() -> NonZeroU64 {
161 NonZeroU64::MIN.saturating_add(29_999)
162}
163
164const fn connections() -> NonZeroUsize {
165 NonZeroUsize::MIN.saturating_add(63)
166}
167
168impl Lys {
169 fn check(&self, origin: &str) -> Result<(), String> {
172 let issuer =
173 url::Url::parse(&self.issuer).map_err(|_| "lys issuer is not an address".to_owned())?;
174 if !matches!(issuer.scheme(), "http" | "https") {
175 return Err("lys issuer must be an http or https address".to_owned());
176 }
177 if self.client_id.is_empty() || self.client_id.chars().any(char::is_control) {
178 return Err("lys client_id must be a name with no control character".to_owned());
179 }
180 if !self.secret_file.is_absolute() {
181 return Err("lys secret_file must be an absolute path".to_owned());
182 }
183 if self.redirect != format!("{origin}/callback") {
184 return Err("lys redirect must be the origin's /callback".to_owned());
185 }
186 Ok(())
187 }
188}
189
190fn studio_missing(stores: &BTreeMap<String, Store>) -> String {
195 let entry = stores
196 .values()
197 .find_map(|store| match store {
198 Store::Socket(socket) => Some(
199 serde_json::Value::from(
200 socket
201 .with_file_name("studio.sock")
202 .to_string_lossy()
203 .into_owned(),
204 )
205 .to_string(),
206 ),
207 Store::Network(_) => None,
208 })
209 .unwrap_or_else(|| {
210 "{\"endpoint\": \"<host>:<port>\", \"server_name\": \"<TLS name>\"}".to_owned()
211 });
212 format!(
213 "Studio keeps its arrangements and saved views in a store named {STUDIO_STORE}, and this \
214 door's stores name none: serve a store for Studio and add it to stores, as \
215 \"{STUDIO_STORE}\": {entry}."
216 )
217}
218
219impl Config {
220 pub fn parse(bytes: &[u8]) -> Result<Self, String> {
226 let config: Self = serde_json::from_slice(bytes)
227 .map_err(|error| format!("the door configuration is not valid: {error}"))?;
228 config.check()?;
229 Ok(config)
230 }
231
232 fn check(&self) -> Result<(), String> {
233 super::children::check(&self.children, &self.stores)?;
234 if self
235 .assistant
236 .as_ref()
237 .is_some_and(|assistant| !assistant.socket.is_absolute())
238 {
239 return Err("assistant.socket must be absolute".to_owned());
240 }
241 if !self.listen.ip().is_loopback() {
242 return Err("listen must be a loopback address behind a TLS proxy".to_owned());
243 }
244 match &self.lys {
245 Some(_) => {
246 let origin = url::Url::parse(&self.origin)
247 .map_err(|_| "origin is not an address".to_owned())?;
248 if origin.scheme() != "https"
249 || origin.origin().ascii_serialization() != self.origin
250 {
251 return Err(
252 "origin must be an https origin with no path, as the browser sends it"
253 .to_owned(),
254 );
255 }
256 }
257 None => local_origin(&self.origin)?,
258 }
259 if !self.files.is_absolute() {
260 return Err("files must be an absolute folder".to_owned());
261 }
262 if self.stores.is_empty() {
263 return Err("stores must name at least one store".to_owned());
264 }
265 for (name, store) in &self.stores {
266 if name.is_empty()
267 || !name
268 .bytes()
269 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
270 {
271 return Err(format!(
272 "store name {name:?} must be letters, digits, - or _"
273 ));
274 }
275 match store {
276 Store::Socket(socket) if !socket.is_absolute() => {
277 return Err(format!("store socket for {name} must be an absolute path"));
278 }
279 Store::Socket(_) => {}
280 Store::Network(network) => network.check(name)?,
281 }
282 }
283 if let Some(lys) = &self.lys {
284 lys.check(&self.origin)?;
285 }
286 if !self.stores.contains_key(STUDIO_STORE) {
288 return Err(studio_missing(&self.stores));
289 }
290 Ok(())
291 }
292
293 #[must_use]
295 pub const fn through_lys(&self) -> bool {
296 self.lys.is_some()
297 }
298
299 #[must_use]
301 pub const fn timeout(&self) -> Duration {
302 Duration::from_millis(self.timeout_ms.get())
303 }
304
305 pub fn addresses(&self, trust: &Trust) -> Result<BTreeMap<String, Address>, String> {
311 self.stores
312 .iter()
313 .map(|(name, store)| {
314 let address = match store {
315 Store::Socket(socket) => Address::Socket(socket.clone()),
316 Store::Network(network) => Address::Network(
317 Remote::new(
318 network.endpoint.as_deref().unwrap_or_default(),
319 network.server_name.as_deref().unwrap_or_default(),
320 trust,
321 )
322 .map_err(|why| {
323 format!("door_store_endpoint_invalid: store {name}: {why}")
324 })?,
325 ),
326 };
327 Ok((name.clone(), address))
328 })
329 .collect()
330 }
331
332 pub fn site(&self, trust: &Trust) -> Result<Site, String> {
338 Ok(Site {
339 origin: self.origin.clone(),
340 files: self.files.clone(),
341 stores: self.addresses(trust)?,
342 store_children: self
343 .children
344 .iter()
345 .filter_map(|child| {
346 child.store.as_ref().map(|store| {
347 (
348 store.clone(),
349 (child.name.clone(), std::sync::Arc::clone(&child.given_up)),
350 )
351 })
352 })
353 .collect(),
354 timeout: self.timeout(),
355 packed: Packed::default(),
356 })
357 }
358
359 #[must_use]
361 pub const fn limits(&self) -> Limits {
362 Limits {
363 max_connections: self.max_connections.get(),
364 read_timeout: READ_TIMEOUT,
365 head_cap: HEAD_CAP,
366 body_cap: BODY_CAP,
367 }
368 }
369}
370
371fn local_origin(origin: &str) -> Result<(), String> {
375 let refused = || {
376 format!(
377 "door_local_origin_not_loopback: origin {origin} is not an http or https origin on \
378 127.0.0.1, localhost or [::1] with no path; a door with no lys block serves only \
379 this machine, and a lys block is needed for any other origin"
380 )
381 };
382 let parsed = url::Url::parse(origin).map_err(|_| refused())?;
383 let loopback = parsed.host_str().is_some_and(crate::loopback_http::host);
384 if !matches!(parsed.scheme(), "http" | "https")
385 || !loopback
386 || parsed.origin().ascii_serialization() != origin
387 {
388 return Err(refused());
389 }
390 Ok(())
391}
392
393#[cfg(test)]
394#[path = "config_tests.rs"]
395mod tests;
396
397#[derive(Clone, Copy)]
401pub struct SignInBounds {
402 pub max_pending: NonZeroUsize,
403 pub session_seconds: NonZeroU64,
404 pub connect_ms: NonZeroU64,
405 pub total_ms: NonZeroU64,
406}
407
408impl Default for SignInBounds {
409 fn default() -> Self {
410 Self {
411 max_pending: NonZeroUsize::MIN.saturating_add(511),
412 session_seconds: NonZeroU64::MIN.saturating_add(43_199),
413 connect_ms: NonZeroU64::MIN.saturating_add(4_999),
414 total_ms: timeout(),
415 }
416 }
417}
418
419impl SignInBounds {
420 pub fn from_env() -> Result<Self, String> {
426 let defaults = Self::default();
427 let max_pending = positive_env(
428 "HAEMATITE_PENDING_SIGNINS",
429 defaults.max_pending.get() as u64,
430 )?;
431 let bounds = Self {
432 max_pending: usize::try_from(max_pending.get())
433 .ok()
434 .and_then(NonZeroUsize::new)
435 .ok_or("HAEMATITE_PENDING_SIGNINS exceeds this machine's range")?,
436 session_seconds: positive_env(
437 "HAEMATITE_SESSION_SECONDS",
438 defaults.session_seconds.get(),
439 )?,
440 connect_ms: positive_env(
441 "HAEMATITE_LYS_CONNECT_TIMEOUT_MS",
442 defaults.connect_ms.get(),
443 )?,
444 total_ms: positive_env("HAEMATITE_LYS_TIMEOUT_MS", defaults.total_ms.get())?,
445 };
446 if bounds.connect_ms > bounds.total_ms {
447 return Err(
448 "HAEMATITE_LYS_CONNECT_TIMEOUT_MS exceeds HAEMATITE_LYS_TIMEOUT_MS".to_owned(),
449 );
450 }
451 if std::time::Instant::now()
452 .checked_add(Duration::from_millis(bounds.total_ms.get()))
453 .is_none()
454 {
455 return Err("HAEMATITE_LYS_TIMEOUT_MS exceeds the monotonic clock".to_owned());
456 }
457 Ok(bounds)
458 }
459}
460
461pub(crate) fn positive_env(name: &str, default: u64) -> Result<NonZeroU64, String> {
462 match std::env::var(name) {
463 Ok(value) => value
464 .parse()
465 .map_err(|_| format!("{name} must be a positive integer")),
466 Err(std::env::VarError::NotPresent) => {
467 NonZeroU64::new(default).ok_or_else(|| format!("{name} has a zero default"))
468 }
469 Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} must be a positive integer")),
470 }
471}