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;
19use crate::client::Address;
20use crate::client::network::{Remote, Trust};
21
22/// The bound on a request's head, in bytes.
23const HEAD_CAP: usize = 16 * 1024;
24/// The bound on a request's body, in bytes.
25const BODY_CAP: usize = 1024 * 1024;
26/// The bound on each read and write of a browser's connection.
27const READ_TIMEOUT: Duration = Duration::from_secs(10);
28
29/// The whole of `haematite door`'s configuration file.
30#[derive(Debug, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct Config {
33    /// Programs whose stdin pipes are owned by this door.
34    #[serde(default)]
35    pub children: Vec<super::children::Config>,
36    /// A loopback address; TLS is the proxy's in front of it.
37    pub listen: SocketAddr,
38    /// Studio's origin, as the browser sends it.
39    pub origin: String,
40    /// The absolute folder holding Studio's built files.
41    pub files: PathBuf,
42    /// Each store by name: its socket's absolute path, or a HAEMNET-001
43    /// network endpoint (HAEMNET-003 R1).
44    pub stores: BTreeMap<String, Store>,
45    /// The bound on each store connection's steps, in milliseconds.
46    #[serde(default = "timeout")]
47    pub timeout_ms: NonZeroU64,
48    /// Sixty-four bounds the threads serving browser and seat connections together.
49    #[serde(default = "connections")]
50    pub max_connections: NonZeroUsize,
51    /// The Lys app the door signs people in as.
52    pub lys: Lys,
53    /// The optional seat socket and its admission and replay bounds.
54    pub assistant: Option<Assistant>,
55}
56
57/// One store the door carries verbs to (HAEMNET-003 R1).
58///
59/// Today's absolute socket path, or
60/// `{"endpoint": "<host>:<port>", "server_name": "<TLS name>"}` naming a
61/// HAEMNET-001 network endpoint, reached only over TLS verified for
62/// `server_name`.
63#[derive(Clone, Debug, Deserialize)]
64#[serde(untagged)]
65pub enum Store {
66    Socket(PathBuf),
67    Network(NetworkStore),
68}
69
70/// A network store entry as written, checked whole by [`Config::parse`] so
71/// each fault is refused by its own name.
72#[derive(Clone, Debug, Deserialize)]
73pub struct NetworkStore {
74    #[serde(default)]
75    pub endpoint: Option<String>,
76    #[serde(default)]
77    pub server_name: Option<String>,
78    /// Any other field, refused by name: there is no setting that serves a
79    /// network store without verified TLS.
80    #[serde(flatten)]
81    pub other: BTreeMap<String, serde_json::Value>,
82}
83
84impl NetworkStore {
85    fn check(&self, name: &str) -> Result<(), String> {
86        if let Some(field) = self.other.keys().next() {
87            return Err(format!(
88                "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"
89            ));
90        }
91        let endpoint = self.endpoint.as_deref().unwrap_or_default();
92        if !crate::client::network::endpoint_parses(endpoint) {
93            return Err(format!(
94                "door_store_endpoint_invalid: store {name}: endpoint {endpoint:?} is not <host>:<port> with a port from 1 to 65535"
95            ));
96        }
97        let server_name = self.server_name.as_deref().unwrap_or_default();
98        if server_name.is_empty() {
99            return Err(format!(
100                "door_store_server_name_missing: store {name}: a network store names the server_name its TLS certificate is verified for"
101            ));
102        }
103        crate::client::network::name(server_name)
104            .map(drop)
105            .map_err(|why| format!("door_store_server_name_invalid: store {name}: {why}"))
106    }
107}
108
109/// Bounds on seats, attached streams and outstanding operations, counted separately.
110#[derive(Clone, Debug, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct Assistant {
113    /// Private local transport; a relative path could select another install.
114    pub socket: PathBuf,
115    /// 256 KiB admits bounded tool items without unbounded line allocation.
116    #[serde(default = "assistant_frame")]
117    pub frame_bytes: NonZeroUsize,
118    /// Two seats or tabs let a person work in two stores or windows.
119    #[serde(default = "assistant_person")]
120    pub max_per_person: NonZeroUsize,
121    /// Thirty-two limits one store's assistant and stream memory.
122    #[serde(default = "assistant_store")]
123    pub max_per_store: NonZeroUsize,
124    /// One frame bounds each replay page and its compressed sequence coverage.
125    #[serde(default = "assistant_frame")]
126    pub events_after_bytes: NonZeroUsize,
127}
128
129const fn assistant_frame() -> NonZeroUsize {
130    NonZeroUsize::MIN.saturating_add(262_143)
131}
132const fn assistant_person() -> NonZeroUsize {
133    NonZeroUsize::MIN.saturating_add(1)
134}
135const fn assistant_store() -> NonZeroUsize {
136    NonZeroUsize::MIN.saturating_add(31)
137}
138
139/// The Lys app the door signs people in as.
140#[derive(Debug, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct Lys {
143    /// Lys's origin.
144    pub issuer: String,
145    /// The app's client id in Lys.
146    pub client_id: String,
147    /// The absolute path of the file holding the app's secret.
148    pub secret_file: PathBuf,
149    /// Where Lys sends the browser back: the origin's `/callback`.
150    pub redirect: String,
151}
152
153const fn timeout() -> NonZeroU64 {
154    NonZeroU64::MIN.saturating_add(29_999)
155}
156
157const fn connections() -> NonZeroUsize {
158    NonZeroUsize::MIN.saturating_add(63)
159}
160
161impl Config {
162    /// The configuration in `bytes`, checked whole.
163    ///
164    /// # Errors
165    ///
166    /// A message naming the field that is missing, unknown or unsafe.
167    pub fn parse(bytes: &[u8]) -> Result<Self, String> {
168        let config: Self = serde_json::from_slice(bytes)
169            .map_err(|error| format!("the door configuration is not valid: {error}"))?;
170        config.check()?;
171        Ok(config)
172    }
173
174    fn check(&self) -> Result<(), String> {
175        super::children::check(&self.children, &self.stores)?;
176        if self
177            .assistant
178            .as_ref()
179            .is_some_and(|assistant| !assistant.socket.is_absolute())
180        {
181            return Err("assistant.socket must be absolute".to_owned());
182        }
183        if !self.listen.ip().is_loopback() {
184            return Err("listen must be a loopback address behind a TLS proxy".to_owned());
185        }
186        let origin =
187            url::Url::parse(&self.origin).map_err(|_| "origin is not an address".to_owned())?;
188        if origin.scheme() != "https" || origin.origin().ascii_serialization() != self.origin {
189            return Err(
190                "origin must be an https origin with no path, as the browser sends it".to_owned(),
191            );
192        }
193        if !self.files.is_absolute() {
194            return Err("files must be an absolute folder".to_owned());
195        }
196        if self.stores.is_empty() {
197            return Err("stores must name at least one store".to_owned());
198        }
199        for (name, store) in &self.stores {
200            if name.is_empty()
201                || !name
202                    .bytes()
203                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
204            {
205                return Err(format!(
206                    "store name {name:?} must be letters, digits, - or _"
207                ));
208            }
209            match store {
210                Store::Socket(socket) if !socket.is_absolute() => {
211                    return Err(format!("store socket for {name} must be an absolute path"));
212                }
213                Store::Socket(_) => {}
214                Store::Network(network) => network.check(name)?,
215            }
216        }
217        let issuer = url::Url::parse(&self.lys.issuer)
218            .map_err(|_| "lys issuer is not an address".to_owned())?;
219        if !matches!(issuer.scheme(), "http" | "https") {
220            return Err("lys issuer must be an http or https address".to_owned());
221        }
222        if self.lys.client_id.is_empty() || self.lys.client_id.chars().any(char::is_control) {
223            return Err("lys client_id must be a name with no control character".to_owned());
224        }
225        if !self.lys.secret_file.is_absolute() {
226            return Err("lys secret_file must be an absolute path".to_owned());
227        }
228        if self.lys.redirect != format!("{}/callback", self.origin) {
229            return Err("lys redirect must be the origin's /callback".to_owned());
230        }
231        Ok(())
232    }
233
234    /// The bound on each store connection's steps.
235    #[must_use]
236    pub const fn timeout(&self) -> Duration {
237        Duration::from_millis(self.timeout_ms.get())
238    }
239
240    /// Where each store is reached, a network store verified against `trust`.
241    ///
242    /// # Errors
243    ///
244    /// The entry that does not parse, which [`Config::parse`] already refused.
245    pub fn addresses(&self, trust: &Trust) -> Result<BTreeMap<String, Address>, String> {
246        self.stores
247            .iter()
248            .map(|(name, store)| {
249                let address = match store {
250                    Store::Socket(socket) => Address::Socket(socket.clone()),
251                    Store::Network(network) => Address::Network(
252                        Remote::new(
253                            network.endpoint.as_deref().unwrap_or_default(),
254                            network.server_name.as_deref().unwrap_or_default(),
255                            trust,
256                        )
257                        .map_err(|why| {
258                            format!("door_store_endpoint_invalid: store {name}: {why}")
259                        })?,
260                    ),
261                };
262                Ok((name.clone(), address))
263            })
264            .collect()
265    }
266
267    /// What the door serves, its network stores verified against `trust`.
268    ///
269    /// # Errors
270    ///
271    /// As [`Config::addresses`].
272    pub fn site(&self, trust: &Trust) -> Result<Site, String> {
273        Ok(Site {
274            origin: self.origin.clone(),
275            files: self.files.clone(),
276            stores: self.addresses(trust)?,
277            store_children: self
278                .children
279                .iter()
280                .filter_map(|child| {
281                    child.store.as_ref().map(|store| {
282                        (
283                            store.clone(),
284                            (child.name.clone(), std::sync::Arc::clone(&child.given_up)),
285                        )
286                    })
287                })
288                .collect(),
289            timeout: self.timeout(),
290            packed: Packed::default(),
291        })
292    }
293
294    /// The door's bounds on its connections.
295    #[must_use]
296    pub const fn limits(&self) -> Limits {
297        Limits {
298            max_connections: self.max_connections.get(),
299            read_timeout: READ_TIMEOUT,
300            head_cap: HEAD_CAP,
301            body_cap: BODY_CAP,
302        }
303    }
304}
305
306#[cfg(test)]
307#[path = "config_tests.rs"]
308mod tests;
309
310/// Bounds read from the process configuration, with no environment mutation.
311/// Defaults: 512 pending states, 12 hour sessions, 5 second connects and
312/// 30 second whole Lys requests.
313#[derive(Clone, Copy)]
314pub struct SignInBounds {
315    pub max_pending: NonZeroUsize,
316    pub session_seconds: NonZeroU64,
317    pub connect_ms: NonZeroU64,
318    pub total_ms: NonZeroU64,
319}
320
321impl Default for SignInBounds {
322    fn default() -> Self {
323        Self {
324            max_pending: NonZeroUsize::MIN.saturating_add(511),
325            session_seconds: NonZeroU64::MIN.saturating_add(43_199),
326            connect_ms: NonZeroU64::MIN.saturating_add(4_999),
327            total_ms: timeout(),
328        }
329    }
330}
331
332impl SignInBounds {
333    /// Read sign-in bounds from process configuration.
334    ///
335    /// # Errors
336    ///
337    /// Names an invalid bound, a connect bound beyond the total, or an unrepresentable timeout.
338    pub fn from_env() -> Result<Self, String> {
339        let defaults = Self::default();
340        let max_pending = positive_env(
341            "HAEMATITE_PENDING_SIGNINS",
342            defaults.max_pending.get() as u64,
343        )?;
344        let bounds = Self {
345            max_pending: usize::try_from(max_pending.get())
346                .ok()
347                .and_then(NonZeroUsize::new)
348                .ok_or("HAEMATITE_PENDING_SIGNINS exceeds this machine's range")?,
349            session_seconds: positive_env(
350                "HAEMATITE_SESSION_SECONDS",
351                defaults.session_seconds.get(),
352            )?,
353            connect_ms: positive_env(
354                "HAEMATITE_LYS_CONNECT_TIMEOUT_MS",
355                defaults.connect_ms.get(),
356            )?,
357            total_ms: positive_env("HAEMATITE_LYS_TIMEOUT_MS", defaults.total_ms.get())?,
358        };
359        if bounds.connect_ms > bounds.total_ms {
360            return Err(
361                "HAEMATITE_LYS_CONNECT_TIMEOUT_MS exceeds HAEMATITE_LYS_TIMEOUT_MS".to_owned(),
362            );
363        }
364        if std::time::Instant::now()
365            .checked_add(Duration::from_millis(bounds.total_ms.get()))
366            .is_none()
367        {
368            return Err("HAEMATITE_LYS_TIMEOUT_MS exceeds the monotonic clock".to_owned());
369        }
370        Ok(bounds)
371    }
372}
373
374pub(crate) fn positive_env(name: &str, default: u64) -> Result<NonZeroU64, String> {
375    match std::env::var(name) {
376        Ok(value) => value
377            .parse()
378            .map_err(|_| format!("{name} must be a positive integer")),
379        Err(std::env::VarError::NotPresent) => {
380            NonZeroU64::new(default).ok_or_else(|| format!("{name} has a zero default"))
381        }
382        Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} must be a positive integer")),
383    }
384}