forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
57 kB
1678 lines
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}