Skip to main content

mildly_basic_auth/
config.rs

1//! Startup configuration, validated once from the environment.
2//!
3//! `Config` is only constructible when every value is valid, so the rest
4//! of the program never has to defend against a half-configured state.
5
6use std::error::Error;
7use std::fmt;
8use std::net::SocketAddr;
9
10use axum::http::Uri;
11use blake3::Hash;
12
13/// Default IP socket address the service listens on.
14const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:8000";
15/// Environment variable holding the IP socket address to listen on.
16const ENV_ADDRESS: &str = "MBA_ADDRESS";
17/// Environment variable holding the plain password (required, non-empty).
18const ENV_PASSWORD: &str = "MBA_PASSWORD";
19/// Environment variable holding the upstream URL (required, absolute
20/// `http(s)://host[:port]`).
21const ENV_UPSTREAM: &str = "MBA_UPSTREAM";
22
23/// Immutable runtime configuration, built once at startup.
24///
25/// Cloned per request by the gate middleware (`from_fn_with_state`
26/// requires the state to be `Clone`), which is why fields are cheap to
27/// copy/clone (`Hash` and `SocketAddr` are `Copy`, `String` is `Clone`).
28#[derive(Clone)]
29pub struct Config {
30    /// Validated IP socket address the service listens on.
31    bind_address: SocketAddr,
32    /// BLAKE3 digest of the configured password. The session cookie
33    /// carries this same value as hex; a request authenticates by
34    /// presenting a cookie that matches it. Storing the digest (not the
35    /// password) keeps the plaintext out of memory after startup and out
36    /// of the browser jar.
37    session: Hash,
38    /// Validated absolute `http(s)://host[:port]` upstream. Stored as a
39    /// `String` (not `Uri`) because `ReverseProxy::new` takes one generic
40    /// `S: Into<String>` for both arguments; a `&Uri` would not satisfy
41    /// that bound.
42    upstream: String,
43}
44
45impl Config {
46    /// Build from the process environment. Delegates to the same pure
47    /// validation as [`Config::from_values`] so tests do not have to mutate
48    /// process-wide env (which is `unsafe` in Rust 2024).
49    ///
50    /// # Errors
51    ///
52    /// Returns [`ConfigError`] if `MBA_ADDRESS` is not a valid non-zero IP
53    /// socket address, `MBA_PASSWORD` is empty, or `MBA_UPSTREAM` is not a
54    /// valid absolute HTTP(S) URL.
55    pub fn from_env() -> Result<Self, ConfigError> {
56        let address = match std::env::var(ENV_ADDRESS) {
57            Ok(address) => address,
58            Err(std::env::VarError::NotPresent) => DEFAULT_BIND_ADDRESS.to_string(),
59            Err(std::env::VarError::NotUnicode(address)) => {
60                return Err(ConfigError::InvalidAddress {
61                    value: address.to_string_lossy().into_owned(),
62                    reason: "not valid Unicode",
63                });
64            }
65        };
66        let password = std::env::var(ENV_PASSWORD).unwrap_or_default();
67        let upstream = std::env::var(ENV_UPSTREAM).unwrap_or_default();
68        Self::from_values_and_address(&password, &upstream, &address)
69    }
70
71    /// Build and validate from explicit values (pure, env-free).
72    ///
73    /// # Examples
74    ///
75    /// ```
76    /// # use mildly_basic_auth::Config;
77    /// assert!(Config::from_values("hunter2", "http://app:2001").is_ok());
78    /// assert!(Config::from_values("", "http://app:2001").is_err()); // No password.
79    /// assert!(Config::from_values("hunter2", "/relative").is_err()); // No host.
80    /// ```
81    ///
82    /// # Errors
83    ///
84    /// Returns [`ConfigError::MissingPassword`] for an empty password, or
85    /// [`ConfigError::MissingUpstream`] / [`ConfigError::InvalidUpstream`]
86    /// if the upstream is not a valid absolute HTTP(S) URL. The bind
87    /// address uses the default `0.0.0.0:8000`.
88    pub fn from_values(password: &str, upstream: &str) -> Result<Self, ConfigError> {
89        Self::from_values_and_address(password, upstream, DEFAULT_BIND_ADDRESS)
90    }
91
92    /// Validated IP socket address the service listens on.
93    #[must_use]
94    pub fn bind_address(&self) -> SocketAddr {
95        self.bind_address
96    }
97
98    /// Build and validate the complete runtime configuration.
99    fn from_values_and_address(
100        password: &str,
101        upstream: &str,
102        address: &str,
103    ) -> Result<Self, ConfigError> {
104        if password.is_empty() {
105            return Err(ConfigError::MissingPassword);
106        }
107        let upstream = validate_upstream(upstream)?;
108        let address = validate_address(address)?;
109        Ok(Self {
110            bind_address: address,
111            session: blake3::hash(password.as_bytes()),
112            upstream,
113        })
114    }
115
116    /// Expected session-cookie digest.
117    pub(crate) fn session(&self) -> &Hash {
118        &self.session
119    }
120
121    /// Validated upstream URL, ready for `ReverseProxy::new`.
122    pub(crate) fn upstream(&self) -> &str {
123        &self.upstream
124    }
125}
126
127impl fmt::Debug for Config {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        // Never print the session digest: it is a password-equivalent
130        // bearer token, so an accidental log must not leak it.
131        f.debug_struct("Config")
132            .field("bind_address", &self.bind_address)
133            .field("session", &"<redacted>")
134            .field("upstream", &self.upstream)
135            .finish()
136    }
137}
138
139/// Validate `address` as a non-zero IP socket address.
140///
141/// # Examples
142///
143/// ```ignore
144/// assert!(validate_address("0.0.0.0:4630").is_ok());
145/// assert!(validate_address("[::]:4630").is_ok());
146/// assert!(validate_address("localhost:4630").is_err()); // Not an IP.
147/// assert!(validate_address("0.0.0.0:0").is_err()); // Zero port.
148/// ```
149fn validate_address(address: &str) -> Result<SocketAddr, ConfigError> {
150    let address = address.trim();
151    let invalid = |reason: &'static str| ConfigError::InvalidAddress {
152        value: address.to_string(),
153        reason,
154    };
155    let address: SocketAddr = address
156        .parse()
157        .map_err(|_| invalid("expected an IP address and port"))?;
158    if address.port() == 0 {
159        return Err(invalid("port must not be zero"));
160    }
161    Ok(address)
162}
163
164/// Validate `upstream` as an absolute `http(s)://host[:port]` URL.
165///
166/// Strictness matters: `http::Uri` parses relative refs like `/foo` or
167/// `example:8080` that carry no authority, and `axum-reverse-proxy` then
168/// `.expect()`s an authority and **panics** at request time. We reject
169/// anything that isn't an absolute HTTP(S) URL up front.
170///
171/// # Examples
172///
173/// ```ignore
174/// assert!(validate_upstream("http://app:2001").is_ok());
175/// assert!(validate_upstream("").is_err()); // Empty.
176/// assert!(validate_upstream("/foo").is_err()); // No scheme/host.
177/// assert!(validate_upstream("example:8080").is_err()); // No host.
178/// assert!(validate_upstream("ftp://host").is_err()); // Not HTTP(S).
179/// ```
180fn validate_upstream(upstream: &str) -> Result<String, ConfigError> {
181    let upstream = upstream.trim();
182    if upstream.is_empty() {
183        return Err(ConfigError::MissingUpstream);
184    }
185
186    let invalid = |reason: &'static str| ConfigError::InvalidUpstream {
187        value: upstream.to_string(),
188        reason,
189    };
190
191    let uri: Uri = upstream.parse().map_err(|_| invalid("not a valid URL"))?;
192
193    if !matches!(uri.scheme_str(), Some("http" | "https")) {
194        return Err(invalid("scheme must be http or https"));
195    }
196    let host = uri.host().unwrap_or_default();
197    // An authority can parse with an empty host (`http://:8080`), which the
198    // connector cannot use.
199    if host.is_empty() {
200        return Err(invalid("missing host"));
201    }
202    // Port 0 is never a real destination.
203    if uri.port_u16() == Some(0) {
204        return Err(invalid("invalid port"));
205    }
206    // `http::Uri` silently *drops* malformed detail: a bad port (`:abc`,
207    // `:`, `:99999`) or junk after an IPv6 literal (`[::1]junk`) all parse
208    // with that text discarded, so the connector would target the wrong
209    // place (e.g. the scheme default port). Catch it by reconstructing the
210    // canonical `host[:port]` from the parsed parts and requiring it to
211    // equal the input authority (ignoring any `userinfo@`); a mismatch is
212    // exactly the garbage that was dropped. The port is taken from
213    // `port().as_str()` (its original text), not `port_u16()`, so a valid
214    // leading-zero port like `:080` round-trips instead of collapsing to
215    // `:80` and failing the comparison.
216    let authority = uri.authority().map_or("", |a| a.as_str());
217    let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
218    let canonical = match uri.port() {
219        Some(port) => format!("{host}:{}", port.as_str()),
220        None => host.to_string(),
221    };
222    if host_port != canonical {
223        return Err(invalid("invalid host or port"));
224    }
225
226    // Normalized form. The proxy trims any trailing slash when joining
227    // paths, so the canonical `http://host:port/` cannot produce `//`.
228    Ok(uri.to_string())
229}
230
231/// Why a `Config` could not be built. Surfaced to stderr at startup so a
232/// misconfiguration fails fast with an actionable message.
233#[derive(Debug, PartialEq, Eq)]
234pub enum ConfigError {
235    /// `MBA_ADDRESS` was not a valid non-zero IP socket address.
236    InvalidAddress { value: String, reason: &'static str },
237    /// `MBA_PASSWORD` was unset or empty.
238    MissingPassword,
239    /// `MBA_UPSTREAM` was unset or empty.
240    MissingUpstream,
241    /// `MBA_UPSTREAM` was set but is not an absolute HTTP(S) URL.
242    InvalidUpstream { value: String, reason: &'static str },
243}
244
245impl fmt::Display for ConfigError {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        match self {
248            Self::InvalidAddress { value, reason } => write!(
249                f,
250                "`{ENV_ADDRESS}` is not a valid IP socket address ({reason}): {value:?}"
251            ),
252            Self::MissingPassword => {
253                write!(f, "`{ENV_PASSWORD}` must be set to a non-empty value")
254            }
255            Self::MissingUpstream => write!(f, "`{ENV_UPSTREAM}` must be set"),
256            Self::InvalidUpstream { value, reason } => write!(
257                f,
258                "`{ENV_UPSTREAM}` is not a valid absolute http(s) URL \
259                 ({reason}): {value:?}"
260            ),
261        }
262    }
263}
264
265impl Error for ConfigError {}
266
267#[cfg(test)]
268mod tests {
269    use std::assert_matches;
270
271    use super::*;
272
273    #[test]
274    fn valid_values_build_a_config() {
275        assert!(Config::from_values("hunter2", "http://app:2001").is_ok());
276    }
277
278    #[test]
279    fn values_use_the_default_address() {
280        let config = Config::from_values("hunter2", "http://app:2001").unwrap();
281        assert_eq!(config.bind_address(), "0.0.0.0:8000".parse().unwrap());
282    }
283
284    #[test]
285    fn custom_ipv4_address_is_accepted() {
286        let config =
287            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:4630")
288                .unwrap();
289        assert_eq!(config.bind_address(), "127.0.0.1:4630".parse().unwrap());
290    }
291
292    #[test]
293    fn custom_ipv6_address_is_accepted() {
294        let config =
295            Config::from_values_and_address("hunter2", "http://app:2001", "[::1]:4630").unwrap();
296        assert_eq!(config.bind_address(), "[::1]:4630".parse().unwrap());
297    }
298
299    #[test]
300    fn surrounding_whitespace_in_address_is_trimmed() {
301        let config =
302            Config::from_values_and_address("hunter2", "http://app:2001", "  127.0.0.1:4630  ")
303                .unwrap();
304        assert_eq!(config.bind_address(), "127.0.0.1:4630".parse().unwrap());
305    }
306
307    #[test]
308    fn empty_address_is_rejected() {
309        assert_matches!(
310            Config::from_values_and_address("hunter2", "http://app:2001", ""),
311            Err(ConfigError::InvalidAddress { .. })
312        );
313    }
314
315    #[test]
316    fn hostname_address_is_rejected() {
317        assert_matches!(
318            Config::from_values_and_address("hunter2", "http://app:2001", "localhost:4630"),
319            Err(ConfigError::InvalidAddress { .. })
320        );
321    }
322
323    #[test]
324    fn address_without_port_is_rejected() {
325        assert_matches!(
326            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1"),
327            Err(ConfigError::InvalidAddress { .. })
328        );
329    }
330
331    #[test]
332    fn address_with_non_numeric_port_is_rejected() {
333        assert_matches!(
334            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:abc"),
335            Err(ConfigError::InvalidAddress { .. })
336        );
337    }
338
339    #[test]
340    fn address_with_out_of_range_port_is_rejected() {
341        assert_matches!(
342            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:99999"),
343            Err(ConfigError::InvalidAddress { .. })
344        );
345    }
346
347    #[test]
348    fn address_with_zero_port_is_rejected() {
349        assert_matches!(
350            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:0"),
351            Err(ConfigError::InvalidAddress { .. })
352        );
353    }
354
355    #[test]
356    fn https_upstream_is_accepted() {
357        assert!(Config::from_values("hunter2", "https://app.example.com").is_ok());
358    }
359
360    #[test]
361    fn empty_password_is_rejected() {
362        assert_matches!(
363            Config::from_values("", "http://app:2001"),
364            Err(ConfigError::MissingPassword)
365        );
366    }
367
368    #[test]
369    fn empty_upstream_is_rejected() {
370        assert_matches!(
371            Config::from_values("hunter2", ""),
372            Err(ConfigError::MissingUpstream)
373        );
374    }
375
376    #[test]
377    fn relative_upstream_is_rejected() {
378        assert_matches!(
379            Config::from_values("hunter2", "/foo"),
380            Err(ConfigError::InvalidUpstream { .. })
381        );
382    }
383
384    #[test]
385    fn scheme_only_upstream_without_authority_is_rejected() {
386        assert_matches!(
387            Config::from_values("hunter2", "example:8080"),
388            Err(ConfigError::InvalidUpstream { .. })
389        );
390    }
391
392    #[test]
393    fn non_http_scheme_is_rejected() {
394        assert_matches!(
395            Config::from_values("hunter2", "ftp://host"),
396            Err(ConfigError::InvalidUpstream { .. })
397        );
398    }
399
400    #[test]
401    fn empty_host_upstream_is_rejected() {
402        assert_matches!(
403            Config::from_values("hunter2", "http://:8080"),
404            Err(ConfigError::InvalidUpstream { .. })
405        );
406    }
407
408    #[test]
409    fn out_of_range_port_is_rejected() {
410        assert_matches!(
411            Config::from_values("hunter2", "http://host:99999"),
412            Err(ConfigError::InvalidUpstream { .. })
413        );
414    }
415
416    #[test]
417    fn non_numeric_port_is_rejected() {
418        assert_matches!(
419            Config::from_values("hunter2", "http://host:abc"),
420            Err(ConfigError::InvalidUpstream { .. })
421        );
422    }
423
424    #[test]
425    fn empty_port_is_rejected() {
426        assert_matches!(
427            Config::from_values("hunter2", "http://host:"),
428            Err(ConfigError::InvalidUpstream { .. })
429        );
430    }
431
432    #[test]
433    fn zero_port_is_rejected() {
434        assert_matches!(
435            Config::from_values("hunter2", "http://host:0"),
436            Err(ConfigError::InvalidUpstream { .. })
437        );
438    }
439
440    #[test]
441    fn leading_zero_port_is_accepted() {
442        // `:080` is a valid port (80); the connector resolves it fine.
443        assert!(Config::from_values("hunter2", "http://host:080").is_ok());
444    }
445
446    #[test]
447    fn all_zero_port_is_rejected() {
448        // `:00` is still port 0.
449        assert_matches!(
450            Config::from_values("hunter2", "http://host:00"),
451            Err(ConfigError::InvalidUpstream { .. })
452        );
453    }
454
455    #[test]
456    fn ipv6_upstream_is_accepted() {
457        assert!(Config::from_values("hunter2", "http://[::1]:8080").is_ok());
458    }
459
460    #[test]
461    fn ipv6_with_trailing_junk_is_rejected() {
462        assert_matches!(
463            Config::from_values("hunter2", "http://[::1]junk"),
464            Err(ConfigError::InvalidUpstream { .. })
465        );
466    }
467
468    #[test]
469    fn ipv6_junk_before_port_is_rejected() {
470        assert_matches!(
471            Config::from_values("hunter2", "http://[::1]junk:8080"),
472            Err(ConfigError::InvalidUpstream { .. })
473        );
474    }
475
476    #[test]
477    fn session_digest_matches_blake3_of_password() {
478        let config = Config::from_values("hunter2", "http://app:2001").unwrap();
479        assert_eq!(config.session(), &blake3::hash(b"hunter2"));
480    }
481
482    #[test]
483    fn surrounding_whitespace_in_upstream_is_trimmed() {
484        let config = Config::from_values("hunter2", "  http://app:2001  ").unwrap();
485        assert_eq!(config.upstream(), "http://app:2001/");
486    }
487}