diff --git a/bobbin/crates/bobbin/src/config.rs b/bobbin/crates/bobbin/src/config.rs index a85c0537..5018f177 100644 --- a/bobbin/crates/bobbin/src/config.rs +++ b/bobbin/crates/bobbin/src/config.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::fmt; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -15,6 +16,7 @@ const KNOWN_KEYS: &[&str] = &[ "server.binds", "server.shutdown_grace_secs", "server.debug_bind", + "server.hostname", "server.trusted_proxies", "hydrant.url", "hydrant.start_cursor", @@ -38,6 +40,7 @@ const KNOWN_ENVS: &[&str] = &[ "BOBBIN_BIND", "BOBBIN_SHUTDOWN_GRACE_SECS", "BOBBIN_DEBUG_BIND", + "BOBBIN_HOSTNAME", "BOBBIN_TRUSTED_PROXIES", "BOBBIN_HYDRANT_URL", "BOBBIN_START_CURSOR", @@ -107,6 +110,12 @@ pub struct ServerConfig { #[config(env = "BOBBIN_DEBUG_BIND", default = "")] pub debug_bind: String, + /// Required. Public hostname clients reach this instance on, for example + /// `bobbin.example.com` or `localhost:8090`. Its `did:web` is the audience service-auth + /// tokens must be addressed to, so it has to match the host callers actually use. + #[config(env = "BOBBIN_HOSTNAME", default = "")] + pub hostname: String, + /// Reverse proxies in front of bobbin, /// each a bare IP address without a port or a CIDR block such as `173.245.48.0/20`. /// Bobbin will read the client address out of `x-forwarded-for` @@ -287,6 +296,48 @@ impl std::str::FromStr for LogFormat { } } +impl BobbinConfig { + pub fn validate(&self) -> Result<(), ConfigError> { + let errors: Vec = [ + check( + !self.server.hostname.is_empty(), + "server.hostname mustn't be empty", + ), + self.server.trusted_proxies() + .context("server.trusted_proxies takes a bare IP address or a CIDR block") + .err() + .map(|error| error.to_string()), + ] + .into_iter() + .flatten() + .collect(); + + if errors.is_empty() { + Ok(()) + } else { + Err(ConfigError { errors }) + } + } +} + +fn check(ok: bool, message: &str) -> Option { + (!ok).then(|| message.to_string()) +} + +#[derive(Debug, thiserror::Error)] +pub struct ConfigError { + pub errors: Vec, +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{} configuration problem(s):", self.errors.len())?; + self.errors + .iter() + .try_for_each(|error| writeln!(f, " - {error}")) + } +} + pub fn load(path: Option<&PathBuf>) -> anyhow::Result { check_envs(std::env::vars().map(|(k, _)| k))?; if let Some(p) = path { @@ -301,10 +352,7 @@ pub fn load(path: Option<&PathBuf>) -> anyhow::Result { .file(SYSTEM_CONFIG_PATH) .load() .context("load configuration")?; - config - .server - .trusted_proxies() - .context("server.trusted_proxies takes a bare IP address or a CIDR block")?; + config.validate()?; Ok(config) } diff --git a/bobbin/example.toml b/bobbin/example.toml index e9646645..c9e10c30 100644 --- a/bobbin/example.toml +++ b/bobbin/example.toml @@ -23,6 +23,15 @@ # Default value: "" #debug_bind = "" +# Required. Public hostname clients reach this instance on, for example +# `bobbin.example.com` or `localhost:8090`. Its `did:web` is the audience service-auth +# tokens must be addressed to, so it has to match the host callers actually use. +# +# Can also be specified via environment variable `BOBBIN_HOSTNAME`. +# +# Default value: "" +#hostname = "" + # Reverse proxies in front of bobbin, # each a bare IP address without a port or a CIDR block such as `173.245.48.0/20`. # Bobbin will read the client address out of `x-forwarded-for` diff --git a/docker-compose.yml b/docker-compose.yml index 4efb96db..8e62d64f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -423,6 +423,8 @@ services: restart: unless-stopped environment: BOBBIN_BIND: 0.0.0.0:8090 + # must match the host in the web service's BOBBIN_URL, or every token's aud check fails + BOBBIN_HOSTNAME: bobbin.tngl.boltless.dev BOBBIN_HYDRANT_URL: http://hydrant:3000 BOBBIN_SLINGSHOT_URL: http://hydrant:3000 BOBBIN_KNOT_ALLOW_PRIVATE: "true"