use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::num::{NonZeroU64, NonZeroUsize};
use std::path::PathBuf;
use std::time::Duration;
use serde::Deserialize;
use super::listen::Limits;
use super::packed::Packed;
use super::route::Site;
const HEAD_CAP: usize = 16 * 1024;
const BODY_CAP: usize = 1024 * 1024;
const READ_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub listen: SocketAddr,
pub origin: String,
pub files: PathBuf,
pub stores: BTreeMap<String, PathBuf>,
#[serde(default = "timeout")]
pub timeout_ms: NonZeroU64,
#[serde(default = "connections")]
pub max_connections: NonZeroUsize,
pub lys: Lys,
pub assistant: Option<Assistant>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Assistant {
pub socket: PathBuf,
#[serde(default = "assistant_frame")]
pub frame_bytes: NonZeroUsize,
#[serde(default = "assistant_person")]
pub max_per_person: NonZeroUsize,
#[serde(default = "assistant_store")]
pub max_per_store: NonZeroUsize,
#[serde(default = "assistant_frame")]
pub events_after_bytes: NonZeroUsize,
}
const fn assistant_frame() -> NonZeroUsize {
NonZeroUsize::MIN.saturating_add(262_143)
}
const fn assistant_person() -> NonZeroUsize {
NonZeroUsize::MIN.saturating_add(1)
}
const fn assistant_store() -> NonZeroUsize {
NonZeroUsize::MIN.saturating_add(31)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Lys {
pub issuer: String,
pub client_id: String,
pub secret_file: PathBuf,
pub redirect: String,
}
const fn timeout() -> NonZeroU64 {
NonZeroU64::MIN.saturating_add(29_999)
}
const fn connections() -> NonZeroUsize {
NonZeroUsize::MIN.saturating_add(63)
}
impl Config {
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
let config: Self = serde_json::from_slice(bytes)
.map_err(|error| format!("the door configuration is not valid: {error}"))?;
config.check()?;
Ok(config)
}
fn check(&self) -> Result<(), String> {
if self
.assistant
.as_ref()
.is_some_and(|assistant| !assistant.socket.is_absolute())
{
return Err("assistant.socket must be absolute".to_owned());
}
if !self.listen.ip().is_loopback() {
return Err("listen must be a loopback address behind a TLS proxy".to_owned());
}
let origin =
url::Url::parse(&self.origin).map_err(|_| "origin is not an address".to_owned())?;
if origin.scheme() != "https" || origin.origin().ascii_serialization() != self.origin {
return Err(
"origin must be an https origin with no path, as the browser sends it".to_owned(),
);
}
if !self.files.is_absolute() {
return Err("files must be an absolute folder".to_owned());
}
if self.stores.is_empty() {
return Err("stores must name at least one store".to_owned());
}
for (name, socket) in &self.stores {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
{
return Err(format!(
"store name {name:?} must be letters, digits, - or _"
));
}
if !socket.is_absolute() {
return Err(format!("store socket for {name} must be an absolute path"));
}
}
let issuer = url::Url::parse(&self.lys.issuer)
.map_err(|_| "lys issuer is not an address".to_owned())?;
if !matches!(issuer.scheme(), "http" | "https") {
return Err("lys issuer must be an http or https address".to_owned());
}
if self.lys.client_id.is_empty() || self.lys.client_id.chars().any(char::is_control) {
return Err("lys client_id must be a name with no control character".to_owned());
}
if !self.lys.secret_file.is_absolute() {
return Err("lys secret_file must be an absolute path".to_owned());
}
if self.lys.redirect != format!("{}/callback", self.origin) {
return Err("lys redirect must be the origin's /callback".to_owned());
}
Ok(())
}
#[must_use]
pub fn site(&self) -> Site {
Site {
origin: self.origin.clone(),
files: self.files.clone(),
stores: self.stores.clone(),
timeout: Duration::from_millis(self.timeout_ms.get()),
packed: Packed::default(),
}
}
#[must_use]
pub const fn limits(&self) -> Limits {
Limits {
max_connections: self.max_connections.get(),
read_timeout: READ_TIMEOUT,
head_cap: HEAD_CAP,
body_cap: BODY_CAP,
}
}
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;
#[derive(Clone, Copy)]
pub struct SignInBounds {
pub max_pending: NonZeroUsize,
pub session_seconds: NonZeroU64,
pub connect_ms: NonZeroU64,
pub total_ms: NonZeroU64,
}
impl Default for SignInBounds {
fn default() -> Self {
Self {
max_pending: NonZeroUsize::MIN.saturating_add(511),
session_seconds: NonZeroU64::MIN.saturating_add(43_199),
connect_ms: NonZeroU64::MIN.saturating_add(4_999),
total_ms: timeout(),
}
}
}
impl SignInBounds {
pub fn from_env() -> Result<Self, String> {
let defaults = Self::default();
let max_pending = positive_env(
"HAEMATITE_PENDING_SIGNINS",
defaults.max_pending.get() as u64,
)?;
let bounds = Self {
max_pending: usize::try_from(max_pending.get())
.ok()
.and_then(NonZeroUsize::new)
.ok_or("HAEMATITE_PENDING_SIGNINS exceeds this machine's range")?,
session_seconds: positive_env(
"HAEMATITE_SESSION_SECONDS",
defaults.session_seconds.get(),
)?,
connect_ms: positive_env(
"HAEMATITE_LYS_CONNECT_TIMEOUT_MS",
defaults.connect_ms.get(),
)?,
total_ms: positive_env("HAEMATITE_LYS_TIMEOUT_MS", defaults.total_ms.get())?,
};
if bounds.connect_ms > bounds.total_ms {
return Err(
"HAEMATITE_LYS_CONNECT_TIMEOUT_MS exceeds HAEMATITE_LYS_TIMEOUT_MS".to_owned(),
);
}
if std::time::Instant::now()
.checked_add(Duration::from_millis(bounds.total_ms.get()))
.is_none()
{
return Err("HAEMATITE_LYS_TIMEOUT_MS exceeds the monotonic clock".to_owned());
}
Ok(bounds)
}
}
pub(crate) fn positive_env(name: &str, default: u64) -> Result<NonZeroU64, String> {
match std::env::var(name) {
Ok(value) => value
.parse()
.map_err(|_| format!("{name} must be a positive integer")),
Err(std::env::VarError::NotPresent) => {
NonZeroU64::new(default).ok_or_else(|| format!("{name} has a zero default"))
}
Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} must be a positive integer")),
}
}