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