Skip to main content

haematite_cli/door/
config.rs

1//! The door's configuration (ACCESS-002 R2).
2//!
3//! It names where the door listens, Studio's origin and files, the stores it
4//! carries verbs to, and the Lys app it signs people in as. The app's secret
5//! is named by its file; the door's Lys client reads it and hands it to
6//! the sign-in helper.
7
8use 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;
19
20/// The bound on a request's head, in bytes.
21const HEAD_CAP: usize = 16 * 1024;
22/// The bound on a request's body, in bytes.
23const BODY_CAP: usize = 1024 * 1024;
24/// The bound on each read and write of a browser's connection.
25const READ_TIMEOUT: Duration = Duration::from_secs(10);
26
27/// The whole of `haematite door`'s configuration file.
28#[derive(Debug, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct Config {
31    /// Programs whose stdin pipes are owned by this door.
32    #[serde(default)]
33    pub children: Vec<super::children::Config>,
34    /// A loopback address; TLS is the proxy's in front of it.
35    pub listen: SocketAddr,
36    /// Studio's origin, as the browser sends it.
37    pub origin: String,
38    /// The absolute folder holding Studio's built files.
39    pub files: PathBuf,
40    /// Each store by name, with its socket's absolute path.
41    pub stores: BTreeMap<String, PathBuf>,
42    /// The bound on each store connection's steps, in milliseconds.
43    #[serde(default = "timeout")]
44    pub timeout_ms: NonZeroU64,
45    /// Sixty-four bounds the threads serving browser and seat connections together.
46    #[serde(default = "connections")]
47    pub max_connections: NonZeroUsize,
48    /// The Lys app the door signs people in as.
49    pub lys: Lys,
50    /// The optional seat socket and its admission and replay bounds.
51    pub assistant: Option<Assistant>,
52}
53
54/// Bounds on seats, attached streams and outstanding operations, counted separately.
55#[derive(Clone, Debug, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Assistant {
58    /// Private local transport; a relative path could select another install.
59    pub socket: PathBuf,
60    /// 256 KiB admits bounded tool items without unbounded line allocation.
61    #[serde(default = "assistant_frame")]
62    pub frame_bytes: NonZeroUsize,
63    /// Two seats or tabs let a person work in two stores or windows.
64    #[serde(default = "assistant_person")]
65    pub max_per_person: NonZeroUsize,
66    /// Thirty-two limits one store's assistant and stream memory.
67    #[serde(default = "assistant_store")]
68    pub max_per_store: NonZeroUsize,
69    /// One frame bounds each replay page and its compressed sequence coverage.
70    #[serde(default = "assistant_frame")]
71    pub events_after_bytes: NonZeroUsize,
72}
73
74const fn assistant_frame() -> NonZeroUsize {
75    NonZeroUsize::MIN.saturating_add(262_143)
76}
77const fn assistant_person() -> NonZeroUsize {
78    NonZeroUsize::MIN.saturating_add(1)
79}
80const fn assistant_store() -> NonZeroUsize {
81    NonZeroUsize::MIN.saturating_add(31)
82}
83
84/// The Lys app the door signs people in as.
85#[derive(Debug, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct Lys {
88    /// Lys's origin.
89    pub issuer: String,
90    /// The app's client id in Lys.
91    pub client_id: String,
92    /// The absolute path of the file holding the app's secret.
93    pub secret_file: PathBuf,
94    /// Where Lys sends the browser back: the origin's `/callback`.
95    pub redirect: String,
96}
97
98const fn timeout() -> NonZeroU64 {
99    NonZeroU64::MIN.saturating_add(29_999)
100}
101
102const fn connections() -> NonZeroUsize {
103    NonZeroUsize::MIN.saturating_add(63)
104}
105
106impl Config {
107    /// The configuration in `bytes`, checked whole.
108    ///
109    /// # Errors
110    ///
111    /// A message naming the field that is missing, unknown or unsafe.
112    pub fn parse(bytes: &[u8]) -> Result<Self, String> {
113        let config: Self = serde_json::from_slice(bytes)
114            .map_err(|error| format!("the door configuration is not valid: {error}"))?;
115        config.check()?;
116        Ok(config)
117    }
118
119    fn check(&self) -> Result<(), String> {
120        super::children::check(&self.children, &self.stores)?;
121        if self
122            .assistant
123            .as_ref()
124            .is_some_and(|assistant| !assistant.socket.is_absolute())
125        {
126            return Err("assistant.socket must be absolute".to_owned());
127        }
128        if !self.listen.ip().is_loopback() {
129            return Err("listen must be a loopback address behind a TLS proxy".to_owned());
130        }
131        let origin =
132            url::Url::parse(&self.origin).map_err(|_| "origin is not an address".to_owned())?;
133        if origin.scheme() != "https" || origin.origin().ascii_serialization() != self.origin {
134            return Err(
135                "origin must be an https origin with no path, as the browser sends it".to_owned(),
136            );
137        }
138        if !self.files.is_absolute() {
139            return Err("files must be an absolute folder".to_owned());
140        }
141        if self.stores.is_empty() {
142            return Err("stores must name at least one store".to_owned());
143        }
144        for (name, socket) in &self.stores {
145            if name.is_empty()
146                || !name
147                    .bytes()
148                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
149            {
150                return Err(format!(
151                    "store name {name:?} must be letters, digits, - or _"
152                ));
153            }
154            if !socket.is_absolute() {
155                return Err(format!("store socket for {name} must be an absolute path"));
156            }
157        }
158        let issuer = url::Url::parse(&self.lys.issuer)
159            .map_err(|_| "lys issuer is not an address".to_owned())?;
160        if !matches!(issuer.scheme(), "http" | "https") {
161            return Err("lys issuer must be an http or https address".to_owned());
162        }
163        if self.lys.client_id.is_empty() || self.lys.client_id.chars().any(char::is_control) {
164            return Err("lys client_id must be a name with no control character".to_owned());
165        }
166        if !self.lys.secret_file.is_absolute() {
167            return Err("lys secret_file must be an absolute path".to_owned());
168        }
169        if self.lys.redirect != format!("{}/callback", self.origin) {
170            return Err("lys redirect must be the origin's /callback".to_owned());
171        }
172        Ok(())
173    }
174
175    /// What the door serves.
176    #[must_use]
177    pub fn site(&self) -> Site {
178        Site {
179            origin: self.origin.clone(),
180            files: self.files.clone(),
181            stores: self.stores.clone(),
182            store_children: self
183                .children
184                .iter()
185                .filter_map(|child| {
186                    child.store.as_ref().map(|store| {
187                        (
188                            store.clone(),
189                            (child.name.clone(), std::sync::Arc::clone(&child.given_up)),
190                        )
191                    })
192                })
193                .collect(),
194            timeout: Duration::from_millis(self.timeout_ms.get()),
195            packed: Packed::default(),
196        }
197    }
198
199    /// The door's bounds on its connections.
200    #[must_use]
201    pub const fn limits(&self) -> Limits {
202        Limits {
203            max_connections: self.max_connections.get(),
204            read_timeout: READ_TIMEOUT,
205            head_cap: HEAD_CAP,
206            body_cap: BODY_CAP,
207        }
208    }
209}
210
211#[cfg(test)]
212#[path = "config_tests.rs"]
213mod tests;
214
215/// Bounds read from the process configuration, with no environment mutation.
216/// Defaults: 512 pending states, 12 hour sessions, 5 second connects and
217/// 30 second whole Lys requests.
218#[derive(Clone, Copy)]
219pub struct SignInBounds {
220    pub max_pending: NonZeroUsize,
221    pub session_seconds: NonZeroU64,
222    pub connect_ms: NonZeroU64,
223    pub total_ms: NonZeroU64,
224}
225
226impl Default for SignInBounds {
227    fn default() -> Self {
228        Self {
229            max_pending: NonZeroUsize::MIN.saturating_add(511),
230            session_seconds: NonZeroU64::MIN.saturating_add(43_199),
231            connect_ms: NonZeroU64::MIN.saturating_add(4_999),
232            total_ms: timeout(),
233        }
234    }
235}
236
237impl SignInBounds {
238    /// Read sign-in bounds from process configuration.
239    ///
240    /// # Errors
241    ///
242    /// Names an invalid bound, a connect bound beyond the total, or an unrepresentable timeout.
243    pub fn from_env() -> Result<Self, String> {
244        let defaults = Self::default();
245        let max_pending = positive_env(
246            "HAEMATITE_PENDING_SIGNINS",
247            defaults.max_pending.get() as u64,
248        )?;
249        let bounds = Self {
250            max_pending: usize::try_from(max_pending.get())
251                .ok()
252                .and_then(NonZeroUsize::new)
253                .ok_or("HAEMATITE_PENDING_SIGNINS exceeds this machine's range")?,
254            session_seconds: positive_env(
255                "HAEMATITE_SESSION_SECONDS",
256                defaults.session_seconds.get(),
257            )?,
258            connect_ms: positive_env(
259                "HAEMATITE_LYS_CONNECT_TIMEOUT_MS",
260                defaults.connect_ms.get(),
261            )?,
262            total_ms: positive_env("HAEMATITE_LYS_TIMEOUT_MS", defaults.total_ms.get())?,
263        };
264        if bounds.connect_ms > bounds.total_ms {
265            return Err(
266                "HAEMATITE_LYS_CONNECT_TIMEOUT_MS exceeds HAEMATITE_LYS_TIMEOUT_MS".to_owned(),
267            );
268        }
269        if std::time::Instant::now()
270            .checked_add(Duration::from_millis(bounds.total_ms.get()))
271            .is_none()
272        {
273            return Err("HAEMATITE_LYS_TIMEOUT_MS exceeds the monotonic clock".to_owned());
274        }
275        Ok(bounds)
276    }
277}
278
279pub(crate) fn positive_env(name: &str, default: u64) -> Result<NonZeroU64, String> {
280    match std::env::var(name) {
281        Ok(value) => value
282            .parse()
283            .map_err(|_| format!("{name} must be a positive integer")),
284        Err(std::env::VarError::NotPresent) => {
285            NonZeroU64::new(default).ok_or_else(|| format!("{name} has a zero default"))
286        }
287        Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} must be a positive integer")),
288    }
289}