Something went wrong. Try again.
Chris’s PDS implementation
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331//! Configuration is parsed once, at the process boundary.
mod origin;mod relay;
use std::{env, fmt::Display, net::SocketAddr, path::PathBuf, str::FromStr, time::Duration};
use atrium_api::types::string::{Did, Handle};use atrium_crypto::keypair::Secp256k1Keypair;use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};use url::Url;
use crate::{ domain::oauth::OAuthClient, error::{Error, ErrorContext, Result},};
pub use origin::HttpOrigin;
const DEVELOPMENT_PASSWORD: &str = "development-only";const DEVELOPMENT_SIGNING_KEY: &str = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub enum DeploymentMode { Local, Production,}
#[derive(Clone)]pub struct AccountConfig { pub did: String, pub handle: String, pub password: String, pub signing_key: [u8; 32],}
#[derive(Clone)]pub struct Config { pub deployment_mode: DeploymentMode, pub listen_address: SocketAddr, pub public_origin: HttpOrigin, pub database_path: PathBuf, pub blob_directory: PathBuf, pub blob_orphan_ttl: Duration, pub blob_gc_interval: Duration, pub identity_revalidation_interval: Duration, pub repo_event_retention: usize, pub repo_stream_send_timeout: Duration, pub relay_origins: Vec<HttpOrigin>, pub account: AccountConfig, pub oauth_clients: Vec<OAuthClient>,}
impl Config { /// Read configuration from environment variables. /// /// Defaults intentionally describe a local development server. Production /// mode requires explicit identity, storage, secret, and client values. /// /// # Errors /// /// Returns an error when an address or URL environment value is malformed. pub fn from_environment() -> Result<Self> { let mut invalid = None; let result = Self::from_values(|name| match env::var(name) { Ok(value) => Some(value), Err(env::VarError::NotPresent) => None, Err(env::VarError::NotUnicode(_)) => { invalid = Some(name.to_owned()); None } }); if let Some(name) = invalid { return Err(Error::Configuration(format!( "{name} contains non-Unicode data" ))); } result }
fn from_values(mut source: impl FnMut(&str) -> Option<String>) -> Result<Self> { let deployment_mode = match source("CPDS_ENVIRONMENT").as_deref() { None | Some("local") => DeploymentMode::Local, Some("production") => DeploymentMode::Production, _ => { return Err(Error::Configuration( "CPDS_ENVIRONMENT must be `local` or `production`".to_owned(), )); } }; let mut settings = Settings { source, deployment_mode, }; let config = Self { deployment_mode, listen_address: settings .value("CPDS_LISTEN", "127.0.0.1:3000", false)? .parse() .context(Error::Configuration, "invalid CPDS_LISTEN")?, public_origin: HttpOrigin::parse_public( &settings.value("CPDS_PUBLIC_URL", "http://127.0.0.1:3000", true)?, deployment_mode, )?, database_path: settings .value("CPDS_DATABASE", "cpds.sqlite3", true)? .into(), blob_directory: settings.value("CPDS_BLOB_DIRECTORY", "blobs", true)?.into(), blob_orphan_ttl: Duration::from_secs(settings.bounded( "CPDS_BLOB_ORPHAN_TTL_SECONDS", 86_400, 60, 31_536_000, )?), blob_gc_interval: Duration::from_secs(settings.bounded( "CPDS_BLOB_GC_INTERVAL_SECONDS", 3_600, 1, 86_400, )?), identity_revalidation_interval: Duration::from_secs(settings.bounded( "CPDS_IDENTITY_REVALIDATION_INTERVAL_SECONDS", 3_600, 60, 86_400, )?), repo_event_retention: settings.bounded( "CPDS_REPO_EVENT_RETENTION", 100_000, 1, 10_000_000, )?, repo_stream_send_timeout: Duration::from_millis(settings.bounded( "CPDS_REPO_STREAM_SEND_TIMEOUT_MS", 10_000, 100, 60_000, )?), relay_origins: settings.relay_origins()?, account: settings.account_config()?, oauth_clients: settings.oauth_clients()?, };
if deployment_mode == DeploymentMode::Production { validate_production(&config)?; }
Ok(config) }}
struct Settings<F> { source: F, deployment_mode: DeploymentMode,}
impl<F: FnMut(&str) -> Option<String>> Settings<F> { fn relay_origins(&mut self) -> Result<Vec<HttpOrigin>> { let Some(value) = (self.source)("CPDS_RELAYS") else { return Ok(Vec::new()); }; if value.is_empty() { return Err(Error::Configuration( "CPDS_RELAYS must not be empty".to_owned(), )); } let origins = value .split(',') .map(str::trim) .map(|value| HttpOrigin::parse_relay(value, self.deployment_mode)) .collect::<Result<Vec<_>>>()?; if origins.len() > 16 { return Err(Error::Configuration( "CPDS_RELAYS must contain at most 16 origins".to_owned(), )); } if origins .iter() .enumerate() .any(|(index, origin)| origins[..index].contains(origin)) { return Err(Error::Configuration( "CPDS_RELAYS must not contain duplicate origins".to_owned(), )); } Ok(origins) }
fn account_config(&mut self) -> Result<AccountConfig> { let signing_key: [u8; 32] = URL_SAFE_NO_PAD .decode(self.value("CPDS_ACCOUNT_SIGNING_KEY", DEVELOPMENT_SIGNING_KEY, true)?) .context( Error::Configuration, "CPDS_ACCOUNT_SIGNING_KEY must be base64url", )? .try_into() .map_err(|_| { Error::Configuration( "CPDS_ACCOUNT_SIGNING_KEY must decode to exactly 32 bytes".to_owned(), ) })?; Secp256k1Keypair::import(&signing_key).map_err(|_| { Error::Configuration( "CPDS_ACCOUNT_SIGNING_KEY is not a valid secp256k1 secret".to_owned(), ) })?;
let did = self.value("CPDS_ACCOUNT_DID", "did:web:localhost", true)?; Did::new(did.clone()).context(Error::Configuration, "invalid CPDS_ACCOUNT_DID")?; let handle = self.value("CPDS_ACCOUNT_HANDLE", "alice.test", true)?; Handle::new(handle.clone()).context(Error::Configuration, "invalid CPDS_ACCOUNT_HANDLE")?;
Ok(AccountConfig { did, handle, password: self.value("CPDS_ACCOUNT_PASSWORD", DEVELOPMENT_PASSWORD, true)?, signing_key, }) }
fn oauth_clients(&mut self) -> Result<Vec<OAuthClient>> { let client_id = self.required_url( "CPDS_OAUTH_CLIENT_ID", "http://127.0.0.1:4000/client-metadata.json", )?; let redirect_uri = self.required_url("CPDS_OAUTH_REDIRECT_URI", "http://127.0.0.1:4000/callback")?; let name = self.value("CPDS_OAUTH_CLIENT_NAME", "cpds development client", false)?; Ok(vec![OAuthClient { client_id, redirect_uris: vec![redirect_uri], name, }]) }
fn required_url(&mut self, name: &str, local_default: &str) -> Result<Url> { Url::parse(&self.value(name, local_default, true)?) .context(Error::Configuration, format!("invalid {name}")) }
fn bounded<T: FromStr + PartialOrd + Display + Copy>( &mut self, name: &str, default: T, minimum: T, maximum: T, ) -> Result<T> { self.value(name, &default.to_string(), false)? .parse::<T>() .ok() .filter(|value| (minimum..=maximum).contains(value)) .ok_or_else(|| { Error::Configuration(format!("{name} must be between {minimum} and {maximum}")) }) }
fn value( &mut self, name: &str, local_default: &str, required_in_production: bool, ) -> Result<String> { match (self.source)(name) { Some(value) if value.is_empty() => { Err(Error::Configuration(format!("{name} must not be empty"))) } Some(value) => Ok(value), None if self.deployment_mode == DeploymentMode::Production && required_in_production => { Err(Error::Configuration(format!( "{name} is required in production" ))) } None => Ok(local_default.to_owned()), } }}
fn validate_production(config: &Config) -> Result<()> { let public_origin = &config.public_origin; let account = &config.account; if config.database_path.as_os_str() == ":memory:" { return Err(Error::Configuration( "CPDS_DATABASE must be durable in production".to_owned(), )); } if account.did == "did:web:localhost" { return Err(Error::Configuration( "CPDS_ACCOUNT_DID must not use the local development identity in production".to_owned(), )); } let authority = public_origin.as_url().host_str().unwrap_or_default(); let authority = public_origin.as_url().port().map_or_else( || authority.to_owned(), |port| format!("{authority}%3A{port}"), ); if account.did.strip_prefix("did:web:") != Some(authority.as_str()) { return Err(Error::Configuration( "CPDS_ACCOUNT_DID must be the did:web identity hosted by CPDS_PUBLIC_URL".to_owned(), )); } if account.handle == "alice.test" { return Err(Error::Configuration( "CPDS_ACCOUNT_HANDLE must not use the local development handle in production" .to_owned(), )); } if account.password == DEVELOPMENT_PASSWORD || account.password.len() < 16 { return Err(Error::Configuration( "CPDS_ACCOUNT_PASSWORD must contain at least 16 bytes and must not use the development password" .to_owned(), )); } if account.signing_key == [7; 32] { return Err(Error::Configuration( "CPDS_ACCOUNT_SIGNING_KEY must not use the published development key in production" .to_owned(), )); } if config.oauth_clients[0].client_id.scheme() != "https" { return Err(Error::Configuration( "CPDS_OAUTH_CLIENT_ID must use HTTPS in production".to_owned(), )); } Ok(())}
#[cfg(test)]mod tests;