Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

1use confique::Config; 2use std::fmt; 3use std::path::PathBuf; 4use std::sync::OnceLock; 5 6static CONFIG: OnceLock<TranquilConfig> = OnceLock::new(); 7 8const REMOVED_ENV_VARS: &[(&str, &str)] = &[( 9 "SENDMAIL_PATH", 10 "the sendmail-binary transport was replaced with native SMTP. \ 11 Configure MAIL_SMARTHOST_HOST for relay delivery, or leave it unset to \ 12 deliver directly via recipient MX records. See example.toml for the full \ 13 MAIL_* surface.", 14)]; 15 16/// Errors discovered during configuration validation. 17#[derive(Debug)] 18pub struct ConfigError { 19 pub errors: Vec<String>, 20} 21 22impl fmt::Display for ConfigError { 23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 24 writeln!(f, "configuration validation failed:")?; 25 for err in &self.errors { 26 writeln!(f, " - {err}")?; 27 } 28 Ok(()) 29 } 30} 31 32impl std::error::Error for ConfigError {} 33 34/// Initialize the global configuration. Must be called once at startup before 35/// any other code accesses the configuration. Panics if called more than once. 36pub fn init(config: TranquilConfig) { 37 CONFIG 38 .set(config) 39 .expect("tranquil-config: configuration already initialized"); 40} 41 42/// Returns a reference to the global configuration. 43/// Panics if [`init`] has not been called yet. 44pub fn get() -> &'static TranquilConfig { 45 CONFIG 46 .get() 47 .expect("tranquil-config: not initialized - call tranquil_config::init() first") 48} 49 50/// Returns a reference to the global configuration if it has been initialized. 51pub fn try_get() -> Option<&'static TranquilConfig> { 52 CONFIG.get() 53} 54 55/// Initialize with minimal defaults for unit tests. 56/// Noop if already initialized. 57pub fn ensure_test_defaults() { 58 use std::env; 59 let _ = CONFIG.get_or_init(|| { 60 unsafe { 61 if env::var("PDS_HOSTNAME").is_err() { 62 env::set_var("PDS_HOSTNAME", "test.local"); 63 } 64 if env::var("DATABASE_URL").is_err() { 65 env::set_var("DATABASE_URL", "postgres://localhost/test"); 66 } 67 if env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() { 68 env::set_var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"); 69 } 70 if env::var("INVITE_CODE_REQUIRED").is_err() { 71 env::set_var("INVITE_CODE_REQUIRED", "false"); 72 } 73 if env::var("ENABLE_PDS_HOSTED_DID_WEB").is_err() { 74 env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true"); 75 } 76 if env::var("TRANQUIL_LEXICON_OFFLINE").is_err() { 77 env::set_var("TRANQUIL_LEXICON_OFFLINE", "1"); 78 } 79 } 80 TranquilConfig::builder() 81 .env() 82 .load() 83 .expect("failed to load test config defaults") 84 }); 85} 86 87/// Load configuration from an optional TOML file path, with environment 88/// variable overrides applied on top. Fields annotated with `#[config(env)]` 89/// are read from the corresponding environment variables when the `.env()` 90/// layer is active. 91/// 92/// Precedence (highest to lowest): 93/// 1. Environment variables 94/// 2. Toml config file passed as `config_path`, if provided 95/// 3. `/etc/tranquil-pds/config.toml` - hardcoded fallback, silently skipped if absent 96/// 4. Built-in defaults 97pub fn load(config_path: Option<&PathBuf>) -> Result<TranquilConfig, confique::Error> { 98 let mut builder = TranquilConfig::builder().env(); 99 if let Some(path) = config_path { 100 builder = builder.file(path); 101 } 102 builder.file("/etc/tranquil-pds/config.toml").load() 103} 104 105// Root configuration 106#[derive(Debug, Config)] 107pub struct TranquilConfig { 108 #[config(nested)] 109 pub server: ServerConfig, 110 111 #[config(nested)] 112 pub frontend: FrontendConfig, 113 114 #[config(nested)] 115 pub database: DatabaseConfig, 116 117 #[config(nested)] 118 pub secrets: SecretsConfig, 119 120 #[config(nested)] 121 pub storage: StorageConfig, 122 123 #[config(nested)] 124 pub tranquil_store: TranquilStoreConfig, 125 126 #[config(nested)] 127 pub cache: CacheConfig, 128 129 #[config(nested)] 130 pub plc: PlcConfig, 131 132 #[config(nested)] 133 pub firehose: FirehoseConfig, 134 135 #[config(nested)] 136 pub email: EmailConfig, 137 138 #[config(nested)] 139 pub discord: DiscordConfig, 140 141 #[config(nested)] 142 pub telegram: TelegramConfig, 143 144 #[config(nested)] 145 pub signal: SignalConfig, 146 147 #[config(nested)] 148 pub notifications: NotificationConfig, 149 150 #[config(nested)] 151 pub sso: SsoConfig, 152 153 #[config(nested)] 154 pub moderation: ModerationConfig, 155 156 #[config(nested)] 157 pub import: ImportConfig, 158 159 #[config(nested)] 160 pub scheduled: ScheduledConfig, 161} 162 163impl TranquilConfig { 164 /// Validate cross-field constraints that cannot be expressed through 165 /// confique's declarative defaults alone. Call this once after loading 166 /// the configuration and before [`init`]. 167 /// 168 /// Returns `Ok(())` when the configuration is consistent, or a 169 /// [`ConfigError`] listing every problem found. 170 pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> { 171 let mut errors = Vec::new(); 172 173 // -- removed config --------------------------------------------------- 174 errors.extend( 175 REMOVED_ENV_VARS 176 .iter() 177 .filter(|(var, _)| std::env::var_os(var).is_some()) 178 .map(|(var, guidance)| format!("{var} is no longer supported: {guidance}")), 179 ); 180 181 // -- secrets ---------------------------------------------------------- 182 if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) { 183 if let Some(ref s) = self.secrets.jwt_secret { 184 if s.len() < 32 { 185 errors.push( 186 "secrets.jwt_secret (JWT_SECRET) must be at least 32 characters" 187 .to_string(), 188 ); 189 } 190 } else { 191 errors.push( 192 "secrets.jwt_secret (JWT_SECRET) is required in production \ 193 (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" 194 .to_string(), 195 ); 196 } 197 198 if let Some(ref s) = self.secrets.dpop_secret { 199 if s.len() < 32 { 200 errors.push( 201 "secrets.dpop_secret (DPOP_SECRET) must be at least 32 characters" 202 .to_string(), 203 ); 204 } 205 } else { 206 errors.push( 207 "secrets.dpop_secret (DPOP_SECRET) is required in production \ 208 (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" 209 .to_string(), 210 ); 211 } 212 213 if let Some(ref s) = self.secrets.master_key { 214 if s.len() < 32 { 215 errors.push( 216 "secrets.master_key (MASTER_KEY) must be at least 32 characters" 217 .to_string(), 218 ); 219 } 220 } else { 221 errors.push( 222 "secrets.master_key (MASTER_KEY) is required in production \ 223 (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" 224 .to_string(), 225 ); 226 } 227 } 228 229 // -- email ----------------------------------------------------------- 230 self.email 231 .validate(self.server.hostname_without_port(), &mut errors); 232 233 // -- telegram --------------------------------------------------------- 234 if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() { 235 errors.push( 236 "telegram.bot_token is set but telegram.webhook_secret is missing; \ 237 both are required for secure Telegram integration" 238 .to_string(), 239 ); 240 } 241 242 // -- blob storage ----------------------------------------------------- 243 match self.storage.backend.as_str() { 244 "s3" => { 245 if self.storage.s3_bucket.is_none() { 246 errors.push( 247 "storage.backend is \"s3\" but storage.s3_bucket (S3_BUCKET) \ 248 is not set" 249 .to_string(), 250 ); 251 } 252 } 253 "filesystem" => {} 254 other => { 255 errors.push(format!( 256 "storage.backend must be \"filesystem\" or \"s3\", got \"{other}\"" 257 )); 258 } 259 } 260 261 // -- SSO providers ---------------------------------------------------- 262 self.validate_sso_provider("sso.github", &self.sso.github, &mut errors); 263 self.validate_sso_provider("sso.google", &self.sso.google, &mut errors); 264 self.validate_sso_provider("sso.discord", &self.sso.discord, &mut errors); 265 self.validate_sso_with_issuer("sso.gitlab", &self.sso.gitlab, &mut errors); 266 self.validate_sso_with_issuer("sso.oidc", &self.sso.oidc, &mut errors); 267 self.validate_sso_apple(&mut errors); 268 269 // -- moderation ------------------------------------------------------- 270 let has_url = self.moderation.report_service_url.is_some(); 271 let has_did = self.moderation.report_service_did.is_some(); 272 if has_url != has_did { 273 errors.push( 274 "moderation.report_service_url and moderation.report_service_did \ 275 must both be set or both be unset" 276 .to_string(), 277 ); 278 } 279 280 // -- repo backend ----------------------------------------------------- 281 if let Err(e) = self.storage.repo_backend.parse::<RepoBackend>() { 282 errors.push(e); 283 } 284 285 // -- tranquil-store --------------------------------------------------- 286 if let Some(mb) = self.tranquil_store.memory_budget_mb 287 && mb == 0 288 { 289 errors.push("tranquil_store.memory_budget_mb must be at least 1".to_string()); 290 } 291 if let Some(threads) = self.tranquil_store.handler_threads 292 && threads == 0 293 { 294 errors.push("tranquil_store.handler_threads must be at least 1".to_string()); 295 } 296 if self.tranquil_store.eventlog_max_event_payload == 0 { 297 errors.push( 298 "tranquil_store.eventlog_max_event_payload \ 299 (TRANQUIL_STORE_EVENTLOG_MAX_EVENT_PAYLOAD) must be at least 1; \ 300 a value of 0 would reject every event" 301 .to_string(), 302 ); 303 } 304 305 // -- scheduled / event retention -------------------------------------- 306 const MAX_RETENTION_SECS: u64 = (i64::MAX / 1000) as u64; 307 if self.scheduled.event_retention_max_age_secs > MAX_RETENTION_SECS { 308 errors.push(format!( 309 "scheduled.event_retention_max_age_secs (EVENT_RETENTION_MAX_AGE_SECS) \ 310 must be at most {MAX_RETENTION_SECS} (chrono::Duration limit); got {}", 311 self.scheduled.event_retention_max_age_secs 312 )); 313 } 314 if self.scheduled.event_retention_interval_secs > 0 { 315 let backfill_secs = u64::try_from(self.firehose.backfill_hours.max(0)) 316 .unwrap_or(0) 317 .saturating_mul(3600); 318 if self.scheduled.event_retention_max_age_secs < backfill_secs { 319 errors.push(format!( 320 "scheduled.event_retention_max_age_secs ({}) is shorter than \ 321 firehose.backfill_hours ({}h = {backfill_secs}s): \ 322 relays would receive cursor responses pointing at pruned events. \ 323 Increase event_retention_max_age_secs or decrease firehose.backfill_hours.", 324 self.scheduled.event_retention_max_age_secs, self.firehose.backfill_hours, 325 )); 326 } 327 } 328 329 // -- cache ------------------------------------------------------------ 330 match self.cache.backend.as_str() { 331 "valkey" => { 332 if self.cache.valkey_url.is_none() { 333 errors.push( 334 "cache.backend is \"valkey\" but cache.valkey_url (VALKEY_URL) \ 335 is not set" 336 .to_string(), 337 ); 338 } 339 } 340 "ripple" => {} 341 other => { 342 errors.push(format!( 343 "cache.backend must be \"ripple\" or \"valkey\", got \"{other}\"" 344 )); 345 } 346 } 347 348 if errors.is_empty() { 349 Ok(()) 350 } else { 351 Err(ConfigError { errors }) 352 } 353 } 354 355 fn validate_sso_provider( 356 &self, 357 prefix: &str, 358 p: &impl SsoProviderConfig, 359 errors: &mut Vec<String>, 360 ) { 361 if p.get_enabled() { 362 if p.get_client_id().is_none() { 363 errors.push(format!( 364 "{prefix}.client_id is required when {prefix}.enabled = true" 365 )); 366 } 367 if p.get_client_secret().is_none() { 368 errors.push(format!( 369 "{prefix}.client_secret is required when {prefix}.enabled = true" 370 )); 371 } 372 } 373 } 374 375 fn validate_sso_with_issuer( 376 &self, 377 prefix: &str, 378 p: &(impl SsoProviderConfig + SsoProviderIssuerConfig), 379 errors: &mut Vec<String>, 380 ) { 381 self.validate_sso_provider(prefix, p, errors); 382 if p.get_enabled() && p.get_issuer().is_none() { 383 errors.push(format!( 384 "{prefix}.issuer is required when {prefix}.enabled = true" 385 )); 386 } 387 } 388 389 fn validate_sso_apple(&self, errors: &mut Vec<String>) { 390 let p = &self.sso.apple; 391 if p.enabled { 392 if p.client_id.is_none() { 393 errors.push( 394 "sso.apple.client_id is required when sso.apple.enabled = true".to_string(), 395 ); 396 } 397 if p.team_id.is_none() { 398 errors.push( 399 "sso.apple.team_id is required when sso.apple.enabled = true".to_string(), 400 ); 401 } 402 if p.key_id.is_none() { 403 errors 404 .push("sso.apple.key_id is required when sso.apple.enabled = true".to_string()); 405 } 406 if p.private_key.is_none() { 407 errors.push( 408 "sso.apple.private_key is required when sso.apple.enabled = true".to_string(), 409 ); 410 } 411 } 412 } 413} 414 415// --------------------------------------------------------------------------- 416// Server 417// --------------------------------------------------------------------------- 418 419#[derive(Debug, Config)] 420pub struct ServerConfig { 421 /// Public hostname of the PDS, such as `pds.example.com`. 422 #[config(env = "PDS_HOSTNAME")] 423 pub hostname: String, 424 425 /// Address to bind the HTTP server to. 426 #[config(env = "SERVER_HOST", default = "127.0.0.1")] 427 pub host: String, 428 429 /// Port to bind the HTTP server to. 430 #[config(env = "SERVER_PORT", default = 3000)] 431 pub port: u16, 432 433 /// List of domains for user handles. 434 /// Defaults to the PDS hostname when not set. 435 #[config(env = "PDS_USER_HANDLE_DOMAINS", parse_env = split_comma_list)] 436 pub user_handle_domains: Option<Vec<String>>, 437 438 /// Enable PDS-hosted did:web identities. Hosting did:web requires a 439 /// long-term commitment to serve DID documents; opt-in only. 440 #[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)] 441 pub enable_pds_hosted_did_web: bool, 442 443 /// When set to true, skip age-assurance birthday prompt for all accounts. 444 #[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)] 445 pub age_assurance_override: bool, 446 447 /// Require an invite code for new account registration. 448 #[config(env = "INVITE_CODE_REQUIRED", default = true)] 449 pub invite_code_required: bool, 450 451 /// Allow HTTP (non-TLS) proxy requests. Only useful during development. 452 #[config(env = "ALLOW_HTTP_PROXY", default = false)] 453 pub allow_http_proxy: bool, 454 455 /// Disable all rate limiting. Should only be used in testing. 456 #[config(env = "DISABLE_RATE_LIMITING", default = false)] 457 pub disable_rate_limiting: bool, 458 459 /// Skip the verified-comms-channel gate for login and record writes. 460 /// Please keep this off unless you're an invite-only PDS! 461 #[config(env = "DISABLE_ACCOUNT_VERIFICATION_GATE", default = false)] 462 pub disable_account_verification_gate: bool, 463 464 /// List of additional banned words for handle validation. 465 #[config(env = "PDS_BANNED_WORDS", parse_env = split_comma_list)] 466 pub banned_words: Option<Vec<String>>, 467 468 /// URL to a privacy policy page. 469 #[config(env = "PRIVACY_POLICY_URL")] 470 pub privacy_policy_url: Option<String>, 471 472 /// URL to terms of service page. 473 #[config(env = "TERMS_OF_SERVICE_URL")] 474 pub terms_of_service_url: Option<String>, 475 476 /// Operator contact email address. 477 #[config(env = "CONTACT_EMAIL")] 478 pub contact_email: Option<String>, 479 480 /// Maximum allowed blob size in bytes (default 10 GiB). 481 #[config(env = "MAX_BLOB_SIZE", default = 10_737_418_240u64)] 482 pub max_blob_size: u64, 483 484 /// Maximum allowed number of preferences 485 #[config(env = "MAX_PREFERENCES_COUNT", default = 1000)] 486 pub max_preferences_count: usize, 487} 488 489impl ServerConfig { 490 /// The public HTTPS URL for this PDS. 491 pub fn public_url(&self) -> String { 492 format!("https://{}", self.hostname) 493 } 494 495 /// Hostname without port suffix. Returns `pds.example.com` from `pds.example.com:443`. 496 pub fn hostname_without_port(&self) -> &str { 497 self.hostname.split(':').next().unwrap_or(&self.hostname) 498 } 499 500 /// Returns the extra banned words list, or an empty vec when unset. 501 pub fn banned_word_list(&self) -> Vec<String> { 502 self.banned_words.clone().unwrap_or_default() 503 } 504 505 /// Returns the user handle domains, falling back to `[hostname_without_port]`. 506 pub fn user_handle_domain_list(&self) -> Vec<String> { 507 self.user_handle_domains 508 .as_deref() 509 .filter(|v| !v.is_empty()) 510 .map(|v| v.to_vec()) 511 .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) 512 } 513 514 /// Alias for `user_handle_domain_list` (for callers that were using the now-removed `available_user_domains` field). 515 pub fn available_user_domain_list(&self) -> Vec<String> { 516 self.user_handle_domain_list() 517 } 518} 519 520#[derive(Debug, Config)] 521pub struct FrontendConfig { 522 /// Whether to enable the built in serving of the frontend. 523 #[config(env = "FRONTEND_ENABLED", default = true)] 524 pub enabled: bool, 525 526 /// Directory to serve as the frontend. The oauth_client_metadata.json will have any references to 527 /// the frontend hostname replaced by the configured frontend hostname. 528 #[config(env = "FRONTEND_DIR", default = "/var/lib/tranquil-pds/frontend")] 529 pub dir: String, 530} 531 532#[derive(Debug, Config)] 533pub struct DatabaseConfig { 534 /// PostgreSQL connection URL. 535 #[config(env = "DATABASE_URL")] 536 pub url: String, 537 538 /// Maximum number of connections in the pool. 539 #[config(env = "DATABASE_MAX_CONNECTIONS", default = 100)] 540 pub max_connections: u32, 541 542 /// Minimum number of idle connections kept in the pool. 543 #[config(env = "DATABASE_MIN_CONNECTIONS", default = 10)] 544 pub min_connections: u32, 545 546 /// Timeout in seconds when acquiring a connection from the pool. 547 #[config(env = "DATABASE_ACQUIRE_TIMEOUT_SECS", default = 10)] 548 pub acquire_timeout_secs: u64, 549} 550 551#[derive(Config)] 552pub struct SecretsConfig { 553 /// Secret used for signing JWTs. Must be at least 32 characters in 554 /// production. 555 #[config(env = "JWT_SECRET")] 556 pub jwt_secret: Option<String>, 557 558 /// Secret used for DPoP proof validation. Must be at least 32 characters 559 /// in production. 560 #[config(env = "DPOP_SECRET")] 561 pub dpop_secret: Option<String>, 562 563 /// Master key used for key-encryption and HKDF derivation. Must be at 564 /// least 32 characters in production. 565 #[config(env = "MASTER_KEY")] 566 pub master_key: Option<String>, 567 568 /// PLC rotation key (DID key). If not set, user-level keys are used. 569 #[config(env = "PLC_ROTATION_KEY")] 570 pub plc_rotation_key: Option<String>, 571 572 /// Allow insecure/test secrets. NEVER enable in production. 573 #[config(env = "TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", default = false)] 574 pub allow_insecure: bool, 575} 576 577impl std::fmt::Debug for SecretsConfig { 578 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 579 f.debug_struct("SecretsConfig") 580 .field( 581 "jwt_secret", 582 &self.jwt_secret.as_ref().map(|_| "[REDACTED]"), 583 ) 584 .field( 585 "dpop_secret", 586 &self.dpop_secret.as_ref().map(|_| "[REDACTED]"), 587 ) 588 .field( 589 "master_key", 590 &self.master_key.as_ref().map(|_| "[REDACTED]"), 591 ) 592 .field( 593 "plc_rotation_key", 594 &self.plc_rotation_key.as_ref().map(|_| "[REDACTED]"), 595 ) 596 .field("allow_insecure", &self.allow_insecure) 597 .finish() 598 } 599} 600 601impl SecretsConfig { 602 /// Resolve the JWT secret, falling back to an insecure default if 603 /// `allow_insecure` is true. 604 pub fn jwt_secret_or_default(&self) -> String { 605 self.jwt_secret.clone().unwrap_or_else(|| { 606 if cfg!(test) || self.allow_insecure { 607 "test-jwt-secret-not-for-production".to_string() 608 } else { 609 panic!( 610 "JWT_SECRET must be set in production. \ 611 Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." 612 ); 613 } 614 }) 615 } 616 617 /// Resolve the DPoP secret, falling back to an insecure default if 618 /// `allow_insecure` is true. 619 pub fn dpop_secret_or_default(&self) -> String { 620 self.dpop_secret.clone().unwrap_or_else(|| { 621 if cfg!(test) || self.allow_insecure { 622 "test-dpop-secret-not-for-production".to_string() 623 } else { 624 panic!( 625 "DPOP_SECRET must be set in production. \ 626 Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." 627 ); 628 } 629 }) 630 } 631 632 /// Resolve the master key, falling back to an insecure default if 633 /// `allow_insecure` is true. 634 pub fn master_key_or_default(&self) -> String { 635 self.master_key.clone().unwrap_or_else(|| { 636 if cfg!(test) || self.allow_insecure { 637 "test-master-key-not-for-production".to_string() 638 } else { 639 panic!( 640 "MASTER_KEY must be set in production. \ 641 Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." 642 ); 643 } 644 }) 645 } 646} 647 648#[derive(Debug, Clone, Copy, PartialEq, Eq)] 649pub enum RepoBackend { 650 Postgres, 651 TranquilStore, 652} 653 654impl std::str::FromStr for RepoBackend { 655 type Err = String; 656 657 fn from_str(s: &str) -> Result<Self, Self::Err> { 658 match s { 659 "postgres" => Ok(Self::Postgres), 660 "tranquil-store" => Ok(Self::TranquilStore), 661 other => Err(format!( 662 "unknown repo backend \"{other}\", expected \"postgres\" or \"tranquil-store\"" 663 )), 664 } 665 } 666} 667 668impl fmt::Display for RepoBackend { 669 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 670 match self { 671 Self::Postgres => f.write_str("postgres"), 672 Self::TranquilStore => f.write_str("tranquil-store"), 673 } 674 } 675} 676 677#[derive(Debug, Config)] 678pub struct StorageConfig { 679 /// Storage backend: `filesystem` or `s3`. 680 #[config(env = "BLOB_STORAGE_BACKEND", default = "filesystem")] 681 pub backend: String, 682 683 /// Path on disk for the filesystem blob backend. 684 #[config(env = "BLOB_STORAGE_PATH", default = "/var/lib/tranquil-pds/blobs")] 685 pub path: String, 686 687 /// S3 bucket name for blob storage. 688 #[config(env = "S3_BUCKET")] 689 pub s3_bucket: Option<String>, 690 691 /// Custom S3 endpoint URL. 692 #[config(env = "S3_ENDPOINT")] 693 pub s3_endpoint: Option<String>, 694 695 /// Repository backend: `postgres` by default, or `tranquil-store`, our embedded db. 696 /// tranquil-store is EXPERIMENTAL!!!! RISK OF TOTAL DATA LOSS. 697 #[config(env = "REPO_BACKEND", default = "postgres")] 698 pub repo_backend: String, 699} 700 701impl StorageConfig { 702 pub fn repo_backend(&self) -> RepoBackend { 703 self.repo_backend 704 .parse() 705 .expect("repo_backend must be validated before use") 706 } 707} 708 709#[derive(Debug, Config)] 710pub struct CacheConfig { 711 /// Cache backend: `ripple` by default, or `valkey`. 712 #[config(env = "CACHE_BACKEND", default = "ripple")] 713 pub backend: String, 714 715 /// Valkey / Redis connection URL. Required when `backend = "valkey"`. 716 #[config(env = "VALKEY_URL")] 717 pub valkey_url: Option<String>, 718 719 #[config(nested)] 720 pub ripple: RippleCacheConfig, 721} 722 723#[derive(Debug, Config)] 724pub struct PlcConfig { 725 /// Base URL of the PLC directory. 726 #[config(env = "PLC_DIRECTORY_URL", default = "https://plc.directory")] 727 pub directory_url: String, 728 729 /// HTTP request timeout in seconds. 730 #[config(env = "PLC_TIMEOUT_SECS", default = 10)] 731 pub timeout_secs: u64, 732 733 /// TCP connect timeout in seconds. 734 #[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)] 735 pub connect_timeout_secs: u64, 736 737 /// Seconds to cache DID documents in memory. 738 #[config(env = "DID_CACHE_TTL_SECS", default = 300)] 739 pub did_cache_ttl_secs: u64, 740} 741 742#[derive(Debug, Config)] 743pub struct FirehoseConfig { 744 /// Size of the in-memory broadcast buffer for firehose events. 745 #[config(env = "FIREHOSE_BUFFER_SIZE", default = 10000)] 746 pub buffer_size: usize, 747 748 /// How many hours of historical events to replay for cursor-based 749 /// firehose connections. 750 #[config(env = "FIREHOSE_BACKFILL_HOURS", default = 72)] 751 pub backfill_hours: i64, 752 753 /// Maximum concurrent full-repo exports, eg. getRepo without `since`. 754 #[config(env = "MAX_CONCURRENT_REPO_EXPORTS", default = 4)] 755 pub max_concurrent_repo_exports: usize, 756 757 /// List of relay / crawler notification URLs. 758 #[config(env = "CRAWLERS", parse_env = split_comma_list)] 759 pub crawlers: Option<Vec<String>>, 760} 761 762impl FirehoseConfig { 763 /// Returns the list of crawler URLs, falling back to `["https://bsky.network"]` 764 /// when none are configured. 765 pub fn crawler_list(&self) -> Vec<String> { 766 self.crawlers 767 .clone() 768 .unwrap_or_else(|| vec!["https://bsky.network".to_string()]) 769 } 770} 771 772#[derive(Debug, Config)] 773pub struct EmailConfig { 774 /// Sender email address. When unset, email sending is disabled. 775 #[config(env = "MAIL_FROM_ADDRESS")] 776 pub from_address: Option<String>, 777 778 /// Display name used in the `From` header. 779 #[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")] 780 pub from_name: String, 781 782 /// HELO/EHLO name announced to remote SMTP servers. Applies to both 783 /// smarthost and direct-MX modes. Defaults to the server hostname. 784 #[config(env = "MAIL_HELO_NAME")] 785 pub helo_name: Option<String>, 786 787 #[config(nested)] 788 pub smarthost: SmarthostConfig, 789 790 #[config(nested)] 791 pub direct_mx: DirectMxConfig, 792 793 #[config(nested)] 794 pub dkim: DkimConfig, 795} 796 797impl EmailConfig { 798 pub fn validate(&self, server_hostname: &str, errors: &mut Vec<String>) { 799 match self.smarthost.tls.to_ascii_lowercase().as_str() { 800 "implicit" | "starttls" => {} 801 "none" => { 802 if self.smarthost.password.is_some() { 803 errors.push( 804 "email.smarthost.tls = \"none\" with email.smarthost.password set \ 805 would transmit credentials in plaintext; use \"starttls\" or \"implicit\"" 806 .to_string(), 807 ); 808 } 809 } 810 other => errors.push(format!( 811 "email.smarthost.tls must be \"implicit\", \"starttls\", or \"none\", got \"{other}\"" 812 )), 813 } 814 815 let smarthost_host_set = self 816 .smarthost 817 .host 818 .as_deref() 819 .is_some_and(|h| !h.is_empty()); 820 let username_set = self.smarthost.username.is_some(); 821 let password_set = self.smarthost.password.is_some(); 822 if !smarthost_host_set && (username_set || password_set) { 823 errors.push( 824 "email.smarthost.username or email.smarthost.password is set but \ 825 email.smarthost.host is empty; credentials would be silently ignored" 826 .to_string(), 827 ); 828 } 829 if smarthost_host_set && username_set != password_set { 830 errors.push( 831 "email.smarthost.username and email.smarthost.password must both be set or \ 832 both unset; otherwise authentication would silently degrade to anonymous" 833 .to_string(), 834 ); 835 } 836 837 if self.smarthost.command_timeout_secs == 0 { 838 errors.push("email.smarthost.command_timeout_secs must be at least 1".to_string()); 839 } 840 if self.smarthost.total_timeout_secs == 0 { 841 errors.push("email.smarthost.total_timeout_secs must be at least 1".to_string()); 842 } 843 if self.smarthost.pool_size == 0 { 844 errors.push("email.smarthost.pool_size must be at least 1".to_string()); 845 } 846 847 if self.direct_mx.max_concurrent_sends == 0 { 848 errors.push("email.direct_mx.max_concurrent_sends must be at least 1".to_string()); 849 } 850 if self.direct_mx.command_timeout_secs == 0 { 851 errors.push("email.direct_mx.command_timeout_secs must be at least 1".to_string()); 852 } 853 if self.direct_mx.total_timeout_secs == 0 { 854 errors.push("email.direct_mx.total_timeout_secs must be at least 1".to_string()); 855 } 856 857 let dkim_set = self.dkim.selector.is_some() 858 || self.dkim.domain.is_some() 859 || self.dkim.private_key_path.is_some(); 860 if dkim_set { 861 if self.dkim.selector.is_none() { 862 errors 863 .push("email.dkim.selector is required when any DKIM field is set".to_string()); 864 } 865 if self.dkim.domain.is_none() { 866 errors.push("email.dkim.domain is required when any DKIM field is set".to_string()); 867 } 868 if self.dkim.private_key_path.is_none() { 869 errors.push( 870 "email.dkim.private_key_path is required when any DKIM field is set" 871 .to_string(), 872 ); 873 } 874 } 875 876 let Some(from_address) = self.from_address.as_deref().filter(|s| !s.is_empty()) else { 877 return; 878 }; 879 880 if !looks_like_email_address(from_address) { 881 errors.push(format!( 882 "email.from_address {from_address:?} is not a valid email address" 883 )); 884 } 885 if self.from_name.chars().any(|c| c.is_control()) { 886 errors.push("email.from_name must not contain control characters".to_string()); 887 } 888 889 let helo_raw = self 890 .helo_name 891 .as_deref() 892 .map(str::to_string) 893 .unwrap_or_else(|| server_hostname.to_string()); 894 if !is_non_whitespace_token(&helo_raw) { 895 errors.push(format!( 896 "email HELO name {helo_raw:?} must be non-empty and contain no whitespace" 897 )); 898 } 899 900 if smarthost_host_set { 901 let host = self.smarthost.host.as_deref().unwrap_or(""); 902 if !is_non_whitespace_token(host) { 903 errors.push(format!( 904 "email.smarthost.host {host:?} must contain no whitespace" 905 )); 906 } 907 if self.smarthost.port == 0 { 908 errors.push("email.smarthost.port must be non-zero".to_string()); 909 } 910 if let Some(u) = self.smarthost.username.as_deref() 911 && u.is_empty() 912 { 913 errors.push("email.smarthost.username must be non-empty".to_string()); 914 } 915 if let Some(p) = self.smarthost.password.as_deref() 916 && p.is_empty() 917 { 918 errors.push("email.smarthost.password must be non-empty".to_string()); 919 } 920 } 921 922 if let Some(selector) = self.dkim.selector.as_deref() 923 && !is_valid_dkim_selector(selector) 924 { 925 errors.push(format!( 926 "email.dkim.selector {selector:?} must be valid subdomain syntax" 927 )); 928 } 929 if let Some(domain) = self.dkim.domain.as_deref() 930 && !is_non_whitespace_token(domain) 931 { 932 errors.push(format!( 933 "email.dkim.domain {domain:?} must be non-empty and contain no whitespace" 934 )); 935 } 936 if let Some(key_path) = self.dkim.private_key_path.as_deref() 937 && key_path.trim().is_empty() 938 { 939 errors.push("email.dkim.private_key_path must be non-empty".to_string()); 940 } 941 } 942} 943 944fn looks_like_email_address(s: &str) -> bool { 945 let trimmed = s.trim(); 946 if trimmed.is_empty() || trimmed.chars().any(char::is_whitespace) { 947 return false; 948 } 949 let mut parts = trimmed.split('@'); 950 let local = parts.next().unwrap_or(""); 951 let domain = parts.next().unwrap_or(""); 952 parts.next().is_none() && !local.is_empty() && !domain.is_empty() && domain.contains('.') 953} 954 955fn is_non_whitespace_token(s: &str) -> bool { 956 let trimmed = s.trim(); 957 !trimmed.is_empty() && !trimmed.chars().any(char::is_whitespace) 958} 959 960fn is_valid_dkim_selector(s: &str) -> bool { 961 let trimmed = s.trim(); 962 !trimmed.is_empty() 963 && trimmed.split('.').all(|seg| { 964 let starts_alnum = seg 965 .chars() 966 .next() 967 .is_some_and(|c| c.is_ascii_alphanumeric()); 968 let ends_alnum = seg 969 .chars() 970 .next_back() 971 .is_some_and(|c| c.is_ascii_alphanumeric()); 972 let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); 973 starts_alnum && ends_alnum && body_ok 974 }) 975} 976 977#[derive(Debug, Config)] 978pub struct SmarthostConfig { 979 /// SMTP relay host. When set, mail is delivered through this host 980 /// instead of resolving recipient MX records directly. 981 #[config(env = "MAIL_SMARTHOST_HOST")] 982 pub host: Option<String>, 983 984 /// SMTP relay port. 985 #[config(env = "MAIL_SMARTHOST_PORT", default = 587)] 986 pub port: u16, 987 988 /// SMTP authentication username. 989 #[config(env = "MAIL_SMARTHOST_USERNAME")] 990 pub username: Option<String>, 991 992 /// SMTP authentication password. 993 #[config(env = "MAIL_SMARTHOST_PASSWORD")] 994 pub password: Option<String>, 995 996 /// TLS mode. Valid values: "implicit", "starttls", "none". Setting "none" 997 /// alongside a password is rejected at startup to prevent transmitting 998 /// credentials in plaintext. 999 #[config(env = "MAIL_SMARTHOST_TLS", default = "starttls")] 1000 pub tls: String, 1001 1002 /// Max size of the connection pool. 1003 #[config(env = "MAIL_SMARTHOST_POOL_SIZE", default = 4)] 1004 pub pool_size: u32, 1005 1006 /// Per-command SMTP timeout in seconds. Bounds the security handshake. 1007 #[config(env = "MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS", default = 30)] 1008 pub command_timeout_secs: u64, 1009 1010 /// Total per-message timeout in seconds. Wraps the entire send so a 1011 /// stuck relay cannot stall the comms queue. 1012 #[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)] 1013 pub total_timeout_secs: u64, 1014} 1015 1016#[derive(Debug, Config)] 1017pub struct DirectMxConfig { 1018 /// Per-command SMTP timeout in seconds. 1019 #[config(env = "MAIL_COMMAND_TIMEOUT_SECS", default = 30)] 1020 pub command_timeout_secs: u64, 1021 1022 /// Total per-message timeout across all MX attempts in seconds. 1023 #[config(env = "MAIL_TOTAL_TIMEOUT_SECS", default = 60)] 1024 pub total_timeout_secs: u64, 1025 1026 /// Max number of concurrent direct-MX sends. Limits the load placed 1027 /// on any single recipient MX during a backlog drain. 1028 #[config(env = "MAIL_MAX_CONCURRENT_SENDS", default = 8)] 1029 pub max_concurrent_sends: usize, 1030 1031 /// Require STARTTLS on every MX hop. When false, TLS is 1032 /// attempted opportunistically and the session falls back to plaintext 1033 /// if the remote does not advertise STARTTLS. Set true to refuse 1034 /// plaintext delivery, at the cost of failing sends to MX hosts that 1035 /// do not support TLS. 1036 #[config(env = "MAIL_REQUIRE_TLS", default = false)] 1037 pub require_tls: bool, 1038} 1039 1040#[derive(Debug, Config)] 1041pub struct DkimConfig { 1042 /// DKIM selector. When unset, outgoing mail is not signed. 1043 #[config(env = "MAIL_DKIM_SELECTOR")] 1044 pub selector: Option<String>, 1045 1046 /// DKIM signing domain. 1047 #[config(env = "MAIL_DKIM_DOMAIN")] 1048 pub domain: Option<String>, 1049 1050 /// Path to the DKIM private key in PEM format. Supports RSA and 1051 /// Ed25519 keys. 1052 #[config(env = "MAIL_DKIM_KEY_PATH")] 1053 pub private_key_path: Option<String>, 1054} 1055 1056#[derive(Debug, Config)] 1057pub struct DiscordConfig { 1058 /// Discord bot token. When unset, Discord integration is disabled. 1059 #[config(env = "DISCORD_BOT_TOKEN")] 1060 pub bot_token: Option<String>, 1061} 1062 1063#[derive(Debug, Config)] 1064pub struct TelegramConfig { 1065 /// Telegram bot token. When unset, Telegram integration is disabled. 1066 #[config(env = "TELEGRAM_BOT_TOKEN")] 1067 pub bot_token: Option<String>, 1068 1069 /// Secret token for incoming webhook verification. 1070 #[config(env = "TELEGRAM_WEBHOOK_SECRET")] 1071 pub webhook_secret: Option<String>, 1072} 1073 1074#[derive(Debug, Config)] 1075pub struct SignalConfig { 1076 /// Protocol state is stored in postgres' signal_* tables. 1077 /// Link a device via the admin API before enabling. 1078 #[config(env = "SIGNAL_ENABLED", default = false)] 1079 pub enabled: bool, 1080} 1081 1082#[derive(Debug, Config)] 1083pub struct NotificationConfig { 1084 /// Polling interval in milliseconds for the comms queue. 1085 #[config(env = "NOTIFICATION_POLL_INTERVAL_MS", default = 1000)] 1086 pub poll_interval_ms: u64, 1087 1088 /// Number of notifications to process per batch. 1089 #[config(env = "NOTIFICATION_BATCH_SIZE", default = 100)] 1090 pub batch_size: i64, 1091} 1092 1093pub trait SsoProviderConfig { 1094 fn get_enabled(&self) -> bool; 1095 fn get_client_id(&self) -> &Option<String>; 1096 fn get_client_secret(&self) -> &Option<String>; 1097 fn get_display_name(&self) -> &Option<String>; 1098} 1099 1100pub trait SsoProviderIssuerConfig { 1101 fn get_issuer(&self) -> &Option<String>; 1102} 1103 1104#[derive(Debug, Config)] 1105pub struct SsoConfig { 1106 #[config(nested)] 1107 pub github: SsoGitHubConfig, 1108 1109 #[config(nested)] 1110 pub discord: SsoDiscordConfig, 1111 1112 #[config(nested)] 1113 pub google: SsoGoogleConfig, 1114 1115 #[config(nested)] 1116 pub gitlab: SsoGitLabConfig, 1117 1118 #[config(nested)] 1119 pub oidc: SsoOidcConfig, 1120 1121 #[config(nested)] 1122 pub apple: SsoAppleConfig, 1123} 1124 1125#[derive(Debug, Config)] 1126pub struct SsoGitHubConfig { 1127 #[config(env = "SSO_GITHUB_ENABLED", default = false)] 1128 pub enabled: bool, 1129 1130 #[config(env = "SSO_GITHUB_CLIENT_ID")] 1131 pub client_id: Option<String>, 1132 1133 #[config(env = "SSO_GITHUB_CLIENT_SECRET")] 1134 pub client_secret: Option<String>, 1135 1136 #[config(env = "SSO_GITHUB_DISPLAY_NAME")] 1137 pub display_name: Option<String>, 1138} 1139 1140impl SsoProviderConfig for SsoGitHubConfig { 1141 fn get_enabled(&self) -> bool { 1142 self.enabled 1143 } 1144 1145 fn get_client_id(&self) -> &Option<String> { 1146 &self.client_id 1147 } 1148 1149 fn get_client_secret(&self) -> &Option<String> { 1150 &self.client_secret 1151 } 1152 1153 fn get_display_name(&self) -> &Option<String> { 1154 &self.display_name 1155 } 1156} 1157 1158#[derive(Debug, Config)] 1159pub struct SsoDiscordConfig { 1160 #[config(env = "SSO_DISCORD_ENABLED", default = false)] 1161 pub enabled: bool, 1162 1163 #[config(env = "SSO_DISCORD_CLIENT_ID")] 1164 pub client_id: Option<String>, 1165 1166 #[config(env = "SSO_DISCORD_CLIENT_SECRET")] 1167 pub client_secret: Option<String>, 1168 1169 #[config(env = "SSO_DISCORD_DISPLAY_NAME")] 1170 pub display_name: Option<String>, 1171} 1172 1173impl SsoProviderConfig for SsoDiscordConfig { 1174 fn get_enabled(&self) -> bool { 1175 self.enabled 1176 } 1177 1178 fn get_client_id(&self) -> &Option<String> { 1179 &self.client_id 1180 } 1181 1182 fn get_client_secret(&self) -> &Option<String> { 1183 &self.client_secret 1184 } 1185 1186 fn get_display_name(&self) -> &Option<String> { 1187 &self.display_name 1188 } 1189} 1190 1191#[derive(Debug, Config)] 1192pub struct SsoGoogleConfig { 1193 #[config(env = "SSO_GOOGLE_ENABLED", default = false)] 1194 pub enabled: bool, 1195 1196 #[config(env = "SSO_GOOGLE_CLIENT_ID")] 1197 pub client_id: Option<String>, 1198 1199 #[config(env = "SSO_GOOGLE_CLIENT_SECRET")] 1200 pub client_secret: Option<String>, 1201 1202 #[config(env = "SSO_GOOGLE_DISPLAY_NAME")] 1203 pub display_name: Option<String>, 1204} 1205 1206impl SsoProviderConfig for SsoGoogleConfig { 1207 fn get_enabled(&self) -> bool { 1208 self.enabled 1209 } 1210 1211 fn get_client_id(&self) -> &Option<String> { 1212 &self.client_id 1213 } 1214 1215 fn get_client_secret(&self) -> &Option<String> { 1216 &self.client_secret 1217 } 1218 1219 fn get_display_name(&self) -> &Option<String> { 1220 &self.display_name 1221 } 1222} 1223 1224#[derive(Debug, Config)] 1225pub struct SsoGitLabConfig { 1226 #[config(env = "SSO_GITLAB_ENABLED", default = false)] 1227 pub enabled: bool, 1228 1229 #[config(env = "SSO_GITLAB_CLIENT_ID")] 1230 pub client_id: Option<String>, 1231 1232 #[config(env = "SSO_GITLAB_CLIENT_SECRET")] 1233 pub client_secret: Option<String>, 1234 1235 #[config(env = "SSO_GITLAB_ISSUER")] 1236 pub issuer: Option<String>, 1237 1238 #[config(env = "SSO_GITLAB_DISPLAY_NAME")] 1239 pub display_name: Option<String>, 1240} 1241 1242impl SsoProviderConfig for SsoGitLabConfig { 1243 fn get_enabled(&self) -> bool { 1244 self.enabled 1245 } 1246 1247 fn get_client_id(&self) -> &Option<String> { 1248 &self.client_id 1249 } 1250 1251 fn get_client_secret(&self) -> &Option<String> { 1252 &self.client_secret 1253 } 1254 1255 fn get_display_name(&self) -> &Option<String> { 1256 &self.display_name 1257 } 1258} 1259 1260impl SsoProviderIssuerConfig for SsoGitLabConfig { 1261 fn get_issuer(&self) -> &Option<String> { 1262 &self.issuer 1263 } 1264} 1265 1266#[derive(Debug, Config)] 1267pub struct SsoOidcConfig { 1268 #[config(env = "SSO_OIDC_ENABLED", default = false)] 1269 pub enabled: bool, 1270 1271 #[config(env = "SSO_OIDC_CLIENT_ID")] 1272 pub client_id: Option<String>, 1273 1274 #[config(env = "SSO_OIDC_CLIENT_SECRET")] 1275 pub client_secret: Option<String>, 1276 1277 #[config(env = "SSO_OIDC_ISSUER")] 1278 pub issuer: Option<String>, 1279 1280 #[config(env = "SSO_OIDC_DISPLAY_NAME")] 1281 pub display_name: Option<String>, 1282} 1283 1284impl SsoProviderConfig for SsoOidcConfig { 1285 fn get_enabled(&self) -> bool { 1286 self.enabled 1287 } 1288 1289 fn get_client_id(&self) -> &Option<String> { 1290 &self.client_id 1291 } 1292 1293 fn get_client_secret(&self) -> &Option<String> { 1294 &self.client_secret 1295 } 1296 1297 fn get_display_name(&self) -> &Option<String> { 1298 &self.display_name 1299 } 1300} 1301 1302impl SsoProviderIssuerConfig for SsoOidcConfig { 1303 fn get_issuer(&self) -> &Option<String> { 1304 &self.issuer 1305 } 1306} 1307 1308#[derive(Debug, Config)] 1309pub struct SsoAppleConfig { 1310 #[config(env = "SSO_APPLE_ENABLED", default = false)] 1311 pub enabled: bool, 1312 1313 #[config(env = "SSO_APPLE_CLIENT_ID")] 1314 pub client_id: Option<String>, 1315 1316 #[config(env = "SSO_APPLE_TEAM_ID")] 1317 pub team_id: Option<String>, 1318 1319 #[config(env = "SSO_APPLE_KEY_ID")] 1320 pub key_id: Option<String>, 1321 1322 #[config(env = "SSO_APPLE_PRIVATE_KEY")] 1323 pub private_key: Option<String>, 1324} 1325 1326#[derive(Debug, Config)] 1327pub struct ModerationConfig { 1328 /// External report-handling service URL. 1329 #[config(env = "REPORT_SERVICE_URL")] 1330 pub report_service_url: Option<String>, 1331 1332 /// DID of the external report-handling service. 1333 #[config(env = "REPORT_SERVICE_DID")] 1334 pub report_service_did: Option<String>, 1335} 1336 1337#[derive(Debug, Config)] 1338pub struct ImportConfig { 1339 /// Whether the PDS accepts repo imports. 1340 #[config(env = "ACCEPTING_REPO_IMPORTS", default = true)] 1341 pub accepting: bool, 1342 1343 /// Maximum allowed import archive size in bytes (default 1 GiB). 1344 #[config(env = "MAX_IMPORT_SIZE", default = 1_073_741_824)] 1345 pub max_size: u64, 1346 1347 /// Maximum number of blocks allowed in an import. 1348 #[config(env = "MAX_IMPORT_BLOCKS", default = 500000)] 1349 pub max_blocks: u64, 1350 1351 /// Skip CAR verification during import. Only for development/debugging. 1352 #[config(env = "SKIP_IMPORT_VERIFICATION", default = false)] 1353 pub skip_verification: bool, 1354} 1355 1356/// Parse a comma-separated environment variable into a `Vec<String>`, 1357/// trimming whitespace and dropping empty entries. 1358/// 1359/// Signature matches confique's `parse_env` expectation: `fn(&str) -> Result<T, E>`. 1360fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible> { 1361 Ok(value 1362 .split(',') 1363 .map(|item| item.trim().to_string()) 1364 .filter(|item| !item.is_empty()) 1365 .collect()) 1366} 1367 1368#[derive(Debug, Config)] 1369pub struct RippleCacheConfig { 1370 /// Address to bind the Ripple gossip protocol listener. 1371 #[config(env = "RIPPLE_BIND", default = "0.0.0.0:0")] 1372 pub bind_addr: String, 1373 1374 /// List of seed peer addresses. 1375 #[config(env = "RIPPLE_PEERS", parse_env = split_comma_list)] 1376 pub peers: Option<Vec<String>>, 1377 1378 /// Unique machine identifier. Auto-derived from hostname when not set. 1379 #[config(env = "RIPPLE_MACHINE_ID")] 1380 pub machine_id: Option<u64>, 1381 1382 /// Gossip protocol interval in milliseconds. 1383 #[config(env = "RIPPLE_GOSSIP_INTERVAL_MS", default = 200)] 1384 pub gossip_interval_ms: u64, 1385 1386 /// Maximum cache size in megabytes. 1387 #[config(env = "RIPPLE_CACHE_MAX_MB", default = 256)] 1388 pub cache_max_mb: usize, 1389} 1390 1391#[derive(Debug, Config)] 1392pub struct ScheduledConfig { 1393 /// Interval in seconds between scheduled delete checks. 1394 #[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)] 1395 pub delete_check_interval_secs: u64, 1396 1397 /// Interval in seconds between data file compaction scans (tranquil-store only). 1398 /// Set to 0 to disable. 1399 #[config(env = "COMPACTION_INTERVAL_SECS", default = 3600)] 1400 pub compaction_interval_secs: u64, 1401 1402 /// Liveness ratio threshold below which a data file is compacted (0.0-1.0). 1403 #[config(env = "COMPACTION_LIVENESS_THRESHOLD", default = 0.7)] 1404 pub compaction_liveness_threshold: f64, 1405 1406 /// Grace period in milliseconds before a zero-refcount block can be removed by compaction. 1407 #[config(env = "COMPACTION_GRACE_PERIOD_MS", default = 600000)] 1408 pub compaction_grace_period_ms: u64, 1409 1410 /// Interval in seconds between reachability walk runs (tranquil-store only). 1411 /// Set to 0 to disable. Default: weekly. 1412 #[config(env = "REACHABILITY_WALK_INTERVAL_SECS", default = 604800)] 1413 pub reachability_walk_interval_secs: u64, 1414 1415 /// Interval in seconds between continuous archival passes (tranquil-store only). 1416 /// Sealed eventlog segments are copied to the archival destination each tick. 1417 /// Set to 0 to disable. Default: 60 seconds. 1418 #[config(env = "ARCHIVAL_INTERVAL_SECS", default = 60)] 1419 pub archival_interval_secs: u64, 1420 1421 /// Archival destination directory for sealed eventlog segments. 1422 /// If unset, archival is disabled. 1423 #[config(env = "ARCHIVAL_DEST_DIR")] 1424 pub archival_dest_dir: Option<String>, 1425 1426 /// Maximum age of events retained in the eventlog before pruning. 1427 /// Per the atproto firehose spec, the relay backfill window only needs 1428 /// to cover "hours or days". 1429 #[config(env = "EVENT_RETENTION_MAX_AGE_SECS", default = 604800)] 1430 pub event_retention_max_age_secs: u64, 1431 1432 /// Interval in seconds between event retention prune passes. 1433 /// Set to 0 to disable. 1434 #[config(env = "EVENT_RETENTION_INTERVAL_SECS", default = 3600)] 1435 pub event_retention_interval_secs: u64, 1436} 1437 1438#[derive(Debug, Config)] 1439pub struct TranquilStoreConfig { 1440 /// Directory for tranquil-store data: the metastore, eventlog, and blockstore. 1441 #[config( 1442 env = "TRANQUIL_STORE_DATA_DIR", 1443 default = "/var/lib/tranquil-pds/store" 1444 )] 1445 pub data_dir: String, 1446 1447 /// Fjall block cache size in megabytes. Defaults to 20% of system RAM when unset. 1448 #[config(env = "TRANQUIL_STORE_MEMORY_BUDGET_MB")] 1449 pub memory_budget_mb: Option<u64>, 1450 1451 /// Number of handler threads. Defaults to available_parallelism / 2. 1452 #[config(env = "TRANQUIL_STORE_HANDLER_THREADS")] 1453 pub handler_threads: Option<usize>, 1454 1455 /// Maximum total bytes of pending (unsynced) eventlog payloads. Appenders block 1456 /// once this budget is exhausted until in-flight events drain via fsync. Set to 1457 /// 0 to disable backpressure. Default: 1 GiB. 1458 #[config( 1459 env = "TRANQUIL_STORE_EVENTLOG_PENDING_BYTES_BUDGET", 1460 default = 1_073_741_824 1461 )] 1462 pub eventlog_pending_bytes_budget: u64, 1463 1464 /// Maximum size of an individual eventlog payload in bytes. Single events 1465 /// larger than this are rejected at append time. Default: 256 MiB. 1466 #[config( 1467 env = "TRANQUIL_STORE_EVENTLOG_MAX_EVENT_PAYLOAD", 1468 default = 268_435_456 1469 )] 1470 pub eventlog_max_event_payload: u32, 1471 1472 /// Maximum size of an individual blockstore data file in bytes. When the 1473 /// active data file reaches this size it is rolled over and becomes 1474 /// eligible for compaction. Default: 256 MiB. 1475 #[config(env = "TRANQUIL_STORE_MAX_BLOCKSTORE_FILE_SIZE", default = 268_435_456)] 1476 pub max_blockstore_file_size: u64, 1477 1478 /// Maximum size of an individual eventlog segment file in bytes. When the 1479 /// active segment reaches this size it is sealed and a new one is created. 1480 /// Safe to change on a running instance. Default: 256 MiB. 1481 #[config( 1482 env = "TRANQUIL_STORE_MAX_EVENTLOG_SEGMENT_SIZE", 1483 default = 268_435_456 1484 )] 1485 pub max_eventlog_segment_size: u64, 1486} 1487 1488/// Generate a TOML configuration template with all available options, 1489/// defaults, and documentation comments. 1490pub fn template() -> String { 1491 confique::toml::template::<TranquilConfig>(confique::toml::FormatOptions::default()) 1492} 1493 1494#[cfg(test)] 1495mod tests { 1496 use super::*; 1497 1498 fn seed_required_env() { 1499 let required = [ 1500 ("PDS_HOSTNAME", "test.local"), 1501 ("DATABASE_URL", "postgres://localhost/test"), 1502 ("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"), 1503 ("INVITE_CODE_REQUIRED", "false"), 1504 ("ENABLE_PDS_HOSTED_DID_WEB", "true"), 1505 ("TRANQUIL_LEXICON_OFFLINE", "1"), 1506 ]; 1507 required 1508 .iter() 1509 .filter(|(k, _)| std::env::var_os(k).is_none()) 1510 .for_each(|(k, v)| unsafe { std::env::set_var(k, v) }); 1511 } 1512 1513 #[test] 1514 fn serial_validate_rejects_legacy_sendmail_path() { 1515 seed_required_env(); 1516 unsafe { std::env::set_var("SENDMAIL_PATH", "/usr/sbin/sendmail") }; 1517 let config = TranquilConfig::builder() 1518 .env() 1519 .load() 1520 .expect("load fresh config"); 1521 let result = config.validate(true); 1522 unsafe { std::env::remove_var("SENDMAIL_PATH") }; 1523 1524 let err = result.expect_err("validate must reject SENDMAIL_PATH"); 1525 let mentions_sendmail = err.errors.iter().any(|e| e.contains("SENDMAIL_PATH")); 1526 assert!( 1527 mentions_sendmail, 1528 "errors did not mention SENDMAIL_PATH: {:?}", 1529 err.errors 1530 ); 1531 } 1532 1533 #[test] 1534 fn serial_validate_passes_when_no_legacy_env_set() { 1535 seed_required_env(); 1536 unsafe { std::env::remove_var("SENDMAIL_PATH") }; 1537 let config = TranquilConfig::builder() 1538 .env() 1539 .load() 1540 .expect("load fresh config"); 1541 let result = config.validate(true); 1542 let leaked_legacy = result 1543 .as_ref() 1544 .err() 1545 .map(|e| e.errors.iter().any(|s| s.contains("SENDMAIL_PATH"))) 1546 .unwrap_or(false); 1547 assert!( 1548 !leaked_legacy, 1549 "validate spuriously flagged SENDMAIL_PATH when unset: {:?}", 1550 result 1551 ); 1552 } 1553 1554 #[test] 1555 fn email_address_predicate_accepts_typical_addresses() { 1556 assert!(looks_like_email_address("alice@nel.pet")); 1557 assert!(looks_like_email_address("a.b+tag@example.co.uk")); 1558 } 1559 1560 #[test] 1561 fn email_address_predicate_rejects_malformed() { 1562 assert!(!looks_like_email_address("")); 1563 assert!(!looks_like_email_address("no-at-sign")); 1564 assert!(!looks_like_email_address("@nel.pet")); 1565 assert!(!looks_like_email_address("alice@")); 1566 assert!(!looks_like_email_address("alice@nel")); 1567 assert!(!looks_like_email_address("a@b@c.com")); 1568 assert!(!looks_like_email_address("alice @nel.pet")); 1569 } 1570 1571 #[test] 1572 fn dkim_selector_predicate_matches_subdomain_syntax() { 1573 assert!(is_valid_dkim_selector("default")); 1574 assert!(is_valid_dkim_selector("s2024-q1")); 1575 assert!(is_valid_dkim_selector("mailo-2024.nel.pet")); 1576 assert!(!is_valid_dkim_selector("")); 1577 assert!(!is_valid_dkim_selector("a..b")); 1578 assert!(!is_valid_dkim_selector("-leading")); 1579 assert!(!is_valid_dkim_selector("trailing-")); 1580 assert!(!is_valid_dkim_selector("s_under")); 1581 } 1582 1583 #[test] 1584 fn email_validate_disabled_when_from_address_unset() { 1585 let cfg = email_config_for_test(EmailOverrides::default()); 1586 let mut errors = Vec::new(); 1587 cfg.validate("test.local", &mut errors); 1588 assert!(errors.is_empty(), "expected no errors, got {errors:?}"); 1589 } 1590 1591 #[test] 1592 fn email_validate_rejects_bad_from_address() { 1593 let cfg = email_config_for_test(EmailOverrides { 1594 from_address: Some("not-an-email"), 1595 ..Default::default() 1596 }); 1597 let mut errors = Vec::new(); 1598 cfg.validate("test.local", &mut errors); 1599 assert!( 1600 errors.iter().any(|e| e.contains("from_address")), 1601 "expected from_address error, got {errors:?}" 1602 ); 1603 } 1604 1605 #[test] 1606 fn email_validate_rejects_smarthost_with_bad_credentials() { 1607 let cfg = email_config_for_test(EmailOverrides { 1608 from_address: Some("alice@nel.pet"), 1609 smarthost_host: Some("smtp.nel.pet"), 1610 smarthost_username: Some(""), 1611 smarthost_password: Some("hunter2"), 1612 ..Default::default() 1613 }); 1614 let mut errors = Vec::new(); 1615 cfg.validate("test.local", &mut errors); 1616 assert!( 1617 errors.iter().any(|e| e.contains("smarthost.username")), 1618 "expected smarthost.username error, got {errors:?}" 1619 ); 1620 } 1621 1622 #[test] 1623 fn email_validate_rejects_bad_dkim_selector() { 1624 let cfg = email_config_for_test(EmailOverrides { 1625 from_address: Some("alice@nel.pet"), 1626 dkim_selector: Some("-bad"), 1627 dkim_domain: Some("nel.pet"), 1628 dkim_key_path: Some("/etc/dkim.key"), 1629 ..Default::default() 1630 }); 1631 let mut errors = Vec::new(); 1632 cfg.validate("test.local", &mut errors); 1633 assert!( 1634 errors.iter().any(|e| e.contains("dkim.selector")), 1635 "expected dkim.selector error, got {errors:?}" 1636 ); 1637 } 1638 1639 #[derive(Default)] 1640 struct EmailOverrides { 1641 from_address: Option<&'static str>, 1642 smarthost_host: Option<&'static str>, 1643 smarthost_username: Option<&'static str>, 1644 smarthost_password: Option<&'static str>, 1645 dkim_selector: Option<&'static str>, 1646 dkim_domain: Option<&'static str>, 1647 dkim_key_path: Option<&'static str>, 1648 } 1649 1650 fn email_config_for_test(o: EmailOverrides) -> EmailConfig { 1651 EmailConfig { 1652 from_address: o.from_address.map(str::to_string), 1653 from_name: "Tranquil PDS".to_string(), 1654 helo_name: None, 1655 smarthost: SmarthostConfig { 1656 host: o.smarthost_host.map(str::to_string), 1657 port: 587, 1658 username: o.smarthost_username.map(str::to_string), 1659 password: o.smarthost_password.map(str::to_string), 1660 tls: "starttls".to_string(), 1661 pool_size: 4, 1662 command_timeout_secs: 30, 1663 total_timeout_secs: 60, 1664 }, 1665 direct_mx: DirectMxConfig { 1666 command_timeout_secs: 30, 1667 total_timeout_secs: 60, 1668 max_concurrent_sends: 8, 1669 require_tls: false, 1670 }, 1671 dkim: DkimConfig { 1672 selector: o.dkim_selector.map(str::to_string), 1673 domain: o.dkim_domain.map(str::to_string), 1674 private_key_path: o.dkim_key_path.map(str::to_string), 1675 }, 1676 } 1677 } 1678}