Skip to main content

kasl_server/
config.rs

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