Skip to main content

kasl_server/
config.rs

1use std::net::SocketAddr;
2
3use anyhow::{Context, Result};
4
5/// Default bind address when `KASL_SERVER_ADDR` is not set.
6const DEFAULT_ADDR: &str = "0.0.0.0:8080";
7
8/// Days one batch may carry. A month of backfill in a single request, which is
9/// generous for the case it exists for and still bounded.
10const DEFAULT_MAX_BATCH_DAYS: usize = 31;
11
12/// Largest upload body accepted, in bytes. A day of dense activity is a few
13/// kilobytes; a month of them, with room to spare, is well under this.
14const DEFAULT_MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
15
16/// Runtime configuration, read from the environment.
17#[derive(Debug, Clone)]
18pub struct Config {
19    /// Address the HTTP server binds to (`KASL_SERVER_ADDR`).
20    pub addr: SocketAddr,
21    /// PostgreSQL connection string (`DATABASE_URL`).
22    pub database_url: String,
23    /// Agents to provision on startup (`KASL_AGENTS`), as `email:token` pairs.
24    /// The bootstrap way in until the admin UI issues tokens.
25    pub agents: String,
26    /// Days one batch upload may carry (`KASL_MAX_BATCH_DAYS`).
27    pub max_batch_days: usize,
28    /// Largest request body accepted (`KASL_MAX_BODY_BYTES`).
29    pub max_body_bytes: usize,
30    /// Bootstrap administrator (`KASL_ADMIN`), as `email:password`.
31    pub admin: String,
32    /// Whether session cookies carry `Secure` (`KASL_SECURE_COOKIES`).
33    ///
34    /// On by default, because a server holding a team's hours belongs behind
35    /// TLS. Turned off only for a stand reached over plain http, where a
36    /// `Secure` cookie is dropped by the browser and login silently does
37    /// nothing at all.
38    pub secure_cookies: bool,
39}
40
41impl Config {
42    pub fn from_env() -> Result<Self> {
43        Self::from_lookup(|key| std::env::var(key).ok())
44    }
45
46    /// A configuration with every limit at its default. Used where the limits
47    /// are not the subject - the router built for tests, for instance - so the
48    /// defaults live in one place rather than being restated.
49    pub fn defaults_for_database(database_url: String) -> Self {
50        Self {
51            addr: DEFAULT_ADDR.parse().expect("the default address is valid"),
52            database_url,
53            agents: String::new(),
54            max_batch_days: DEFAULT_MAX_BATCH_DAYS,
55            max_body_bytes: DEFAULT_MAX_BODY_BYTES,
56            admin: String::new(),
57            secure_cookies: true,
58        }
59    }
60
61    /// The environment is passed in as a lookup so tests can supply their own.
62    fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self> {
63        let addr = lookup("KASL_SERVER_ADDR").unwrap_or_else(|| DEFAULT_ADDR.to_string());
64        let addr = addr
65            .parse()
66            .with_context(|| format!("KASL_SERVER_ADDR is not a valid socket address: {addr}"))?;
67        let database_url = lookup("DATABASE_URL").context("DATABASE_URL is not set (e.g. postgres://kasl:kasl@localhost:5432/kasl)")?;
68        let agents = lookup("KASL_AGENTS").unwrap_or_default();
69        let max_batch_days = positive("KASL_MAX_BATCH_DAYS", &lookup, DEFAULT_MAX_BATCH_DAYS)?;
70        let max_body_bytes = positive("KASL_MAX_BODY_BYTES", &lookup, DEFAULT_MAX_BODY_BYTES)?;
71        let admin = lookup("KASL_ADMIN").unwrap_or_default();
72        let secure_cookies = boolean("KASL_SECURE_COOKIES", &lookup, true)?;
73        Ok(Self {
74            addr,
75            database_url,
76            agents,
77            max_batch_days,
78            max_body_bytes,
79            admin,
80            secure_cookies,
81        })
82    }
83}
84
85/// Reads a flag written the way an operator would write one.
86///
87/// Refuses anything else rather than guessing: reading `KASL_SECURE_COOKIES=no`
88/// as true would turn a typo into a server that quietly cannot be logged into.
89fn boolean(key: &str, lookup: &impl Fn(&str) -> Option<String>, default: bool) -> Result<bool> {
90    let Some(raw) = lookup(key) else { return Ok(default) };
91    match raw.trim().to_ascii_lowercase().as_str() {
92        "1" | "true" | "yes" | "on" => Ok(true),
93        "0" | "false" | "no" | "off" => Ok(false),
94        other => anyhow::bail!("{key} is not a yes/no value: {other}"),
95    }
96}
97
98/// Reads a limit, refusing zero: a limit of nothing accepts nothing, and a
99/// server that silently rejects every upload is worse than one that will not
100/// start.
101fn positive(key: &str, lookup: &impl Fn(&str) -> Option<String>, default: usize) -> Result<usize> {
102    let Some(raw) = lookup(key) else { return Ok(default) };
103    let value: usize = raw.parse().with_context(|| format!("{key} is not a positive whole number: {raw}"))?;
104    if value == 0 {
105        anyhow::bail!("{key} must be greater than zero");
106    }
107    Ok(value)
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
115        move |key| pairs.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string())
116    }
117
118    #[test]
119    fn defaults_the_bind_address() {
120        let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).expect("config should build with only DATABASE_URL set");
121        assert_eq!(config.addr, DEFAULT_ADDR.parse().unwrap());
122        assert_eq!(config.database_url, "postgres://localhost/kasl");
123    }
124
125    #[test]
126    fn reads_the_bind_address_override() {
127        let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "127.0.0.1:9090")]))
128            .expect("config should accept a valid override");
129        assert_eq!(config.addr, "127.0.0.1:9090".parse().unwrap());
130    }
131
132    #[test]
133    fn requires_database_url() {
134        let error = Config::from_lookup(env(&[])).unwrap_err();
135        assert!(error.to_string().contains("DATABASE_URL"));
136    }
137
138    #[test]
139    fn limits_have_defaults_and_can_be_overridden() {
140        let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
141        assert_eq!(config.max_batch_days, DEFAULT_MAX_BATCH_DAYS);
142        assert_eq!(config.max_body_bytes, DEFAULT_MAX_BODY_BYTES);
143
144        let config = Config::from_lookup(env(&[
145            ("DATABASE_URL", "postgres://localhost/kasl"),
146            ("KASL_MAX_BATCH_DAYS", "7"),
147            ("KASL_MAX_BODY_BYTES", "1048576"),
148        ]))
149        .unwrap();
150        assert_eq!(config.max_batch_days, 7);
151        assert_eq!(config.max_body_bytes, 1048576);
152    }
153
154    #[test]
155    fn a_limit_of_zero_is_refused() {
156        // Zero accepts nothing. A server that silently rejects every upload is
157        // worse than one that refuses to start and says why.
158        let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BATCH_DAYS", "0")])).unwrap_err();
159        assert!(error.to_string().contains("KASL_MAX_BATCH_DAYS"), "{error}");
160
161        let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BODY_BYTES", "nope")])).unwrap_err();
162        assert!(error.to_string().contains("KASL_MAX_BODY_BYTES"), "{error}");
163    }
164
165    #[test]
166    fn secure_cookies_default_on_and_refuse_a_typo() {
167        let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
168        assert!(config.secure_cookies, "TLS is the assumption; opting out has to be deliberate");
169
170        for value in ["0", "false", "no", "off", "OFF"] {
171            let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SECURE_COOKIES", value)])).unwrap();
172            assert!(!config.secure_cookies, "`{value}` should turn it off");
173        }
174
175        // The failure mode this guards: a value nobody parses as false, read as
176        // true, giving a server that cannot be logged into over plain http with
177        // nothing in the log to say why.
178        let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SECURE_COOKIES", "nope")])).unwrap_err();
179        assert!(error.to_string().contains("KASL_SECURE_COOKIES"), "{error}");
180    }
181
182    #[test]
183    fn rejects_a_malformed_bind_address() {
184        let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "not-an-address")])).unwrap_err();
185        assert!(error.to_string().contains("KASL_SERVER_ADDR"));
186    }
187}