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