diff --git a/bobbin/crates/bobbin/src/config.rs b/bobbin/crates/bobbin/src/config.rs index b3f897e1..71fd6754 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; @@ -14,6 +15,7 @@ const KNOWN_KEYS: &[&str] = &[ "server.binds", "server.shutdown_grace_secs", "server.debug_bind", + "server.hostname", "hydrant.url", "hydrant.start_cursor", "ingest.parallelism", @@ -36,6 +38,7 @@ const KNOWN_ENVS: &[&str] = &[ "BOBBIN_BIND", "BOBBIN_SHUTDOWN_GRACE_SECS", "BOBBIN_DEBUG_BIND", + "BOBBIN_HOSTNAME", "BOBBIN_HYDRANT_URL", "BOBBIN_START_CURSOR", "BOBBIN_INGEST_PARALLELISM", @@ -103,6 +106,12 @@ pub struct ServerConfig { /// never reachable on the public listener. Bind to loopback only. #[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, } #[derive(Debug, thiserror::Error)] @@ -259,6 +268,45 @@ 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", + ), + // TODO: add more + ] + .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 { @@ -269,10 +317,12 @@ pub fn load(path: Option<&PathBuf>) -> anyhow::Result { if let Some(p) = path { builder = builder.file(p); } - builder + let config: BobbinConfig = builder .file(SYSTEM_CONFIG_PATH) .load() - .context("load configuration") + .context("load configuration")?; + config.validate()?; + Ok(config) } pub fn template() -> String { diff --git a/bobbin/example.toml b/bobbin/example.toml index b5cbdb9b..d6f13dc9 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 = "" + [hydrant] # Base URL of the hydrant instance - the cursor-replayable /stream lives # under this. Use `ws://` or `wss://` - `http://` and `https://` diff --git a/docker-compose.yml b/docker-compose.yml index 31b4ef03..6bf67785 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -422,6 +422,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"