use std::collections::HashSet;
use std::error::Error;
use std::ffi::OsString;
use std::fmt;
use std::net::SocketAddr;
use axum::body::Bytes;
use axum::http::Uri;
use blake3::Hash;
const WALL_TEMPLATE: &str = include_str!("index.html");
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:8000";
const ENV_ADDRESS: &str = "MBA_ADDRESS";
const ENV_PASSWORD: &str = "MBA_PASSWORD";
const ENV_PASSWORD_PREFIX: &str = "MBA_PASSWORD_";
const ENV_TEMPLATE_PAGE_LANGUAGE: &str = "MBA_TEMPLATE_PAGE_LANGUAGE";
const ENV_TEMPLATE_PAGE_TITLE: &str = "MBA_TEMPLATE_PAGE_TITLE";
const ENV_TEMPLATE_PASSWORD_LABEL: &str = "MBA_TEMPLATE_PASSWORD_LABEL";
const ENV_TEMPLATE_PASSWORD_PLACEHOLDER: &str = "MBA_TEMPLATE_PASSWORD_PLACEHOLDER";
const ENV_TEMPLATE_SUBMIT_BUTTON_TEXT: &str = "MBA_TEMPLATE_SUBMIT_BUTTON_TEXT";
const ENV_UPSTREAM: &str = "MBA_UPSTREAM";
#[derive(Clone)]
pub struct Config {
bind_address: SocketAddr,
sessions: Vec<Hash>,
upstream: String,
wall: Bytes,
}
impl Config {
pub fn from_env() -> Result<Self, ConfigError> {
let address = match std::env::var(ENV_ADDRESS) {
Ok(address) => address,
Err(std::env::VarError::NotPresent) => DEFAULT_BIND_ADDRESS.to_string(),
Err(std::env::VarError::NotUnicode(address)) => {
return Err(ConfigError::InvalidAddress {
value: address.to_string_lossy().into_owned(),
reason: "not valid Unicode",
});
}
};
let passwords = passwords_from_env(std::env::vars_os());
let passwords: Vec<&str> = passwords.iter().map(String::as_str).collect();
let template_text = TemplateText::from_env(std::env::vars_os())?;
let upstream = std::env::var(ENV_UPSTREAM).unwrap_or_default();
Self::from_passwords_address_and_template_text(
&passwords,
&upstream,
&address,
&template_text,
)
}
pub fn from_values(password: &str, upstream: &str) -> Result<Self, ConfigError> {
Self::from_values_and_address(password, upstream, DEFAULT_BIND_ADDRESS)
}
pub fn from_passwords(passwords: &[&str], upstream: &str) -> Result<Self, ConfigError> {
Self::from_passwords_and_address(passwords, upstream, DEFAULT_BIND_ADDRESS)
}
#[must_use]
pub fn bind_address(&self) -> SocketAddr {
self.bind_address
}
fn from_values_and_address(
password: &str,
upstream: &str,
address: &str,
) -> Result<Self, ConfigError> {
Self::from_passwords_and_address(&[password], upstream, address)
}
fn from_passwords_and_address(
passwords: &[&str],
upstream: &str,
address: &str,
) -> Result<Self, ConfigError> {
Self::from_passwords_address_and_template_text(
passwords,
upstream,
address,
&TemplateText::default(),
)
}
fn from_passwords_address_and_template_text(
passwords: &[&str],
upstream: &str,
address: &str,
template_text: &TemplateText,
) -> Result<Self, ConfigError> {
let passwords: Vec<&str> = passwords
.iter()
.copied()
.filter(|p| !p.is_empty())
.collect();
if passwords.is_empty() {
return Err(ConfigError::MissingPassword);
}
let unique: HashSet<&str> = passwords.iter().copied().collect();
if unique.len() != passwords.len() {
return Err(ConfigError::DuplicatePassword);
}
let sessions: Vec<Hash> = passwords
.iter()
.map(|p| blake3::hash(p.as_bytes()))
.collect();
let upstream = validate_upstream(upstream)?;
let address = validate_address(address)?;
Ok(Self {
bind_address: address,
sessions,
upstream,
wall: render_template(WALL_TEMPLATE, template_text),
})
}
pub(crate) fn sessions(&self) -> &[Hash] {
&self.sessions
}
pub(crate) fn upstream(&self) -> &str {
&self.upstream
}
pub(crate) fn wall(&self) -> &Bytes {
&self.wall
}
}
impl fmt::Debug for Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Config")
.field("bind_address", &self.bind_address)
.field("sessions", &"<redacted>")
.field("upstream", &self.upstream)
.field("wall", &"<rendered HTML>")
.finish()
}
}
struct TemplateText {
page_language: String,
page_title: String,
password_label: String,
password_placeholder: String,
submit_button_text: String,
}
impl TemplateText {
fn from_env(vars: impl Iterator<Item = (OsString, OsString)>) -> Result<Self, ConfigError> {
let mut text = Self::default();
for (name, value) in vars {
let Some(name) = name.to_str() else { continue };
let target = match name {
ENV_TEMPLATE_PAGE_LANGUAGE => &mut text.page_language,
ENV_TEMPLATE_PAGE_TITLE => &mut text.page_title,
ENV_TEMPLATE_PASSWORD_LABEL => &mut text.password_label,
ENV_TEMPLATE_PASSWORD_PLACEHOLDER => &mut text.password_placeholder,
ENV_TEMPLATE_SUBMIT_BUTTON_TEXT => &mut text.submit_button_text,
_ => continue,
};
*target = value
.into_string()
.map_err(|_| ConfigError::InvalidTemplateText {
variable: name.to_owned(),
})?;
}
Ok(text)
}
}
impl Default for TemplateText {
fn default() -> Self {
Self {
page_language: "en".to_owned(),
page_title: "Welcome!".to_owned(),
password_label: "Password".to_owned(),
password_placeholder: "Password".to_owned(),
submit_button_text: "Enter".to_owned(),
}
}
}
fn render_template(template: &str, text: &TemplateText) -> Bytes {
let mut rendered = String::with_capacity(template.len());
let mut remaining = template;
while let Some(marker_start) = remaining.find("{{") {
rendered.push_str(&remaining[..marker_start]);
remaining = &remaining[marker_start..];
let replacement = [
("{{PAGE_LANGUAGE}}", text.page_language.as_str()),
("{{PAGE_TITLE}}", text.page_title.as_str()),
("{{PASSWORD_LABEL}}", text.password_label.as_str()),
(
"{{PASSWORD_PLACEHOLDER}}",
text.password_placeholder.as_str(),
),
("{{SUBMIT_BUTTON_TEXT}}", text.submit_button_text.as_str()),
]
.into_iter()
.find(|(marker, _)| remaining.starts_with(marker));
if let Some((marker, value)) = replacement {
push_html_escaped(&mut rendered, value);
remaining = &remaining[marker.len()..];
} else {
rendered.push_str("{{");
remaining = &remaining[2..];
}
}
rendered.push_str(remaining);
Bytes::from(rendered)
}
fn push_html_escaped(rendered: &mut String, value: &str) {
for character in value.chars() {
match character {
'&' => rendered.push_str("&"),
'<' => rendered.push_str("<"),
'>' => rendered.push_str(">"),
'"' => rendered.push_str("""),
'\'' => rendered.push_str("'"),
_ => rendered.push(character),
}
}
}
fn passwords_from_env(vars: impl Iterator<Item = (OsString, OsString)>) -> Vec<String> {
vars.filter_map(|(name, value)| {
let name = name.to_str()?;
(name == ENV_PASSWORD || name.starts_with(ENV_PASSWORD_PREFIX))
.then(|| value.into_string().ok())
.flatten()
})
.collect()
}
fn validate_address(address: &str) -> Result<SocketAddr, ConfigError> {
let address = address.trim();
let invalid = |reason: &'static str| ConfigError::InvalidAddress {
value: address.to_string(),
reason,
};
let address: SocketAddr = address
.parse()
.map_err(|_| invalid("expected an IP address and port"))?;
if address.port() == 0 {
return Err(invalid("port must not be zero"));
}
Ok(address)
}
fn validate_upstream(upstream: &str) -> Result<String, ConfigError> {
let upstream = upstream.trim();
if upstream.is_empty() {
return Err(ConfigError::MissingUpstream);
}
let invalid = |reason: &'static str| ConfigError::InvalidUpstream {
value: upstream.to_string(),
reason,
};
let uri: Uri = upstream.parse().map_err(|_| invalid("not a valid URL"))?;
if !matches!(uri.scheme_str(), Some("http" | "https")) {
return Err(invalid("scheme must be http or https"));
}
let host = uri.host().unwrap_or_default();
if host.is_empty() {
return Err(invalid("missing host"));
}
if uri.port_u16() == Some(0) {
return Err(invalid("invalid port"));
}
let authority = uri.authority().map_or("", |a| a.as_str());
let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
let canonical = match uri.port() {
Some(port) => format!("{host}:{}", port.as_str()),
None => host.to_string(),
};
if host_port != canonical {
return Err(invalid("invalid host or port"));
}
Ok(uri.to_string())
}
#[derive(Debug, PartialEq, Eq)]
pub enum ConfigError {
InvalidAddress { value: String, reason: &'static str },
MissingPassword,
DuplicatePassword,
InvalidTemplateText { variable: String },
MissingUpstream,
InvalidUpstream { value: String, reason: &'static str },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidAddress { value, reason } => write!(
f,
"`{ENV_ADDRESS}` is not a valid IP socket address ({reason}): {value:?}"
),
Self::MissingPassword => write!(
f,
"at least one password variable (`{ENV_PASSWORD}` or \
`{ENV_PASSWORD_PREFIX}<label>`) must have a non-empty UTF-8 value"
),
Self::DuplicatePassword => write!(
f,
"two password variables share the same value; each password \
must be distinct to be revoked independently"
),
Self::InvalidTemplateText { variable } => {
write!(f, "`{variable}` must contain valid Unicode")
}
Self::MissingUpstream => write!(f, "`{ENV_UPSTREAM}` must be set"),
Self::InvalidUpstream { value, reason } => write!(
f,
"`{ENV_UPSTREAM}` is not a valid absolute http(s) URL \
({reason}): {value:?}"
),
}
}
}
impl Error for ConfigError {}
#[cfg(test)]
mod tests {
use std::assert_matches;
use super::*;
fn template_text_from_vars(vars: &[(&str, &str)]) -> TemplateText {
TemplateText::from_env(
vars.iter()
.map(|(name, value)| (OsString::from(name), OsString::from(value))),
)
.unwrap()
}
#[test]
fn valid_values_build_a_config() {
assert!(Config::from_values("hunter2", "http://app:2001").is_ok());
}
#[test]
fn default_template_text_renders_the_existing_page_text() {
let config = Config::from_values("hunter2", "http://app:2001").unwrap();
let wall = std::str::from_utf8(config.wall()).unwrap();
assert!(wall.contains("<html lang=\"en\">"));
assert!(wall.contains("<title>Welcome!</title>"));
assert!(wall.contains(">Password</label>"));
assert!(wall.contains("placeholder=\"Password\""));
assert!(wall.contains(">Enter</button>"));
}
#[test]
fn page_language_variable_overrides_the_default() {
let text = template_text_from_vars(&[(ENV_TEMPLATE_PAGE_LANGUAGE, "fr")]);
assert_eq!(text.page_language, "fr");
}
#[test]
fn page_title_variable_overrides_the_default() {
let text = template_text_from_vars(&[(ENV_TEMPLATE_PAGE_TITLE, "Mon site")]);
assert_eq!(text.page_title, "Mon site");
}
#[test]
fn password_label_variable_overrides_the_default() {
let text = template_text_from_vars(&[(ENV_TEMPLATE_PASSWORD_LABEL, "Mot de passe")]);
assert_eq!(text.password_label, "Mot de passe");
}
#[test]
fn password_placeholder_variable_overrides_the_default() {
let text = template_text_from_vars(&[(ENV_TEMPLATE_PASSWORD_PLACEHOLDER, "Secret")]);
assert_eq!(text.password_placeholder, "Secret");
}
#[test]
fn submit_button_text_variable_overrides_the_default() {
let text = template_text_from_vars(&[(ENV_TEMPLATE_SUBMIT_BUTTON_TEXT, "Entrer")]);
assert_eq!(text.submit_button_text, "Entrer");
}
#[test]
fn empty_template_text_variable_overrides_the_default() {
let text = template_text_from_vars(&[(ENV_TEMPLATE_PAGE_TITLE, "")]);
assert!(text.page_title.is_empty());
}
#[test]
fn unknown_template_variable_is_ignored() {
let text = template_text_from_vars(&[("MBA_TEMPLATE_FILE", "/tmp/wall.html")]);
assert_eq!(text.page_title, "Welcome!");
}
#[cfg(unix)]
#[test]
fn non_unicode_template_text_is_rejected() {
use std::os::unix::ffi::OsStringExt;
let result = TemplateText::from_env(
[(
OsString::from(ENV_TEMPLATE_PAGE_TITLE),
OsString::from_vec(vec![0xff]),
)]
.into_iter(),
);
let Err(error) = result else {
panic!("non-Unicode template text was accepted");
};
assert_matches!(
error,
ConfigError::InvalidTemplateText { variable }
if variable == ENV_TEMPLATE_PAGE_TITLE
);
}
#[test]
fn template_values_are_html_escaped() {
let text = TemplateText {
page_title: "&<>\"'".to_owned(),
..TemplateText::default()
};
let rendered = render_template("{{PAGE_TITLE}}", &text);
assert_eq!(rendered, "&<>"'");
}
#[test]
fn template_values_are_not_rendered_recursively() {
let text = TemplateText {
page_title: "{{SUBMIT_BUTTON_TEXT}}".to_owned(),
submit_button_text: "Submit".to_owned(),
..TemplateText::default()
};
let rendered = render_template("{{PAGE_TITLE}}", &text);
assert_eq!(rendered, "{{SUBMIT_BUTTON_TEXT}}");
}
#[test]
fn template_without_known_markers_is_unchanged() {
let rendered = render_template("<p>{{UNKNOWN}}</p>", &TemplateText::default());
assert_eq!(rendered, "<p>{{UNKNOWN}}</p>");
}
#[test]
fn empty_template_is_valid() {
let rendered = render_template("", &TemplateText::default());
assert!(rendered.is_empty());
}
#[test]
fn values_use_the_default_address() {
let config = Config::from_values("hunter2", "http://app:2001").unwrap();
assert_eq!(config.bind_address(), "0.0.0.0:8000".parse().unwrap());
}
#[test]
fn custom_ipv4_address_is_accepted() {
let config =
Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:4630")
.unwrap();
assert_eq!(config.bind_address(), "127.0.0.1:4630".parse().unwrap());
}
#[test]
fn custom_ipv6_address_is_accepted() {
let config =
Config::from_values_and_address("hunter2", "http://app:2001", "[::1]:4630").unwrap();
assert_eq!(config.bind_address(), "[::1]:4630".parse().unwrap());
}
#[test]
fn surrounding_whitespace_in_address_is_trimmed() {
let config =
Config::from_values_and_address("hunter2", "http://app:2001", " 127.0.0.1:4630 ")
.unwrap();
assert_eq!(config.bind_address(), "127.0.0.1:4630".parse().unwrap());
}
#[test]
fn empty_address_is_rejected() {
assert_matches!(
Config::from_values_and_address("hunter2", "http://app:2001", ""),
Err(ConfigError::InvalidAddress { .. })
);
}
#[test]
fn hostname_address_is_rejected() {
assert_matches!(
Config::from_values_and_address("hunter2", "http://app:2001", "localhost:4630"),
Err(ConfigError::InvalidAddress { .. })
);
}
#[test]
fn address_without_port_is_rejected() {
assert_matches!(
Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1"),
Err(ConfigError::InvalidAddress { .. })
);
}
#[test]
fn address_with_non_numeric_port_is_rejected() {
assert_matches!(
Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:abc"),
Err(ConfigError::InvalidAddress { .. })
);
}
#[test]
fn address_with_out_of_range_port_is_rejected() {
assert_matches!(
Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:99999"),
Err(ConfigError::InvalidAddress { .. })
);
}
#[test]
fn address_with_zero_port_is_rejected() {
assert_matches!(
Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:0"),
Err(ConfigError::InvalidAddress { .. })
);
}
#[test]
fn https_upstream_is_accepted() {
assert!(Config::from_values("hunter2", "https://app.example.com").is_ok());
}
#[test]
fn empty_password_is_rejected() {
assert_matches!(
Config::from_values("", "http://app:2001"),
Err(ConfigError::MissingPassword)
);
}
#[test]
fn empty_upstream_is_rejected() {
assert_matches!(
Config::from_values("hunter2", ""),
Err(ConfigError::MissingUpstream)
);
}
#[test]
fn relative_upstream_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "/foo"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn scheme_only_upstream_without_authority_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "example:8080"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn non_http_scheme_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "ftp://host"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn empty_host_upstream_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://:8080"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn out_of_range_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://host:99999"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn non_numeric_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://host:abc"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn empty_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://host:"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn zero_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://host:0"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn leading_zero_port_is_accepted() {
assert!(Config::from_values("hunter2", "http://host:080").is_ok());
}
#[test]
fn all_zero_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://host:00"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn ipv6_upstream_is_accepted() {
assert!(Config::from_values("hunter2", "http://[::1]:8080").is_ok());
}
#[test]
fn ipv6_with_trailing_junk_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://[::1]junk"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn ipv6_junk_before_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://[::1]junk:8080"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn session_digest_matches_blake3_of_password() {
let config = Config::from_values("hunter2", "http://app:2001").unwrap();
assert_eq!(config.sessions(), &[blake3::hash(b"hunter2")]);
}
#[test]
fn multiple_passwords_build_one_digest_each() {
let config = Config::from_passwords(&["hunter2", "swordfish"], "http://app:2001").unwrap();
assert_eq!(
config.sessions(),
&[blake3::hash(b"hunter2"), blake3::hash(b"swordfish")]
);
}
#[test]
fn empty_passwords_are_filtered_out() {
let config = Config::from_passwords(&["", "hunter2", ""], "http://app:2001").unwrap();
assert_eq!(config.sessions(), &[blake3::hash(b"hunter2")]);
}
#[test]
fn all_empty_passwords_are_rejected() {
assert_matches!(
Config::from_passwords(&["", ""], "http://app:2001"),
Err(ConfigError::MissingPassword)
);
}
#[test]
fn no_passwords_are_rejected() {
assert_matches!(
Config::from_passwords(&[], "http://app:2001"),
Err(ConfigError::MissingPassword)
);
}
#[test]
fn duplicate_passwords_are_rejected() {
assert_matches!(
Config::from_passwords(&["hunter2", "hunter2"], "http://app:2001"),
Err(ConfigError::DuplicatePassword)
);
}
#[test]
fn duplicate_check_ignores_filtered_empties() {
assert!(Config::from_passwords(&["a", "", ""], "http://app:2001").is_ok());
}
#[test]
fn passwords_from_env_collects_base_and_suffixed() {
let vars = [
("MBA_PASSWORD", "alpha"),
("MBA_PASSWORD_BOB", "bravo"),
("MBA_ADDRESS", "0.0.0.0:8000"),
("MBA_UPSTREAM", "http://app:2001"),
("PATH", "/usr/bin"),
]
.into_iter()
.map(|(k, v)| (OsString::from(k), OsString::from(v)));
let mut got = passwords_from_env(vars);
got.sort();
assert_eq!(got, ["alpha".to_string(), "bravo".to_string()]);
}
#[test]
fn passwords_from_env_keeps_empty_values_for_later_filtering() {
let vars = [("MBA_PASSWORD_BOB", "")]
.into_iter()
.map(|(k, v)| (OsString::from(k), OsString::from(v)));
assert_eq!(passwords_from_env(vars), [String::new()]);
}
#[cfg(unix)]
#[test]
fn passwords_from_env_skips_non_unicode_without_panicking() {
use std::os::unix::ffi::OsStringExt;
let vars = vec![
(OsString::from_vec(vec![0xff]), OsString::from("ignored")),
(
OsString::from("MBA_PASSWORD_BAD"),
OsString::from_vec(vec![0xff]),
),
(OsString::from("MBA_PASSWORD"), OsString::from("alpha")),
]
.into_iter();
assert_eq!(passwords_from_env(vars), ["alpha".to_string()]);
}
#[test]
fn surrounding_whitespace_in_upstream_is_trimmed() {
let config = Config::from_values("hunter2", " http://app:2001 ").unwrap();
assert_eq!(config.upstream(), "http://app:2001/");
}
}