From 66eb9b7dbb1132dcc9656be1c647034acbc2ab1c Mon Sep 17 00:00:00 2001 From: isabel Date: Fri, 13 Feb 2026 15:13:35 +0000 Subject: [PATCH] refactor: toml config --- .env.example | 4 +- Cargo.lock | 213 ++++- Cargo.toml | 4 + README.md | 5 +- crates/tranquil-auth/Cargo.toml | 1 + crates/tranquil-auth/src/token.rs | 8 +- crates/tranquil-cache/Cargo.toml | 1 + crates/tranquil-cache/src/lib.rs | 36 +- crates/tranquil-comms/Cargo.toml | 2 + crates/tranquil-comms/src/sender.rs | 30 +- crates/tranquil-config/Cargo.toml | 9 + crates/tranquil-config/src/lib.rs | 883 ++++++++++++++++++ crates/tranquil-infra/Cargo.toml | 2 + crates/tranquil-infra/src/lib.rs | 10 +- crates/tranquil-pds/Cargo.toml | 2 + .../src/api/admin/account/email.rs | 3 +- .../src/api/admin/account/update.rs | 3 +- crates/tranquil-pds/src/api/delegation.rs | 14 +- .../tranquil-pds/src/api/discord_webhook.rs | 4 +- .../tranquil-pds/src/api/identity/account.rs | 37 +- crates/tranquil-pds/src/api/identity/did.rs | 20 +- .../src/api/identity/plc/request.rs | 3 +- .../src/api/identity/plc/submit.rs | 10 +- crates/tranquil-pds/src/api/moderation/mod.rs | 5 +- .../src/api/notification_prefs.rs | 5 +- crates/tranquil-pds/src/api/proxy.rs | 2 +- crates/tranquil-pds/src/api/proxy_client.rs | 2 +- crates/tranquil-pds/src/api/repo/blob.rs | 4 +- crates/tranquil-pds/src/api/repo/import.rs | 21 +- crates/tranquil-pds/src/api/repo/meta.rs | 3 +- .../tranquil-pds/src/api/repo/record/read.rs | 5 +- .../src/api/server/account_status.rs | 7 +- crates/tranquil-pds/src/api/server/email.rs | 7 +- crates/tranquil-pds/src/api/server/invite.rs | 3 +- crates/tranquil-pds/src/api/server/meta.rs | 28 +- .../tranquil-pds/src/api/server/migration.rs | 3 +- .../src/api/server/passkey_account.rs | 28 +- .../tranquil-pds/src/api/server/password.rs | 5 +- crates/tranquil-pds/src/api/server/session.rs | 19 +- crates/tranquil-pds/src/api/server/totp.rs | 3 +- .../src/api/server/verify_email.rs | 3 +- .../src/api/server/verify_token.rs | 5 +- .../tranquil-pds/src/api/telegram_webhook.rs | 9 +- crates/tranquil-pds/src/appview/mod.rs | 9 +- crates/tranquil-pds/src/auth/service.rs | 6 +- .../src/auth/verification_token.rs | 8 +- crates/tranquil-pds/src/comms/service.rs | 11 +- crates/tranquil-pds/src/config.rs | 52 +- crates/tranquil-pds/src/crawlers.rs | 12 +- crates/tranquil-pds/src/handle/mod.rs | 6 +- crates/tranquil-pds/src/lib.rs | 4 +- crates/tranquil-pds/src/main.rs | 114 ++- crates/tranquil-pds/src/moderation/mod.rs | 9 +- .../src/oauth/endpoints/authorize.rs | 36 +- .../src/oauth/endpoints/metadata.rs | 5 +- .../src/oauth/endpoints/token/grants.rs | 5 +- .../src/oauth/endpoints/token/helpers.rs | 3 +- .../src/oauth/endpoints/token/introspect.rs | 3 +- crates/tranquil-pds/src/plc/mod.rs | 16 +- crates/tranquil-pds/src/scheduled.rs | 8 +- crates/tranquil-pds/src/sso/config.rs | 160 ++-- crates/tranquil-pds/src/sso/endpoints.rs | 28 +- crates/tranquil-pds/src/state.rs | 40 +- .../tranquil-pds/src/sync/subscribe_repos.rs | 10 +- crates/tranquil-pds/src/sync/verify.rs | 3 +- crates/tranquil-pds/src/util.rs | 36 +- crates/tranquil-ripple/Cargo.toml | 1 + crates/tranquil-ripple/src/config.rs | 65 +- crates/tranquil-storage/Cargo.toml | 1 + crates/tranquil-storage/src/lib.rs | 68 +- docker-compose.prod.yaml | 1 + docker-compose.yaml | 1 + docs/install-containers.md | 38 +- docs/install-debian.md | 19 +- docs/install-kubernetes.md | 5 +- example.toml | 509 ++++++++++ 76 files changed, 2165 insertions(+), 598 deletions(-) create mode 100644 crates/tranquil-config/Cargo.toml create mode 100644 crates/tranquil-config/src/lib.rs create mode 100644 example.toml diff --git a/.env.example b/.env.example index d356d34..ce18c1f 100644 --- a/.env.example +++ b/.env.example @@ -131,13 +131,13 @@ BACKUP_STORAGE_PATH=/var/lib/tranquil/backups # Account Registration # ============================================================================= # Require invite codes for registration -# INVITE_CODE_REQUIRED=false +# INVITE_CODE_REQUIRED=true # Comma-separated list of available user domains # AVAILABLE_USER_DOMAINS=example.com # Enable self-hosted did:web identities (default: true) # Hosting did:web requires a long-term commitment to serve DID documents. # Set to false if you don't want to offer this option. -# ENABLE_SELF_HOSTED_DID_WEB=true +# ENABLE_PDS_HOSTED_DID_WEB=false # ============================================================================= # Server Metadata (returned by describeServer) # ============================================================================= diff --git a/Cargo.lock b/Cargo.lock index 9097a32..c07b64f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -1216,6 +1266,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + [[package]] name = "cmake" version = "0.1.57" @@ -1240,6 +1330,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "combine" version = "4.6.7" @@ -1280,6 +1376,29 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "confique" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06b4f5ec222421e22bb0a8cbaa36b1d2b50fd45cdd30c915ded34108da78b29f" +dependencies = [ + "confique-macro", + "serde", + "toml", +] + +[[package]] +name = "confique-macro" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d1754680cd218e7bcb4c960cc9bae3444b5197d64563dccccfdf83cab9e1a7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -2762,7 +2881,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -3059,6 +3178,12 @@ dependencies = [ "unsigned-varint 0.7.2", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -3715,6 +3840,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -4209,7 +4340,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.35", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.17", "tokio", "tracing", @@ -4246,7 +4377,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] @@ -4909,6 +5040,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5708,6 +5848,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.12.1", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "tonic" version = "0.14.2" @@ -5908,6 +6087,7 @@ dependencies = [ "sha2", "subtle", "totp-rs", + "tranquil-config", "tranquil-crypto", "urlencoding", "uuid", @@ -5922,6 +6102,7 @@ dependencies = [ "redis", "tokio-util", "tracing", + "tranquil-config", "tranquil-infra", "tranquil-ripple", ] @@ -5936,10 +6117,19 @@ dependencies = [ "serde_json", "thiserror 2.0.17", "tokio", + "tranquil-config", "tranquil-db-traits", "uuid", ] +[[package]] +name = "tranquil-config" +version = "0.2.1" +dependencies = [ + "confique", + "serde", +] + [[package]] name = "tranquil-crypto" version = "0.2.1" @@ -5997,6 +6187,7 @@ dependencies = [ "bytes", "futures", "thiserror 2.0.17", + "tranquil-config", ] [[package]] @@ -6041,6 +6232,7 @@ dependencies = [ "chrono", "ciborium", "cid", + "clap", "ctor", "dotenvy", "ed25519-dalek", @@ -6091,6 +6283,7 @@ dependencies = [ "tranquil-auth", "tranquil-cache", "tranquil-comms", + "tranquil-config", "tranquil-crypto", "tranquil-db", "tranquil-db-traits", @@ -6139,6 +6332,7 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "tranquil-config", "tranquil-infra", "uuid", ] @@ -6171,6 +6365,7 @@ dependencies = [ "sha2", "tokio", "tracing", + "tranquil-config", "tranquil-infra", "uuid", ] @@ -6356,6 +6551,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.19.0" @@ -6930,6 +7131,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + [[package]] name = "winreg" version = "0.50.0" diff --git a/Cargo.toml b/Cargo.toml index b53e1fd..4de6567 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/tranquil-types", + "crates/tranquil-config", "crates/tranquil-infra", "crates/tranquil-crypto", "crates/tranquil-storage", @@ -24,6 +25,7 @@ license = "AGPL-3.0-or-later" [workspace.dependencies] tranquil-types = { path = "crates/tranquil-types" } +tranquil-config = { path = "crates/tranquil-config" } tranquil-infra = { path = "crates/tranquil-infra" } tranquil-crypto = { path = "crates/tranquil-crypto" } tranquil-storage = { path = "crates/tranquil-storage" } @@ -52,6 +54,8 @@ bs58 = "0.5" bytes = "1.11" chrono = { version = "0.4", features = ["serde"] } cid = "0.11" +clap = { version = "4", features = ["derive", "env"] } +confique = { version = "0.4", features = ["toml"] } dotenvy = "0.15" ed25519-dalek = { version = "2.1", features = ["pkcs8"] } foca = { version = "1", features = ["bincode-codec", "tracing"] } diff --git a/README.md b/README.md index 656a248..2e29857 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,10 @@ just run ## Configuration -See `.env.example` for all configuration options. +See `example.toml` for all configuration options. + +> [!NOTE] +> The order of configuration precendence is: environment variables, than a config file passed via `--config`, than `/etc/tranquil-pds/config.toml`, than the built-in defaults. So you can use environment variables, or a config file, or both. ## Development diff --git a/crates/tranquil-auth/Cargo.toml b/crates/tranquil-auth/Cargo.toml index 4831e96..280c5c7 100644 --- a/crates/tranquil-auth/Cargo.toml +++ b/crates/tranquil-auth/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +tranquil-config = { workspace = true } tranquil-crypto = { workspace = true } anyhow = { workspace = true } diff --git a/crates/tranquil-auth/src/token.rs b/crates/tranquil-auth/src/token.rs index 21cfb81..1dfc113 100644 --- a/crates/tranquil-auth/src/token.rs +++ b/crates/tranquil-auth/src/token.rs @@ -127,7 +127,9 @@ fn create_signed_token_with_act( let jti = uuid::Uuid::new_v4().to_string(); let aud_hostname = hostname.map(|h| h.to_string()).unwrap_or_else(|| { - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) + tranquil_config::try_get() + .map(|c| c.server.hostname.clone()) + .unwrap_or_else(|| "localhost".to_string()) }); let claims = Claims { @@ -253,7 +255,9 @@ fn create_hs256_token_with_metadata( sub: did.to_owned(), aud: format!( "did:web:{}", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) + tranquil_config::try_get() + .map(|c| c.server.hostname.clone()) + .unwrap_or_else(|| "localhost".to_string()) ), exp: expiration, iat: Utc::now().timestamp(), diff --git a/crates/tranquil-cache/Cargo.toml b/crates/tranquil-cache/Cargo.toml index d374b03..f6f6580 100644 --- a/crates/tranquil-cache/Cargo.toml +++ b/crates/tranquil-cache/Cargo.toml @@ -9,6 +9,7 @@ default = [] valkey = ["dep:redis"] [dependencies] +tranquil-config = { workspace = true } tranquil-infra = { workspace = true } tranquil-ripple = { workspace = true } diff --git a/crates/tranquil-cache/src/lib.rs b/crates/tranquil-cache/src/lib.rs index 8fa57d5..d0ea4b1 100644 --- a/crates/tranquil-cache/src/lib.rs +++ b/crates/tranquil-cache/src/lib.rs @@ -172,28 +172,36 @@ impl DistributedRateLimiter for NoOpRateLimiter { pub async fn create_cache( shutdown: tokio_util::sync::CancellationToken, ) -> (Arc, Arc) { + let cache_cfg = tranquil_config::try_get().map(|c| &c.cache); + let backend = cache_cfg.map(|c| c.backend.as_str()).unwrap_or("ripple"); + let valkey_url = cache_cfg.and_then(|c| c.valkey_url.as_deref()); + #[cfg(feature = "valkey")] - if let Ok(url) = std::env::var("VALKEY_URL") { - match ValkeyCache::new(&url).await { - Ok(cache) => { - tracing::info!("using valkey cache at {url}"); - let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection())); - return (Arc::new(cache), rate_limiter); - } - Err(e) => { - tracing::warn!("failed to connect to valkey: {e}. falling back to ripple."); + if backend == "valkey" { + if let Some(url) = valkey_url { + match ValkeyCache::new(url).await { + Ok(cache) => { + tracing::info!("using valkey cache at {url}"); + let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection())); + return (Arc::new(cache), rate_limiter); + } + Err(e) => { + tracing::warn!("failed to connect to valkey: {e}. falling back to ripple."); + } } + } else { + tracing::warn!("cache.backend is \"valkey\" but VALKEY_URL is not set. using ripple."); } } #[cfg(not(feature = "valkey"))] - if std::env::var("VALKEY_URL").is_ok() { + if backend == "valkey" { tracing::warn!( - "VALKEY_URL is set but binary was compiled without valkey feature. using ripple." + "cache.backend is \"valkey\" but binary was compiled without valkey feature. using ripple." ); } - match tranquil_ripple::RippleConfig::from_env() { + match tranquil_ripple::RippleConfig::from_config() { Ok(config) => { let peer_count = config.seed_peers.len(); match tranquil_ripple::RippleEngine::start(config, shutdown).await { @@ -205,13 +213,13 @@ pub async fn create_cache( (cache, rate_limiter) } Err(e) => { - tracing::error!("ripple engine failed to start: {e}. running without cache."); + tracing::error!("ripple engine failed to start: {e:#}. running without cache."); (Arc::new(NoOpCache), Arc::new(NoOpRateLimiter)) } } } Err(e) => { - tracing::error!("ripple config error: {e}. running without cache."); + tracing::error!("ripple config error: {e:#}. running without cache."); (Arc::new(NoOpCache), Arc::new(NoOpRateLimiter)) } } diff --git a/crates/tranquil-comms/Cargo.toml b/crates/tranquil-comms/Cargo.toml index 870e1b6..67d16e3 100644 --- a/crates/tranquil-comms/Cargo.toml +++ b/crates/tranquil-comms/Cargo.toml @@ -5,6 +5,8 @@ edition.workspace = true license.workspace = true [dependencies] +tranquil-config = { workspace = true } + async-trait = { workspace = true } base64 = { workspace = true } reqwest = { workspace = true } diff --git a/crates/tranquil-comms/src/sender.rs b/crates/tranquil-comms/src/sender.rs index d2d1453..0380a9f 100644 --- a/crates/tranquil-comms/src/sender.rs +++ b/crates/tranquil-comms/src/sender.rs @@ -112,20 +112,19 @@ pub struct EmailSender { } impl EmailSender { - pub fn new(from_address: String, from_name: String) -> Self { + pub fn new(from_address: String, from_name: String, sendmail_path: String) -> Self { Self { from_address, from_name, - sendmail_path: std::env::var("SENDMAIL_PATH") - .unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()), + sendmail_path, } } - pub fn from_env() -> Option { - let from_address = std::env::var("MAIL_FROM_ADDRESS").ok()?; - let from_name = - std::env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Tranquil PDS".to_string()); - Some(Self::new(from_address, from_name)) + pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option { + let from_address = cfg.email.from_address.clone()?; + let from_name = cfg.email.from_name.clone(); + let sendmail_path = cfg.email.sendmail_path.clone(); + Some(Self::new(from_address, from_name, sendmail_path)) } pub fn format_email(&self, notification: &QueuedComms) -> String { @@ -190,8 +189,8 @@ impl DiscordSender { } } - pub fn from_env() -> Option { - let bot_token = std::env::var("DISCORD_BOT_TOKEN").ok()?; + pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option { + let bot_token = cfg.discord.bot_token.clone()?; Some(Self::new(bot_token)) } @@ -454,8 +453,8 @@ impl TelegramSender { } } - pub fn from_env() -> Option { - let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok()?; + pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option { + let bot_token = cfg.telegram.bot_token.clone()?; Some(Self::new(bot_token)) } @@ -586,10 +585,9 @@ impl SignalSender { } } - pub fn from_env() -> Option { - let signal_cli_path = std::env::var("SIGNAL_CLI_PATH") - .unwrap_or_else(|_| "/usr/local/bin/signal-cli".to_string()); - let sender_number = std::env::var("SIGNAL_SENDER_NUMBER").ok()?; + pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option { + let signal_cli_path = cfg.signal.cli_path.clone(); + let sender_number = cfg.signal.sender_number.clone()?; Some(Self::new(signal_cli_path, sender_number)) } } diff --git a/crates/tranquil-config/Cargo.toml b/crates/tranquil-config/Cargo.toml new file mode 100644 index 0000000..a5973c7 --- /dev/null +++ b/crates/tranquil-config/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "tranquil-config" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +confique = { workspace = true } +serde = { workspace = true } diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs new file mode 100644 index 0000000..5efcfbe --- /dev/null +++ b/crates/tranquil-config/src/lib.rs @@ -0,0 +1,883 @@ +use confique::Config; +use std::fmt; +use std::path::PathBuf; +use std::sync::OnceLock; + +static CONFIG: OnceLock = OnceLock::new(); + +/// Errors discovered during configuration validation. +#[derive(Debug)] +pub struct ConfigError { + pub errors: Vec, +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "configuration validation failed:")?; + for err in &self.errors { + writeln!(f, " - {err}")?; + } + Ok(()) + } +} + +impl std::error::Error for ConfigError {} + +/// Initialize the global configuration. Must be called once at startup before +/// any other code accesses the configuration. Panics if called more than once. +pub fn init(config: TranquilConfig) { + CONFIG + .set(config) + .expect("tranquil-config: configuration already initialized"); +} + +/// Returns a reference to the global configuration. +/// Panics if [`init`] has not been called yet. +pub fn get() -> &'static TranquilConfig { + CONFIG + .get() + .expect("tranquil-config: not initialized - call tranquil_config::init() first") +} + +/// Returns a reference to the global configuration if it has been initialized. +pub fn try_get() -> Option<&'static TranquilConfig> { + CONFIG.get() +} + +/// Load configuration from an optional TOML file path, with environment +/// variable overrides applied on top. Fields annotated with `#[config(env)]` +/// are read from the corresponding environment variables when the `.env()` +/// layer is active. +/// +/// Precedence (highest to lowest): +/// 1. Environment variables +/// 2. TOML config file (if provided) +/// 3. Built-in defaults +pub fn load(config_path: Option<&PathBuf>) -> Result { + let mut builder = TranquilConfig::builder().env(); + if let Some(path) = config_path { + builder = builder.file(path); + } + builder.file("/etc/tranquil-pds/config.toml").load() +} + +// Root configuration +#[derive(Debug, Config)] +pub struct TranquilConfig { + #[config(nested)] + pub server: ServerConfig, + + #[config(nested)] + pub database: DatabaseConfig, + + #[config(nested)] + pub secrets: SecretsConfig, + + #[config(nested)] + pub storage: StorageConfig, + + #[config(nested)] + pub backup: BackupConfig, + + #[config(nested)] + pub cache: CacheConfig, + + #[config(nested)] + pub plc: PlcConfig, + + #[config(nested)] + pub firehose: FirehoseConfig, + + #[config(nested)] + pub email: EmailConfig, + + #[config(nested)] + pub discord: DiscordConfig, + + #[config(nested)] + pub telegram: TelegramConfig, + + #[config(nested)] + pub signal: SignalConfig, + + #[config(nested)] + pub notifications: NotificationConfig, + + #[config(nested)] + pub sso: SsoConfig, + + #[config(nested)] + pub moderation: ModerationConfig, + + #[config(nested)] + pub import: ImportConfig, + + #[config(nested)] + pub scheduled: ScheduledConfig, +} + +impl TranquilConfig { + /// Validate cross-field constraints that cannot be expressed through + /// confique's declarative defaults alone. Call this once after loading + /// the configuration and before [`init`]. + /// + /// Returns `Ok(())` when the configuration is consistent, or a + /// [`ConfigError`] listing every problem found. + pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> { + let mut errors = Vec::new(); + + // -- secrets ---------------------------------------------------------- + if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) { + if let Some(ref s) = self.secrets.jwt_secret { + if s.len() < 32 { + errors.push( + "secrets.jwt_secret (JWT_SECRET) must be at least 32 characters" + .to_string(), + ); + } + } else { + errors.push( + "secrets.jwt_secret (JWT_SECRET) is required in production \ + (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" + .to_string(), + ); + } + + if let Some(ref s) = self.secrets.dpop_secret { + if s.len() < 32 { + errors.push( + "secrets.dpop_secret (DPOP_SECRET) must be at least 32 characters" + .to_string(), + ); + } + } else { + errors.push( + "secrets.dpop_secret (DPOP_SECRET) is required in production \ + (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" + .to_string(), + ); + } + + if let Some(ref s) = self.secrets.master_key { + if s.len() < 32 { + errors.push( + "secrets.master_key (MASTER_KEY) must be at least 32 characters" + .to_string(), + ); + } + } else { + errors.push( + "secrets.master_key (MASTER_KEY) is required in production \ + (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" + .to_string(), + ); + } + } + + // -- telegram --------------------------------------------------------- + if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() { + errors.push( + "telegram.bot_token is set but telegram.webhook_secret is missing; \ + both are required for secure Telegram integration" + .to_string(), + ); + } + + // -- blob storage ----------------------------------------------------- + match self.storage.backend.as_str() { + "s3" => { + if self.storage.s3_bucket.is_none() { + errors.push( + "storage.backend is \"s3\" but storage.s3_bucket (S3_BUCKET) \ + is not set" + .to_string(), + ); + } + } + "filesystem" => {} + other => { + errors.push(format!( + "storage.backend must be \"filesystem\" or \"s3\", got \"{other}\"" + )); + } + } + + // -- backup storage --------------------------------------------------- + if self.backup.enabled { + match self.backup.backend.as_str() { + "s3" => { + if self.backup.s3_bucket.is_none() { + errors.push( + "backup.backend is \"s3\" but backup.s3_bucket \ + (BACKUP_S3_BUCKET) is not set" + .to_string(), + ); + } + } + "filesystem" => {} + other => { + errors.push(format!( + "backup.backend must be \"filesystem\" or \"s3\", got \"{other}\"" + )); + } + } + } + + // -- SSO providers ---------------------------------------------------- + self.validate_sso_provider("sso.github", &self.sso.github, &mut errors); + self.validate_sso_provider("sso.google", &self.sso.google, &mut errors); + self.validate_sso_discord(&mut errors); + self.validate_sso_with_issuer("sso.gitlab", &self.sso.gitlab, &mut errors); + self.validate_sso_with_issuer("sso.oidc", &self.sso.oidc, &mut errors); + self.validate_sso_apple(&mut errors); + + // -- moderation ------------------------------------------------------- + let has_url = self.moderation.report_service_url.is_some(); + let has_did = self.moderation.report_service_did.is_some(); + if has_url != has_did { + errors.push( + "moderation.report_service_url and moderation.report_service_did \ + must both be set or both be unset" + .to_string(), + ); + } + + // -- cache ------------------------------------------------------------ + match self.cache.backend.as_str() { + "valkey" => { + if self.cache.valkey_url.is_none() { + errors.push( + "cache.backend is \"valkey\" but cache.valkey_url (VALKEY_URL) \ + is not set" + .to_string(), + ); + } + } + "ripple" => {} + other => { + errors.push(format!( + "cache.backend must be \"ripple\" or \"valkey\", got \"{other}\"" + )); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(ConfigError { errors }) + } + } + + fn validate_sso_provider(&self, prefix: &str, p: &SsoProviderConfig, errors: &mut Vec) { + if p.enabled { + if p.client_id.is_none() { + errors.push(format!( + "{prefix}.client_id is required when {prefix}.enabled = true" + )); + } + if p.client_secret.is_none() { + errors.push(format!( + "{prefix}.client_secret is required when {prefix}.enabled = true" + )); + } + } + } + + fn validate_sso_discord(&self, errors: &mut Vec) { + let p = &self.sso.discord; + if p.enabled { + if p.client_id.is_none() { + errors.push( + "sso.discord.client_id is required when sso.discord.enabled = true".to_string(), + ); + } + if p.client_secret.is_none() { + errors.push( + "sso.discord.client_secret is required when sso.discord.enabled = true" + .to_string(), + ); + } + } + } + + fn validate_sso_with_issuer( + &self, + prefix: &str, + p: &SsoProviderWithIssuerConfig, + errors: &mut Vec, + ) { + if p.enabled { + if p.client_id.is_none() { + errors.push(format!( + "{prefix}.client_id is required when {prefix}.enabled = true" + )); + } + if p.client_secret.is_none() { + errors.push(format!( + "{prefix}.client_secret is required when {prefix}.enabled = true" + )); + } + if p.issuer.is_none() { + errors.push(format!( + "{prefix}.issuer is required when {prefix}.enabled = true" + )); + } + } + } + + fn validate_sso_apple(&self, errors: &mut Vec) { + let p = &self.sso.apple; + if p.enabled { + if p.client_id.is_none() { + errors.push( + "sso.apple.client_id is required when sso.apple.enabled = true".to_string(), + ); + } + if p.team_id.is_none() { + errors.push( + "sso.apple.team_id is required when sso.apple.enabled = true".to_string(), + ); + } + if p.key_id.is_none() { + errors + .push("sso.apple.key_id is required when sso.apple.enabled = true".to_string()); + } + if p.private_key.is_none() { + errors.push( + "sso.apple.private_key is required when sso.apple.enabled = true".to_string(), + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +#[derive(Debug, Config)] +pub struct ServerConfig { + /// Public hostname of the PDS (e.g. `pds.example.com`). + #[config(env = "PDS_HOSTNAME")] + pub hostname: String, + + /// Address to bind the HTTP server to. + #[config(env = "SERVER_HOST", default = "127.0.0.1")] + pub host: String, + + /// Port to bind the HTTP server to. + #[config(env = "SERVER_PORT", default = 3000)] + pub port: u16, + + /// List of domains for user handles. + /// Defaults to the PDS hostname when not set. + #[config(env = "PDS_USER_HANDLE_DOMAINS", parse_env = split_comma_list)] + pub user_handle_domains: Option>, + + /// List of domains available for user registration. + /// Defaults to the PDS hostname when not set. + #[config(env = "AVAILABLE_USER_DOMAINS", parse_env = split_comma_list)] + pub available_user_domains: Option>, + + /// Enable PDS-hosted did:web identities. Hosting did:web requires a + /// long-term commitment to serve DID documents; opt-in only. + #[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)] + pub enable_pds_hosted_did_web: bool, + + /// When set to true, skip age-assurance birthday prompt for all accounts. + #[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)] + pub age_assurance_override: bool, + + /// Require an invite code for new account registration. + #[config(env = "INVITE_CODE_REQUIRED", default = true)] + pub invite_code_required: bool, + + /// Allow HTTP (non-TLS) proxy requests. Only useful during development. + #[config(env = "ALLOW_HTTP_PROXY", default = false)] + pub allow_http_proxy: bool, + + /// Disable all rate limiting. Should only be used in testing. + #[config(env = "DISABLE_RATE_LIMITING", default = false)] + pub disable_rate_limiting: bool, + + /// List of additional banned words for handle validation. + #[config(env = "PDS_BANNED_WORDS", parse_env = split_comma_list)] + pub banned_words: Option>, + + /// URL to a privacy policy page. + #[config(env = "PRIVACY_POLICY_URL")] + pub privacy_policy_url: Option, + + /// URL to terms of service page. + #[config(env = "TERMS_OF_SERVICE_URL")] + pub terms_of_service_url: Option, + + /// Operator contact email address. + #[config(env = "CONTACT_EMAIL")] + pub contact_email: Option, + + /// Maximum allowed blob size in bytes (default 10 GiB). + #[config(env = "MAX_BLOB_SIZE", default = 10_737_418_240u64)] + pub max_blob_size: u64, +} + +impl ServerConfig { + /// The public HTTPS URL for this PDS. + pub fn public_url(&self) -> String { + format!("https://{}", self.hostname) + } + + /// Hostname without port suffix (e.g. `pds.example.com` from + /// `pds.example.com:443`). + pub fn hostname_without_port(&self) -> &str { + self.hostname.split(':').next().unwrap_or(&self.hostname) + } + + /// Returns the extra banned words list, or an empty vec when unset. + pub fn banned_word_list(&self) -> Vec { + self.banned_words.clone().unwrap_or_default() + } + + /// Returns the available user domains, falling back to `[hostname_without_port]`. + pub fn available_user_domain_list(&self) -> Vec { + self.available_user_domains + .clone() + .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) + } + + /// Returns the user handle domains, falling back to `[hostname_without_port]`. + pub fn user_handle_domain_list(&self) -> Vec { + self.user_handle_domains + .clone() + .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) + } +} + +#[derive(Debug, Config)] +pub struct DatabaseConfig { + /// PostgreSQL connection URL. + #[config(env = "DATABASE_URL")] + pub url: String, + + /// Maximum number of connections in the pool. + #[config(env = "DATABASE_MAX_CONNECTIONS", default = 100)] + pub max_connections: u32, + + /// Minimum number of idle connections kept in the pool. + #[config(env = "DATABASE_MIN_CONNECTIONS", default = 10)] + pub min_connections: u32, + + /// Timeout in seconds when acquiring a connection from the pool. + #[config(env = "DATABASE_ACQUIRE_TIMEOUT_SECS", default = 10)] + pub acquire_timeout_secs: u64, +} + +#[derive(Config)] +pub struct SecretsConfig { + /// Secret used for signing JWTs. Must be at least 32 characters in + /// production. + #[config(env = "JWT_SECRET")] + pub jwt_secret: Option, + + /// Secret used for DPoP proof validation. Must be at least 32 characters + /// in production. + #[config(env = "DPOP_SECRET")] + pub dpop_secret: Option, + + /// Master key used for key-encryption and HKDF derivation. Must be at + /// least 32 characters in production. + #[config(env = "MASTER_KEY")] + pub master_key: Option, + + /// PLC rotation key (DID key). If not set, user-level keys are used. + #[config(env = "PLC_ROTATION_KEY")] + pub plc_rotation_key: Option, + + /// Allow insecure/test secrets. NEVER enable in production. + #[config(env = "TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", default = false)] + pub allow_insecure: bool, +} + +impl std::fmt::Debug for SecretsConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecretsConfig") + .field( + "jwt_secret", + &self.jwt_secret.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "dpop_secret", + &self.dpop_secret.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "master_key", + &self.master_key.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "plc_rotation_key", + &self.plc_rotation_key.as_ref().map(|_| "[REDACTED]"), + ) + .field("allow_insecure", &self.allow_insecure) + .finish() + } +} + +impl SecretsConfig { + /// Resolve the JWT secret, falling back to an insecure default if + /// `allow_insecure` is true. + pub fn jwt_secret_or_default(&self) -> String { + self.jwt_secret.clone().unwrap_or_else(|| { + if cfg!(test) || self.allow_insecure { + "test-jwt-secret-not-for-production".to_string() + } else { + panic!( + "JWT_SECRET must be set in production. \ + Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." + ); + } + }) + } + + /// Resolve the DPoP secret, falling back to an insecure default if + /// `allow_insecure` is true. + pub fn dpop_secret_or_default(&self) -> String { + self.dpop_secret.clone().unwrap_or_else(|| { + if cfg!(test) || self.allow_insecure { + "test-dpop-secret-not-for-production".to_string() + } else { + panic!( + "DPOP_SECRET must be set in production. \ + Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." + ); + } + }) + } + + /// Resolve the master key, falling back to an insecure default if + /// `allow_insecure` is true. + pub fn master_key_or_default(&self) -> String { + self.master_key.clone().unwrap_or_else(|| { + if cfg!(test) || self.allow_insecure { + "test-master-key-not-for-production".to_string() + } else { + panic!( + "MASTER_KEY must be set in production. \ + Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." + ); + } + }) + } +} + +#[derive(Debug, Config)] +pub struct StorageConfig { + /// Storage backend: `filesystem` or `s3`. + #[config(env = "BLOB_STORAGE_BACKEND", default = "filesystem")] + pub backend: String, + + /// Path on disk for the filesystem blob backend. + #[config(env = "BLOB_STORAGE_PATH", default = "/var/lib/tranquil-pds/blobs")] + pub path: String, + + /// S3 bucket name for blob storage. + #[config(env = "S3_BUCKET")] + pub s3_bucket: Option, + + /// Custom S3 endpoint URL (for MinIO, R2, etc.). + #[config(env = "S3_ENDPOINT")] + pub s3_endpoint: Option, +} + +#[derive(Debug, Config)] +pub struct BackupConfig { + /// Enable automatic backups. + #[config(env = "BACKUP_ENABLED", default = true)] + pub enabled: bool, + + /// Backup storage backend: `filesystem` or `s3`. + #[config(env = "BACKUP_STORAGE_BACKEND", default = "filesystem")] + pub backend: String, + + /// Path on disk for the filesystem backup backend. + #[config(env = "BACKUP_STORAGE_PATH", default = "/var/lib/tranquil-pds/backups")] + pub path: String, + + /// S3 bucket name for backups. + #[config(env = "BACKUP_S3_BUCKET")] + pub s3_bucket: Option, + + /// Number of backup revisions to keep per account. + #[config(env = "BACKUP_RETENTION_COUNT", default = 7)] + pub retention_count: u32, + + /// Seconds between backup runs. + #[config(env = "BACKUP_INTERVAL_SECS", default = 86400)] + pub interval_secs: u64, +} + +#[derive(Debug, Config)] +pub struct CacheConfig { + /// Cache backend: `ripple` (default, built-in gossip) or `valkey`. + #[config(env = "CACHE_BACKEND", default = "ripple")] + pub backend: String, + + /// Valkey / Redis connection URL. Required when `backend = "valkey"`. + #[config(env = "VALKEY_URL")] + pub valkey_url: Option, + + #[config(nested)] + pub ripple: RippleCacheConfig, +} + +#[derive(Debug, Config)] +pub struct PlcConfig { + /// Base URL of the PLC directory. + #[config(env = "PLC_DIRECTORY_URL", default = "https://plc.directory")] + pub directory_url: String, + + /// HTTP request timeout in seconds. + #[config(env = "PLC_TIMEOUT_SECS", default = 10)] + pub timeout_secs: u64, + + /// TCP connect timeout in seconds. + #[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)] + pub connect_timeout_secs: u64, + + /// Seconds to cache DID documents in memory. + #[config(env = "DID_CACHE_TTL_SECS", default = 300)] + pub did_cache_ttl_secs: u64, +} + +#[derive(Debug, Config)] +pub struct FirehoseConfig { + /// Size of the in-memory broadcast buffer for firehose events. + #[config(env = "FIREHOSE_BUFFER_SIZE", default = 10000)] + pub buffer_size: usize, + + /// How many hours of historical events to replay for cursor-based + /// firehose connections. + #[config(env = "FIREHOSE_BACKFILL_HOURS", default = 72)] + pub backfill_hours: i64, + + /// Maximum number of lagged events before disconnecting a slow consumer. + #[config(env = "FIREHOSE_MAX_LAG", default = 5000)] + pub max_lag: u64, + + /// List of relay / crawler notification URLs. + #[config(env = "CRAWLERS", parse_env = split_comma_list)] + pub crawlers: Option>, +} + +impl FirehoseConfig { + /// Returns the list of crawler URLs, falling back to `["https://bsky.network"]` + /// when none are configured. + pub fn crawler_list(&self) -> Vec { + self.crawlers + .clone() + .unwrap_or_else(|| vec!["https://bsky.network".to_string()]) + } +} + +#[derive(Debug, Config)] +pub struct EmailConfig { + /// Sender email address. When unset, email sending is disabled. + #[config(env = "MAIL_FROM_ADDRESS")] + pub from_address: Option, + + /// Display name used in the `From` header. + #[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")] + pub from_name: String, + + /// Path to the `sendmail` binary. + #[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")] + pub sendmail_path: String, +} + +#[derive(Debug, Config)] +pub struct DiscordConfig { + /// Discord bot token. When unset, Discord integration is disabled. + #[config(env = "DISCORD_BOT_TOKEN")] + pub bot_token: Option, +} + +#[derive(Debug, Config)] +pub struct TelegramConfig { + /// Telegram bot token. When unset, Telegram integration is disabled. + #[config(env = "TELEGRAM_BOT_TOKEN")] + pub bot_token: Option, + + /// Secret token for incoming webhook verification. + #[config(env = "TELEGRAM_WEBHOOK_SECRET")] + pub webhook_secret: Option, +} + +#[derive(Debug, Config)] +pub struct SignalConfig { + /// Path to the `signal-cli` binary. + #[config(env = "SIGNAL_CLI_PATH", default = "/usr/local/bin/signal-cli")] + pub cli_path: String, + + /// Sender phone number. When unset, Signal integration is disabled. + #[config(env = "SIGNAL_SENDER_NUMBER")] + pub sender_number: Option, +} + +#[derive(Debug, Config)] +pub struct NotificationConfig { + /// Polling interval in milliseconds for the comms queue. + #[config(env = "NOTIFICATION_POLL_INTERVAL_MS", default = 1000)] + pub poll_interval_ms: u64, + + /// Number of notifications to process per batch. + #[config(env = "NOTIFICATION_BATCH_SIZE", default = 100)] + pub batch_size: i64, +} + +#[derive(Debug, Config)] +pub struct SsoConfig { + #[config(nested)] + pub github: SsoProviderConfig, + + #[config(nested)] + pub discord: SsoDiscordProviderConfig, + + #[config(nested)] + pub google: SsoProviderConfig, + + #[config(nested)] + pub gitlab: SsoProviderWithIssuerConfig, + + #[config(nested)] + pub oidc: SsoProviderWithIssuerConfig, + + #[config(nested)] + pub apple: SsoAppleConfig, +} + +// Generic SSO provider (GitHub, Google) +#[derive(Debug, Config)] +pub struct SsoProviderConfig { + #[config(default = false)] + pub enabled: bool, + pub client_id: Option, + pub client_secret: Option, + pub display_name: Option, +} + +// SSO provider with custom env prefixes for Discord +// (since the nested TOML key is `sso.discord` but env vars are `SSO_DISCORD_*`) +#[derive(Debug, Config)] +pub struct SsoDiscordProviderConfig { + #[config(default = false)] + pub enabled: bool, + pub client_id: Option, + pub client_secret: Option, + pub display_name: Option, +} + +// SSO providers that require an issuer URL (GitLab, OIDC) +#[derive(Debug, Config)] +pub struct SsoProviderWithIssuerConfig { + #[config(default = false)] + pub enabled: bool, + pub client_id: Option, + pub client_secret: Option, + pub issuer: Option, + pub display_name: Option, +} + +#[derive(Debug, Config)] +pub struct SsoAppleConfig { + #[config(env = "SSO_APPLE_ENABLED", default = false)] + pub enabled: bool, + + #[config(env = "SSO_APPLE_CLIENT_ID")] + pub client_id: Option, + + #[config(env = "SSO_APPLE_TEAM_ID")] + pub team_id: Option, + + #[config(env = "SSO_APPLE_KEY_ID")] + pub key_id: Option, + + #[config(env = "SSO_APPLE_PRIVATE_KEY")] + pub private_key: Option, +} + +#[derive(Debug, Config)] +pub struct ModerationConfig { + /// External report-handling service URL. + #[config(env = "REPORT_SERVICE_URL")] + pub report_service_url: Option, + + /// DID of the external report-handling service. + #[config(env = "REPORT_SERVICE_DID")] + pub report_service_did: Option, +} + +#[derive(Debug, Config)] +pub struct ImportConfig { + /// Whether the PDS accepts repo imports. + #[config(env = "ACCEPTING_REPO_IMPORTS", default = true)] + pub accepting: bool, + + /// Maximum allowed import archive size in bytes (default 1 GiB). + #[config(env = "MAX_IMPORT_SIZE", default = 1_073_741_824)] + pub max_size: u64, + + /// Maximum number of blocks allowed in an import. + #[config(env = "MAX_IMPORT_BLOCKS", default = 500000)] + pub max_blocks: u64, + + /// Skip CAR verification during import. Only for development/debugging. + #[config(env = "SKIP_IMPORT_VERIFICATION", default = false)] + pub skip_verification: bool, +} + +/// Parse a comma-separated environment variable into a `Vec`, +/// trimming whitespace and dropping empty entries. +/// +/// Signature matches confique's `parse_env` expectation: `fn(&str) -> Result`. +fn split_comma_list(value: &str) -> Result, std::convert::Infallible> { + Ok(value + .split(',') + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect()) +} + +#[derive(Debug, Config)] +pub struct RippleCacheConfig { + /// Address to bind the Ripple gossip protocol listener. + #[config(env = "RIPPLE_BIND", default = "0.0.0.0:0")] + pub bind_addr: String, + + /// List of seed peer addresses. + #[config(env = "RIPPLE_PEERS", parse_env = split_comma_list)] + pub peers: Option>, + + /// Unique machine identifier. Auto-derived from hostname when not set. + #[config(env = "RIPPLE_MACHINE_ID")] + pub machine_id: Option, + + /// Gossip protocol interval in milliseconds. + #[config(env = "RIPPLE_GOSSIP_INTERVAL_MS", default = 200)] + pub gossip_interval_ms: u64, + + /// Maximum cache size in megabytes. + #[config(env = "RIPPLE_CACHE_MAX_MB", default = 256)] + pub cache_max_mb: usize, +} + +#[derive(Debug, Config)] +pub struct ScheduledConfig { + /// Interval in seconds between scheduled delete checks. + #[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)] + pub delete_check_interval_secs: u64, +} + +/// Generate a TOML configuration template with all available options, +/// defaults, and documentation comments. +pub fn template() -> String { + confique::toml::template::(confique::toml::FormatOptions::default()) +} diff --git a/crates/tranquil-infra/Cargo.toml b/crates/tranquil-infra/Cargo.toml index 4c7436e..ac01504 100644 --- a/crates/tranquil-infra/Cargo.toml +++ b/crates/tranquil-infra/Cargo.toml @@ -5,6 +5,8 @@ edition.workspace = true license.workspace = true [dependencies] +tranquil-config = { workspace = true } + async-trait = { workspace = true } bytes = { workspace = true } futures = { workspace = true } diff --git a/crates/tranquil-infra/src/lib.rs b/crates/tranquil-infra/src/lib.rs index 9539bfb..73e6faf 100644 --- a/crates/tranquil-infra/src/lib.rs +++ b/crates/tranquil-infra/src/lib.rs @@ -45,16 +45,14 @@ pub trait BackupStorage: Send + Sync { } pub fn backup_retention_count() -> u32 { - std::env::var("BACKUP_RETENTION_COUNT") - .ok() - .and_then(|v| v.parse().ok()) + tranquil_config::try_get() + .map(|c| c.backup.retention_count) .unwrap_or(7) } pub fn backup_interval_secs() -> u64 { - std::env::var("BACKUP_INTERVAL_SECS") - .ok() - .and_then(|v| v.parse().ok()) + tranquil_config::try_get() + .map(|c| c.backup.interval_secs) .unwrap_or(86400) } diff --git a/crates/tranquil-pds/Cargo.toml b/crates/tranquil-pds/Cargo.toml index 873b289..adbb578 100644 --- a/crates/tranquil-pds/Cargo.toml +++ b/crates/tranquil-pds/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] tranquil-types = { workspace = true } +tranquil-config = { workspace = true } tranquil-crypto = { workspace = true } tranquil-storage = { workspace = true } tranquil-cache = { workspace = true } @@ -29,6 +30,7 @@ bs58 = { workspace = true } bytes = { workspace = true } chrono = { workspace = true } cid = { workspace = true } +clap = { workspace = true } dotenvy = { workspace = true } ed25519-dalek = { workspace = true } futures = { workspace = true } diff --git a/crates/tranquil-pds/src/api/admin/account/email.rs b/crates/tranquil-pds/src/api/admin/account/email.rs index 2f90ad5..dc051a5 100644 --- a/crates/tranquil-pds/src/api/admin/account/email.rs +++ b/crates/tranquil-pds/src/api/admin/account/email.rs @@ -2,7 +2,6 @@ use crate::api::error::{ApiError, DbResultExt}; use crate::auth::{Admin, Auth}; use crate::state::AppState; use crate::types::Did; -use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -45,7 +44,7 @@ pub async fn send_email( let email = user.email.ok_or(ApiError::NoEmail)?; let (user_id, handle) = (user.id, user.handle); - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let subject = input .subject .clone() diff --git a/crates/tranquil-pds/src/api/admin/account/update.rs b/crates/tranquil-pds/src/api/admin/account/update.rs index 5cd89dd..70dd060 100644 --- a/crates/tranquil-pds/src/api/admin/account/update.rs +++ b/crates/tranquil-pds/src/api/admin/account/update.rs @@ -3,7 +3,6 @@ use crate::api::error::ApiError; use crate::auth::{Admin, Auth}; use crate::state::AppState; use crate::types::{Did, Handle, PlainPassword}; -use crate::util::pds_hostname_without_port; use axum::{ Json, extract::State, @@ -70,7 +69,7 @@ pub async fn update_account_handle( { return Err(ApiError::InvalidHandle(None)); } - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let handle = if !input_handle.contains('.') { format!("{}.{}", input_handle, hostname_for_handles) } else { diff --git a/crates/tranquil-pds/src/api/delegation.rs b/crates/tranquil-pds/src/api/delegation.rs index 580c68f..c563623 100644 --- a/crates/tranquil-pds/src/api/delegation.rs +++ b/crates/tranquil-pds/src/api/delegation.rs @@ -8,7 +8,6 @@ use crate::delegation::{ use crate::rate_limit::{AccountCreationLimit, RateLimited}; use crate::state::AppState; use crate::types::{Did, Handle}; -use crate::util::{pds_hostname, pds_hostname_without_port}; use axum::{ Json, extract::{Query, State}, @@ -435,8 +434,8 @@ pub async fn create_delegated_account( Err(response) => return Ok(response), }; - let hostname = pds_hostname(); - let hostname_for_handles = pds_hostname_without_port(); + let hostname = &tranquil_config::get().server.hostname; + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let pds_suffix = format!(".{}", hostname_for_handles); let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { @@ -475,7 +474,7 @@ pub async fn create_delegated_account( Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()), } } else { - let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); + let invite_required = tranquil_config::get().server.invite_code_required; if invite_required { return Ok(ApiError::InviteCodeRequired.into_response()); } @@ -497,8 +496,11 @@ pub async fn create_delegated_account( } }; - let rotation_key = std::env::var("PLC_ROTATION_KEY") - .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key)); + let rotation_key = tranquil_config::get() + .secrets + .plc_rotation_key + .clone() + .unwrap_or_else(|| crate::plc::signing_key_to_did_key(&signing_key)); let genesis_result = match crate::plc::create_genesis_operation( &signing_key, diff --git a/crates/tranquil-pds/src/api/discord_webhook.rs b/crates/tranquil-pds/src/api/discord_webhook.rs index 90bd6a9..ecca1b2 100644 --- a/crates/tranquil-pds/src/api/discord_webhook.rs +++ b/crates/tranquil-pds/src/api/discord_webhook.rs @@ -12,7 +12,7 @@ use tranquil_types::Handle; use crate::comms::comms_repo; use crate::state::AppState; -use crate::util::{discord_public_key, pds_hostname}; +use crate::util::discord_public_key; #[derive(Deserialize)] struct Interaction { @@ -185,7 +185,7 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response { user_id, tranquil_db_traits::CommsChannel::Discord, &discord_user_id, - pds_hostname(), + &tranquil_config::get().server.hostname, ) .await { diff --git a/crates/tranquil-pds/src/api/identity/account.rs b/crates/tranquil-pds/src/api/identity/account.rs index ca0e255..ceb9edc 100644 --- a/crates/tranquil-pds/src/api/identity/account.rs +++ b/crates/tranquil-pds/src/api/identity/account.rs @@ -6,7 +6,6 @@ use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; use crate::rate_limit::{AccountCreationLimit, RateLimited}; use crate::state::AppState; use crate::types::{Did, Handle, PlainPassword}; -use crate::util::{pds_hostname, pds_hostname_without_port}; use crate::validation::validate_password; use axum::{ Json, @@ -141,7 +140,7 @@ pub async fn create_account( } } - let hostname_for_validation = pds_hostname_without_port(); + let hostname_for_validation = tranquil_config::get().server.hostname_without_port(); let pds_suffix = format!(".{}", hostname_for_validation); let validated_short_handle = if !input.handle.contains('.') @@ -233,8 +232,8 @@ pub async fn create_account( }, }) }; - let hostname = pds_hostname(); - let hostname_for_handles = pds_hostname_without_port(); + let hostname = &tranquil_config::get().server.hostname; + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let pds_endpoint = format!("https://{}", hostname); let suffix = format!(".{}", hostname_for_handles); let handle = if input.handle.ends_with(&suffix) { @@ -326,8 +325,11 @@ pub async fn create_account( ) .into_response(); } else { - let rotation_key = std::env::var("PLC_ROTATION_KEY") - .unwrap_or_else(|_| signing_key_to_did_key(&signing_key)); + let rotation_key = tranquil_config::get() + .secrets + .plc_rotation_key + .clone() + .unwrap_or_else(|| signing_key_to_did_key(&signing_key)); let genesis_result = match create_genesis_operation( &signing_key, &rotation_key, @@ -359,8 +361,11 @@ pub async fn create_account( genesis_result.did } } else { - let rotation_key = std::env::var("PLC_ROTATION_KEY") - .unwrap_or_else(|_| signing_key_to_did_key(&signing_key)); + let rotation_key = tranquil_config::get() + .secrets + .plc_rotation_key + .clone() + .unwrap_or_else(|| signing_key_to_did_key(&signing_key)); let genesis_result = match create_genesis_operation( &signing_key, &rotation_key, @@ -473,7 +478,7 @@ pub async fn create_account( error!("Error creating session: {:?}", e); return ApiError::InternalError(None).into_response(); } - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let verification_required = if let Some(ref user_email) = email { let token = crate::auth::verification_token::generate_migration_token( &did_typed, user_email, @@ -543,7 +548,7 @@ pub async fn create_account( return ApiError::HandleTaken.into_response(); } - let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); + let invite_code_required = tranquil_config::get().server.invite_code_required; if invite_code_required && input .invite_code @@ -632,12 +637,14 @@ pub async fn create_account( let rev_str = rev.as_ref().to_string(); let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; - let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| { - json!({ + let birthdate_pref = if tranquil_config::get().server.age_assurance_override { + Some(json!({ "$type": "app.bsky.actor.defs#personalDetailsPref", "birthDate": "1998-05-06T00:00:00.000Z" - }) - }); + })) + } else { + None + }; let preferred_comms_channel = verification_channel; @@ -748,7 +755,7 @@ pub async fn create_account( warn!("Failed to create default profile for {}: {}", did, e); } } - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if !is_migration { if let Some(ref recipient) = verification_recipient { let verification_token = crate::auth::verification_token::generate_signup_token( diff --git a/crates/tranquil-pds/src/api/identity/did.rs b/crates/tranquil-pds/src/api/identity/did.rs index e4736a9..b7697b0 100644 --- a/crates/tranquil-pds/src/api/identity/did.rs +++ b/crates/tranquil-pds/src/api/identity/did.rs @@ -6,7 +6,7 @@ use crate::rate_limit::{ }; use crate::state::AppState; use crate::types::Handle; -use crate::util::{get_header_str, pds_hostname, pds_hostname_without_port}; +use crate::util::get_header_str; use axum::{ Json, extract::{Path, Query, State}, @@ -122,8 +122,8 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result { } pub async fn well_known_did(State(state): State, headers: HeaderMap) -> Response { - let hostname = pds_hostname(); - let hostname_without_port = pds_hostname_without_port(); + let hostname = &tranquil_config::get().server.hostname; + let hostname_without_port = tranquil_config::get().server.hostname_without_port(); let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname); let host_without_port = host_header.split(':').next().unwrap_or(host_header); if host_without_port != hostname_without_port @@ -275,8 +275,8 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s } pub async fn user_did_doc(State(state): State, Path(handle): Path) -> Response { - let hostname = pds_hostname(); - let hostname_for_handles = pds_hostname_without_port(); + let hostname = &tranquil_config::get().server.hostname; + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let current_handle = format!("{}.{}", handle, hostname_for_handles); let current_handle_typed: Handle = match current_handle.parse() { Ok(h) => h, @@ -571,7 +571,7 @@ pub async fn get_recommended_did_credentials( ApiError::AuthenticationFailed(Some("OAuth tokens cannot get DID credentials".into())) })?; - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let pds_endpoint = format!("https://{}", hostname); let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes) .map_err(|_| ApiError::InternalError(None))?; @@ -579,9 +579,9 @@ pub async fn get_recommended_did_credentials( let rotation_keys = if auth.did.starts_with("did:web:") { vec![] } else { - let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") { - Ok(key) => key, - Err(_) => { + let server_rotation_key = match &tranquil_config::get().secrets.plc_rotation_key { + Some(key) => key.clone(), + None => { warn!( "PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation" ); @@ -675,7 +675,7 @@ pub async fn update_handle( "Inappropriate language in handle".into(), ))); } - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let suffix = format!(".{}", hostname_for_handles); let is_service_domain = crate::handle::is_service_domain_handle(&new_handle, hostname_for_handles); diff --git a/crates/tranquil-pds/src/api/identity/plc/request.rs b/crates/tranquil-pds/src/api/identity/plc/request.rs index 570859f..fe488c1 100644 --- a/crates/tranquil-pds/src/api/identity/plc/request.rs +++ b/crates/tranquil-pds/src/api/identity/plc/request.rs @@ -2,7 +2,6 @@ use crate::api::EmptyResponse; use crate::api::error::{ApiError, DbResultExt}; use crate::auth::{Auth, Permissive}; use crate::state::AppState; -use crate::util::pds_hostname; use axum::{ extract::State, response::{IntoResponse, Response}, @@ -41,7 +40,7 @@ pub async fn request_plc_operation_signature( .await .log_db_err("creating PLC token")?; - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = crate::comms::comms_repo::enqueue_plc_operation( state.user_repo.as_ref(), state.infra_repo.as_ref(), diff --git a/crates/tranquil-pds/src/api/identity/plc/submit.rs b/crates/tranquil-pds/src/api/identity/plc/submit.rs index 6b4c505..7c12c2f 100644 --- a/crates/tranquil-pds/src/api/identity/plc/submit.rs +++ b/crates/tranquil-pds/src/api/identity/plc/submit.rs @@ -4,7 +4,6 @@ use crate::auth::{Auth, Permissive}; use crate::circuit_breaker::with_circuit_breaker; use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation}; use crate::state::AppState; -use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -42,7 +41,7 @@ pub async fn submit_plc_operation( .map_err(|e| ApiError::InvalidRequest(format!("Invalid operation: {}", e)))?; let op = &input.operation; - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let public_url = format!("https://{}", hostname); let user = state .user_repo @@ -70,8 +69,11 @@ pub async fn submit_plc_operation( })?; let user_did_key = signing_key_to_did_key(&signing_key); - let server_rotation_key = - std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone()); + let server_rotation_key = tranquil_config::get() + .secrets + .plc_rotation_key + .clone() + .unwrap_or_else(|| user_did_key.clone()); if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) { let has_server_key = rotation_keys .iter() diff --git a/crates/tranquil-pds/src/api/moderation/mod.rs b/crates/tranquil-pds/src/api/moderation/mod.rs index 2a26ff1..69d6722 100644 --- a/crates/tranquil-pds/src/api/moderation/mod.rs +++ b/crates/tranquil-pds/src/api/moderation/mod.rs @@ -69,8 +69,9 @@ struct ReportServiceConfig { } fn get_report_service_config() -> Option { - let url = std::env::var("REPORT_SERVICE_URL").ok()?; - let did = std::env::var("REPORT_SERVICE_DID").ok()?; + let cfg = tranquil_config::get(); + let url = cfg.moderation.report_service_url.clone()?; + let did = cfg.moderation.report_service_did.clone()?; if url.is_empty() || did.is_empty() { return None; } diff --git a/crates/tranquil-pds/src/api/notification_prefs.rs b/crates/tranquil-pds/src/api/notification_prefs.rs index d9ef9c3..763b3f9 100644 --- a/crates/tranquil-pds/src/api/notification_prefs.rs +++ b/crates/tranquil-pds/src/api/notification_prefs.rs @@ -1,7 +1,6 @@ use crate::api::error::ApiError; use crate::auth::{Active, Auth}; use crate::state::AppState; -use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -148,7 +147,7 @@ pub async fn request_channel_verification( match channel { CommsChannel::Email => { - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let handle_str = handle.unwrap_or("user"); crate::comms::comms_repo::enqueue_email_update( state.infra_repo.as_ref(), @@ -167,7 +166,7 @@ pub async fn request_channel_verification( })?; } _ => { - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let encoded_token = urlencoding::encode(&formatted_token); let encoded_identifier = urlencoding::encode(identifier); let verify_link = format!( diff --git a/crates/tranquil-pds/src/api/proxy.rs b/crates/tranquil-pds/src/api/proxy.rs index 1f46283..7a63b9a 100644 --- a/crates/tranquil-pds/src/api/proxy.rs +++ b/crates/tranquil-pds/src/api/proxy.rs @@ -168,7 +168,7 @@ impl> Service Result<(), SsrfError> { let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?; let scheme = parsed.scheme(); if scheme != "https" { - let allow_http = std::env::var("ALLOW_HTTP_PROXY").is_ok() + let allow_http = tranquil_config::get().server.allow_http_proxy || url.starts_with("http://127.0.0.1") || url.starts_with("http://localhost"); if !allow_http { diff --git a/crates/tranquil-pds/src/api/repo/blob.rs b/crates/tranquil-pds/src/api/repo/blob.rs index 52dc31a..67be8f9 100644 --- a/crates/tranquil-pds/src/api/repo/blob.rs +++ b/crates/tranquil-pds/src/api/repo/blob.rs @@ -3,7 +3,7 @@ use crate::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope}; use crate::delegation::DelegationActionType; use crate::state::AppState; use crate::types::{CidLink, Did}; -use crate::util::{get_header_str, get_max_blob_size}; +use crate::util::get_header_str; use axum::body::Body; use axum::{ Json, @@ -89,7 +89,7 @@ pub async fn upload_blob( .ok_or(ApiError::InternalError(None))?; let temp_key = format!("temp/{}", uuid::Uuid::new_v4()); - let max_size = u64::try_from(get_max_blob_size()).unwrap_or(u64::MAX); + let max_size = tranquil_config::get().server.max_blob_size; let body_stream = body.into_data_stream(); let mapped_stream = diff --git a/crates/tranquil-pds/src/api/repo/import.rs b/crates/tranquil-pds/src/api/repo/import.rs index 01d3ee6..66720c4 100644 --- a/crates/tranquil-pds/src/api/repo/import.rs +++ b/crates/tranquil-pds/src/api/repo/import.rs @@ -18,26 +18,18 @@ use serde_json::json; use tracing::{debug, error, info, warn}; use tranquil_types::{AtUri, CidLink}; -const DEFAULT_MAX_IMPORT_SIZE: usize = 1024 * 1024 * 1024; -const DEFAULT_MAX_BLOCKS: usize = 500000; - pub async fn import_repo( State(state): State, auth: Auth, body: Bytes, ) -> Result { - let accepting_imports = std::env::var("ACCEPTING_REPO_IMPORTS") - .map(|v| v != "false" && v != "0") - .unwrap_or(true); + let accepting_imports = tranquil_config::get().import.accepting; if !accepting_imports { return Err(ApiError::InvalidRequest( "Service is not accepting repo imports".into(), )); } - let max_size: usize = std::env::var("MAX_IMPORT_SIZE") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(DEFAULT_MAX_IMPORT_SIZE); + let max_size = tranquil_config::get().import.max_size as usize; if body.len() > max_size { return Err(ApiError::PayloadTooLarge(format!( "Import size exceeds limit of {} bytes", @@ -108,7 +100,7 @@ pub async fn import_repo( commit_did, did ))); } - let skip_verification = crate::util::parse_env_bool("SKIP_IMPORT_VERIFICATION"); + let skip_verification = tranquil_config::get().import.skip_verification; let is_migration = user.deactivated_at.is_some(); if skip_verification { warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)"); @@ -196,10 +188,7 @@ pub async fn import_repo( } } } - let max_blocks: usize = std::env::var("MAX_IMPORT_BLOCKS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(DEFAULT_MAX_BLOCKS); + let max_blocks = tranquil_config::get().import.max_blocks as usize; let _write_lock = state.repo_write_locks.lock(user_id).await; match apply_import( &state.repo_repo, @@ -324,7 +313,7 @@ pub async fn import_repo( { warn!("Failed to sequence import event: {:?}", e); } - if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() { + if tranquil_config::get().server.age_assurance_override { let birthdate_pref = json!({ "$type": "app.bsky.actor.defs#personalDetailsPref", "birthDate": "1998-05-06T00:00:00.000Z" diff --git a/crates/tranquil-pds/src/api/repo/meta.rs b/crates/tranquil-pds/src/api/repo/meta.rs index d299afe..25726fd 100644 --- a/crates/tranquil-pds/src/api/repo/meta.rs +++ b/crates/tranquil-pds/src/api/repo/meta.rs @@ -1,7 +1,6 @@ use crate::api::error::ApiError; use crate::state::AppState; use crate::types::AtIdentifier; -use crate::util::pds_hostname_without_port; use axum::{ Json, extract::{Query, State}, @@ -19,7 +18,7 @@ pub async fn describe_repo( State(state): State, Query(input): Query, ) -> Response { - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let user_row = if input.repo.is_did() { let did: crate::types::Did = match input.repo.as_str().parse() { Ok(d) => d, diff --git a/crates/tranquil-pds/src/api/repo/record/read.rs b/crates/tranquil-pds/src/api/repo/record/read.rs index 2bd9fa2..c502060 100644 --- a/crates/tranquil-pds/src/api/repo/record/read.rs +++ b/crates/tranquil-pds/src/api/repo/record/read.rs @@ -2,7 +2,6 @@ use super::pagination::{PaginationDirection, deserialize_pagination_direction}; use crate::api::error::ApiError; use crate::state::AppState; use crate::types::{AtIdentifier, Nsid, Rkey}; -use crate::util::pds_hostname_without_port; use axum::{ Json, extract::{Query, State}, @@ -60,7 +59,7 @@ pub async fn get_record( _headers: HeaderMap, Query(input): Query, ) -> Response { - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let user_id_opt = if input.repo.is_did() { let did: crate::types::Did = match input.repo.as_str().parse() { Ok(d) => d, @@ -159,7 +158,7 @@ pub async fn list_records( State(state): State, Query(input): Query, ) -> Response { - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let user_id_opt = if input.repo.is_did() { let did: crate::types::Did = match input.repo.as_str().parse() { Ok(d) => d, diff --git a/crates/tranquil-pds/src/api/server/account_status.rs b/crates/tranquil-pds/src/api/server/account_status.rs index 9a1a267..0bbb493 100644 --- a/crates/tranquil-pds/src/api/server/account_status.rs +++ b/crates/tranquil-pds/src/api/server/account_status.rs @@ -5,7 +5,6 @@ use crate::cache::Cache; use crate::plc::PlcClient; use crate::state::AppState; use crate::types::PlainPassword; -use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -131,7 +130,7 @@ async fn assert_valid_did_document_for_service( did: &crate::types::Did, with_retry: bool, ) -> Result<(), ApiError> { - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let expected_endpoint = format!("https://{}", hostname); if did.as_str().starts_with("did:plc:") { @@ -201,7 +200,7 @@ async fn assert_valid_did_document_for_service( .await .map_err(ApiError::InvalidRequest)?; - let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok(); + let server_rotation_key = tranquil_config::get().secrets.plc_rotation_key.clone(); if let Some(ref expected_rotation_key) = server_rotation_key { let rotation_keys = doc_data .get("rotationKeys") @@ -552,7 +551,7 @@ pub async fn request_account_delete( .create_deletion_request(&confirmation_token, session_mfa.did(), expires_at) .await .log_db_err("creating deletion token")?; - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = crate::comms::comms_repo::enqueue_account_deletion( state.user_repo.as_ref(), state.infra_repo.as_ref(), diff --git a/crates/tranquil-pds/src/api/server/email.rs b/crates/tranquil-pds/src/api/server/email.rs index 4034feb..44829c5 100644 --- a/crates/tranquil-pds/src/api/server/email.rs +++ b/crates/tranquil-pds/src/api/server/email.rs @@ -3,7 +3,6 @@ use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse}; use crate::auth::{Auth, NotTakendown}; use crate::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit}; use crate::state::AppState; -use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -105,7 +104,7 @@ pub async fn request_email_update( } } - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = crate::comms::comms_repo::enqueue_short_token_email( state.user_repo.as_ref(), state.infra_repo.as_ref(), @@ -367,7 +366,7 @@ pub async fn update_email( ); let formatted_token = crate::auth::verification_token::format_token_for_display(&verification_token); - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( state.user_repo.as_ref(), state.infra_repo.as_ref(), @@ -531,7 +530,7 @@ pub async fn authorize_email_update( info!(did = %did, "Email update authorized via link click"); - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let redirect_url = format!( "https://{}/app/verify?type=email-authorize-success", hostname diff --git a/crates/tranquil-pds/src/api/server/invite.rs b/crates/tranquil-pds/src/api/server/invite.rs index 4f6c36b..35ee9a1 100644 --- a/crates/tranquil-pds/src/api/server/invite.rs +++ b/crates/tranquil-pds/src/api/server/invite.rs @@ -3,7 +3,6 @@ use crate::api::error::DbResultExt; use crate::auth::{Admin, Auth, NotTakendown}; use crate::state::AppState; use crate::types::Did; -use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -26,7 +25,7 @@ fn gen_random_token() -> String { } fn gen_invite_code() -> String { - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let hostname_prefix = hostname.replace('.', "-"); format!("{}-{}", hostname_prefix, gen_random_token()) } diff --git a/crates/tranquil-pds/src/api/server/meta.rs b/crates/tranquil-pds/src/api/server/meta.rs index bf1a58c..b76a24e 100644 --- a/crates/tranquil-pds/src/api/server/meta.rs +++ b/crates/tranquil-pds/src/api/server/meta.rs @@ -1,18 +1,19 @@ use crate::state::AppState; -use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_bot_username}; +use crate::util::{discord_app_id, discord_bot_username, telegram_bot_username}; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use serde_json::json; fn get_available_comms_channels() -> Vec { use tranquil_db_traits::CommsChannel; + let cfg = tranquil_config::get(); let mut channels = vec![CommsChannel::Email]; - if std::env::var("DISCORD_BOT_TOKEN").is_ok() { + if cfg.discord.bot_token.is_some() { channels.push(CommsChannel::Discord); } - if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() { + if cfg.telegram.bot_token.is_some() { channels.push(CommsChannel::Telegram); } - if std::env::var("SIGNAL_CLI_PATH").is_ok() && std::env::var("SIGNAL_SENDER_NUMBER").is_ok() { + if cfg.signal.sender_number.is_some() { channels.push(CommsChannel::Signal); } channels @@ -26,20 +27,17 @@ pub async fn robots_txt() -> impl IntoResponse { ) } pub fn is_self_hosted_did_web_enabled() -> bool { - std::env::var("ENABLE_SELF_HOSTED_DID_WEB") - .map(|v| v != "false" && v != "0") - .unwrap_or(true) + tranquil_config::get().server.enable_pds_hosted_did_web } pub async fn describe_server() -> impl IntoResponse { - let pds_hostname = pds_hostname(); - let domains_str = - std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.to_string()); - let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect(); - let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); - let privacy_policy = std::env::var("PRIVACY_POLICY_URL").ok(); - let terms_of_service = std::env::var("TERMS_OF_SERVICE_URL").ok(); - let contact_email = std::env::var("CONTACT_EMAIL").ok(); + let cfg = tranquil_config::get(); + let pds_hostname = &cfg.server.hostname; + let domains = cfg.server.available_user_domain_list(); + let invite_code_required = cfg.server.invite_code_required; + let privacy_policy = cfg.server.privacy_policy_url.clone(); + let terms_of_service = cfg.server.terms_of_service_url.clone(); + let contact_email = cfg.server.contact_email.clone(); let mut links = serde_json::Map::new(); if let Some(pp) = privacy_policy { links.insert("privacyPolicy".to_string(), json!(pp)); diff --git a/crates/tranquil-pds/src/api/server/migration.rs b/crates/tranquil-pds/src/api/server/migration.rs index da295ae..600a3a2 100644 --- a/crates/tranquil-pds/src/api/server/migration.rs +++ b/crates/tranquil-pds/src/api/server/migration.rs @@ -2,7 +2,6 @@ use crate::api::ApiError; use crate::api::error::DbResultExt; use crate::auth::{Active, Auth}; use crate::state::AppState; -use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -147,7 +146,7 @@ pub async fn get_did_document( } async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_json::Value { - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let user = match state.user_repo.get_user_for_did_doc_build(did).await { Ok(Some(row)) => row, diff --git a/crates/tranquil-pds/src/api/server/passkey_account.rs b/crates/tranquil-pds/src/api/server/passkey_account.rs index 412718b..d32634e 100644 --- a/crates/tranquil-pds/src/api/server/passkey_account.rs +++ b/crates/tranquil-pds/src/api/server/passkey_account.rs @@ -24,7 +24,6 @@ use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token} use crate::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited}; use crate::state::AppState; use crate::types::{Did, Handle, PlainPassword}; -use crate::util::{pds_hostname, pds_hostname_without_port}; use crate::validation::validate_password; fn generate_setup_token() -> String { @@ -113,8 +112,8 @@ pub async fn create_passkey_account( .map(|d| d.starts_with("did:web:")) .unwrap_or(false); - let hostname = pds_hostname(); - let hostname_for_handles = pds_hostname_without_port(); + let hostname = &tranquil_config::get().server.hostname; + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let pds_suffix = format!(".{}", hostname_for_handles); let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { @@ -153,7 +152,7 @@ pub async fn create_passkey_account( Err(_) => return ApiError::InvalidInviteCode.into_response(), } } else { - let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); + let invite_required = tranquil_config::get().server.invite_code_required; if invite_required { return ApiError::InviteCodeRequired.into_response(); } @@ -309,8 +308,11 @@ pub async fn create_passkey_account( .into_response(); } } else { - let rotation_key = std::env::var("PLC_ROTATION_KEY") - .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key)); + let rotation_key = tranquil_config::get() + .secrets + .plc_rotation_key + .clone() + .unwrap_or_else(|| crate::plc::signing_key_to_did_key(&secret_key)); let genesis_result = match crate::plc::create_genesis_operation( &secret_key, @@ -401,12 +403,14 @@ pub async fn create_passkey_account( }; let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; - let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| { - json!({ + let birthdate_pref = if tranquil_config::get().server.age_assurance_override { + Some(json!({ "$type": "app.bsky.actor.defs#personalDetailsPref", "birthDate": "1998-05-06T00:00:00.000Z" - }) - }); + })) + } else { + None + }; let handle_typed: Handle = match handle.parse() { Ok(h) => h, @@ -820,7 +824,7 @@ pub async fn request_passkey_recovery( _rate_limit: RateLimited, Json(input): Json, ) -> Response { - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let identifier = input.email.trim().to_lowercase(); let identifier = identifier.strip_prefix('@').unwrap_or(&identifier); let normalized_handle = @@ -855,7 +859,7 @@ pub async fn request_passkey_recovery( return ApiError::InternalError(None).into_response(); } - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let recovery_url = format!( "https://{}/app/recover-passkey?did={}&token={}", hostname, diff --git a/crates/tranquil-pds/src/api/server/password.rs b/crates/tranquil-pds/src/api/server/password.rs index 8d38b39..db5612d 100644 --- a/crates/tranquil-pds/src/api/server/password.rs +++ b/crates/tranquil-pds/src/api/server/password.rs @@ -7,7 +7,6 @@ use crate::auth::{ use crate::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit}; use crate::state::AppState; use crate::types::PlainPassword; -use crate::util::{pds_hostname, pds_hostname_without_port}; use crate::validation::validate_password; use axum::{ Json, @@ -38,7 +37,7 @@ pub async fn request_password_reset( if identifier.is_empty() { return ApiError::InvalidRequest("email or handle is required".into()).into_response(); } - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let normalized = identifier.to_lowercase(); let normalized = normalized.strip_prefix('@').unwrap_or(&normalized); let is_email_lookup = normalized.contains('@'); @@ -78,7 +77,7 @@ pub async fn request_password_reset( error!("DB error setting reset code: {:?}", e); return ApiError::InternalError(None).into_response(); } - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = crate::comms::comms_repo::enqueue_password_reset( state.user_repo.as_ref(), state.infra_repo.as_ref(), diff --git a/crates/tranquil-pds/src/api/server/session.rs b/crates/tranquil-pds/src/api/server/session.rs index 01dc2f9..3d823b6 100644 --- a/crates/tranquil-pds/src/api/server/session.rs +++ b/crates/tranquil-pds/src/api/server/session.rs @@ -7,7 +7,6 @@ use crate::auth::{ use crate::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit}; use crate::state::AppState; use crate::types::{AccountState, Did, Handle, PlainPassword}; -use crate::util::{pds_hostname, pds_hostname_without_port}; use axum::{ Json, extract::State, @@ -66,10 +65,10 @@ pub async fn create_session( "create_session called with identifier: {}", input.identifier ); - let pds_host = pds_hostname(); - let hostname_for_handles = pds_hostname_without_port(); + let pds_host = &tranquil_config::get().server.hostname; + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let normalized_identifier = - NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles); + NormalizedLoginIdentifier::normalize(&input.identifier, &hostname_for_handles); info!( "Normalized identifier: {} -> {}", input.identifier, normalized_identifier @@ -182,7 +181,7 @@ pub async fn create_session( return ApiError::LegacyLoginBlocked.into_response(); } Ok(crate::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => { - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = crate::comms::comms_repo::enqueue_2fa_code( state.user_repo.as_ref(), state.infra_repo.as_ref(), @@ -286,7 +285,7 @@ pub async fn create_session( ip = %client_ip, "Legacy login on TOTP-enabled account - sending notification" ); - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = crate::comms::comms_repo::enqueue_legacy_login( state.user_repo.as_ref(), state.infra_repo.as_ref(), @@ -341,7 +340,7 @@ pub async fn get_session( let preferred_channel_verified = row .channel_verification .is_verified(row.preferred_comms_channel); - let pds_hostname = pds_hostname(); + let pds_hostname = &tranquil_config::get().server.hostname; let handle = full_handle(&row.handle, pds_hostname); let account_state = AccountState::from_db_fields( row.deactivated_at, @@ -545,7 +544,7 @@ pub async fn refresh_session( let preferred_channel_verified = u .channel_verification .is_verified(u.preferred_comms_channel); - let pds_hostname = pds_hostname(); + let pds_hostname = &tranquil_config::get().server.hostname; let handle = full_handle(&u.handle, pds_hostname); let account_state = AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None); @@ -707,7 +706,7 @@ pub async fn confirm_signup( return ApiError::InternalError(None).into_response(); } - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = crate::comms::comms_repo::enqueue_welcome( state.user_repo.as_ref(), state.infra_repo.as_ref(), @@ -777,7 +776,7 @@ pub async fn resend_verification( let formatted_token = crate::auth::verification_token::format_token_for_display(&verification_token); - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( state.user_repo.as_ref(), state.infra_repo.as_ref(), diff --git a/crates/tranquil-pds/src/api/server/totp.rs b/crates/tranquil-pds/src/api/server/totp.rs index a7f8336..6359e9f 100644 --- a/crates/tranquil-pds/src/api/server/totp.rs +++ b/crates/tranquil-pds/src/api/server/totp.rs @@ -9,7 +9,6 @@ use crate::auth::{ use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message}; use crate::state::AppState; use crate::types::PlainPassword; -use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -52,7 +51,7 @@ pub async fn create_totp_secret( .log_db_err("fetching handle")? .ok_or(ApiError::AccountNotFound)?; - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let uri = generate_totp_uri(&secret, &handle, hostname); let qr_code = generate_qr_png_base64(&secret, &handle, hostname).map_err(|e| { diff --git a/crates/tranquil-pds/src/api/server/verify_email.rs b/crates/tranquil-pds/src/api/server/verify_email.rs index 4ea5f4d..3003677 100644 --- a/crates/tranquil-pds/src/api/server/verify_email.rs +++ b/crates/tranquil-pds/src/api/server/verify_email.rs @@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use crate::state::AppState; -use crate::util::pds_hostname; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -71,7 +70,7 @@ pub async fn resend_migration_verification( return Ok(Json(ResendMigrationVerificationOutput { sent: true })); } - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; let token = crate::auth::verification_token::generate_migration_token(&user.did, &email); let formatted_token = crate::auth::verification_token::format_token_for_display(&token); diff --git a/crates/tranquil-pds/src/api/server/verify_token.rs b/crates/tranquil-pds/src/api/server/verify_token.rs index f1237a2..bf8aef3 100644 --- a/crates/tranquil-pds/src/api/server/verify_token.rs +++ b/crates/tranquil-pds/src/api/server/verify_token.rs @@ -1,7 +1,6 @@ use crate::api::error::{ApiError, DbResultExt}; use crate::comms::comms_repo; use crate::types::Did; -use crate::util::pds_hostname; use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; @@ -162,7 +161,7 @@ async fn handle_channel_update( user_id, channel, &recipient, - pds_hostname(), + &tranquil_config::get().server.hostname, ) .await { @@ -260,7 +259,7 @@ async fn handle_signup_verification( user.id, channel, &recipient, - pds_hostname(), + &tranquil_config::get().server.hostname, ) .await { diff --git a/crates/tranquil-pds/src/api/telegram_webhook.rs b/crates/tranquil-pds/src/api/telegram_webhook.rs index a4be92c..35f6620 100644 --- a/crates/tranquil-pds/src/api/telegram_webhook.rs +++ b/crates/tranquil-pds/src/api/telegram_webhook.rs @@ -8,7 +8,6 @@ use tracing::{debug, info, warn}; use crate::comms::comms_repo; use crate::state::AppState; -use crate::util::pds_hostname; #[derive(Deserialize)] struct TelegramUpdate { @@ -32,9 +31,9 @@ pub async fn handle_telegram_webhook( headers: HeaderMap, body: String, ) -> impl IntoResponse { - let expected_secret = match std::env::var("TELEGRAM_WEBHOOK_SECRET") { - Ok(s) => s, - Err(_) => { + let expected_secret = match &tranquil_config::get().telegram.webhook_secret { + Some(s) => s.clone(), + None => { warn!("Telegram webhook called but TELEGRAM_WEBHOOK_SECRET is not configured"); return StatusCode::FORBIDDEN; } @@ -88,7 +87,7 @@ pub async fn handle_telegram_webhook( user_id, tranquil_db_traits::CommsChannel::Telegram, &from.id.to_string(), - pds_hostname(), + &tranquil_config::get().server.hostname, ) .await { diff --git a/crates/tranquil-pds/src/appview/mod.rs b/crates/tranquil-pds/src/appview/mod.rs index 0eae912..277df34 100644 --- a/crates/tranquil-pds/src/appview/mod.rs +++ b/crates/tranquil-pds/src/appview/mod.rs @@ -64,13 +64,10 @@ pub struct DidResolver { impl DidResolver { pub fn new() -> Self { - let cache_ttl_secs: u64 = std::env::var("DID_CACHE_TTL_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(300); + let cfg = tranquil_config::get(); + let cache_ttl_secs = cfg.plc.did_cache_ttl_secs; - let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") - .unwrap_or_else(|_| "https://plc.directory".to_string()); + let plc_directory_url = cfg.plc.directory_url.clone(); let client = Client::builder() .timeout(Duration::from_secs(10)) diff --git a/crates/tranquil-pds/src/auth/service.rs b/crates/tranquil-pds/src/auth/service.rs index d66ffa6..1cafb69 100644 --- a/crates/tranquil-pds/src/auth/service.rs +++ b/crates/tranquil-pds/src/auth/service.rs @@ -1,4 +1,3 @@ -use crate::util::pds_hostname; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::Utc; @@ -146,10 +145,9 @@ pub struct ServiceTokenVerifier { impl ServiceTokenVerifier { pub fn new() -> Self { - let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") - .unwrap_or_else(|_| "https://plc.directory".to_string()); + let plc_directory_url = tranquil_config::get().plc.directory_url.clone(); - let pds_hostname = pds_hostname(); + let pds_hostname = &tranquil_config::get().server.hostname; let pds_did: Did = format!("did:web:{}", pds_hostname) .parse() .expect("PDS hostname produces a valid DID"); diff --git a/crates/tranquil-pds/src/auth/verification_token.rs b/crates/tranquil-pds/src/auth/verification_token.rs index 8b32335..470ad40 100644 --- a/crates/tranquil-pds/src/auth/verification_token.rs +++ b/crates/tranquil-pds/src/auth/verification_token.rs @@ -61,13 +61,7 @@ pub struct VerificationToken { fn derive_verification_key() -> [u8; 32] { use hkdf::Hkdf; - let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| { - if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() { - "test-master-key-not-for-production".to_string() - } else { - panic!("MASTER_KEY must be set"); - } - }); + let master_key = tranquil_config::get().secrets.master_key_or_default(); let hk = Hkdf::::new(None, master_key.as_bytes()); let mut key = [0u8; 32]; hk.expand(b"tranquil-pds-verification-token-v1", &mut key) diff --git a/crates/tranquil-pds/src/comms/service.rs b/crates/tranquil-pds/src/comms/service.rs index fa5d315..bfe1277 100644 --- a/crates/tranquil-pds/src/comms/service.rs +++ b/crates/tranquil-pds/src/comms/service.rs @@ -21,14 +21,9 @@ pub struct CommsService { impl CommsService { pub fn new(infra_repo: Arc) -> Self { - let poll_interval_ms: u64 = std::env::var("NOTIFICATION_POLL_INTERVAL_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1000); - let batch_size: i64 = std::env::var("NOTIFICATION_BATCH_SIZE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(100); + let cfg = tranquil_config::get(); + let poll_interval_ms = cfg.notifications.poll_interval_ms; + let batch_size = cfg.notifications.batch_size; Self { infra_repo, senders: HashMap::new(), diff --git a/crates/tranquil-pds/src/config.rs b/crates/tranquil-pds/src/config.rs index 655e251..b7c00bf 100644 --- a/crates/tranquil-pds/src/config.rs +++ b/crates/tranquil-pds/src/config.rs @@ -48,39 +48,10 @@ pub struct AuthConfig { impl AuthConfig { pub fn init() -> &'static Self { CONFIG.get_or_init(|| { - let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| { - if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() { - "test-jwt-secret-not-for-production".to_string() - } else { - panic!( - "JWT_SECRET environment variable must be set in production. \ - Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing." - ); - } - }); + let secrets = &tranquil_config::get().secrets; - let dpop_secret = std::env::var("DPOP_SECRET").unwrap_or_else(|_| { - if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() { - "test-dpop-secret-not-for-production".to_string() - } else { - panic!( - "DPOP_SECRET environment variable must be set in production. \ - Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing." - ); - } - }); - - if jwt_secret.len() < 32 - && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() - { - panic!("JWT_SECRET must be at least 32 characters"); - } - - if dpop_secret.len() < 32 - && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() - { - panic!("DPOP_SECRET must be at least 32 characters"); - } + let jwt_secret = secrets.jwt_secret_or_default(); + let dpop_secret = secrets.dpop_secret_or_default(); let mut hasher = Sha256::new(); hasher.update(b"oauth-signing-key-derivation:"); @@ -114,22 +85,7 @@ impl AuthConfig { let kid_hash = kid_hasher.finalize(); let signing_key_id = URL_SAFE_NO_PAD.encode(&kid_hash[..8]); - let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| { - if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() { - "test-master-key-not-for-production".to_string() - } else { - panic!( - "MASTER_KEY environment variable must be set in production. \ - Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing." - ); - } - }); - - if master_key.len() < 32 - && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() - { - panic!("MASTER_KEY must be at least 32 characters"); - } + let master_key = secrets.master_key_or_default(); let hk = Hkdf::::new(None, master_key.as_bytes()); let mut key_encryption_key = [0u8; 32]; diff --git a/crates/tranquil-pds/src/crawlers.rs b/crates/tranquil-pds/src/crawlers.rs index bb9ee94..40f7205 100644 --- a/crates/tranquil-pds/src/crawlers.rs +++ b/crates/tranquil-pds/src/crawlers.rs @@ -1,6 +1,5 @@ use crate::circuit_breaker::CircuitBreaker; use crate::sync::firehose::SequencedEvent; -use crate::util::pds_hostname; use reqwest::Client; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -42,18 +41,13 @@ impl Crawlers { self } - pub fn from_env() -> Option { - let hostname = pds_hostname(); + pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option { + let hostname = &cfg.server.hostname; if hostname == "localhost" { return None; } - let crawler_urls: Vec = std::env::var("CRAWLERS") - .unwrap_or_default() - .split(',') - .filter(|s| !s.is_empty()) - .map(|s| s.trim().to_string()) - .collect(); + let crawler_urls = cfg.firehose.crawler_list(); if crawler_urls.is_empty() { return None; diff --git a/crates/tranquil-pds/src/handle/mod.rs b/crates/tranquil-pds/src/handle/mod.rs index 0ab1e96..7b08950 100644 --- a/crates/tranquil-pds/src/handle/mod.rs +++ b/crates/tranquil-pds/src/handle/mod.rs @@ -87,13 +87,11 @@ pub async fn verify_handle_ownership( } } -pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool { +pub fn is_service_domain_handle(handle: &str, _hostname: &str) -> bool { if !handle.contains('.') { return true; } - let service_domains: Vec = std::env::var("PDS_SERVICE_HANDLE_DOMAINS") - .map(|s| s.split(',').map(|d| d.trim().to_string()).collect()) - .unwrap_or_else(|_| vec![hostname.to_string()]); + let service_domains = tranquil_config::get().server.user_handle_domain_list(); service_domains .iter() .any(|domain| handle.ends_with(&format!(".{}", domain)) || handle == domain) diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index 810f53b..fb22eaf 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -658,7 +658,9 @@ pub fn app(state: AppState) -> Router { post(api::discord_webhook::handle_discord_webhook) .layer(DefaultBodyLimit::max(64 * 1024)), ) - .layer(DefaultBodyLimit::max(util::get_max_blob_size())) + .layer(DefaultBodyLimit::max( + tranquil_config::get().server.max_blob_size as usize, + )) .layer(axum::middleware::map_response(rewrite_422_to_400)) .layer(middleware::from_fn(metrics::metrics_middleware)) .layer( diff --git a/crates/tranquil-pds/src/main.rs b/crates/tranquil-pds/src/main.rs index 73bbbb9..afca1c4 100644 --- a/crates/tranquil-pds/src/main.rs +++ b/crates/tranquil-pds/src/main.rs @@ -1,4 +1,6 @@ +use clap::{Parser, Subcommand}; use std::net::SocketAddr; +use std::path::PathBuf; use std::process::ExitCode; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -18,10 +20,82 @@ use tranquil_pds::scheduled::{ }; use tranquil_pds::state::AppState; +#[derive(Parser)] +#[command(name = "tranquil-pds", version = BUILD_VERSION, about = "Tranquil AT Protocol PDS")] +struct Cli { + /// Path to a TOML configuration file (also settable via TRANQUIL_PDS_CONFIG env var) + #[arg(short, long, value_name = "FILE", env = "TRANQUIL_PDS_CONFIG")] + config: Option, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Validate the configuration and exit + Validate { + /// Skip validation of secrets and database URL (useful when secrets + /// are provided at runtime via environment variables / secret files) + #[arg(long)] + ignore_secrets: bool, + }, + /// Print a TOML configuration template to stdout + ConfigTemplate, +} + #[tokio::main] async fn main() -> ExitCode { dotenvy::dotenv().ok(); + + let cli = Cli::parse(); + + // Handle subcommands that don't need full startup + if let Some(command) = &cli.command { + return match command { + Command::ConfigTemplate => { + print!("{}", tranquil_config::template()); + ExitCode::SUCCESS + } + Command::Validate { ignore_secrets } => { + let config = match tranquil_config::load(cli.config.as_ref()) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to load configuration: {e:#}"); + return ExitCode::FAILURE; + } + }; + match config.validate(*ignore_secrets) { + Ok(()) => { + println!("Configuration is valid."); + ExitCode::SUCCESS + } + Err(e) => { + eprint!("{e}"); + ExitCode::FAILURE + } + } + } + }; + } + tracing_subscriber::fmt::init(); + + let config = match tranquil_config::load(cli.config.as_ref()) { + Ok(c) => c, + Err(e) => { + error!("Failed to load configuration: {e:#}"); + return ExitCode::FAILURE; + } + }; + + if let Err(e) = config.validate(false) { + error!("{e}"); + return ExitCode::FAILURE; + } + + tranquil_config::init(config); + tranquil_pds::metrics::init_metrics(); match run().await { @@ -66,14 +140,16 @@ async fn run() -> Result<(), Box> { let mut comms_service = CommsService::new(state.infra_repo.clone()); let mut deferred_discord_endpoint: Option<(DiscordSender, String, String)> = None; - if let Some(email_sender) = EmailSender::from_env() { + let cfg = tranquil_config::get(); + + if let Some(email_sender) = EmailSender::from_config(cfg) { info!("Email comms enabled"); comms_service = comms_service.register_sender(email_sender); } else { warn!("Email comms disabled (MAIL_FROM_ADDRESS not set)"); } - if let Some(discord_sender) = DiscordSender::from_env() { + if let Some(discord_sender) = DiscordSender::from_config(cfg) { info!("Discord comms enabled"); match discord_sender.resolve_bot_username().await { Ok(username) => { @@ -96,8 +172,7 @@ async fn run() -> Result<(), Box> { Some(public_key) => { tranquil_pds::util::set_discord_public_key(public_key); info!("Discord Ed25519 public key loaded"); - let hostname = std::env::var("PDS_HOSTNAME") - .unwrap_or_else(|_| "localhost".to_string()); + let hostname = &tranquil_config::get().server.hostname; let webhook_url = format!("https://{}/webhook/discord", hostname); match discord_sender.register_slash_command(&app_id).await { Ok(()) => info!("Discord /start slash command registered"), @@ -118,22 +193,19 @@ async fn run() -> Result<(), Box> { comms_service = comms_service.register_sender(discord_sender); } - if let Some(telegram_sender) = TelegramSender::from_env() { - let secret_token = match std::env::var("TELEGRAM_WEBHOOK_SECRET") { - Ok(s) => s, - Err(_) => { - return Err( - "TELEGRAM_BOT_TOKEN is set but TELEGRAM_WEBHOOK_SECRET is missing. Both are required for secure Telegram integration.".into() - ); - } - }; + if let Some(telegram_sender) = TelegramSender::from_config(cfg) { + // Safe to unwrap: validated in TranquilConfig::validate() + let secret_token = tranquil_config::get() + .telegram + .webhook_secret + .clone() + .expect("telegram.webhook_secret checked during config validation"); info!("Telegram comms enabled"); match telegram_sender.resolve_bot_username().await { Ok(username) => { info!(bot_username = %username, "Resolved Telegram bot username"); tranquil_pds::util::set_telegram_bot_username(username); - let hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = tranquil_config::get().server.hostname.clone(); let webhook_url = format!("https://{}/webhook/telegram", hostname); match telegram_sender .set_webhook(&webhook_url, Some(&secret_token)) @@ -150,14 +222,14 @@ async fn run() -> Result<(), Box> { comms_service = comms_service.register_sender(telegram_sender); } - if let Some(signal_sender) = SignalSender::from_env() { + if let Some(signal_sender) = SignalSender::from_config(cfg) { info!("Signal comms enabled"); comms_service = comms_service.register_sender(signal_sender); } let comms_handle = tokio::spawn(comms_service.run(shutdown.clone())); - let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() { + let crawlers_handle = if let Some(crawlers) = Crawlers::from_config(cfg) { let crawlers = Arc::new( crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone()), ); @@ -197,11 +269,9 @@ async fn run() -> Result<(), Box> { let app = tranquil_pds::app(state); - let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); - let port: u16 = std::env::var("SERVER_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(3000); + let cfg = tranquil_config::get(); + let host = &cfg.server.host; + let port = cfg.server.port; let addr: SocketAddr = format!("{}:{}", host, port) .parse() diff --git a/crates/tranquil-pds/src/moderation/mod.rs b/crates/tranquil-pds/src/moderation/mod.rs index fc02c31..5bb864e 100644 --- a/crates/tranquil-pds/src/moderation/mod.rs +++ b/crates/tranquil-pds/src/moderation/mod.rs @@ -34,14 +34,7 @@ fn get_slur_regexes() -> &'static Vec { } fn get_extra_banned_words() -> &'static Vec { - EXTRA_BANNED_WORDS.get_or_init(|| { - std::env::var("PDS_BANNED_WORDS") - .unwrap_or_default() - .split(',') - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .collect() - }) + EXTRA_BANNED_WORDS.get_or_init(|| tranquil_config::get().server.banned_word_list()) } fn strip_trailing_digits(s: &str) -> &str { diff --git a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs index 4369e7a..f2921cf 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs @@ -10,7 +10,7 @@ use crate::rate_limit::{ }; use crate::state::AppState; use crate::types::{Did, Handle, PlainPassword}; -use crate::util::{extract_client_ip, pds_hostname, pds_hostname_without_port}; +use crate::util::extract_client_ip; use axum::{ Json, extract::{Query, State}, @@ -253,8 +253,8 @@ pub async fn authorize_get( if let Some(ref login_hint) = request_data.parameters.login_hint { tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation"); - let hostname_for_handles = pds_hostname_without_port(); - let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); + let normalized = NormalizedLoginIdentifier::normalize(login_hint, &hostname_for_handles); tracing::info!(normalized = %normalized, "Normalized login_hint"); match state @@ -526,13 +526,13 @@ pub async fn authorize_post( url_encode(error_msg) )) }; - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let normalized_username = - NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles); + NormalizedLoginIdentifier::normalize(&form.username, &hostname_for_handles); tracing::debug!( original_username = %form.username, normalized_username = %normalized_username, - pds_hostname = %pds_hostname(), + pds_hostname = %tranquil_config::get().server.hostname, "Normalized username for lookup" ); let user = match state @@ -677,7 +677,7 @@ pub async fn authorize_post( .await { Ok(challenge) => { - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = enqueue_2fa_code( state.user_repo.as_ref(), state.infra_repo.as_ref(), @@ -992,7 +992,7 @@ pub async fn authorize_select( .await { Ok(challenge) => { - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = enqueue_2fa_code( state.user_repo.as_ref(), state.infra_repo.as_ref(), @@ -1116,7 +1116,7 @@ fn build_success_redirect( '?' }; redirect_url.push(separator); - let pds_host = pds_hostname(); + let pds_host = &tranquil_config::get().server.hostname; redirect_url.push_str(&format!( "iss={}", url_encode(&format!("https://{}", pds_host)) @@ -1134,7 +1134,7 @@ fn build_intermediate_redirect_url( state: Option<&str>, response_mode: Option<&str>, ) -> String { - let pds_host = pds_hostname(); + let pds_host = &tranquil_config::get().server.hostname; let mut url = format!( "https://{}/oauth/authorize/redirect?redirect_uri={}&code={}", pds_host, @@ -1991,9 +1991,9 @@ pub async fn check_user_has_passkeys( State(state): State, Query(query): Query, ) -> Response { - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let bare_identifier = - BareLoginIdentifier::from_identifier(&query.identifier, hostname_for_handles); + BareLoginIdentifier::from_identifier(&query.identifier, &hostname_for_handles); let user = state .user_repo @@ -2023,9 +2023,9 @@ pub async fn check_user_security_status( State(state): State, Query(query): Query, ) -> Response { - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let normalized_identifier = - NormalizedLoginIdentifier::normalize(&query.identifier, hostname_for_handles); + NormalizedLoginIdentifier::normalize(&query.identifier, &hostname_for_handles); let user = state .user_repo @@ -2131,9 +2131,9 @@ pub async fn passkey_start( .into_response(); } - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let normalized_username = - NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles); + NormalizedLoginIdentifier::normalize(&form.identifier, &hostname_for_handles); let user = match state .user_repo @@ -2602,7 +2602,7 @@ pub async fn passkey_finish( .await { Ok(challenge) => { - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = enqueue_2fa_code( state.user_repo.as_ref(), state.infra_repo.as_ref(), @@ -2881,7 +2881,7 @@ pub async fn authorize_passkey_finish( headers: HeaderMap, Json(form): Json, ) -> Response { - let pds_hostname = pds_hostname(); + let pds_hostname = &tranquil_config::get().server.hostname; let passkey_finish_request_id = RequestId::from(form.request_uri.clone()); let request_data = match state diff --git a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs index 59cb828..8c74278 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs @@ -1,6 +1,5 @@ use crate::oauth::jwks::{JwkSet, create_jwk_set}; use crate::state::AppState; -use crate::util::pds_hostname; use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; @@ -58,7 +57,7 @@ pub struct AuthorizationServerMetadata { pub async fn oauth_protected_resource( State(_state): State, ) -> Json { - let pds_hostname = pds_hostname(); + let pds_hostname = &tranquil_config::get().server.hostname; let public_url = format!("https://{}", pds_hostname); Json(ProtectedResourceMetadata { resource: public_url.clone(), @@ -72,7 +71,7 @@ pub async fn oauth_protected_resource( pub async fn oauth_authorization_server( State(_state): State, ) -> Json { - let pds_hostname = pds_hostname(); + let pds_hostname = &tranquil_config::get().server.hostname; let issuer = format!("https://{}", pds_hostname); Json(AuthorizationServerMetadata { issuer: issuer.clone(), diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs b/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs index a3ece52..c817992 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs @@ -12,7 +12,6 @@ use crate::oauth::{ verify_client_auth, }; use crate::state::AppState; -use crate::util::pds_hostname; use axum::Json; use axum::http::{HeaderMap, Method}; use chrono::{Duration, Utc}; @@ -101,7 +100,7 @@ pub async fn handle_authorization_code_grant( let dpop_jkt = if let Some(proof) = &dpop_proof { let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - let pds_hostname = pds_hostname(); + let pds_hostname = &tranquil_config::get().server.hostname; let token_endpoint = format!("https://{}/oauth/token", pds_hostname); let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?; if !state @@ -348,7 +347,7 @@ pub async fn handle_refresh_token_grant( let dpop_jkt = if let Some(proof) = &dpop_proof { let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - let pds_hostname = pds_hostname(); + let pds_hostname = &tranquil_config::get().server.hostname; let token_endpoint = format!("https://{}/oauth/token", pds_hostname); let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?; if !state diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs b/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs index b0f02da..62d7a72 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs @@ -1,6 +1,5 @@ use crate::config::AuthConfig; use crate::oauth::OAuthError; -use crate::util::pds_hostname; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::Utc; @@ -52,7 +51,7 @@ pub fn create_access_token_with_delegation( ) -> Result { use serde_json::json; let jti = uuid::Uuid::new_v4().to_string(); - let pds_hostname = pds_hostname(); + let pds_hostname = &tranquil_config::get().server.hostname; let issuer = format!("https://{}", pds_hostname); let now = Utc::now().timestamp(); let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS; diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/introspect.rs b/crates/tranquil-pds/src/oauth/endpoints/token/introspect.rs index 07f5c87..5987b0c 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/introspect.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/introspect.rs @@ -2,7 +2,6 @@ use super::helpers::extract_token_claims; use crate::oauth::OAuthError; use crate::rate_limit::{OAuthIntrospectLimit, OAuthRateLimited}; use crate::state::AppState; -use crate::util::pds_hostname; use axum::extract::State; use axum::http::StatusCode; use axum::{Form, Json}; @@ -112,7 +111,7 @@ pub async fn introspect_token( if token_data.expires_at < Utc::now() { return Ok(Json(inactive_response)); } - let pds_hostname = pds_hostname(); + let pds_hostname = &tranquil_config::get().server.hostname; let issuer = format!("https://{}", pds_hostname); Ok(Json(IntrospectResponse { active: true, diff --git a/crates/tranquil-pds/src/plc/mod.rs b/crates/tranquil-pds/src/plc/mod.rs index 8d6b6a4..c2e1e18 100644 --- a/crates/tranquil-pds/src/plc/mod.rs +++ b/crates/tranquil-pds/src/plc/mod.rs @@ -124,18 +124,10 @@ impl PlcClient { } pub fn with_cache(base_url: Option, cache: Option>) -> Self { - let base_url = base_url.unwrap_or_else(|| { - std::env::var("PLC_DIRECTORY_URL") - .unwrap_or_else(|_| "https://plc.directory".to_string()) - }); - let timeout_secs: u64 = std::env::var("PLC_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10); - let connect_timeout_secs: u64 = std::env::var("PLC_CONNECT_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(5); + let cfg = tranquil_config::get(); + let base_url = base_url.unwrap_or_else(|| cfg.plc.directory_url.clone()); + let timeout_secs = cfg.plc.timeout_secs; + let connect_timeout_secs = cfg.plc.connect_timeout_secs; let client = Client::builder() .timeout(Duration::from_secs(timeout_secs)) .connect_timeout(Duration::from_secs(connect_timeout_secs)) diff --git a/crates/tranquil-pds/src/scheduled.rs b/crates/tranquil-pds/src/scheduled.rs index b292c11..b1390f6 100644 --- a/crates/tranquil-pds/src/scheduled.rs +++ b/crates/tranquil-pds/src/scheduled.rs @@ -438,12 +438,8 @@ pub async fn start_scheduled_tasks( sso_repo: Arc, shutdown: CancellationToken, ) { - let check_interval = Duration::from_secs( - std::env::var("SCHEDULED_DELETE_CHECK_INTERVAL_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(3600), - ); + let check_interval = + Duration::from_secs(tranquil_config::get().scheduled.delete_check_interval_secs); info!( check_interval_secs = check_interval.as_secs(), diff --git a/crates/tranquil-pds/src/sso/config.rs b/crates/tranquil-pds/src/sso/config.rs index 3924ccb..460843d 100644 --- a/crates/tranquil-pds/src/sso/config.rs +++ b/crates/tranquil-pds/src/sso/config.rs @@ -1,4 +1,3 @@ -use crate::util::pds_hostname; use std::sync::OnceLock; use tranquil_db_traits::SsoProviderType; @@ -34,24 +33,58 @@ pub struct SsoConfig { impl SsoConfig { pub fn init() -> &'static Self { SSO_CONFIG.get_or_init(|| { - let github = Self::load_provider("GITHUB", false); - let discord = Self::load_provider("DISCORD", false); - let google = Self::load_provider("GOOGLE", false); - let gitlab = Self::load_provider("GITLAB", true); - let oidc = Self::load_provider("OIDC", true); - let apple = Self::load_apple_provider(); - - let config = SsoConfig { - github, - discord, - google, - gitlab, - oidc, - apple, + let sso = &tranquil_config::get().sso; + let config = SsoConfig { + github: Self::provider_from_config( + sso.github.enabled, + sso.github.client_id.as_deref(), + sso.github.client_secret.as_deref(), + None, + sso.github.display_name.as_deref(), + "GITHUB", + false, + ), + discord: Self::provider_from_config( + sso.discord.enabled, + sso.discord.client_id.as_deref(), + sso.discord.client_secret.as_deref(), + None, + sso.discord.display_name.as_deref(), + "DISCORD", + false, + ), + google: Self::provider_from_config( + sso.google.enabled, + sso.google.client_id.as_deref(), + sso.google.client_secret.as_deref(), + None, + sso.google.display_name.as_deref(), + "GOOGLE", + false, + ), + gitlab: Self::provider_from_config( + sso.gitlab.enabled, + sso.gitlab.client_id.as_deref(), + sso.gitlab.client_secret.as_deref(), + sso.gitlab.issuer.as_deref(), + sso.gitlab.display_name.as_deref(), + "GITLAB", + true, + ), + oidc: Self::provider_from_config( + sso.oidc.enabled, + sso.oidc.client_id.as_deref(), + sso.oidc.client_secret.as_deref(), + sso.oidc.issuer.as_deref(), + sso.oidc.display_name.as_deref(), + "OIDC", + true, + ), + apple: Self::apple_from_config(&sso.apple), }; if config.is_any_enabled() { - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if hostname.is_empty() || hostname == "localhost" { panic!( "PDS_HOSTNAME must be set to a valid hostname when SSO is enabled. \ @@ -72,89 +105,70 @@ impl SsoConfig { }) } - pub fn get_redirect_uri() -> &'static str { - SSO_REDIRECT_URI - .get() - .map(|s| s.as_str()) - .expect("SSO redirect URI not initialized - call SsoConfig::init() first") - } - - fn load_provider(name: &str, needs_issuer: bool) -> Option { - let enabled = crate::util::parse_env_bool(&format!("SSO_{}_ENABLED", name)); - + fn provider_from_config( + enabled: bool, + client_id: Option<&str>, + client_secret: Option<&str>, + issuer: Option<&str>, + display_name: Option<&str>, + name: &str, + needs_issuer: bool, + ) -> Option { if !enabled { return None; } + let client_id = client_id.filter(|s| !s.is_empty())?; + let client_secret = client_secret.filter(|s| !s.is_empty())?; - let client_id = std::env::var(format!("SSO_{}_CLIENT_ID", name)).ok()?; - let client_secret = std::env::var(format!("SSO_{}_CLIENT_SECRET", name)).ok()?; - - if client_id.is_empty() || client_secret.is_empty() { - tracing::warn!( - "SSO_{} enabled but missing client_id or client_secret", - name - ); - return None; - } - - let issuer = if needs_issuer { - let issuer_val = std::env::var(format!("SSO_{}_ISSUER", name)).ok(); - if issuer_val.is_none() || issuer_val.as_ref().map(|s| s.is_empty()).unwrap_or(true) { + if needs_issuer { + let issuer_val = issuer.filter(|s| !s.is_empty()); + if issuer_val.is_none() { tracing::warn!("SSO_{} requires ISSUER but none provided", name); return None; } - issuer_val - } else { - None - }; - - let display_name = std::env::var(format!("SSO_{}_NAME", name)).ok(); + } Some(ProviderConfig { - client_id, - client_secret, - issuer, - display_name, + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + issuer: issuer.map(|s| s.to_string()), + display_name: display_name.map(|s| s.to_string()), }) } - fn load_apple_provider() -> Option { - let enabled = crate::util::parse_env_bool("SSO_APPLE_ENABLED"); - - if !enabled { + fn apple_from_config(cfg: &tranquil_config::SsoAppleConfig) -> Option { + if !cfg.enabled { return None; } + let client_id = cfg.client_id.as_deref().filter(|s| !s.is_empty())?; + let team_id = cfg.team_id.as_deref().filter(|s| !s.is_empty())?; + let key_id = cfg.key_id.as_deref().filter(|s| !s.is_empty())?; + let private_key_pem = cfg.private_key.as_deref().filter(|s| !s.is_empty())?; - let client_id = std::env::var("SSO_APPLE_CLIENT_ID").ok()?; - let team_id = std::env::var("SSO_APPLE_TEAM_ID").ok()?; - let key_id = std::env::var("SSO_APPLE_KEY_ID").ok()?; - let private_key_pem = std::env::var("SSO_APPLE_PRIVATE_KEY").ok()?; - - if client_id.is_empty() { - tracing::warn!("SSO_APPLE enabled but missing CLIENT_ID"); - return None; - } - if team_id.is_empty() || team_id.len() != 10 { + if team_id.len() != 10 { tracing::warn!("SSO_APPLE enabled but TEAM_ID is invalid (must be 10 characters)"); return None; } - if key_id.is_empty() { - tracing::warn!("SSO_APPLE enabled but missing KEY_ID"); - return None; - } - if private_key_pem.is_empty() || !private_key_pem.contains("PRIVATE KEY") { + if !private_key_pem.contains("PRIVATE KEY") { tracing::warn!("SSO_APPLE enabled but PRIVATE_KEY is invalid"); return None; } Some(AppleProviderConfig { - client_id, - team_id, - key_id, - private_key_pem, + client_id: client_id.to_string(), + team_id: team_id.to_string(), + key_id: key_id.to_string(), + private_key_pem: private_key_pem.to_string(), }) } + pub fn get_redirect_uri() -> &'static str { + SSO_REDIRECT_URI + .get() + .map(|s| s.as_str()) + .expect("SSO redirect URI not initialized - call SsoConfig::init() first") + } + pub fn get() -> &'static Self { SSO_CONFIG.get_or_init(SsoConfig::default) } diff --git a/crates/tranquil-pds/src/sso/endpoints.rs b/crates/tranquil-pds/src/sso/endpoints.rs index ad5c881..4295d6b 100644 --- a/crates/tranquil-pds/src/sso/endpoints.rs +++ b/crates/tranquil-pds/src/sso/endpoints.rs @@ -18,7 +18,6 @@ use crate::rate_limit::{ check_user_rate_limit_with_message, }; use crate::state::AppState; -use crate::util::{pds_hostname, pds_hostname_without_port}; fn generate_state() -> String { use rand::RngCore; @@ -773,7 +772,7 @@ pub async fn check_handle_available( } }; - let hostname_for_handles = pds_hostname_without_port(); + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let full_handle = format!("{}.{}", validated, hostname_for_handles); let handle_typed: crate::types::Handle = match full_handle.parse() { Ok(h) => h, @@ -856,8 +855,8 @@ pub async fn complete_registration( .await? .ok_or(ApiError::SsoSessionExpired)?; - let hostname = pds_hostname(); - let hostname_for_handles = pds_hostname_without_port(); + let hostname = &tranquil_config::get().server.hostname; + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let handle = match crate::api::validation::validate_short_handle(&input.handle) { Ok(h) => format!("{}.{}", h, hostname_for_handles), @@ -948,7 +947,7 @@ pub async fn complete_registration( Err(_) => return Err(ApiError::InvalidInviteCode), } } else { - let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); + let invite_required = tranquil_config::get().server.invite_code_required; if invite_required { return Err(ApiError::InviteCodeRequired); } @@ -1006,8 +1005,11 @@ pub async fn complete_registration( d.to_string() } _ => { - let rotation_key = std::env::var("PLC_ROTATION_KEY") - .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key)); + let rotation_key = tranquil_config::get() + .secrets + .plc_rotation_key + .clone() + .unwrap_or_else(|| crate::plc::signing_key_to_did_key(&signing_key)); let genesis_result = match crate::plc::create_genesis_operation( &signing_key, @@ -1085,12 +1087,14 @@ pub async fn complete_registration( let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; - let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| { - json!({ + let birthdate_pref = if tranquil_config::get().server.age_assurance_override { + Some(json!({ "$type": "app.bsky.actor.defs#personalDetailsPref", "birthDate": "1998-05-06T00:00:00.000Z" - }) - }); + })) + } else { + None + }; let create_input = tranquil_db_traits::CreateSsoAccountInput { handle: handle_typed.clone(), @@ -1299,7 +1303,7 @@ pub async fn complete_registration( return Err(ApiError::InternalError(None)); } - let hostname = pds_hostname(); + let hostname = &tranquil_config::get().server.hostname; if let Err(e) = crate::comms::comms_repo::enqueue_welcome( state.user_repo.as_ref(), state.infra_repo.as_ref(), diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index ec2642a..1282f89 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -1,19 +1,18 @@ use crate::appview::DidResolver; use crate::auth::webauthn::WebAuthnConfig; -use crate::cache::{Cache, DistributedRateLimiter, create_cache}; +use crate::cache::{create_cache, Cache, DistributedRateLimiter}; use crate::circuit_breaker::CircuitBreakers; use crate::config::AuthConfig; use crate::rate_limit::RateLimiters; use crate::repo::PostgresBlockStore; use crate::repo_write_lock::RepoWriteLocks; use crate::sso::{SsoConfig, SsoManager}; -use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage}; +use crate::storage::{create_backup_storage, create_blob_storage, BackupStorage, BlobStorage}; use crate::sync::firehose::SequencedEvent; -use crate::util::pds_hostname; use sqlx::PgPool; use std::error::Error; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; use tranquil_db::{ @@ -25,10 +24,10 @@ use tranquil_db::{ static RATE_LIMITING_DISABLED: AtomicBool = AtomicBool::new(false); pub fn init_rate_limit_override() { - let disabled = std::env::var("DISABLE_RATE_LIMITING").is_ok(); + let disabled = tranquil_config::get().server.disable_rate_limiting; RATE_LIMITING_DISABLED.store(disabled, Ordering::Relaxed); if disabled { - tracing::warn!("rate limiting is DISABLED via DISABLE_RATE_LIMITING env var"); + tracing::warn!("rate limiting is DISABLED via configuration"); } } @@ -205,23 +204,11 @@ impl RateLimitKind { impl AppState { pub async fn new(shutdown: CancellationToken) -> Result> { - let database_url = std::env::var("DATABASE_URL") - .map_err(|_| "DATABASE_URL environment variable must be set")?; - - let max_connections: u32 = std::env::var("DATABASE_MAX_CONNECTIONS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(100); - - let min_connections: u32 = std::env::var("DATABASE_MIN_CONNECTIONS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10); - - let acquire_timeout_secs: u64 = std::env::var("DATABASE_ACQUIRE_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10); + let cfg = tranquil_config::get(); + let database_url = &cfg.database.url; + let max_connections = cfg.database.max_connections; + let min_connections = cfg.database.min_connections; + let acquire_timeout_secs = cfg.database.acquire_timeout_secs; tracing::info!( "Configuring database pool: max={}, min={}, acquire_timeout={}s", @@ -257,10 +244,7 @@ impl AppState { let blob_store = create_blob_storage().await; let backup_storage = create_backup_storage().await; - let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10000); + let firehose_buffer_size = tranquil_config::get().firehose.buffer_size; let (firehose_tx, _) = broadcast::channel(firehose_buffer_size); let rate_limiters = Arc::new(RateLimiters::new()); @@ -271,7 +255,7 @@ impl AppState { let sso_config = SsoConfig::init(); let sso_manager = SsoManager::from_config(sso_config); let webauthn_config = Arc::new( - WebAuthnConfig::new(pds_hostname()) + WebAuthnConfig::new(&tranquil_config::get().server.hostname) .expect("Failed to create WebAuthn config at startup"), ); diff --git a/crates/tranquil-pds/src/sync/subscribe_repos.rs b/crates/tranquil-pds/src/sync/subscribe_repos.rs index e823e80..578aa61 100644 --- a/crates/tranquil-pds/src/sync/subscribe_repos.rs +++ b/crates/tranquil-pds/src/sync/subscribe_repos.rs @@ -59,10 +59,7 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, params: Subscribe } fn get_backfill_hours() -> i64 { - std::env::var("FIREHOSE_BACKFILL_HOURS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(72) + tranquil_config::get().firehose.backfill_hours } async fn handle_socket_inner( @@ -204,10 +201,7 @@ async fn handle_socket_inner( } } } - let max_lag_before_disconnect: u64 = std::env::var("FIREHOSE_MAX_LAG") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(5000); + let max_lag_before_disconnect: u64 = tranquil_config::get().firehose.max_lag; loop { tokio::select! { result = rx.recv() => { diff --git a/crates/tranquil-pds/src/sync/verify.rs b/crates/tranquil-pds/src/sync/verify.rs index d5cf9aa..06a9930 100644 --- a/crates/tranquil-pds/src/sync/verify.rs +++ b/crates/tranquil-pds/src/sync/verify.rs @@ -145,8 +145,7 @@ impl CarVerifier { } async fn resolve_plc_did(&self, did: &str) -> Result, VerifyError> { - let plc_url = std::env::var("PLC_DIRECTORY_URL") - .unwrap_or_else(|_| "https://plc.directory".to_string()); + let plc_url = tranquil_config::get().plc.directory_url.clone(); let url = format!("{}/{}", plc_url, urlencoding::encode(did)); let response = self .http_client diff --git a/crates/tranquil-pds/src/util.rs b/crates/tranquil-pds/src/util.rs index a104e40..43417a8 100644 --- a/crates/tranquil-pds/src/util.rs +++ b/crates/tranquil-pds/src/util.rs @@ -9,25 +9,12 @@ use std::str::FromStr; use std::sync::OnceLock; const BASE32_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz234567"; -const DEFAULT_MAX_BLOB_SIZE: usize = 10 * 1024 * 1024 * 1024; -static MAX_BLOB_SIZE: OnceLock = OnceLock::new(); -static PDS_HOSTNAME: OnceLock = OnceLock::new(); -static PDS_HOSTNAME_WITHOUT_PORT: OnceLock = OnceLock::new(); static DISCORD_BOT_USERNAME: OnceLock = OnceLock::new(); static DISCORD_PUBLIC_KEY: OnceLock = OnceLock::new(); static DISCORD_APP_ID: OnceLock = OnceLock::new(); static TELEGRAM_BOT_USERNAME: OnceLock = OnceLock::new(); -pub fn get_max_blob_size() -> usize { - *MAX_BLOB_SIZE.get_or_init(|| { - std::env::var("MAX_BLOB_SIZE") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(DEFAULT_MAX_BLOB_SIZE) - }) -} - pub fn generate_token_code() -> String { generate_token_code_parts(2, 5) } @@ -109,18 +96,6 @@ pub fn extract_client_ip(headers: &HeaderMap, addr: Option) -> Strin .unwrap_or_else(|| "unknown".to_string()) } -pub fn pds_hostname() -> &'static str { - PDS_HOSTNAME - .get_or_init(|| std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())) -} - -pub fn pds_hostname_without_port() -> &'static str { - PDS_HOSTNAME_WITHOUT_PORT.get_or_init(|| { - let hostname = pds_hostname(); - hostname.split(':').next().unwrap_or(hostname).to_string() - }) -} - pub fn set_discord_bot_username(username: String) { DISCORD_BOT_USERNAME.set(username).ok(); } @@ -154,26 +129,25 @@ pub fn telegram_bot_username() -> Option<&'static str> { } pub fn parse_env_bool(key: &str) -> bool { + // Check the config system first, then fall back to env var for dynamic + // SSO keys that are not in the static config struct. std::env::var(key) .map(|v| v == "true" || v == "1") .unwrap_or(false) } -pub fn pds_public_url() -> String { - format!("https://{}", pds_hostname()) -} - pub fn build_full_url(path: &str) -> String { + let cfg = tranquil_config::get(); let normalized_path = if !path.starts_with("/xrpc/") && (path.starts_with("/com.atproto.") || path.starts_with("/app.bsky.") || path.starts_with("/_")) { - format!("/xrpc{}", path) + format!("/xrpc{path}") } else { path.to_string() }; - format!("{}{}", pds_public_url(), normalized_path) + format!("{}{normalized_path}", cfg.server.public_url()) } pub fn json_to_ipld(value: &JsonValue) -> Ipld { diff --git a/crates/tranquil-ripple/Cargo.toml b/crates/tranquil-ripple/Cargo.toml index 660b00c..79601f6 100644 --- a/crates/tranquil-ripple/Cargo.toml +++ b/crates/tranquil-ripple/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +tranquil-config = { workspace = true } tranquil-infra = { workspace = true } async-trait = { workspace = true } diff --git a/crates/tranquil-ripple/src/config.rs b/crates/tranquil-ripple/src/config.rs index 014b71b..001948a 100644 --- a/crates/tranquil-ripple/src/config.rs +++ b/crates/tranquil-ripple/src/config.rs @@ -15,30 +15,20 @@ pub struct RippleConfig { pub cache_max_bytes: usize, } -fn parse_env_with_warning(var_name: &str, raw: &str) -> Option { - match raw.parse::() { - Ok(v) => Some(v), - Err(_) => { - tracing::warn!( - var = var_name, - value = raw, - "invalid env var value, using default" - ); - None - } - } -} - impl RippleConfig { - pub fn from_env() -> Result { - let bind_addr: SocketAddr = std::env::var("RIPPLE_BIND") - .unwrap_or_else(|_| "0.0.0.0:0".into()) + pub fn from_config() -> Result { + let ripple = &tranquil_config::get().cache.ripple; + + let bind_addr: SocketAddr = ripple + .bind_addr .parse() .map_err(|e| RippleConfigError::InvalidAddr(format!("{e}")))?; - let seed_peers: Vec = std::env::var("RIPPLE_PEERS") - .unwrap_or_default() - .split(',') + let seed_peers: Vec = ripple + .peers + .as_deref() + .unwrap_or(&[]) + .iter() .filter(|s| !s.trim().is_empty()) .map(|s| { s.trim() @@ -47,30 +37,21 @@ impl RippleConfig { }) .collect::, _>>()?; - let machine_id: u64 = std::env::var("RIPPLE_MACHINE_ID") - .ok() - .and_then(|v| parse_env_with_warning::("RIPPLE_MACHINE_ID", &v)) - .unwrap_or_else(|| { - let host_str = std::fs::read_to_string("/etc/hostname") - .map(|s| s.trim().to_string()) - .unwrap_or_else(|_| format!("pid-{}", std::process::id())); - let input = format!("{host_str}:{bind_addr}:{}", std::process::id()); - fnv1a(input.as_bytes()) - }); + let machine_id = ripple.machine_id.unwrap_or_else(|| { + let host_str = std::fs::read_to_string("/etc/hostname") + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| format!("pid-{}", std::process::id())); + let input = format!("{host_str}:{bind_addr}:{}", std::process::id()); + fnv1a(input.as_bytes()) + }); - let gossip_interval_ms: u64 = std::env::var("RIPPLE_GOSSIP_INTERVAL_MS") - .ok() - .and_then(|v| parse_env_with_warning::("RIPPLE_GOSSIP_INTERVAL_MS", &v)) - .unwrap_or(200) - .max(50); + let gossip_interval_ms = ripple.gossip_interval_ms.max(50); - let cache_max_mb: usize = std::env::var("RIPPLE_CACHE_MAX_MB") - .ok() - .and_then(|v| parse_env_with_warning::("RIPPLE_CACHE_MAX_MB", &v)) - .unwrap_or(256) - .clamp(1, 16_384); - - let cache_max_bytes = cache_max_mb.saturating_mul(1024).saturating_mul(1024); + let cache_max_bytes = ripple + .cache_max_mb + .clamp(1, 16_384) + .saturating_mul(1024) + .saturating_mul(1024); Ok(Self { bind_addr, diff --git a/crates/tranquil-storage/Cargo.toml b/crates/tranquil-storage/Cargo.toml index 513415d..77d256f 100644 --- a/crates/tranquil-storage/Cargo.toml +++ b/crates/tranquil-storage/Cargo.toml @@ -9,6 +9,7 @@ default = [] s3 = ["dep:aws-config", "dep:aws-sdk-s3"] [dependencies] +tranquil-config = { workspace = true } tranquil-infra = { workspace = true } async-trait = { workspace = true } diff --git a/crates/tranquil-storage/src/lib.rs b/crates/tranquil-storage/src/lib.rs index 2fc6dfd..855e812 100644 --- a/crates/tranquil-storage/src/lib.rs +++ b/crates/tranquil-storage/src/lib.rs @@ -121,7 +121,12 @@ mod s3 { impl S3BlobStorage { pub async fn new() -> Self { - let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set"); + let cfg = tranquil_config::get(); + let bucket = cfg + .storage + .s3_bucket + .clone() + .expect("storage.s3_bucket (S3_BUCKET) must be set"); let client = create_s3_client().await; Self { client, bucket } } @@ -140,16 +145,20 @@ mod s3 { .load() .await; - std::env::var("S3_ENDPOINT").ok().map_or_else( - || Client::new(&config), - |endpoint| { - let s3_config = aws_sdk_s3::config::Builder::from(&config) - .endpoint_url(endpoint) - .force_path_style(true) - .build(); - Client::from_conf(s3_config) - }, - ) + tranquil_config::get() + .storage + .s3_endpoint + .as_deref() + .map_or_else( + || Client::new(&config), + |endpoint| { + let s3_config = aws_sdk_s3::config::Builder::from(&config) + .endpoint_url(endpoint) + .force_path_style(true) + .build(); + Client::from_conf(s3_config) + }, + ) } pub struct S3BackupStorage { @@ -159,7 +168,7 @@ mod s3 { impl S3BackupStorage { pub async fn new() -> Option { - let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?; + let bucket = tranquil_config::get().backup.s3_bucket.clone()?; let client = create_s3_client().await; Some(Self { client, bucket }) } @@ -499,12 +508,6 @@ impl FilesystemBlobStorage { }) } - pub async fn from_env() -> Result { - let path = std::env::var("BLOB_STORAGE_PATH") - .map_err(|_| StorageError::Other("BLOB_STORAGE_PATH not set".into()))?; - Self::new(path).await - } - fn resolve_path(&self, key: &str) -> Result { validate_key(key)?; Ok(split_cid_path(key).map_or_else( @@ -649,12 +652,6 @@ impl FilesystemBackupStorage { }) } - pub async fn from_env() -> Result { - let path = std::env::var("BACKUP_STORAGE_PATH") - .map_err(|_| StorageError::Other("BACKUP_STORAGE_PATH not set".into()))?; - Self::new(path).await - } - fn resolve_path(&self, key: &str) -> Result { validate_key(key)?; Ok(self.base_path.join(key)) @@ -701,7 +698,8 @@ impl BackupStorage for FilesystemBackupStorage { } pub async fn create_blob_storage() -> Arc { - let backend = std::env::var("BLOB_STORAGE_BACKEND").unwrap_or_else(|_| "filesystem".into()); + let cfg = tranquil_config::get(); + let backend = &cfg.storage.backend; match backend.as_str() { #[cfg(feature = "s3")] @@ -718,7 +716,8 @@ pub async fn create_blob_storage() -> Arc { } _ => { tracing::info!("Initializing filesystem blob storage"); - FilesystemBlobStorage::from_env() + let path = cfg.storage.path.clone(); + FilesystemBlobStorage::new(path) .await .unwrap_or_else(|e| { panic!( @@ -733,16 +732,14 @@ pub async fn create_blob_storage() -> Arc { } pub async fn create_backup_storage() -> Option> { - let enabled = std::env::var("BACKUP_ENABLED") - .map(|v| v != "false" && v != "0") - .unwrap_or(true); + let cfg = tranquil_config::get(); - if !enabled { - tracing::info!("Backup storage disabled via BACKUP_ENABLED=false"); + if !cfg.backup.enabled { + tracing::info!("Backup storage disabled via config"); return None; } - let backend = std::env::var("BACKUP_STORAGE_BACKEND").unwrap_or_else(|_| "filesystem".into()); + let backend = &cfg.backup.backend; match backend.as_str() { #[cfg(feature = "s3")] @@ -767,7 +764,9 @@ pub async fn create_backup_storage() -> Option> { ); None } - _ => FilesystemBackupStorage::from_env().await.map_or_else( + _ => { + let path = cfg.backup.path.clone(); + FilesystemBackupStorage::new(path).await.map_or_else( |e| { tracing::error!( "Failed to initialize filesystem backup storage: {}. \ @@ -781,7 +780,8 @@ pub async fn create_backup_storage() -> Option> { tracing::info!("Initialized filesystem backup storage"); Some(Arc::new(storage) as Arc) }, - ), + ) + } } } diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index 3080590..d03f876 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -17,6 +17,7 @@ services: MASTER_KEY: "${MASTER_KEY:?MASTER_KEY is required (min 32 chars)}" CRAWLERS: "${CRAWLERS:-https://bsky.network}" volumes: + - ./config.toml:/etc/tranquil-pds/config.toml:ro - blob_data:/var/lib/tranquil/blobs - backup_data:/var/lib/tranquil/backups depends_on: diff --git a/docker-compose.yaml b/docker-compose.yaml index 9702c19..7119ce4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,6 +11,7 @@ services: environment: DATABASE_URL: postgres://postgres:postgres@db:5432/pds volumes: + - ./config.toml:/etc/tranquil-pds/config.toml:ro - blob_data:/var/lib/tranquil/blobs - backup_data:/var/lib/tranquil/backups depends_on: diff --git a/docs/install-containers.md b/docs/install-containers.md index 1e9abb3..f892e61 100644 --- a/docs/install-containers.md +++ b/docs/install-containers.md @@ -18,10 +18,10 @@ This guide covers deploying Tranquil PDS using containers with podman. If you just want to get running quickly: ```sh -cp .env.example .env +cp example.toml config.toml ``` -Edit `.env` with your values. Generate secrets with `openssl rand -base64 48`. +Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`. Build and start: ```sh @@ -59,7 +59,7 @@ Run the backend with host networking (so it can access postgres on localhost) an ```sh podman run -d --name tranquil-pds \ --network=host \ - --env-file /etc/tranquil-pds/tranquil-pds.env \ + -v /etc/tranquil-pds/config.toml:/etc/tranquil-pds/config.toml:ro,Z \ -v /var/lib/tranquil:/var/lib/tranquil:Z \ tranquil-pds:latest ``` @@ -113,18 +113,22 @@ mkdir -p /etc/containers/systemd mkdir -p /srv/tranquil-pds/{postgres,blobs,backups,certs,acme,config} ``` -## Create an environment file +## Create a configuration file ```bash -cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env -chmod 600 /srv/tranquil-pds/config/tranquil-pds.env +cp /opt/tranquil-pds/example.toml /srv/tranquil-pds/config/config.toml +chmod 600 /srv/tranquil-pds/config/config.toml ``` -Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with: +Edit `/srv/tranquil-pds/config/config.toml` and fill in your values. Generate secrets with: ```bash openssl rand -base64 48 ``` +> **Note:** Every config option can also be set via environment variables +> (see comments in `example.toml`). Environment variables always take +> precedence over the config file. + ## Install quadlet definitions Copy the quadlet files from the repository: @@ -157,7 +161,6 @@ podman build -t tranquil-pds-frontend:latest ./frontend ## Create podman secrets ```bash -source /srv/tranquil-pds/config/tranquil-pds.env echo "$DB_PASSWORD" | podman secret create tranquil-pds-db-password - ``` @@ -264,18 +267,22 @@ podman build -t tranquil-pds:latest . podman build -t tranquil-pds-frontend:latest ./frontend ``` -## Create an environment file +## Create a configuration file ```sh -cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env -chmod 600 /srv/tranquil-pds/config/tranquil-pds.env +cp /opt/tranquil-pds/example.toml /srv/tranquil-pds/config/config.toml +chmod 600 /srv/tranquil-pds/config/config.toml ``` -Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with: +Edit `/srv/tranquil-pds/config/config.toml` and fill in your values. Generate secrets with: ```sh openssl rand -base64 48 ``` +> **Note:** Every config option can also be set via environment variables +> (see comments in `example.toml`). Environment variables always take +> precedence over the config file. + ## Set up compose and nginx Copy the production compose and nginx configs: @@ -308,16 +315,11 @@ depend() { after firewall } start_pre() { - set -a - . /srv/tranquil-pds/config/tranquil-pds.env - set +a + checkpath -d /srv/tranquil-pds } stop() { ebegin "Stopping ${name}" cd /srv/tranquil-pds - set -a - . /srv/tranquil-pds/config/tranquil-pds.env - set +a podman-compose -f /srv/tranquil-pds/docker-compose.yml down eend $? } diff --git a/docs/install-debian.md b/docs/install-debian.md index 7d8900d..fd084b7 100644 --- a/docs/install-debian.md +++ b/docs/install-debian.md @@ -73,15 +73,25 @@ cargo build --release ```bash mkdir -p /etc/tranquil-pds -cp /opt/tranquil-pds/.env.example /etc/tranquil-pds/tranquil-pds.env -chmod 600 /etc/tranquil-pds/tranquil-pds.env +cp /opt/tranquil-pds/example.toml /etc/tranquil-pds/config.toml +chmod 600 /etc/tranquil-pds/config.toml ``` -Edit `/etc/tranquil-pds/tranquil-pds.env` and fill in your values. Generate secrets with: +Edit `/etc/tranquil-pds/config.toml` and fill in your values. Generate secrets with: ```bash openssl rand -base64 48 ``` +> **Note:** Every config option can also be set via environment variables +> (see comments in `example.toml`). Environment variables always take +> precedence over the config file. You can also pass the config file path +> via the `TRANQUIL_PDS_CONFIG` env var instead of `--config`. + +You can validate your configuration before starting the service: +```bash +/usr/local/bin/tranquil-pds --config /etc/tranquil-pds/config.toml validate +``` + ## Install frontend files ```bash @@ -105,8 +115,7 @@ After=network.target postgresql.service Type=simple User=tranquil-pds Group=tranquil-pds -EnvironmentFile=/etc/tranquil-pds/tranquil-pds.env -ExecStart=/usr/local/bin/tranquil-pds +ExecStart=/usr/local/bin/tranquil-pds --config /etc/tranquil-pds/config.toml Restart=always RestartSec=5 ProtectSystem=strict diff --git a/docs/install-kubernetes.md b/docs/install-kubernetes.md index 44435df..fc538de 100644 --- a/docs/install-kubernetes.md +++ b/docs/install-kubernetes.md @@ -9,13 +9,16 @@ If you're reaching for kubernetes for this app, you're experienced enough to kno You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com`. User handles are served as subdomains. The container image expects: +- A TOML config file mounted at `/etc/tranquil-pds/config.toml` (or passed via `--config`) - `DATABASE_URL` - postgres connection string - `BLOB_STORAGE_PATH` - path to blob storage (mount a PV here) - `BACKUP_STORAGE_PATH` - path for repo backups (optional but recommended) - `PDS_HOSTNAME` - your PDS hostname (without protocol) - `JWT_SECRET`, `DPOP_SECRET`, `MASTER_KEY` - generate with `openssl rand -base64 48` - `CRAWLERS` - typically `https://bsky.network` -and more, check the .env.example. + +and more, check the example.toml for all options. Environment variables can override any TOML value. +You can also point to a config file via the `TRANQUIL_PDS_CONFIG` env var. Health check: `GET /xrpc/_health` diff --git a/example.toml b/example.toml new file mode 100644 index 0000000..d2b07d6 --- /dev/null +++ b/example.toml @@ -0,0 +1,509 @@ +[server] +# Public hostname of the PDS (e.g. `pds.example.com`). +# +# Can also be specified via environment variable `PDS_HOSTNAME`. +# +# Required! This value must be specified. +#hostname = + +# Address to bind the HTTP server to. +# +# Can also be specified via environment variable `SERVER_HOST`. +# +# Default value: "127.0.0.1" +#host = "127.0.0.1" + +# Port to bind the HTTP server to. +# +# Can also be specified via environment variable `SERVER_PORT`. +# +# Default value: 3000 +#port = 3000 + +# List of domains for user handles. +# Defaults to the PDS hostname when not set. +# +# Can also be specified via environment variable `PDS_USER_HANDLE_DOMAINS`. +#user_handle_domains = + +# List of domains available for user registration. +# Defaults to the PDS hostname when not set. +# +# Can also be specified via environment variable `AVAILABLE_USER_DOMAINS`. +#available_user_domains = + +# Enable PDS-hosted did:web identities. Hosting did:web requires a +# long-term commitment to serve DID documents; opt-in only. +# +# Can also be specified via environment variable `ENABLE_PDS_HOSTED_DID_WEB`. +# +# Default value: false +#enable_pds_hosted_did_web = false + +# When set to true, skip age-assurance birthday prompt for all accounts. +# +# Can also be specified via environment variable `PDS_AGE_ASSURANCE_OVERRIDE`. +# +# Default value: false +#age_assurance_override = false + +# Require an invite code for new account registration. +# +# Can also be specified via environment variable `INVITE_CODE_REQUIRED`. +# +# Default value: true +#invite_code_required = true + +# Allow HTTP (non-TLS) proxy requests. Only useful during development. +# +# Can also be specified via environment variable `ALLOW_HTTP_PROXY`. +# +# Default value: false +#allow_http_proxy = false + +# Disable all rate limiting. Should only be used in testing. +# +# Can also be specified via environment variable `DISABLE_RATE_LIMITING`. +# +# Default value: false +#disable_rate_limiting = false + +# List of additional banned words for handle validation. +# +# Can also be specified via environment variable `PDS_BANNED_WORDS`. +#banned_words = + +# URL to a privacy policy page. +# +# Can also be specified via environment variable `PRIVACY_POLICY_URL`. +#privacy_policy_url = + +# URL to terms of service page. +# +# Can also be specified via environment variable `TERMS_OF_SERVICE_URL`. +#terms_of_service_url = + +# Operator contact email address. +# +# Can also be specified via environment variable `CONTACT_EMAIL`. +#contact_email = + +# Maximum allowed blob size in bytes (default 10 GiB). +# +# Can also be specified via environment variable `MAX_BLOB_SIZE`. +# +# Default value: 10737418240 +#max_blob_size = 10737418240 + +[database] +# PostgreSQL connection URL. +# +# Can also be specified via environment variable `DATABASE_URL`. +# +# Required! This value must be specified. +#url = + +# Maximum number of connections in the pool. +# +# Can also be specified via environment variable `DATABASE_MAX_CONNECTIONS`. +# +# Default value: 100 +#max_connections = 100 + +# Minimum number of idle connections kept in the pool. +# +# Can also be specified via environment variable `DATABASE_MIN_CONNECTIONS`. +# +# Default value: 10 +#min_connections = 10 + +# Timeout in seconds when acquiring a connection from the pool. +# +# Can also be specified via environment variable `DATABASE_ACQUIRE_TIMEOUT_SECS`. +# +# Default value: 10 +#acquire_timeout_secs = 10 + +[secrets] +# Secret used for signing JWTs. Must be at least 32 characters in +# production. +# +# Can also be specified via environment variable `JWT_SECRET`. +#jwt_secret = + +# Secret used for DPoP proof validation. Must be at least 32 characters +# in production. +# +# Can also be specified via environment variable `DPOP_SECRET`. +#dpop_secret = + +# Master key used for key-encryption and HKDF derivation. Must be at +# least 32 characters in production. +# +# Can also be specified via environment variable `MASTER_KEY`. +#master_key = + +# PLC rotation key (DID key). If not set, user-level keys are used. +# +# Can also be specified via environment variable `PLC_ROTATION_KEY`. +#plc_rotation_key = + +# Allow insecure/test secrets. NEVER enable in production. +# +# Can also be specified via environment variable `TRANQUIL_PDS_ALLOW_INSECURE_SECRETS`. +# +# Default value: false +#allow_insecure = false + +[storage] +# Storage backend: `filesystem` or `s3`. +# +# Can also be specified via environment variable `BLOB_STORAGE_BACKEND`. +# +# Default value: "filesystem" +#backend = "filesystem" + +# Path on disk for the filesystem blob backend. +# +# Can also be specified via environment variable `BLOB_STORAGE_PATH`. +# +# Default value: "/var/lib/tranquil-pds/blobs" +#path = "/var/lib/tranquil-pds/blobs" + +# S3 bucket name for blob storage. +# +# Can also be specified via environment variable `S3_BUCKET`. +#s3_bucket = + +# Custom S3 endpoint URL (for MinIO, R2, etc.). +# +# Can also be specified via environment variable `S3_ENDPOINT`. +#s3_endpoint = + +[backup] +# Enable automatic backups. +# +# Can also be specified via environment variable `BACKUP_ENABLED`. +# +# Default value: true +#enabled = true + +# Backup storage backend: `filesystem` or `s3`. +# +# Can also be specified via environment variable `BACKUP_STORAGE_BACKEND`. +# +# Default value: "filesystem" +#backend = "filesystem" + +# Path on disk for the filesystem backup backend. +# +# Can also be specified via environment variable `BACKUP_STORAGE_PATH`. +# +# Default value: "/var/lib/tranquil-pds/backups" +#path = "/var/lib/tranquil-pds/backups" + +# S3 bucket name for backups. +# +# Can also be specified via environment variable `BACKUP_S3_BUCKET`. +#s3_bucket = + +# Number of backup revisions to keep per account. +# +# Can also be specified via environment variable `BACKUP_RETENTION_COUNT`. +# +# Default value: 7 +#retention_count = 7 + +# Seconds between backup runs. +# +# Can also be specified via environment variable `BACKUP_INTERVAL_SECS`. +# +# Default value: 86400 +#interval_secs = 86400 + +[cache] +# Cache backend: `ripple` (default, built-in gossip) or `valkey`. +# +# Can also be specified via environment variable `CACHE_BACKEND`. +# +# Default value: "ripple" +#backend = "ripple" + +# Valkey / Redis connection URL. Required when `backend = "valkey"`. +# +# Can also be specified via environment variable `VALKEY_URL`. +#valkey_url = + +[cache.ripple] +# Address to bind the Ripple gossip protocol listener. +# +# Can also be specified via environment variable `RIPPLE_BIND`. +# +# Default value: "0.0.0.0:0" +#bind_addr = "0.0.0.0:0" + +# List of seed peer addresses. +# +# Can also be specified via environment variable `RIPPLE_PEERS`. +#peers = + +# Unique machine identifier. Auto-derived from hostname when not set. +# +# Can also be specified via environment variable `RIPPLE_MACHINE_ID`. +#machine_id = + +# Gossip protocol interval in milliseconds. +# +# Can also be specified via environment variable `RIPPLE_GOSSIP_INTERVAL_MS`. +# +# Default value: 200 +#gossip_interval_ms = 200 + +# Maximum cache size in megabytes. +# +# Can also be specified via environment variable `RIPPLE_CACHE_MAX_MB`. +# +# Default value: 256 +#cache_max_mb = 256 + +[plc] +# Base URL of the PLC directory. +# +# Can also be specified via environment variable `PLC_DIRECTORY_URL`. +# +# Default value: "https://plc.directory" +#directory_url = "https://plc.directory" + +# HTTP request timeout in seconds. +# +# Can also be specified via environment variable `PLC_TIMEOUT_SECS`. +# +# Default value: 10 +#timeout_secs = 10 + +# TCP connect timeout in seconds. +# +# Can also be specified via environment variable `PLC_CONNECT_TIMEOUT_SECS`. +# +# Default value: 5 +#connect_timeout_secs = 5 + +# Seconds to cache DID documents in memory. +# +# Can also be specified via environment variable `DID_CACHE_TTL_SECS`. +# +# Default value: 300 +#did_cache_ttl_secs = 300 + +[firehose] +# Size of the in-memory broadcast buffer for firehose events. +# +# Can also be specified via environment variable `FIREHOSE_BUFFER_SIZE`. +# +# Default value: 10000 +#buffer_size = 10000 + +# How many hours of historical events to replay for cursor-based +# firehose connections. +# +# Can also be specified via environment variable `FIREHOSE_BACKFILL_HOURS`. +# +# Default value: 72 +#backfill_hours = 72 + +# Maximum number of lagged events before disconnecting a slow consumer. +# +# Can also be specified via environment variable `FIREHOSE_MAX_LAG`. +# +# Default value: 5000 +#max_lag = 5000 + +# List of relay / crawler notification URLs. +# +# Can also be specified via environment variable `CRAWLERS`. +#crawlers = + +[email] +# Sender email address. When unset, email sending is disabled. +# +# Can also be specified via environment variable `MAIL_FROM_ADDRESS`. +#from_address = + +# Display name used in the `From` header. +# +# Can also be specified via environment variable `MAIL_FROM_NAME`. +# +# Default value: "Tranquil PDS" +#from_name = "Tranquil PDS" + +# Path to the `sendmail` binary. +# +# Can also be specified via environment variable `SENDMAIL_PATH`. +# +# Default value: "/usr/sbin/sendmail" +#sendmail_path = "/usr/sbin/sendmail" + +[discord] +# Discord bot token. When unset, Discord integration is disabled. +# +# Can also be specified via environment variable `DISCORD_BOT_TOKEN`. +#bot_token = + +[telegram] +# Telegram bot token. When unset, Telegram integration is disabled. +# +# Can also be specified via environment variable `TELEGRAM_BOT_TOKEN`. +#bot_token = + +# Secret token for incoming webhook verification. +# +# Can also be specified via environment variable `TELEGRAM_WEBHOOK_SECRET`. +#webhook_secret = + +[signal] +# Path to the `signal-cli` binary. +# +# Can also be specified via environment variable `SIGNAL_CLI_PATH`. +# +# Default value: "/usr/local/bin/signal-cli" +#cli_path = "/usr/local/bin/signal-cli" + +# Sender phone number. When unset, Signal integration is disabled. +# +# Can also be specified via environment variable `SIGNAL_SENDER_NUMBER`. +#sender_number = + +[notifications] +# Polling interval in milliseconds for the comms queue. +# +# Can also be specified via environment variable `NOTIFICATION_POLL_INTERVAL_MS`. +# +# Default value: 1000 +#poll_interval_ms = 1000 + +# Number of notifications to process per batch. +# +# Can also be specified via environment variable `NOTIFICATION_BATCH_SIZE`. +# +# Default value: 100 +#batch_size = 100 + +[sso] +[sso.github] +# Default value: false +#enabled = false + +#client_id = + +#client_secret = + +#display_name = + +[sso.discord] +# Default value: false +#enabled = false + +#client_id = + +#client_secret = + +#display_name = + +[sso.google] +# Default value: false +#enabled = false + +#client_id = + +#client_secret = + +#display_name = + +[sso.gitlab] +# Default value: false +#enabled = false + +#client_id = + +#client_secret = + +#issuer = + +#display_name = + +[sso.oidc] +# Default value: false +#enabled = false + +#client_id = + +#client_secret = + +#issuer = + +#display_name = + +[sso.apple] +# Can also be specified via environment variable `SSO_APPLE_ENABLED`. +# Default value: false +#enabled = false + +# Can also be specified via environment variable `SSO_APPLE_CLIENT_ID`. +#client_id = + +# Can also be specified via environment variable `SSO_APPLE_TEAM_ID`. +#team_id = + +# Can also be specified via environment variable `SSO_APPLE_KEY_ID`. +#key_id = + +# Can also be specified via environment variable `SSO_APPLE_PRIVATE_KEY`. +#private_key = + +[moderation] +# External report-handling service URL. +# +# Can also be specified via environment variable `REPORT_SERVICE_URL`. +#report_service_url = + +# DID of the external report-handling service. +# +# Can also be specified via environment variable `REPORT_SERVICE_DID`. +#report_service_did = + +[import] +# Whether the PDS accepts repo imports. +# +# Can also be specified via environment variable `ACCEPTING_REPO_IMPORTS`. +# +# Default value: true +#accepting = true + +# Maximum allowed import archive size in bytes (default 1 GiB). +# +# Can also be specified via environment variable `MAX_IMPORT_SIZE`. +# +# Default value: 1073741824 +#max_size = 1073741824 + +# Maximum number of blocks allowed in an import. +# +# Can also be specified via environment variable `MAX_IMPORT_BLOCKS`. +# +# Default value: 500000 +#max_blocks = 500000 + +# Skip CAR verification during import. Only for development/debugging. +# +# Can also be specified via environment variable `SKIP_IMPORT_VERIFICATION`. +# +# Default value: false +#skip_verification = false + +[scheduled] +# Interval in seconds between scheduled delete checks. +# +# Can also be specified via environment variable `SCHEDULED_DELETE_CHECK_INTERVAL_SECS`. +# +# Default value: 3600 +#delete_check_interval_secs = 3600