Something went wrong. Try again.
A downstream fork of Tranquil PDS for use in margin.cafe
Something went wrong. Try again.
75 kB · 2186 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187use confique::Config;use std::fmt;use std::path::PathBuf;use std::sync::OnceLock;
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
const REMOVED_ENV_VARS: &[(&str, &str)] = &[( "SENDMAIL_PATH", "the sendmail-binary transport was replaced with native SMTP. \ Configure MAIL_SMARTHOST_HOST for relay delivery, or leave it unset to \ deliver directly via recipient MX records. See example.toml for the full \ MAIL_* surface.",)];
/// Errors discovered during configuration validation.#[derive(Debug)]pub struct ConfigError { pub errors: Vec<String>,}
impl fmt::Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "configuration validation failed:")?; for err in &self.errors { writeln!(f, " - {err}")?; } Ok(()) }}
impl std::error::Error for ConfigError {}
/// Initialize the global configuration. Must be called once at startup before/// any other code accesses the configuration. Panics if called more than once.pub fn init(config: TranquilConfig) { CONFIG .set(config) .expect("tranquil-config: configuration already initialized");}
/// Returns a reference to the global configuration./// Panics if [`init`] has not been called yet.pub fn get() -> &'static TranquilConfig { CONFIG .get() .expect("tranquil-config: not initialized - call tranquil_config::init() first")}
/// Returns a reference to the global configuration if it has been initialized.pub fn try_get() -> Option<&'static TranquilConfig> { CONFIG.get()}
/// Initialize with minimal defaults for unit tests./// Noop if already initialized.pub fn ensure_test_defaults() { use std::env; let _ = CONFIG.get_or_init(|| { unsafe { if env::var("PDS_HOSTNAME").is_err() { env::set_var("PDS_HOSTNAME", "test.local"); } if env::var("DATABASE_URL").is_err() { env::set_var("DATABASE_URL", "postgres://localhost/test"); } if env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() { env::set_var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"); } if env::var("INVITE_CODE_REQUIRED").is_err() { env::set_var("INVITE_CODE_REQUIRED", "false"); } if env::var("ENABLE_PDS_HOSTED_DID_WEB").is_err() { env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true"); } if env::var("TRANQUIL_LEXICON_OFFLINE").is_err() { env::set_var("TRANQUIL_LEXICON_OFFLINE", "1"); } } TranquilConfig::builder() .env() .load() .expect("failed to load test config defaults") });}
/// Load configuration from an optional TOML file path, with environment/// variable overrides applied on top. Fields annotated with `#[config(env)]`/// are read from the corresponding environment variables when the `.env()`/// layer is active.////// Precedence (highest to lowest):/// 1. Environment variables/// 2. Toml config file passed as `config_path`, if provided/// 3. `/etc/tranquil-pds/config.toml` - hardcoded fallback, silently skipped if absent/// 4. Built-in defaultspub fn load(config_path: Option<&PathBuf>) -> Result<TranquilConfig, confique::Error> { let mut builder = TranquilConfig::builder().env(); if let Some(path) = config_path { builder = builder.file(path); } builder.file("/etc/tranquil-pds/config.toml").load()}
// Root configuration#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct TranquilConfig { #[config(nested)] pub server: ServerConfig,
#[config(nested)] pub frontend: FrontendConfig,
#[config(nested)] pub database: DatabaseConfig,
#[config(nested)] pub secrets: SecretsConfig,
#[config(nested)] pub storage: StorageConfig,
#[config(nested)] pub tranquil_store: TranquilStoreConfig,
#[config(nested)] pub cache: CacheConfig,
#[config(nested)] pub plc: PlcConfig,
#[config(nested)] pub firehose: FirehoseConfig,
#[config(nested)] pub email: EmailConfig,
#[config(nested)] pub discord: DiscordConfig,
#[config(nested)] pub telegram: TelegramConfig,
#[config(nested)] pub notifications: NotificationConfig,
#[config(nested)] pub sso: SsoConfig,
#[config(nested)] pub moderation: ModerationConfig,
#[config(nested)] pub import: ImportConfig,
#[config(nested)] pub scheduled: ScheduledConfig,}
impl TranquilConfig { /// Validate cross-field constraints that cannot be expressed through /// confique's declarative defaults alone. Call this once after loading /// the configuration and before [`init`]. /// /// Returns `Ok(())` when the configuration is consistent, or a /// [`ConfigError`] listing every problem found. pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> { let mut errors = Vec::new();
// -- removed config --------------------------------------------------- errors.extend( REMOVED_ENV_VARS .iter() .filter(|(var, _)| std::env::var_os(var).is_some()) .map(|(var, guidance)| format!("{var} is no longer supported: {guidance}")), );
// -- database -------------------------------------------------------- self.database.validate(&mut errors);
// -- secrets ---------------------------------------------------------- if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) { if let Some(ref s) = self.secrets.jwt_secret { if s.len() < 32 { errors.push( "secrets.jwt_secret (JWT_SECRET) must be at least 32 characters" .to_string(), ); } } else { errors.push( "secrets.jwt_secret (JWT_SECRET) is required in production \ (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" .to_string(), ); }
if let Some(ref s) = self.secrets.dpop_secret { if s.len() < 32 { errors.push( "secrets.dpop_secret (DPOP_SECRET) must be at least 32 characters" .to_string(), ); } } else { errors.push( "secrets.dpop_secret (DPOP_SECRET) is required in production \ (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" .to_string(), ); }
if let Some(ref s) = self.secrets.master_key { if s.len() < 32 { errors.push( "secrets.master_key (MASTER_KEY) must be at least 32 characters" .to_string(), ); } } else { errors.push( "secrets.master_key (MASTER_KEY) is required in production \ (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" .to_string(), ); } }
// -- email ----------------------------------------------------------- self.email .validate(self.server.hostname_without_port(), &mut errors);
// -- telegram --------------------------------------------------------- if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() { errors.push( "telegram.bot_token is set but telegram.webhook_secret is missing; \ both are required for secure Telegram integration" .to_string(), ); }
// -- blob storage ----------------------------------------------------- match self.storage.backend.as_str() { "s3" => { if self.storage.s3_bucket.is_none() { errors.push( "storage.backend is \"s3\" but storage.s3_bucket (S3_BUCKET) \ is not set" .to_string(), ); } } "filesystem" => {} other => { errors.push(format!( "storage.backend must be \"filesystem\" or \"s3\", got \"{other}\"" )); } }
// -- tls -------------------------------------------------------------- self.server.tls.validate(&mut errors); if self .server .rate_limit_bypass_key .as_deref() .is_some_and(|key| key.trim().is_empty()) { errors.push( "server.rate_limit_bypass_key (PDS_RATE_LIMIT_BYPASS_KEY) must not be empty" .to_string(), ); } if let Some(ips) = &self.server.rate_limit_bypass_ips { for ip in ips { if ip.parse::<std::net::IpAddr>().is_err() { errors.push(format!( "server.rate_limit_bypass_ips (PDS_RATE_LIMIT_BYPASS_IPS) contains invalid IP address: {ip}" )); } } }
// -- cache ------------------------------------------------------------ self.cache.validate(&mut errors);
// -- SSO providers ---------------------------------------------------- self.validate_sso_provider("sso.github", &self.sso.github, &mut errors); self.validate_sso_provider("sso.google", &self.sso.google, &mut errors); self.validate_sso_provider("sso.discord", &self.sso.discord, &mut errors); self.validate_sso_with_issuer("sso.gitlab", &self.sso.gitlab, &mut errors); self.validate_sso_with_issuer("sso.oidc", &self.sso.oidc, &mut errors); self.validate_sso_apple(&mut errors);
// -- moderation ------------------------------------------------------- let has_url = self.moderation.report_service_url.is_some(); let has_did = self.moderation.report_service_did.is_some(); if has_url != has_did { errors.push( "moderation.report_service_url and moderation.report_service_did \ must both be set or both be unset" .to_string(), ); }
// -- repo backend ----------------------------------------------------- if let Err(e) = self.storage.repo_backend.parse::<RepoBackend>() { errors.push(e); }
// -- tranquil-store --------------------------------------------------- if let Some(mb) = self.tranquil_store.memory_budget_mb && mb == 0 { errors.push("tranquil_store.memory_budget_mb must be at least 1".to_string()); } if let Some(threads) = self.tranquil_store.handler_threads && threads == 0 { errors.push("tranquil_store.handler_threads must be at least 1".to_string()); } if self.tranquil_store.eventlog_max_event_payload == 0 { errors.push( "tranquil_store.eventlog_max_event_payload \ (TRANQUIL_STORE_EVENTLOG_MAX_EVENT_PAYLOAD) must be at least 1; \ a value of 0 would reject every event" .to_string(), ); }
// -- scheduled / event retention -------------------------------------- const MAX_RETENTION_SECS: u64 = (i64::MAX / 1000) as u64; if self.scheduled.event_retention_max_age_secs > MAX_RETENTION_SECS { errors.push(format!( "scheduled.event_retention_max_age_secs (EVENT_RETENTION_MAX_AGE_SECS) \ must be at most {MAX_RETENTION_SECS} (chrono::Duration limit); got {}", self.scheduled.event_retention_max_age_secs )); } if self.scheduled.event_retention_interval_secs > 0 { let backfill_secs = u64::try_from(self.firehose.backfill_hours.max(0)) .unwrap_or(0) .saturating_mul(3600); if self.scheduled.event_retention_max_age_secs < backfill_secs { errors.push(format!( "scheduled.event_retention_max_age_secs ({}) is shorter than \ firehose.backfill_hours ({}h = {backfill_secs}s): \ relays would receive cursor responses pointing at pruned events. \ Increase event_retention_max_age_secs or decrease firehose.backfill_hours.", self.scheduled.event_retention_max_age_secs, self.firehose.backfill_hours, )); } }
// -- cache ------------------------------------------------------------ match self.cache.backend.as_str() { "valkey" => { if self.cache.valkey_url.is_none() { errors.push( "cache.backend is \"valkey\" but cache.valkey_url (VALKEY_URL) \ is not set" .to_string(), ); } } "ripple" => {} other => { errors.push(format!( "cache.backend must be \"ripple\" or \"valkey\", got \"{other}\"" )); } }
if errors.is_empty() { Ok(()) } else { Err(ConfigError { errors }) } }
fn validate_sso_provider( &self, prefix: &str, p: &impl SsoProviderConfig, errors: &mut Vec<String>, ) { if p.get_enabled() { if p.get_client_id().is_none() { errors.push(format!( "{prefix}.client_id is required when {prefix}.enabled = true" )); } if p.get_client_secret().is_none() { errors.push(format!( "{prefix}.client_secret is required when {prefix}.enabled = true" )); } } }
fn validate_sso_with_issuer( &self, prefix: &str, p: &(impl SsoProviderConfig + SsoProviderIssuerConfig), errors: &mut Vec<String>, ) { self.validate_sso_provider(prefix, p, errors); if p.get_enabled() && p.get_issuer().is_none() { errors.push(format!( "{prefix}.issuer is required when {prefix}.enabled = true" )); } }
fn validate_sso_apple(&self, errors: &mut Vec<String>) { let p = &self.sso.apple; if p.enabled { if p.client_id.is_none() { errors.push( "sso.apple.client_id is required when sso.apple.enabled = true".to_string(), ); } if p.team_id.is_none() { errors.push( "sso.apple.team_id is required when sso.apple.enabled = true".to_string(), ); } if p.key_id.is_none() { errors .push("sso.apple.key_id is required when sso.apple.enabled = true".to_string()); } if p.private_key.is_none() { errors.push( "sso.apple.private_key is required when sso.apple.enabled = true".to_string(), ); } } }}
// ---------------------------------------------------------------------------// Server// ---------------------------------------------------------------------------
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct ServerConfig { /// Public hostname of the PDS, such as `pds.example.com`. #[config(env = "PDS_HOSTNAME")] pub hostname: String,
/// Address to bind the HTTP server to. #[config(env = "SERVER_HOST", default = "127.0.0.1")] pub host: String,
/// Port to bind the HTTP server to. #[config(env = "SERVER_PORT", default = 3000)] pub port: u16,
/// List of domains for user handles. /// Defaults to the PDS hostname when not set. #[config(env = "PDS_USER_HANDLE_DOMAINS", parse_env = split_comma_list)] pub user_handle_domains: Option<Vec<String>>,
/// Enable PDS-hosted did:web identities. Hosting did:web requires a /// long-term commitment to serve DID documents; opt-in only. #[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)] pub enable_pds_hosted_did_web: bool,
/// When set to true, skip age-assurance birthday prompt for all accounts. #[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)] pub age_assurance_override: bool,
/// Require an invite code for new account registration. #[config(env = "INVITE_CODE_REQUIRED", default = true)] pub invite_code_required: bool,
/// Allow HTTP (non-TLS) proxy requests. Only useful during development. #[config(env = "ALLOW_HTTP_PROXY", default = false)] pub allow_http_proxy: bool,
/// Disable all rate limiting. Should only be used in testing. #[config(env = "DISABLE_RATE_LIMITING", default = false)] pub disable_rate_limiting: bool,
/// Client IP addresses that bypass all rate limits. #[config(env = "PDS_RATE_LIMIT_BYPASS_IPS", parse_env = split_comma_list)] pub rate_limit_bypass_ips: Option<Vec<String>>,
/// Optional value accepted in the x-ratelimit-bypass request header. #[config(env = "PDS_RATE_LIMIT_BYPASS_KEY")] pub rate_limit_bypass_key: Option<String>,
/// Skip the verified-comms-channel gate for login and record writes. /// Please keep this off unless you're an invite-only PDS! #[config(env = "DISABLE_ACCOUNT_VERIFICATION_GATE", default = false)] pub disable_account_verification_gate: bool,
/// List of additional banned words for handle validation. #[config(env = "PDS_BANNED_WORDS", parse_env = split_comma_list)] pub banned_words: Option<Vec<String>>,
/// Whether to reserve the built-in list of common and notable account names. /// Protocol-specific names remain reserved regardless of this setting. #[config(env = "PDS_USE_DEFAULT_RESERVED_HANDLES", default = true)] pub use_default_reserved_handles: bool,
/// Additional account names to reserve locally. #[config(env = "PDS_RESERVED_HANDLES", parse_env = split_comma_list)] pub reserved_handles: Option<Vec<String>>,
/// URL to a privacy policy page. #[config(env = "PRIVACY_POLICY_URL")] pub privacy_policy_url: Option<String>,
/// URL to terms of service page. #[config(env = "TERMS_OF_SERVICE_URL")] pub terms_of_service_url: Option<String>,
/// Operator contact email address. #[config(env = "CONTACT_EMAIL")] pub contact_email: Option<String>,
/// Maximum allowed blob size in bytes (default 10 GiB). #[config(env = "MAX_BLOB_SIZE", default = 10_737_418_240u64)] pub max_blob_size: u64,
/// Maximum allowed number of preferences #[config(env = "MAX_PREFERENCES_COUNT", default = 1000)] pub max_preferences_count: usize,
/// If you're not altering TLS config, you don't have to worry about this. /// This is the number of trusted reverse proxies in front of Tranquil. /// We read the client IP used for rate limiting and device records this many hops /// from the right of the X-Forwarded-For header. /// When left unset, Tranquil will assume: /// - 0, if the TLS termination is happening here on Tranquil via the TLS config /// - 1, if the TLS termination *isn't* happening here. #[config(env = "TRUSTED_PROXY_COUNT")] pub trusted_proxy_count: Option<usize>,
#[config(nested)] pub tls: TlsConfig,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct TlsConfig { /// The path to the TLS cert chain. /// If you set both this and `key_path`, the server terminates TLS itself rather than expecting /// a reverse proxy to do it. The certificate and key reload on SIGHUP. #[config(env = "TLS_CERT_PATH")] pub cert_path: Option<String>,
/// Path to the TLS private key. #[config(env = "TLS_KEY_PATH")] pub key_path: Option<String>,
/// Serve HTTP/3 over QUIC on the same UDP port as the TCP listener. /// Requires cert_path and key_path. #[config(env = "TLS_HTTP3", default = false)] pub http3: bool,}
impl TlsConfig { /// The certificate and key paths when both are configured. pub fn material(&self) -> Option<(&str, &str)> { match (self.cert_path.as_deref(), self.key_path.as_deref()) { (Some(cert), Some(key)) => Some((cert, key)), _ => None, } }
pub fn validate(&self, errors: &mut Vec<String>) { if self.cert_path.is_some() != self.key_path.is_some() { errors.push( "server.tls.cert_path (TLS_CERT_PATH) and server.tls.key_path (TLS_KEY_PATH) \ must both be set to enable app-level TLS, or both be unset" .to_string(), ); } if self.http3 && self.material().is_none() { errors.push( "server.tls.http3 (TLS_HTTP3) requires server.tls.cert_path \ and erver.tls.key_path" .to_string(), ); } }}
impl ServerConfig { /// The public HTTPS URL for this PDS. pub fn public_url(&self) -> String { format!("https://{}", self.hostname) }
/// Hostname without port suffix. Returns `pds.example.com` from `pds.example.com:443`. pub fn hostname_without_port(&self) -> &str { self.hostname.split(':').next().unwrap_or(&self.hostname) }
/// Returns the extra banned words list, or an empty vec when unset. pub fn banned_word_list(&self) -> Vec<String> { self.banned_words.clone().unwrap_or_default() }
/// Returns the user handle domains without leading dots, falling back to /// `[hostname_without_port]`. pub fn user_handle_domain_list(&self) -> Vec<String> { self.user_handle_domains .as_deref() .filter(|v| !v.is_empty()) .map(|v| { v.iter() .map(|domain| domain.trim_start_matches('.').to_string()) .collect() }) .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) }
/// Returns the user handle domains with the leading dot expected by /// `com.atproto.server.describeServer` clients. pub fn available_user_domain_list(&self) -> Vec<String> { self.user_handle_domain_list() .into_iter() .map(|domain| format!(".{domain}")) .collect() }}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct FrontendConfig { /// Whether to enable the built in serving of the frontend. #[config(env = "FRONTEND_ENABLED", default = true)] pub enabled: bool,
/// Directory to serve as the frontend. The oauth_client_metadata.json will have any references to /// the frontend hostname replaced by the configured frontend hostname. #[config(env = "FRONTEND_DIR", default = "/var/lib/tranquil-pds/frontend")] pub dir: String,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct DatabaseConfig { /// PostgreSQL connection URL. When PgBouncer support is enabled, this /// should point to a transaction-pooled PgBouncer endpoint. #[config(env = "DATABASE_URL")] pub url: String,
/// Direct PostgreSQL URL used for migrations and LISTEN notifications when /// normal queries go through transaction-pooled PgBouncer. #[config(env = "DATABASE_DIRECT_URL")] pub direct_url: Option<String>,
/// Enable compatibility with PgBouncer transaction pooling. #[config(env = "DATABASE_PGBOUNCER", default = false)] pub pgbouncer: bool,
/// Maximum number of connections in the pool. #[config(env = "DATABASE_MAX_CONNECTIONS", default = 100)] pub max_connections: u32,
/// Minimum number of idle connections kept in the pool. #[config(env = "DATABASE_MIN_CONNECTIONS", default = 10)] pub min_connections: u32,
/// Timeout in seconds when acquiring a connection from the pool. #[config(env = "DATABASE_ACQUIRE_TIMEOUT_SECS", default = 10)] pub acquire_timeout_secs: u64,}
impl DatabaseConfig { fn validate(&self, errors: &mut Vec<String>) { if self.pgbouncer && self .direct_url .as_deref() .is_none_or(|url| url.trim().is_empty()) { errors.push( "database.pgbouncer is enabled but database.direct_url \ (DATABASE_DIRECT_URL) is not set; migrations and LISTEN require a direct \ PostgreSQL connection" .to_string(), ); } if self.max_connections == 0 { errors.push("database.max_connections must be greater than zero".to_string()); } if self.min_connections > self.max_connections { errors.push( "database.min_connections cannot exceed database.max_connections".to_string(), ); } if self.acquire_timeout_secs == 0 { errors.push("database.acquire_timeout_secs must be greater than zero".to_string()); } }}
#[derive(Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct SecretsConfig { /// Secret used for signing JWTs. Must be at least 32 characters in /// production. #[config(env = "JWT_SECRET")] pub jwt_secret: Option<String>,
/// Secret used for DPoP proof validation. Must be at least 32 characters /// in production. #[config(env = "DPOP_SECRET")] pub dpop_secret: Option<String>,
/// Master key used for key-encryption and HKDF derivation. Must be at /// least 32 characters in production. #[config(env = "MASTER_KEY")] pub master_key: Option<String>,
/// Optional operator-held PLC recovery key, as a public `did:key`. The PDS /// continues to sign PLC operations with the per-account signing key, which /// always remains in `rotationKeys`. #[config(env = "PLC_ROTATION_KEY")] pub plc_rotation_key: Option<String>,
/// Allow insecure/test secrets. NEVER enable in production. #[config(env = "TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", default = false)] pub allow_insecure: bool,}
impl std::fmt::Debug for SecretsConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SecretsConfig") .field( "jwt_secret", &self.jwt_secret.as_ref().map(|_| "[REDACTED]"), ) .field( "dpop_secret", &self.dpop_secret.as_ref().map(|_| "[REDACTED]"), ) .field( "master_key", &self.master_key.as_ref().map(|_| "[REDACTED]"), ) .field( "plc_rotation_key", &self.plc_rotation_key.as_ref().map(|_| "[REDACTED]"), ) .field("allow_insecure", &self.allow_insecure) .finish() }}
impl SecretsConfig { /// Resolve the JWT secret, falling back to an insecure default if /// `allow_insecure` is true. pub fn jwt_secret_or_default(&self) -> String { self.jwt_secret.clone().unwrap_or_else(|| { if cfg!(test) || self.allow_insecure { "test-jwt-secret-not-for-production".to_string() } else { panic!( "JWT_SECRET must be set in production. \ Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." ); } }) }
/// Resolve the DPoP secret, falling back to an insecure default if /// `allow_insecure` is true. pub fn dpop_secret_or_default(&self) -> String { self.dpop_secret.clone().unwrap_or_else(|| { if cfg!(test) || self.allow_insecure { "test-dpop-secret-not-for-production".to_string() } else { panic!( "DPOP_SECRET must be set in production. \ Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." ); } }) }
/// Resolve the master key, falling back to an insecure default if /// `allow_insecure` is true. pub fn master_key_or_default(&self) -> String { self.master_key.clone().unwrap_or_else(|| { if cfg!(test) || self.allow_insecure { "test-master-key-not-for-production".to_string() } else { panic!( "MASTER_KEY must be set in production. \ Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." ); } }) }}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum RepoBackend { Postgres, TranquilStore,}
impl std::str::FromStr for RepoBackend { type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> { match s { "postgres" => Ok(Self::Postgres), "tranquil-store" => Ok(Self::TranquilStore), other => Err(format!( "unknown repo backend \"{other}\", expected \"postgres\" or \"tranquil-store\"" )), } }}
impl fmt::Display for RepoBackend { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Postgres => f.write_str("postgres"), Self::TranquilStore => f.write_str("tranquil-store"), } }}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct StorageConfig { /// Storage backend: `filesystem` or `s3`. #[config(env = "BLOB_STORAGE_BACKEND", default = "filesystem")] pub backend: String,
/// Path on disk for the filesystem blob backend. #[config(env = "BLOB_STORAGE_PATH", default = "/var/lib/tranquil-pds/blobs")] pub path: String,
/// S3 bucket name for blob storage. #[config(env = "S3_BUCKET")] pub s3_bucket: Option<String>,
/// Custom S3 endpoint URL. #[config(env = "S3_ENDPOINT")] pub s3_endpoint: Option<String>,
/// Repository backend: `postgres` by default, or `tranquil-store`, our embedded db. /// tranquil-store is EXPERIMENTAL!!!! RISK OF TOTAL DATA LOSS. #[config(env = "REPO_BACKEND", default = "postgres")] pub repo_backend: String,}
impl StorageConfig { pub fn repo_backend(&self) -> RepoBackend { self.repo_backend .parse() .expect("repo_backend must be validated before use") }}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct CacheConfig { /// Cache backend: `ripple` by default, or `valkey`. #[config(env = "CACHE_BACKEND", default = "ripple")] pub backend: String,
/// Valkey / Redis connection URL. Required when `backend = "valkey"`. #[config(env = "VALKEY_URL")] pub valkey_url: Option<String>,
#[config(nested)] pub ripple: RippleCacheConfig,}
impl CacheConfig { pub fn validate(&self, errors: &mut Vec<String>) { let clustered = self .ripple .peers .as_deref() .unwrap_or(&[]) .iter() .any(|p| !p.trim().is_empty()); let keyed = self .ripple .cluster_key .as_deref() .is_some_and(|k| !k.trim().is_empty()); if self.backend == "ripple" && clustered && !keyed && !self.ripple.allow_insecure { errors.push( "cache.ripple.peers (RIPPLE_PEERS) is set without cache.ripple.cluster_key \ (RIPPLE_CLUSTER_KEY); set the cluster key to authenticate peers, or set \ cache.ripple.allow_insecure (RIPPLE_ALLOW_INSECURE) for a trusted private network" .to_string(), ); } }}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct PlcConfig { /// Base URL of the PLC directory. #[config(env = "PLC_DIRECTORY_URL", default = "https://plc.directory")] pub directory_url: String,
/// HTTP request timeout in seconds. #[config(env = "PLC_TIMEOUT_SECS", default = 10)] pub timeout_secs: u64,
/// TCP connect timeout in seconds. #[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)] pub connect_timeout_secs: u64,
/// Seconds to cache DID documents in memory. #[config(env = "DID_CACHE_TTL_SECS", default = 300)] pub did_cache_ttl_secs: u64,
/// Maximum number of entries retained by each in-memory DID cache. /// Set to zero to disable DID caching. #[config(env = "DID_CACHE_MAX_ENTRIES", default = 10000)] pub did_cache_max_entries: usize,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct FirehoseConfig { /// Size of the in-memory broadcast buffer for firehose events. #[config(env = "FIREHOSE_BUFFER_SIZE", default = 10000)] pub buffer_size: usize,
/// How many hours of historical events to replay for cursor-based /// firehose connections. #[config(env = "FIREHOSE_BACKFILL_HOURS", default = 72)] pub backfill_hours: i64,
/// Maximum concurrent full-repo exports, eg. getRepo without `since`. #[config(env = "MAX_CONCURRENT_REPO_EXPORTS", default = 4)] pub max_concurrent_repo_exports: usize,
/// List of relay / crawler notification URLs. #[config(env = "CRAWLERS", parse_env = split_comma_list, default = [ // If you know of more relays it makes sense to have here by all means make a PR! // All we request is that you only add "major" relays. // What exactly "major" means is up to interpretation and we will make the final call, // but a good rule of thumb is "most people are likely to add the relay if they know about it"
// Microcosm relays "https://relay.fire.hose.cam", "https://relay3.fr.hose.cam", // PBC relay "https://bsky.network", // firehose.network relays "https://northamerica.firehose.network", "https://europe.firehose.network", "https://asia.firehose.network", // Blacksky relay "https://atproto.africa", // UpCloud relay "https://relay.upcloud.world", ])] pub crawlers: Vec<String>,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct EmailConfig { /// Sender email address. When unset, email sending is disabled. #[config(env = "MAIL_FROM_ADDRESS")] pub from_address: Option<String>,
/// Display name used in the `From` header. #[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")] pub from_name: String,
/// HELO/EHLO name announced to remote SMTP servers. Applies to both /// smarthost and direct-MX modes. Defaults to the server hostname. #[config(env = "MAIL_HELO_NAME")] pub helo_name: Option<String>,
#[config(nested)] pub smarthost: SmarthostConfig,
#[config(nested)] pub direct_mx: DirectMxConfig,
#[config(nested)] pub dkim: DkimConfig,}
impl EmailConfig { pub fn validate(&self, server_hostname: &str, errors: &mut Vec<String>) { match self.smarthost.tls.to_ascii_lowercase().as_str() { "implicit" | "starttls" => {} "none" => { if self.smarthost.password.is_some() { errors.push( "email.smarthost.tls = \"none\" with email.smarthost.password set \ would transmit credentials in plaintext; use \"starttls\" or \"implicit\"" .to_string(), ); } } other => errors.push(format!( "email.smarthost.tls must be \"implicit\", \"starttls\", or \"none\", got \"{other}\"" )), }
let smarthost_host_set = self .smarthost .host .as_deref() .is_some_and(|h| !h.is_empty()); let username_set = self.smarthost.username.is_some(); let password_set = self.smarthost.password.is_some(); if !smarthost_host_set && (username_set || password_set) { errors.push( "email.smarthost.username or email.smarthost.password is set but \ email.smarthost.host is empty; credentials would be silently ignored" .to_string(), ); } if smarthost_host_set && username_set != password_set { errors.push( "email.smarthost.username and email.smarthost.password must both be set or \ both unset; otherwise authentication would silently degrade to anonymous" .to_string(), ); }
if self.smarthost.command_timeout_secs == 0 { errors.push("email.smarthost.command_timeout_secs must be at least 1".to_string()); } if self.smarthost.total_timeout_secs == 0 { errors.push("email.smarthost.total_timeout_secs must be at least 1".to_string()); } if self.smarthost.pool_size == 0 { errors.push("email.smarthost.pool_size must be at least 1".to_string()); }
if self.direct_mx.max_concurrent_sends == 0 { errors.push("email.direct_mx.max_concurrent_sends must be at least 1".to_string()); } if self.direct_mx.command_timeout_secs == 0 { errors.push("email.direct_mx.command_timeout_secs must be at least 1".to_string()); } if self.direct_mx.total_timeout_secs == 0 { errors.push("email.direct_mx.total_timeout_secs must be at least 1".to_string()); }
let dkim_set = self.dkim.selector.is_some() || self.dkim.domain.is_some() || self.dkim.private_key_path.is_some(); if dkim_set { if self.dkim.selector.is_none() { errors .push("email.dkim.selector is required when any DKIM field is set".to_string()); } if self.dkim.domain.is_none() { errors.push("email.dkim.domain is required when any DKIM field is set".to_string()); } if self.dkim.private_key_path.is_none() { errors.push( "email.dkim.private_key_path is required when any DKIM field is set" .to_string(), ); } }
let Some(from_address) = self.from_address.as_deref().filter(|s| !s.is_empty()) else { return; };
if !looks_like_email_address(from_address) { errors.push(format!( "email.from_address {from_address:?} is not a valid email address" )); } if self.from_name.chars().any(|c| c.is_control()) { errors.push("email.from_name must not contain control characters".to_string()); }
let helo_raw = self .helo_name .as_deref() .map(str::to_string) .unwrap_or_else(|| server_hostname.to_string()); if !is_non_whitespace_token(&helo_raw) { errors.push(format!( "email HELO name {helo_raw:?} must be non-empty and contain no whitespace" )); }
if smarthost_host_set { let host = self.smarthost.host.as_deref().unwrap_or(""); if !is_non_whitespace_token(host) { errors.push(format!( "email.smarthost.host {host:?} must contain no whitespace" )); } if self.smarthost.port == 0 { errors.push("email.smarthost.port must be non-zero".to_string()); } if let Some(u) = self.smarthost.username.as_deref() && u.is_empty() { errors.push("email.smarthost.username must be non-empty".to_string()); } if let Some(p) = self.smarthost.password.as_deref() && p.is_empty() { errors.push("email.smarthost.password must be non-empty".to_string()); } }
if let Some(selector) = self.dkim.selector.as_deref() && !is_valid_dkim_selector(selector) { errors.push(format!( "email.dkim.selector {selector:?} must be valid subdomain syntax" )); } if let Some(domain) = self.dkim.domain.as_deref() && !is_non_whitespace_token(domain) { errors.push(format!( "email.dkim.domain {domain:?} must be non-empty and contain no whitespace" )); } if let Some(key_path) = self.dkim.private_key_path.as_deref() && key_path.trim().is_empty() { errors.push("email.dkim.private_key_path must be non-empty".to_string()); } }}
fn looks_like_email_address(s: &str) -> bool { let trimmed = s.trim(); if trimmed.is_empty() || trimmed.chars().any(char::is_whitespace) { return false; } let mut parts = trimmed.split('@'); let local = parts.next().unwrap_or(""); let domain = parts.next().unwrap_or(""); parts.next().is_none() && !local.is_empty() && !domain.is_empty() && domain.contains('.')}
fn is_non_whitespace_token(s: &str) -> bool { let trimmed = s.trim(); !trimmed.is_empty() && !trimmed.chars().any(char::is_whitespace)}
fn is_valid_dkim_selector(s: &str) -> bool { let trimmed = s.trim(); !trimmed.is_empty() && trimmed.split('.').all(|seg| { let starts_alnum = seg .chars() .next() .is_some_and(|c| c.is_ascii_alphanumeric()); let ends_alnum = seg .chars() .next_back() .is_some_and(|c| c.is_ascii_alphanumeric()); let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); starts_alnum && ends_alnum && body_ok })}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct SmarthostConfig { /// SMTP relay host. When set, mail is delivered through this host /// instead of resolving recipient MX records directly. #[config(env = "MAIL_SMARTHOST_HOST")] pub host: Option<String>,
/// SMTP relay port. #[config(env = "MAIL_SMARTHOST_PORT", default = 587)] pub port: u16,
/// SMTP authentication username. #[config(env = "MAIL_SMARTHOST_USERNAME")] pub username: Option<String>,
/// SMTP authentication password. #[config(env = "MAIL_SMARTHOST_PASSWORD")] pub password: Option<String>,
/// TLS mode. Valid values: "implicit", "starttls", "none". Setting "none" /// alongside a password is rejected at startup to prevent transmitting /// credentials in plaintext. #[config(env = "MAIL_SMARTHOST_TLS", default = "starttls")] pub tls: String,
/// Max size of the connection pool. #[config(env = "MAIL_SMARTHOST_POOL_SIZE", default = 4)] pub pool_size: u32,
/// Per-command SMTP timeout in seconds. Bounds the security handshake. #[config(env = "MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS", default = 30)] pub command_timeout_secs: u64,
/// Total per-message timeout in seconds. Wraps the entire send so a /// stuck relay cannot stall the comms queue. #[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)] pub total_timeout_secs: u64,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct DirectMxConfig { /// Per-command SMTP timeout in seconds. #[config(env = "MAIL_COMMAND_TIMEOUT_SECS", default = 30)] pub command_timeout_secs: u64,
/// Total per-message timeout across all MX attempts in seconds. #[config(env = "MAIL_TOTAL_TIMEOUT_SECS", default = 60)] pub total_timeout_secs: u64,
/// Max number of concurrent direct-MX sends. Limits the load placed /// on any single recipient MX during a backlog drain. #[config(env = "MAIL_MAX_CONCURRENT_SENDS", default = 8)] pub max_concurrent_sends: usize,
/// Require STARTTLS on every MX hop. When false, TLS is /// attempted opportunistically and the session falls back to plaintext /// if the remote does not advertise STARTTLS. Set true to refuse /// plaintext delivery, at the cost of failing sends to MX hosts that /// do not support TLS. #[config(env = "MAIL_REQUIRE_TLS", default = false)] pub require_tls: bool,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct DkimConfig { /// DKIM selector. When unset, outgoing mail is not signed. #[config(env = "MAIL_DKIM_SELECTOR")] pub selector: Option<String>,
/// DKIM signing domain. #[config(env = "MAIL_DKIM_DOMAIN")] pub domain: Option<String>,
/// Path to the DKIM private key in PEM format. Supports RSA and /// Ed25519 keys. #[config(env = "MAIL_DKIM_KEY_PATH")] pub private_key_path: Option<String>,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct DiscordConfig { /// Discord bot token. When unset, Discord integration is disabled. #[config(env = "DISCORD_BOT_TOKEN")] pub bot_token: Option<String>,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct TelegramConfig { /// Telegram bot token. When unset, Telegram integration is disabled. #[config(env = "TELEGRAM_BOT_TOKEN")] pub bot_token: Option<String>,
/// Secret token for incoming webhook verification. #[config(env = "TELEGRAM_WEBHOOK_SECRET")] pub webhook_secret: Option<String>,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct NotificationConfig { /// Polling interval in milliseconds for the comms queue. #[config(env = "NOTIFICATION_POLL_INTERVAL_MS", default = 1000)] pub poll_interval_ms: u64,
/// Number of notifications to process per batch. #[config(env = "NOTIFICATION_BATCH_SIZE", default = 100)] pub batch_size: i64,}
pub trait SsoProviderConfig { fn get_enabled(&self) -> bool; fn get_client_id(&self) -> &Option<String>; fn get_client_secret(&self) -> &Option<String>; fn get_display_name(&self) -> &Option<String>;}
pub trait SsoProviderIssuerConfig { fn get_issuer(&self) -> &Option<String>;}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct SsoConfig { #[config(nested)] pub github: SsoGitHubConfig,
#[config(nested)] pub discord: SsoDiscordConfig,
#[config(nested)] pub google: SsoGoogleConfig,
#[config(nested)] pub gitlab: SsoGitLabConfig,
#[config(nested)] pub oidc: SsoOidcConfig,
#[config(nested)] pub apple: SsoAppleConfig,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct SsoGitHubConfig { #[config(env = "SSO_GITHUB_ENABLED", default = false)] pub enabled: bool,
#[config(env = "SSO_GITHUB_CLIENT_ID")] pub client_id: Option<String>,
#[config(env = "SSO_GITHUB_CLIENT_SECRET")] pub client_secret: Option<String>,
#[config(env = "SSO_GITHUB_DISPLAY_NAME")] pub display_name: Option<String>,}
impl SsoProviderConfig for SsoGitHubConfig { fn get_enabled(&self) -> bool { self.enabled }
fn get_client_id(&self) -> &Option<String> { &self.client_id }
fn get_client_secret(&self) -> &Option<String> { &self.client_secret }
fn get_display_name(&self) -> &Option<String> { &self.display_name }}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct SsoDiscordConfig { #[config(env = "SSO_DISCORD_ENABLED", default = false)] pub enabled: bool,
#[config(env = "SSO_DISCORD_CLIENT_ID")] pub client_id: Option<String>,
#[config(env = "SSO_DISCORD_CLIENT_SECRET")] pub client_secret: Option<String>,
#[config(env = "SSO_DISCORD_DISPLAY_NAME")] pub display_name: Option<String>,}
impl SsoProviderConfig for SsoDiscordConfig { fn get_enabled(&self) -> bool { self.enabled }
fn get_client_id(&self) -> &Option<String> { &self.client_id }
fn get_client_secret(&self) -> &Option<String> { &self.client_secret }
fn get_display_name(&self) -> &Option<String> { &self.display_name }}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct SsoGoogleConfig { #[config(env = "SSO_GOOGLE_ENABLED", default = false)] pub enabled: bool,
#[config(env = "SSO_GOOGLE_CLIENT_ID")] pub client_id: Option<String>,
#[config(env = "SSO_GOOGLE_CLIENT_SECRET")] pub client_secret: Option<String>,
#[config(env = "SSO_GOOGLE_DISPLAY_NAME")] pub display_name: Option<String>,}
impl SsoProviderConfig for SsoGoogleConfig { fn get_enabled(&self) -> bool { self.enabled }
fn get_client_id(&self) -> &Option<String> { &self.client_id }
fn get_client_secret(&self) -> &Option<String> { &self.client_secret }
fn get_display_name(&self) -> &Option<String> { &self.display_name }}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct SsoGitLabConfig { #[config(env = "SSO_GITLAB_ENABLED", default = false)] pub enabled: bool,
#[config(env = "SSO_GITLAB_CLIENT_ID")] pub client_id: Option<String>,
#[config(env = "SSO_GITLAB_CLIENT_SECRET")] pub client_secret: Option<String>,
#[config(env = "SSO_GITLAB_ISSUER")] pub issuer: Option<String>,
#[config(env = "SSO_GITLAB_DISPLAY_NAME")] pub display_name: Option<String>,}
impl SsoProviderConfig for SsoGitLabConfig { fn get_enabled(&self) -> bool { self.enabled }
fn get_client_id(&self) -> &Option<String> { &self.client_id }
fn get_client_secret(&self) -> &Option<String> { &self.client_secret }
fn get_display_name(&self) -> &Option<String> { &self.display_name }}
impl SsoProviderIssuerConfig for SsoGitLabConfig { fn get_issuer(&self) -> &Option<String> { &self.issuer }}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct SsoOidcConfig { #[config(env = "SSO_OIDC_ENABLED", default = false)] pub enabled: bool,
#[config(env = "SSO_OIDC_CLIENT_ID")] pub client_id: Option<String>,
#[config(env = "SSO_OIDC_CLIENT_SECRET")] pub client_secret: Option<String>,
#[config(env = "SSO_OIDC_ISSUER")] pub issuer: Option<String>,
#[config(env = "SSO_OIDC_DISPLAY_NAME")] pub display_name: Option<String>,}
impl SsoProviderConfig for SsoOidcConfig { fn get_enabled(&self) -> bool { self.enabled }
fn get_client_id(&self) -> &Option<String> { &self.client_id }
fn get_client_secret(&self) -> &Option<String> { &self.client_secret }
fn get_display_name(&self) -> &Option<String> { &self.display_name }}
impl SsoProviderIssuerConfig for SsoOidcConfig { fn get_issuer(&self) -> &Option<String> { &self.issuer }}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct SsoAppleConfig { #[config(env = "SSO_APPLE_ENABLED", default = false)] pub enabled: bool,
#[config(env = "SSO_APPLE_CLIENT_ID")] pub client_id: Option<String>,
#[config(env = "SSO_APPLE_TEAM_ID")] pub team_id: Option<String>,
#[config(env = "SSO_APPLE_KEY_ID")] pub key_id: Option<String>,
#[config(env = "SSO_APPLE_PRIVATE_KEY")] pub private_key: Option<String>,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct ModerationConfig { /// External report-handling service URL. #[config(env = "REPORT_SERVICE_URL")] pub report_service_url: Option<String>,
/// DID of the external report-handling service. #[config(env = "REPORT_SERVICE_DID")] pub report_service_did: Option<String>,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct ImportConfig { /// Whether the PDS accepts repo imports. #[config(env = "ACCEPTING_REPO_IMPORTS", default = true)] pub accepting: bool,
/// Maximum allowed import archive size in bytes (default 1 GiB). #[config(env = "MAX_IMPORT_SIZE", default = 1_073_741_824)] pub max_size: u64,
/// Maximum number of blocks allowed in an import. #[config(env = "MAX_IMPORT_BLOCKS", default = 500000)] pub max_blocks: u64,
/// Skip CAR verification during import. Only for development/debugging. #[config(env = "SKIP_IMPORT_VERIFICATION", default = false)] pub skip_verification: bool,}
/// Parse a comma-separated environment variable into a `Vec<String>`,/// trimming whitespace and dropping empty entries.////// Signature matches confique's `parse_env` expectation: `fn(&str) -> Result<T, E>`.fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible> { Ok(value .split(',') .map(|item| item.trim().to_string()) .filter(|item| !item.is_empty()) .collect())}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct RippleCacheConfig { /// Address to bind the Ripple gossip protocol listener. With the default /// value and no cluster_key or peers configured, the listener binds /// loopback instead and runs as a single node. #[config(env = "RIPPLE_BIND", default = "0.0.0.0:0")] pub bind_addr: String,
/// List of seed peer addresses. #[config(env = "RIPPLE_PEERS", parse_env = split_comma_list)] pub peers: Option<Vec<String>>,
/// Unique machine identifier. Auto-derived from hostname when not set. #[config(env = "RIPPLE_MACHINE_ID")] pub machine_id: Option<u64>,
/// Gossip protocol interval in milliseconds. #[config(env = "RIPPLE_GOSSIP_INTERVAL_MS", default = 200)] pub gossip_interval_ms: u64,
/// Maximum cache size in megabytes. #[config(env = "RIPPLE_CACHE_MAX_MB", default = 256)] pub cache_max_mb: usize,
/// Pre-shared cluster key authenticating ripple peers. Every node in the /// cluster must set the same value. When unset, peers are unauthenticated. #[config(env = "RIPPLE_CLUSTER_KEY")] pub cluster_key: Option<String>,
/// Allow ripple to bind a non-loopback address without a cluster key. /// Peers will be unauthenticated. Intended for trusted private networks. #[config(env = "RIPPLE_ALLOW_INSECURE", default = false)] pub allow_insecure: bool,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct ScheduledConfig { /// Interval in seconds between scheduled delete checks. #[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)] pub delete_check_interval_secs: u64,
/// Interval in seconds between data file compaction scans (tranquil-store only). /// Set to 0 to disable. #[config(env = "COMPACTION_INTERVAL_SECS", default = 3600)] pub compaction_interval_secs: u64,
/// Liveness ratio threshold below which a data file is compacted (0.0-1.0). #[config(env = "COMPACTION_LIVENESS_THRESHOLD", default = 0.7)] pub compaction_liveness_threshold: f64,
/// Grace period in milliseconds before a zero-refcount block can be removed by compaction. #[config(env = "COMPACTION_GRACE_PERIOD_MS", default = 600000)] pub compaction_grace_period_ms: u64,
/// Interval in seconds between reachability walk runs (tranquil-store only). /// Set to 0 to disable. Default: weekly. #[config(env = "REACHABILITY_WALK_INTERVAL_SECS", default = 604800)] pub reachability_walk_interval_secs: u64,
/// Interval in seconds between continuous archival passes (tranquil-store only). /// Sealed eventlog segments are copied to the archival destination each tick. /// Set to 0 to disable. Default: 60 seconds. #[config(env = "ARCHIVAL_INTERVAL_SECS", default = 60)] pub archival_interval_secs: u64,
/// Archival destination directory for sealed eventlog segments. /// If unset, archival is disabled. #[config(env = "ARCHIVAL_DEST_DIR")] pub archival_dest_dir: Option<String>,
/// Maximum age of events retained in the eventlog before pruning. /// Per the atproto firehose spec, the relay backfill window only needs /// to cover "hours or days". #[config(env = "EVENT_RETENTION_MAX_AGE_SECS", default = 604800)] pub event_retention_max_age_secs: u64,
/// Interval in seconds between event retention prune passes. /// Set to 0 to disable. #[config(env = "EVENT_RETENTION_INTERVAL_SECS", default = 3600)] pub event_retention_interval_secs: u64,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct TranquilStoreConfig { /// Directory for tranquil-store data: the metastore, eventlog, and blockstore. #[config( env = "TRANQUIL_STORE_DATA_DIR", default = "/var/lib/tranquil-pds/store" )] pub data_dir: String,
/// Fjall block cache size in megabytes. Defaults to 20% of system RAM when unset. #[config(env = "TRANQUIL_STORE_MEMORY_BUDGET_MB")] pub memory_budget_mb: Option<u64>,
/// Number of handler threads. Defaults to available_parallelism / 2. #[config(env = "TRANQUIL_STORE_HANDLER_THREADS")] pub handler_threads: Option<usize>,
/// Maximum total bytes of pending (unsynced) eventlog payloads. Appenders block /// once this budget is exhausted until in-flight events drain via fsync. Set to /// 0 to disable backpressure. Default: 1 GiB. #[config( env = "TRANQUIL_STORE_EVENTLOG_PENDING_BYTES_BUDGET", default = 1_073_741_824 )] pub eventlog_pending_bytes_budget: u64,
/// Maximum size of an individual eventlog payload in bytes. Single events /// larger than this are rejected at append time. Default: 256 MiB. #[config( env = "TRANQUIL_STORE_EVENTLOG_MAX_EVENT_PAYLOAD", default = 268_435_456 )] pub eventlog_max_event_payload: u32,
/// Maximum size of an individual blockstore data file in bytes. When the /// active data file reaches this size it is rolled over and becomes /// eligible for compaction. Default: 256 MiB. #[config(env = "TRANQUIL_STORE_MAX_BLOCKSTORE_FILE_SIZE", default = 268_435_456)] pub max_blockstore_file_size: u64,
/// Maximum size of an individual eventlog segment file in bytes. When the /// active segment reaches this size it is sealed and a new one is created. /// Safe to change on a running instance. Default: 256 MiB. #[config( env = "TRANQUIL_STORE_MAX_EVENTLOG_SEGMENT_SIZE", default = 268_435_456 )] pub max_eventlog_segment_size: u64,}
/// Generate a TOML configuration template with all available options,/// defaults, and documentation comments.pub fn template() -> String { confique::toml::template::<TranquilConfig>(confique::toml::FormatOptions::default())}
#[cfg(test)]mod tests { use super::*;
#[test] fn pgbouncer_requires_a_direct_postgres_url() { let config = DatabaseConfig { url: "postgres://pgbouncer/pds".to_string(), direct_url: None, pgbouncer: true, max_connections: 20, min_connections: 2, acquire_timeout_secs: 10, }; let mut errors = Vec::new();
config.validate(&mut errors);
assert!( errors.iter().any(|error| error.contains("direct_url")), "expected a direct_url error, got {errors:?}" ); }
#[test] fn pgbouncer_accepts_a_direct_postgres_url() { let config = DatabaseConfig { url: "postgres://pgbouncer/pds".to_string(), direct_url: Some("postgres://postgres/pds".to_string()), pgbouncer: true, max_connections: 20, min_connections: 2, acquire_timeout_secs: 10, }; let mut errors = Vec::new();
config.validate(&mut errors);
assert!(errors.is_empty(), "expected no errors, got {errors:?}"); }
#[test] fn database_pool_minimum_cannot_exceed_maximum() { let config = DatabaseConfig { url: "postgres://postgres/pds".to_string(), direct_url: None, pgbouncer: false, max_connections: 2, min_connections: 3, acquire_timeout_secs: 10, }; let mut errors = Vec::new();
config.validate(&mut errors);
assert!( errors.iter().any(|error| error.contains("min_connections")), "expected a pool size error, got {errors:?}" ); }
fn seed_required_env() { let required = [ ("PDS_HOSTNAME", "test.local"), ("DATABASE_URL", "postgres://localhost/test"), ("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"), ("INVITE_CODE_REQUIRED", "false"), ("ENABLE_PDS_HOSTED_DID_WEB", "true"), ("TRANQUIL_LEXICON_OFFLINE", "1"), ]; required .iter() .filter(|(k, _)| std::env::var_os(k).is_none()) .for_each(|(k, v)| unsafe { std::env::set_var(k, v) }); }
#[test] fn load_rejects_unknown_top_level_key() { let dir = std::env::temp_dir().join(format!( "tranquil-config-unknown-toplevel-{}", std::process::id() )); std::fs::create_dir_all(&dir).expect("mkdir tempdir"); let path = dir.join("config.toml"); std::fs::write( &path, r#"[server]hostname = "test.local"
[totally_made_up]foo = "bar""#, ) .expect("write tempfile");
let result = TranquilConfig::builder().file(&path).load(); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(&dir);
let err = format!("{:#}", result.expect_err("load must reject unknown key")); assert!( err.contains("totally_made_up"), "expected totally_made_up in error, got {err:?}" ); }
#[test] fn load_rejects_unknown_nested_key() { let dir = std::env::temp_dir().join(format!( "tranquil-config-unknown-nested-{}", std::process::id() )); std::fs::create_dir_all(&dir).expect("mkdir tempdir"); let path = dir.join("config.toml"); std::fs::write( &path, r#"[server]hostname = "test.local"not_a_real_field = "oops""#, ) .expect("write tempfile");
let result = TranquilConfig::builder().file(&path).load(); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(&dir);
let err = format!("{:#}", result.expect_err("load must reject unknown key")); assert!( err.contains("not_a_real_field"), "expected not_a_real_field in error, got {err:?}" ); }
#[test] fn load_accepts_known_keys() { let dir = std::env::temp_dir().join(format!("tranquil-config-known-keys-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("mkdir tempdir"); let path = dir.join("config.toml"); std::fs::write( &path, r#"[server]hostname = "test.local"port = 3000
[database]url = "postgres://localhost/test"
[email.smarthost]host = "smtp.example"port = 587"#, ) .expect("write tempfile");
let result = TranquilConfig::builder().file(&path).load(); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(&dir);
result.expect("known keys must load successfully"); }
#[test] fn user_handle_domain_list_normalizes_leading_dots() { let dir = std::env::temp_dir().join(format!( "tranquil-config-handle-domains-{}", std::process::id() )); std::fs::create_dir_all(&dir).expect("mkdir tempdir"); let path = dir.join("config.toml"); std::fs::write( &path, r#"[server]hostname = "test.local"user_handle_domains = ["handles.test", ".margin.cafe"]
[database]url = "postgres://localhost/test""#, ) .expect("write tempfile");
let config = TranquilConfig::builder() .file(&path) .load() .expect("known config must load successfully"); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(&dir);
assert_eq!( config.server.user_handle_domain_list(), vec!["handles.test", "margin.cafe"] ); assert_eq!( config.server.available_user_domain_list(), vec![".handles.test", ".margin.cafe"] ); }
#[test] fn serial_validate_rejects_legacy_sendmail_path() { seed_required_env(); unsafe { std::env::set_var("SENDMAIL_PATH", "/usr/sbin/sendmail") }; let config = TranquilConfig::builder() .env() .load() .expect("load fresh config"); let result = config.validate(true); unsafe { std::env::remove_var("SENDMAIL_PATH") };
let err = result.expect_err("validate must reject SENDMAIL_PATH"); let mentions_sendmail = err.errors.iter().any(|e| e.contains("SENDMAIL_PATH")); assert!( mentions_sendmail, "errors did not mention SENDMAIL_PATH: {:?}", err.errors ); }
#[test] fn serial_validate_passes_when_no_legacy_env_set() { seed_required_env(); unsafe { std::env::remove_var("SENDMAIL_PATH") }; let config = TranquilConfig::builder() .env() .load() .expect("load fresh config"); let result = config.validate(true); let leaked_legacy = result .as_ref() .err() .map(|e| e.errors.iter().any(|s| s.contains("SENDMAIL_PATH"))) .unwrap_or(false); assert!( !leaked_legacy, "validate spuriously flagged SENDMAIL_PATH when unset: {:?}", result ); }
#[test] fn email_address_predicate_accepts_typical_addresses() { assert!(looks_like_email_address("alice@nel.pet")); assert!(looks_like_email_address("a.b+tag@example.co.uk")); }
#[test] fn email_address_predicate_rejects_malformed() { assert!(!looks_like_email_address("")); assert!(!looks_like_email_address("no-at-sign")); assert!(!looks_like_email_address("@nel.pet")); assert!(!looks_like_email_address("alice@")); assert!(!looks_like_email_address("alice@nel")); assert!(!looks_like_email_address("a@b@c.com")); assert!(!looks_like_email_address("alice @nel.pet")); }
#[test] fn dkim_selector_predicate_matches_subdomain_syntax() { assert!(is_valid_dkim_selector("default")); assert!(is_valid_dkim_selector("s2024-q1")); assert!(is_valid_dkim_selector("mailo-2024.nel.pet")); assert!(!is_valid_dkim_selector("")); assert!(!is_valid_dkim_selector("a..b")); assert!(!is_valid_dkim_selector("-leading")); assert!(!is_valid_dkim_selector("trailing-")); assert!(!is_valid_dkim_selector("s_under")); }
#[test] fn email_validate_disabled_when_from_address_unset() { let cfg = email_config_for_test(EmailOverrides::default()); let mut errors = Vec::new(); cfg.validate("test.local", &mut errors); assert!(errors.is_empty(), "expected no errors, got {errors:?}"); }
#[test] fn email_validate_rejects_bad_from_address() { let cfg = email_config_for_test(EmailOverrides { from_address: Some("not-an-email"), ..Default::default() }); let mut errors = Vec::new(); cfg.validate("test.local", &mut errors); assert!( errors.iter().any(|e| e.contains("from_address")), "expected from_address error, got {errors:?}" ); }
#[test] fn email_validate_rejects_smarthost_with_bad_credentials() { let cfg = email_config_for_test(EmailOverrides { from_address: Some("alice@nel.pet"), smarthost_host: Some("smtp.nel.pet"), smarthost_username: Some(""), smarthost_password: Some("hunter2"), ..Default::default() }); let mut errors = Vec::new(); cfg.validate("test.local", &mut errors); assert!( errors.iter().any(|e| e.contains("smarthost.username")), "expected smarthost.username error, got {errors:?}" ); }
#[test] fn email_validate_rejects_bad_dkim_selector() { let cfg = email_config_for_test(EmailOverrides { from_address: Some("alice@nel.pet"), dkim_selector: Some("-bad"), dkim_domain: Some("nel.pet"), dkim_key_path: Some("/etc/dkim.key"), ..Default::default() }); let mut errors = Vec::new(); cfg.validate("test.local", &mut errors); assert!( errors.iter().any(|e| e.contains("dkim.selector")), "expected dkim.selector error, got {errors:?}" ); }
#[test] fn tls_validate_accepts_both_paths_unset() { let mut errors = Vec::new(); TlsConfig { cert_path: None, key_path: None, http3: false, } .validate(&mut errors); assert!(errors.is_empty(), "expected no errors, got {errors:?}"); }
#[test] fn tls_validate_accepts_both_paths_set() { let mut errors = Vec::new(); TlsConfig { cert_path: Some("/etc/tranquil/cert.pem".to_string()), key_path: Some("/etc/tranquil/key.pem".to_string()), http3: false, } .validate(&mut errors); assert!(errors.is_empty(), "expected no errors, got {errors:?}"); }
#[test] fn tls_validate_rejects_cert_without_key() { let mut errors = Vec::new(); TlsConfig { cert_path: Some("/etc/tranquil/cert.pem".to_string()), key_path: None, http3: false, } .validate(&mut errors); assert!( errors.iter().any(|e| e.contains("server.tls")), "expected server.tls error, got {errors:?}" ); }
#[test] fn tls_validate_rejects_http3_without_material() { let mut errors = Vec::new(); TlsConfig { cert_path: None, key_path: None, http3: true, } .validate(&mut errors); assert!( errors.iter().any(|e| e.contains("http3")), "expected http3 error, got {errors:?}" ); }
fn cache_config_for_test( peers: Option<Vec<String>>, cluster_key: Option<&str>, allow_insecure: bool, ) -> CacheConfig { CacheConfig { backend: "ripple".to_string(), valkey_url: None, ripple: RippleCacheConfig { bind_addr: "0.0.0.0:0".to_string(), peers, machine_id: None, gossip_interval_ms: 200, cache_max_mb: 256, cluster_key: cluster_key.map(str::to_string), allow_insecure, }, } }
#[test] fn cache_validate_rejects_clustered_keyless_ripple() { let mut errors = Vec::new(); cache_config_for_test(Some(vec!["10.0.0.7:7000".to_string()]), None, false) .validate(&mut errors); assert!( errors.iter().any(|e| e.contains("RIPPLE_CLUSTER_KEY")), "expected cluster key error, got {errors:?}" ); }
#[test] fn cache_validate_accepts_keyed_insecure_or_standalone() { let mut errors = Vec::new(); cache_config_for_test( Some(vec!["10.0.0.7:7000".to_string()]), Some("nautilus-secret"), false, ) .validate(&mut errors); cache_config_for_test(Some(vec!["10.0.0.7:7000".to_string()]), None, true) .validate(&mut errors); cache_config_for_test(None, None, false).validate(&mut errors); assert!(errors.is_empty(), "expected no errors, got {errors:?}"); }
#[derive(Default)] struct EmailOverrides { from_address: Option<&'static str>, smarthost_host: Option<&'static str>, smarthost_username: Option<&'static str>, smarthost_password: Option<&'static str>, dkim_selector: Option<&'static str>, dkim_domain: Option<&'static str>, dkim_key_path: Option<&'static str>, }
fn email_config_for_test(o: EmailOverrides) -> EmailConfig { EmailConfig { from_address: o.from_address.map(str::to_string), from_name: "Tranquil PDS".to_string(), helo_name: None, smarthost: SmarthostConfig { host: o.smarthost_host.map(str::to_string), port: 587, username: o.smarthost_username.map(str::to_string), password: o.smarthost_password.map(str::to_string), tls: "starttls".to_string(), pool_size: 4, command_timeout_secs: 30, total_timeout_secs: 60, }, direct_mx: DirectMxConfig { command_timeout_secs: 30, total_timeout_secs: 60, max_concurrent_sends: 8, require_tls: false, }, dkim: DkimConfig { selector: o.dkim_selector.map(str::to_string), domain: o.dkim_domain.map(str::to_string), private_key_path: o.dkim_key_path.map(str::to_string), }, } }}