1use std::error::Error;
7use std::fmt;
8use std::net::SocketAddr;
9
10use axum::http::Uri;
11use blake3::Hash;
12
13const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:8000";
15const ENV_ADDRESS: &str = "MBA_ADDRESS";
17const ENV_PASSWORD: &str = "MBA_PASSWORD";
19const ENV_UPSTREAM: &str = "MBA_UPSTREAM";
22
23#[derive(Clone)]
29pub struct Config {
30 bind_address: SocketAddr,
32 session: Hash,
38 upstream: String,
43}
44
45impl Config {
46 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 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 #[must_use]
94 pub fn bind_address(&self) -> SocketAddr {
95 self.bind_address
96 }
97
98 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 pub(crate) fn session(&self) -> &Hash {
118 &self.session
119 }
120
121 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 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
139fn 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
164fn 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 if host.is_empty() {
200 return Err(invalid("missing host"));
201 }
202 if uri.port_u16() == Some(0) {
204 return Err(invalid("invalid port"));
205 }
206 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 Ok(uri.to_string())
229}
230
231#[derive(Debug, PartialEq, Eq)]
234pub enum ConfigError {
235 InvalidAddress { value: String, reason: &'static str },
237 MissingPassword,
239 MissingUpstream,
241 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 assert!(Config::from_values("hunter2", "http://host:080").is_ok());
444 }
445
446 #[test]
447 fn all_zero_port_is_rejected() {
448 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}