haematite_cli/door/
config.rs1use 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
20const HEAD_CAP: usize = 16 * 1024;
22const BODY_CAP: usize = 1024 * 1024;
24const READ_TIMEOUT: Duration = Duration::from_secs(10);
26
27#[derive(Debug, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct Config {
31 #[serde(default)]
33 pub children: Vec<super::children::Config>,
34 pub listen: SocketAddr,
36 pub origin: String,
38 pub files: PathBuf,
40 pub stores: BTreeMap<String, PathBuf>,
42 #[serde(default = "timeout")]
44 pub timeout_ms: NonZeroU64,
45 #[serde(default = "connections")]
47 pub max_connections: NonZeroUsize,
48 pub lys: Lys,
50 pub assistant: Option<Assistant>,
52}
53
54#[derive(Clone, Debug, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Assistant {
58 pub socket: PathBuf,
60 #[serde(default = "assistant_frame")]
62 pub frame_bytes: NonZeroUsize,
63 #[serde(default = "assistant_person")]
65 pub max_per_person: NonZeroUsize,
66 #[serde(default = "assistant_store")]
68 pub max_per_store: NonZeroUsize,
69 #[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#[derive(Debug, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct Lys {
88 pub issuer: String,
90 pub client_id: String,
92 pub secret_file: PathBuf,
94 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 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 #[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 #[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#[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 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}