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 store Studio keeps every person's arrangements and saved views in
23/// (surface/studio useWorkspaceKeeper.ts and namedViews.ts). A door always
24/// serves Studio's files, so its stores name this one (ACCESS-004 R1).
25const STUDIO_STORE: &str = "studio";
26/// The bound on a request's head, in bytes.
27const HEAD_CAP: usize = 16 * 1024;
28/// The bound on a request's body, in bytes.
29const BODY_CAP: usize = 1024 * 1024;
30/// The bound on each read and write of a browser's connection.
31const READ_TIMEOUT: Duration = Duration::from_secs(10);
32
33/// The whole of `haematite door`'s configuration file.
34#[derive(Debug, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Config {
37    /// Programs whose stdin pipes are owned by this door.
38    #[serde(default)]
39    pub children: Vec<super::children::Config>,
40    /// A loopback address; TLS is the proxy's in front of it.
41    pub listen: SocketAddr,
42    /// Studio's origin, as the browser sends it.
43    pub origin: String,
44    /// The absolute folder holding Studio's built files.
45    pub files: PathBuf,
46    /// Each store by name: its socket's absolute path, or a HAEMNET-001
47    /// network endpoint (HAEMNET-003 R1).
48    pub stores: BTreeMap<String, Store>,
49    /// The bound on each store connection's steps, in milliseconds.
50    #[serde(default = "timeout")]
51    pub timeout_ms: NonZeroU64,
52    /// Sixty-four bounds the threads serving browser and seat connections together.
53    #[serde(default = "connections")]
54    pub max_connections: NonZeroUsize,
55    /// The Lys app the door signs people in as. With none, the door is the
56    /// person at the keyboard's alone (ACCESS-003): a loopback origin, a
57    /// one-time key printed at start, and stores with no Lys.
58    #[serde(default)]
59    pub lys: Option<Lys>,
60    /// The optional seat socket and its admission and replay bounds.
61    pub assistant: Option<Assistant>,
62}
63
64/// One store the door carries verbs to (HAEMNET-003 R1).
65///
66/// Today's absolute socket path, or
67/// `{"endpoint": "<host>:<port>", "server_name": "<TLS name>"}` naming a
68/// HAEMNET-001 network endpoint, reached only over TLS verified for
69/// `server_name`.
70#[derive(Clone, Debug, Deserialize)]
71#[serde(untagged)]
72pub enum Store {
73    Socket(PathBuf),
74    Network(NetworkStore),
75}
76
77/// A network store entry as written, checked whole by [`Config::parse`] so
78/// each fault is refused by its own name.
79#[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    /// Any other field, refused by name: there is no setting that serves a
86    /// network store without verified TLS.
87    #[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/// Bounds on seats, attached streams and outstanding operations, counted separately.
117#[derive(Clone, Debug, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct Assistant {
120    /// Private local transport; a relative path could select another install.
121    pub socket: PathBuf,
122    /// 256 KiB admits bounded tool items without unbounded line allocation.
123    #[serde(default = "assistant_frame")]
124    pub frame_bytes: NonZeroUsize,
125    /// Two seats or tabs let a person work in two stores or windows.
126    #[serde(default = "assistant_person")]
127    pub max_per_person: NonZeroUsize,
128    /// Thirty-two limits one store's assistant and stream memory.
129    #[serde(default = "assistant_store")]
130    pub max_per_store: NonZeroUsize,
131    /// One frame bounds each replay page and its compressed sequence coverage.
132    #[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/// The Lys app the door signs people in as.
147#[derive(Debug, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct Lys {
150    /// Lys's origin.
151    pub issuer: String,
152    /// The app's client id in Lys.
153    pub client_id: String,
154    /// The absolute path of the file holding the app's secret.
155    pub secret_file: PathBuf,
156    /// Where Lys sends the browser back: the origin's `/callback`.
157    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    /// The lys block's fields, each refused by its own name; `origin` is the
170    /// door's, which the redirect returns to.
171    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
190/// The refusal of a door whose stores do not name [`STUDIO_STORE`]: one
191/// sentence naming it, what Studio keeps there, and the line to add, shaped
192/// as the configuration's own entries are: a socket beside the first socket
193/// it names, or an endpoint entry when every store is on another machine.
194fn 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    /// The configuration in `bytes`, checked whole.
221    ///
222    /// # Errors
223    ///
224    /// A message naming the field that is missing, unknown or unsafe.
225    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        // Last, so each field's own refusal is said first (ACCESS-004 R1).
287        if !self.stores.contains_key(STUDIO_STORE) {
288            return Err(studio_missing(&self.stores));
289        }
290        Ok(())
291    }
292
293    /// Whether the door signs people in through Lys: it has a lys block.
294    #[must_use]
295    pub const fn through_lys(&self) -> bool {
296        self.lys.is_some()
297    }
298
299    /// The bound on each store connection's steps.
300    #[must_use]
301    pub const fn timeout(&self) -> Duration {
302        Duration::from_millis(self.timeout_ms.get())
303    }
304
305    /// Where each store is reached, a network store verified against `trust`.
306    ///
307    /// # Errors
308    ///
309    /// The entry that does not parse, which [`Config::parse`] already refused.
310    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    /// What the door serves, its network stores verified against `trust`.
333    ///
334    /// # Errors
335    ///
336    /// As [`Config::addresses`].
337    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    /// The door's bounds on its connections.
360    #[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
371/// A door with no lys block serves only the machine it runs on: its origin
372/// is http or https on `127.0.0.1`, `localhost` or `[::1]`
373/// ([`crate::loopback_http::host`]), with no path, as the browser sends it.
374fn 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/// Bounds read from the process configuration, with no environment mutation.
398/// Defaults: 512 pending states, 12 hour sessions, 5 second connects and
399/// 30 second whole Lys requests.
400#[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    /// Read sign-in bounds from process configuration.
421    ///
422    /// # Errors
423    ///
424    /// Names an invalid bound, a connect bound beyond the total, or an unrepresentable timeout.
425    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}