Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

refactor: toml config

authored by isabelroses.com and committed by tangled.org 66eb9b7d cbd3b79f

+2165 -598
+2 -2
.env.example
··· 131 131 # Account Registration 132 132 # ============================================================================= 133 133 # Require invite codes for registration 134 - # INVITE_CODE_REQUIRED=false 134 + # INVITE_CODE_REQUIRED=true 135 135 # Comma-separated list of available user domains 136 136 # AVAILABLE_USER_DOMAINS=example.com 137 137 # Enable self-hosted did:web identities (default: true) 138 138 # Hosting did:web requires a long-term commitment to serve DID documents. 139 139 # Set to false if you don't want to offer this option. 140 - # ENABLE_SELF_HOSTED_DID_WEB=true 140 + # ENABLE_PDS_HOSTED_DID_WEB=false 141 141 # ============================================================================= 142 142 # Server Metadata (returned by describeServer) 143 143 # =============================================================================
+210 -3
Cargo.lock
··· 105 105 ] 106 106 107 107 [[package]] 108 + name = "anstream" 109 + version = "0.6.21" 110 + source = "registry+https://github.com/rust-lang/crates.io-index" 111 + checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 112 + dependencies = [ 113 + "anstyle", 114 + "anstyle-parse", 115 + "anstyle-query", 116 + "anstyle-wincon", 117 + "colorchoice", 118 + "is_terminal_polyfill", 119 + "utf8parse", 120 + ] 121 + 122 + [[package]] 123 + name = "anstyle" 124 + version = "1.0.13" 125 + source = "registry+https://github.com/rust-lang/crates.io-index" 126 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 127 + 128 + [[package]] 129 + name = "anstyle-parse" 130 + version = "0.2.7" 131 + source = "registry+https://github.com/rust-lang/crates.io-index" 132 + checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 133 + dependencies = [ 134 + "utf8parse", 135 + ] 136 + 137 + [[package]] 138 + name = "anstyle-query" 139 + version = "1.1.5" 140 + source = "registry+https://github.com/rust-lang/crates.io-index" 141 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 142 + dependencies = [ 143 + "windows-sys 0.61.2", 144 + ] 145 + 146 + [[package]] 147 + name = "anstyle-wincon" 148 + version = "3.0.11" 149 + source = "registry+https://github.com/rust-lang/crates.io-index" 150 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 151 + dependencies = [ 152 + "anstyle", 153 + "once_cell_polyfill", 154 + "windows-sys 0.61.2", 155 + ] 156 + 157 + [[package]] 108 158 name = "anyhow" 109 159 version = "1.0.100" 110 160 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1217 1267 ] 1218 1268 1219 1269 [[package]] 1270 + name = "clap" 1271 + version = "4.5.58" 1272 + source = "registry+https://github.com/rust-lang/crates.io-index" 1273 + checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" 1274 + dependencies = [ 1275 + "clap_builder", 1276 + "clap_derive", 1277 + ] 1278 + 1279 + [[package]] 1280 + name = "clap_builder" 1281 + version = "4.5.58" 1282 + source = "registry+https://github.com/rust-lang/crates.io-index" 1283 + checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" 1284 + dependencies = [ 1285 + "anstream", 1286 + "anstyle", 1287 + "clap_lex", 1288 + "strsim", 1289 + ] 1290 + 1291 + [[package]] 1292 + name = "clap_derive" 1293 + version = "4.5.55" 1294 + source = "registry+https://github.com/rust-lang/crates.io-index" 1295 + checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" 1296 + dependencies = [ 1297 + "heck 0.5.0", 1298 + "proc-macro2", 1299 + "quote", 1300 + "syn 2.0.111", 1301 + ] 1302 + 1303 + [[package]] 1304 + name = "clap_lex" 1305 + version = "1.0.0" 1306 + source = "registry+https://github.com/rust-lang/crates.io-index" 1307 + checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" 1308 + 1309 + [[package]] 1220 1310 name = "cmake" 1221 1311 version = "0.1.57" 1222 1312 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1241 1331 checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" 1242 1332 1243 1333 [[package]] 1334 + name = "colorchoice" 1335 + version = "1.0.4" 1336 + source = "registry+https://github.com/rust-lang/crates.io-index" 1337 + checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 1338 + 1339 + [[package]] 1244 1340 name = "combine" 1245 1341 version = "4.6.7" 1246 1342 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1281 1377 ] 1282 1378 1283 1379 [[package]] 1380 + name = "confique" 1381 + version = "0.4.0" 1382 + source = "registry+https://github.com/rust-lang/crates.io-index" 1383 + checksum = "06b4f5ec222421e22bb0a8cbaa36b1d2b50fd45cdd30c915ded34108da78b29f" 1384 + dependencies = [ 1385 + "confique-macro", 1386 + "serde", 1387 + "toml", 1388 + ] 1389 + 1390 + [[package]] 1391 + name = "confique-macro" 1392 + version = "0.0.13" 1393 + source = "registry+https://github.com/rust-lang/crates.io-index" 1394 + checksum = "e4d1754680cd218e7bcb4c960cc9bae3444b5197d64563dccccfdf83cab9e1a7" 1395 + dependencies = [ 1396 + "heck 0.5.0", 1397 + "proc-macro2", 1398 + "quote", 1399 + "syn 2.0.111", 1400 + ] 1401 + 1402 + [[package]] 1284 1403 name = "const-oid" 1285 1404 version = "0.9.6" 1286 1405 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2762 2881 "libc", 2763 2882 "percent-encoding", 2764 2883 "pin-project-lite", 2765 - "socket2 0.5.10", 2884 + "socket2 0.6.2", 2766 2885 "system-configuration", 2767 2886 "tokio", 2768 2887 "tower-service", ··· 3058 3177 "tokio", 3059 3178 "unsigned-varint 0.7.2", 3060 3179 ] 3180 + 3181 + [[package]] 3182 + name = "is_terminal_polyfill" 3183 + version = "1.70.2" 3184 + source = "registry+https://github.com/rust-lang/crates.io-index" 3185 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 3061 3186 3062 3187 [[package]] 3063 3188 name = "itertools" ··· 3716 3841 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 3717 3842 3718 3843 [[package]] 3844 + name = "once_cell_polyfill" 3845 + version = "1.70.2" 3846 + source = "registry+https://github.com/rust-lang/crates.io-index" 3847 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 3848 + 3849 + [[package]] 3719 3850 name = "opaque-debug" 3720 3851 version = "0.3.1" 3721 3852 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4209 4340 "quinn-udp", 4210 4341 "rustc-hash", 4211 4342 "rustls 0.23.35", 4212 - "socket2 0.5.10", 4343 + "socket2 0.6.2", 4213 4344 "thiserror 2.0.17", 4214 4345 "tokio", 4215 4346 "tracing", ··· 4246 4377 "cfg_aliases", 4247 4378 "libc", 4248 4379 "once_cell", 4249 - "socket2 0.5.10", 4380 + "socket2 0.6.2", 4250 4381 "tracing", 4251 4382 "windows-sys 0.60.2", 4252 4383 ] ··· 4907 5038 "proc-macro2", 4908 5039 "quote", 4909 5040 "syn 2.0.111", 5041 + ] 5042 + 5043 + [[package]] 5044 + name = "serde_spanned" 5045 + version = "1.0.4" 5046 + source = "registry+https://github.com/rust-lang/crates.io-index" 5047 + checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" 5048 + dependencies = [ 5049 + "serde_core", 4910 5050 ] 4911 5051 4912 5052 [[package]] ··· 5709 5849 ] 5710 5850 5711 5851 [[package]] 5852 + name = "toml" 5853 + version = "0.9.12+spec-1.1.0" 5854 + source = "registry+https://github.com/rust-lang/crates.io-index" 5855 + checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" 5856 + dependencies = [ 5857 + "indexmap 2.12.1", 5858 + "serde_core", 5859 + "serde_spanned", 5860 + "toml_datetime", 5861 + "toml_parser", 5862 + "toml_writer", 5863 + "winnow", 5864 + ] 5865 + 5866 + [[package]] 5867 + name = "toml_datetime" 5868 + version = "0.7.5+spec-1.1.0" 5869 + source = "registry+https://github.com/rust-lang/crates.io-index" 5870 + checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" 5871 + dependencies = [ 5872 + "serde_core", 5873 + ] 5874 + 5875 + [[package]] 5876 + name = "toml_parser" 5877 + version = "1.0.7+spec-1.1.0" 5878 + source = "registry+https://github.com/rust-lang/crates.io-index" 5879 + checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" 5880 + dependencies = [ 5881 + "winnow", 5882 + ] 5883 + 5884 + [[package]] 5885 + name = "toml_writer" 5886 + version = "1.0.6+spec-1.1.0" 5887 + source = "registry+https://github.com/rust-lang/crates.io-index" 5888 + checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" 5889 + 5890 + [[package]] 5712 5891 name = "tonic" 5713 5892 version = "0.14.2" 5714 5893 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5908 6087 "sha2", 5909 6088 "subtle", 5910 6089 "totp-rs", 6090 + "tranquil-config", 5911 6091 "tranquil-crypto", 5912 6092 "urlencoding", 5913 6093 "uuid", ··· 5922 6102 "redis", 5923 6103 "tokio-util", 5924 6104 "tracing", 6105 + "tranquil-config", 5925 6106 "tranquil-infra", 5926 6107 "tranquil-ripple", 5927 6108 ] ··· 5936 6117 "serde_json", 5937 6118 "thiserror 2.0.17", 5938 6119 "tokio", 6120 + "tranquil-config", 5939 6121 "tranquil-db-traits", 5940 6122 "uuid", 6123 + ] 6124 + 6125 + [[package]] 6126 + name = "tranquil-config" 6127 + version = "0.2.1" 6128 + dependencies = [ 6129 + "confique", 6130 + "serde", 5941 6131 ] 5942 6132 5943 6133 [[package]] ··· 5997 6187 "bytes", 5998 6188 "futures", 5999 6189 "thiserror 2.0.17", 6190 + "tranquil-config", 6000 6191 ] 6001 6192 6002 6193 [[package]] ··· 6041 6232 "chrono", 6042 6233 "ciborium", 6043 6234 "cid", 6235 + "clap", 6044 6236 "ctor", 6045 6237 "dotenvy", 6046 6238 "ed25519-dalek", ··· 6091 6283 "tranquil-auth", 6092 6284 "tranquil-cache", 6093 6285 "tranquil-comms", 6286 + "tranquil-config", 6094 6287 "tranquil-crypto", 6095 6288 "tranquil-db", 6096 6289 "tranquil-db-traits", ··· 6139 6332 "tokio-util", 6140 6333 "tracing", 6141 6334 "tracing-subscriber", 6335 + "tranquil-config", 6142 6336 "tranquil-infra", 6143 6337 "uuid", 6144 6338 ] ··· 6171 6365 "sha2", 6172 6366 "tokio", 6173 6367 "tracing", 6368 + "tranquil-config", 6174 6369 "tranquil-infra", 6175 6370 "uuid", 6176 6371 ] ··· 6355 6550 version = "1.0.4" 6356 6551 source = "registry+https://github.com/rust-lang/crates.io-index" 6357 6552 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 6553 + 6554 + [[package]] 6555 + name = "utf8parse" 6556 + version = "0.2.2" 6557 + source = "registry+https://github.com/rust-lang/crates.io-index" 6558 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 6358 6559 6359 6560 [[package]] 6360 6561 name = "uuid" ··· 6929 7130 version = "0.53.1" 6930 7131 source = "registry+https://github.com/rust-lang/crates.io-index" 6931 7132 checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 7133 + 7134 + [[package]] 7135 + name = "winnow" 7136 + version = "0.7.14" 7137 + source = "registry+https://github.com/rust-lang/crates.io-index" 7138 + checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" 6932 7139 6933 7140 [[package]] 6934 7141 name = "winreg"
+4
Cargo.toml
··· 2 2 resolver = "2" 3 3 members = [ 4 4 "crates/tranquil-types", 5 + "crates/tranquil-config", 5 6 "crates/tranquil-infra", 6 7 "crates/tranquil-crypto", 7 8 "crates/tranquil-storage", ··· 24 25 25 26 [workspace.dependencies] 26 27 tranquil-types = { path = "crates/tranquil-types" } 28 + tranquil-config = { path = "crates/tranquil-config" } 27 29 tranquil-infra = { path = "crates/tranquil-infra" } 28 30 tranquil-crypto = { path = "crates/tranquil-crypto" } 29 31 tranquil-storage = { path = "crates/tranquil-storage" } ··· 52 54 bytes = "1.11" 53 55 chrono = { version = "0.4", features = ["serde"] } 54 56 cid = "0.11" 57 + clap = { version = "4", features = ["derive", "env"] } 58 + confique = { version = "0.4", features = ["toml"] } 55 59 dotenvy = "0.15" 56 60 ed25519-dalek = { version = "2.1", features = ["pkcs8"] } 57 61 foca = { version = "1", features = ["bincode-codec", "tracing"] }
+4 -1
README.md
··· 24 24 25 25 ## Configuration 26 26 27 - See `.env.example` for all configuration options. 27 + See `example.toml` for all configuration options. 28 + 29 + > [!NOTE] 30 + > 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. 28 31 29 32 ## Development 30 33
+1
crates/tranquil-auth/Cargo.toml
··· 5 5 license.workspace = true 6 6 7 7 [dependencies] 8 + tranquil-config = { workspace = true } 8 9 tranquil-crypto = { workspace = true } 9 10 10 11 anyhow = { workspace = true }
+6 -2
crates/tranquil-auth/src/token.rs
··· 127 127 let jti = uuid::Uuid::new_v4().to_string(); 128 128 129 129 let aud_hostname = hostname.map(|h| h.to_string()).unwrap_or_else(|| { 130 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 130 + tranquil_config::try_get() 131 + .map(|c| c.server.hostname.clone()) 132 + .unwrap_or_else(|| "localhost".to_string()) 131 133 }); 132 134 133 135 let claims = Claims { ··· 253 255 sub: did.to_owned(), 254 256 aud: format!( 255 257 "did:web:{}", 256 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 258 + tranquil_config::try_get() 259 + .map(|c| c.server.hostname.clone()) 260 + .unwrap_or_else(|| "localhost".to_string()) 257 261 ), 258 262 exp: expiration, 259 263 iat: Utc::now().timestamp(),
+1
crates/tranquil-cache/Cargo.toml
··· 9 9 valkey = ["dep:redis"] 10 10 11 11 [dependencies] 12 + tranquil-config = { workspace = true } 12 13 tranquil-infra = { workspace = true } 13 14 tranquil-ripple = { workspace = true } 14 15
+22 -14
crates/tranquil-cache/src/lib.rs
··· 172 172 pub async fn create_cache( 173 173 shutdown: tokio_util::sync::CancellationToken, 174 174 ) -> (Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>) { 175 + let cache_cfg = tranquil_config::try_get().map(|c| &c.cache); 176 + let backend = cache_cfg.map(|c| c.backend.as_str()).unwrap_or("ripple"); 177 + let valkey_url = cache_cfg.and_then(|c| c.valkey_url.as_deref()); 178 + 175 179 #[cfg(feature = "valkey")] 176 - if let Ok(url) = std::env::var("VALKEY_URL") { 177 - match ValkeyCache::new(&url).await { 178 - Ok(cache) => { 179 - tracing::info!("using valkey cache at {url}"); 180 - let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection())); 181 - return (Arc::new(cache), rate_limiter); 182 - } 183 - Err(e) => { 184 - tracing::warn!("failed to connect to valkey: {e}. falling back to ripple."); 180 + if backend == "valkey" { 181 + if let Some(url) = valkey_url { 182 + match ValkeyCache::new(url).await { 183 + Ok(cache) => { 184 + tracing::info!("using valkey cache at {url}"); 185 + let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection())); 186 + return (Arc::new(cache), rate_limiter); 187 + } 188 + Err(e) => { 189 + tracing::warn!("failed to connect to valkey: {e}. falling back to ripple."); 190 + } 185 191 } 192 + } else { 193 + tracing::warn!("cache.backend is \"valkey\" but VALKEY_URL is not set. using ripple."); 186 194 } 187 195 } 188 196 189 197 #[cfg(not(feature = "valkey"))] 190 - if std::env::var("VALKEY_URL").is_ok() { 198 + if backend == "valkey" { 191 199 tracing::warn!( 192 - "VALKEY_URL is set but binary was compiled without valkey feature. using ripple." 200 + "cache.backend is \"valkey\" but binary was compiled without valkey feature. using ripple." 193 201 ); 194 202 } 195 203 196 - match tranquil_ripple::RippleConfig::from_env() { 204 + match tranquil_ripple::RippleConfig::from_config() { 197 205 Ok(config) => { 198 206 let peer_count = config.seed_peers.len(); 199 207 match tranquil_ripple::RippleEngine::start(config, shutdown).await { ··· 205 213 (cache, rate_limiter) 206 214 } 207 215 Err(e) => { 208 - tracing::error!("ripple engine failed to start: {e}. running without cache."); 216 + tracing::error!("ripple engine failed to start: {e:#}. running without cache."); 209 217 (Arc::new(NoOpCache), Arc::new(NoOpRateLimiter)) 210 218 } 211 219 } 212 220 } 213 221 Err(e) => { 214 - tracing::error!("ripple config error: {e}. running without cache."); 222 + tracing::error!("ripple config error: {e:#}. running without cache."); 215 223 (Arc::new(NoOpCache), Arc::new(NoOpRateLimiter)) 216 224 } 217 225 }
+2
crates/tranquil-comms/Cargo.toml
··· 5 5 license.workspace = true 6 6 7 7 [dependencies] 8 + tranquil-config = { workspace = true } 9 + 8 10 async-trait = { workspace = true } 9 11 base64 = { workspace = true } 10 12 reqwest = { workspace = true }
+14 -16
crates/tranquil-comms/src/sender.rs
··· 112 112 } 113 113 114 114 impl EmailSender { 115 - pub fn new(from_address: String, from_name: String) -> Self { 115 + pub fn new(from_address: String, from_name: String, sendmail_path: String) -> Self { 116 116 Self { 117 117 from_address, 118 118 from_name, 119 - sendmail_path: std::env::var("SENDMAIL_PATH") 120 - .unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()), 119 + sendmail_path, 121 120 } 122 121 } 123 122 124 - pub fn from_env() -> Option<Self> { 125 - let from_address = std::env::var("MAIL_FROM_ADDRESS").ok()?; 126 - let from_name = 127 - std::env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Tranquil PDS".to_string()); 128 - Some(Self::new(from_address, from_name)) 123 + pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> { 124 + let from_address = cfg.email.from_address.clone()?; 125 + let from_name = cfg.email.from_name.clone(); 126 + let sendmail_path = cfg.email.sendmail_path.clone(); 127 + Some(Self::new(from_address, from_name, sendmail_path)) 129 128 } 130 129 131 130 pub fn format_email(&self, notification: &QueuedComms) -> String { ··· 190 189 } 191 190 } 192 191 193 - pub fn from_env() -> Option<Self> { 194 - let bot_token = std::env::var("DISCORD_BOT_TOKEN").ok()?; 192 + pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> { 193 + let bot_token = cfg.discord.bot_token.clone()?; 195 194 Some(Self::new(bot_token)) 196 195 } 197 196 ··· 454 453 } 455 454 } 456 455 457 - pub fn from_env() -> Option<Self> { 458 - let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok()?; 456 + pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> { 457 + let bot_token = cfg.telegram.bot_token.clone()?; 459 458 Some(Self::new(bot_token)) 460 459 } 461 460 ··· 586 585 } 587 586 } 588 587 589 - pub fn from_env() -> Option<Self> { 590 - let signal_cli_path = std::env::var("SIGNAL_CLI_PATH") 591 - .unwrap_or_else(|_| "/usr/local/bin/signal-cli".to_string()); 592 - let sender_number = std::env::var("SIGNAL_SENDER_NUMBER").ok()?; 588 + pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> { 589 + let signal_cli_path = cfg.signal.cli_path.clone(); 590 + let sender_number = cfg.signal.sender_number.clone()?; 593 591 Some(Self::new(signal_cli_path, sender_number)) 594 592 } 595 593 }
+9
crates/tranquil-config/Cargo.toml
··· 1 + [package] 2 + name = "tranquil-config" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + 7 + [dependencies] 8 + confique = { workspace = true } 9 + serde = { workspace = true }
+883
crates/tranquil-config/src/lib.rs
··· 1 + use confique::Config; 2 + use std::fmt; 3 + use std::path::PathBuf; 4 + use std::sync::OnceLock; 5 + 6 + static CONFIG: OnceLock<TranquilConfig> = OnceLock::new(); 7 + 8 + /// Errors discovered during configuration validation. 9 + #[derive(Debug)] 10 + pub struct ConfigError { 11 + pub errors: Vec<String>, 12 + } 13 + 14 + impl fmt::Display for ConfigError { 15 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 16 + writeln!(f, "configuration validation failed:")?; 17 + for err in &self.errors { 18 + writeln!(f, " - {err}")?; 19 + } 20 + Ok(()) 21 + } 22 + } 23 + 24 + impl std::error::Error for ConfigError {} 25 + 26 + /// Initialize the global configuration. Must be called once at startup before 27 + /// any other code accesses the configuration. Panics if called more than once. 28 + pub fn init(config: TranquilConfig) { 29 + CONFIG 30 + .set(config) 31 + .expect("tranquil-config: configuration already initialized"); 32 + } 33 + 34 + /// Returns a reference to the global configuration. 35 + /// Panics if [`init`] has not been called yet. 36 + pub fn get() -> &'static TranquilConfig { 37 + CONFIG 38 + .get() 39 + .expect("tranquil-config: not initialized - call tranquil_config::init() first") 40 + } 41 + 42 + /// Returns a reference to the global configuration if it has been initialized. 43 + pub fn try_get() -> Option<&'static TranquilConfig> { 44 + CONFIG.get() 45 + } 46 + 47 + /// Load configuration from an optional TOML file path, with environment 48 + /// variable overrides applied on top. Fields annotated with `#[config(env)]` 49 + /// are read from the corresponding environment variables when the `.env()` 50 + /// layer is active. 51 + /// 52 + /// Precedence (highest to lowest): 53 + /// 1. Environment variables 54 + /// 2. TOML config file (if provided) 55 + /// 3. Built-in defaults 56 + pub fn load(config_path: Option<&PathBuf>) -> Result<TranquilConfig, confique::Error> { 57 + let mut builder = TranquilConfig::builder().env(); 58 + if let Some(path) = config_path { 59 + builder = builder.file(path); 60 + } 61 + builder.file("/etc/tranquil-pds/config.toml").load() 62 + } 63 + 64 + // Root configuration 65 + #[derive(Debug, Config)] 66 + pub struct TranquilConfig { 67 + #[config(nested)] 68 + pub server: ServerConfig, 69 + 70 + #[config(nested)] 71 + pub database: DatabaseConfig, 72 + 73 + #[config(nested)] 74 + pub secrets: SecretsConfig, 75 + 76 + #[config(nested)] 77 + pub storage: StorageConfig, 78 + 79 + #[config(nested)] 80 + pub backup: BackupConfig, 81 + 82 + #[config(nested)] 83 + pub cache: CacheConfig, 84 + 85 + #[config(nested)] 86 + pub plc: PlcConfig, 87 + 88 + #[config(nested)] 89 + pub firehose: FirehoseConfig, 90 + 91 + #[config(nested)] 92 + pub email: EmailConfig, 93 + 94 + #[config(nested)] 95 + pub discord: DiscordConfig, 96 + 97 + #[config(nested)] 98 + pub telegram: TelegramConfig, 99 + 100 + #[config(nested)] 101 + pub signal: SignalConfig, 102 + 103 + #[config(nested)] 104 + pub notifications: NotificationConfig, 105 + 106 + #[config(nested)] 107 + pub sso: SsoConfig, 108 + 109 + #[config(nested)] 110 + pub moderation: ModerationConfig, 111 + 112 + #[config(nested)] 113 + pub import: ImportConfig, 114 + 115 + #[config(nested)] 116 + pub scheduled: ScheduledConfig, 117 + } 118 + 119 + impl TranquilConfig { 120 + /// Validate cross-field constraints that cannot be expressed through 121 + /// confique's declarative defaults alone. Call this once after loading 122 + /// the configuration and before [`init`]. 123 + /// 124 + /// Returns `Ok(())` when the configuration is consistent, or a 125 + /// [`ConfigError`] listing every problem found. 126 + pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> { 127 + let mut errors = Vec::new(); 128 + 129 + // -- secrets ---------------------------------------------------------- 130 + if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) { 131 + if let Some(ref s) = self.secrets.jwt_secret { 132 + if s.len() < 32 { 133 + errors.push( 134 + "secrets.jwt_secret (JWT_SECRET) must be at least 32 characters" 135 + .to_string(), 136 + ); 137 + } 138 + } else { 139 + errors.push( 140 + "secrets.jwt_secret (JWT_SECRET) is required in production \ 141 + (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" 142 + .to_string(), 143 + ); 144 + } 145 + 146 + if let Some(ref s) = self.secrets.dpop_secret { 147 + if s.len() < 32 { 148 + errors.push( 149 + "secrets.dpop_secret (DPOP_SECRET) must be at least 32 characters" 150 + .to_string(), 151 + ); 152 + } 153 + } else { 154 + errors.push( 155 + "secrets.dpop_secret (DPOP_SECRET) is required in production \ 156 + (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" 157 + .to_string(), 158 + ); 159 + } 160 + 161 + if let Some(ref s) = self.secrets.master_key { 162 + if s.len() < 32 { 163 + errors.push( 164 + "secrets.master_key (MASTER_KEY) must be at least 32 characters" 165 + .to_string(), 166 + ); 167 + } 168 + } else { 169 + errors.push( 170 + "secrets.master_key (MASTER_KEY) is required in production \ 171 + (set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development)" 172 + .to_string(), 173 + ); 174 + } 175 + } 176 + 177 + // -- telegram --------------------------------------------------------- 178 + if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() { 179 + errors.push( 180 + "telegram.bot_token is set but telegram.webhook_secret is missing; \ 181 + both are required for secure Telegram integration" 182 + .to_string(), 183 + ); 184 + } 185 + 186 + // -- blob storage ----------------------------------------------------- 187 + match self.storage.backend.as_str() { 188 + "s3" => { 189 + if self.storage.s3_bucket.is_none() { 190 + errors.push( 191 + "storage.backend is \"s3\" but storage.s3_bucket (S3_BUCKET) \ 192 + is not set" 193 + .to_string(), 194 + ); 195 + } 196 + } 197 + "filesystem" => {} 198 + other => { 199 + errors.push(format!( 200 + "storage.backend must be \"filesystem\" or \"s3\", got \"{other}\"" 201 + )); 202 + } 203 + } 204 + 205 + // -- backup storage --------------------------------------------------- 206 + if self.backup.enabled { 207 + match self.backup.backend.as_str() { 208 + "s3" => { 209 + if self.backup.s3_bucket.is_none() { 210 + errors.push( 211 + "backup.backend is \"s3\" but backup.s3_bucket \ 212 + (BACKUP_S3_BUCKET) is not set" 213 + .to_string(), 214 + ); 215 + } 216 + } 217 + "filesystem" => {} 218 + other => { 219 + errors.push(format!( 220 + "backup.backend must be \"filesystem\" or \"s3\", got \"{other}\"" 221 + )); 222 + } 223 + } 224 + } 225 + 226 + // -- SSO providers ---------------------------------------------------- 227 + self.validate_sso_provider("sso.github", &self.sso.github, &mut errors); 228 + self.validate_sso_provider("sso.google", &self.sso.google, &mut errors); 229 + self.validate_sso_discord(&mut errors); 230 + self.validate_sso_with_issuer("sso.gitlab", &self.sso.gitlab, &mut errors); 231 + self.validate_sso_with_issuer("sso.oidc", &self.sso.oidc, &mut errors); 232 + self.validate_sso_apple(&mut errors); 233 + 234 + // -- moderation ------------------------------------------------------- 235 + let has_url = self.moderation.report_service_url.is_some(); 236 + let has_did = self.moderation.report_service_did.is_some(); 237 + if has_url != has_did { 238 + errors.push( 239 + "moderation.report_service_url and moderation.report_service_did \ 240 + must both be set or both be unset" 241 + .to_string(), 242 + ); 243 + } 244 + 245 + // -- cache ------------------------------------------------------------ 246 + match self.cache.backend.as_str() { 247 + "valkey" => { 248 + if self.cache.valkey_url.is_none() { 249 + errors.push( 250 + "cache.backend is \"valkey\" but cache.valkey_url (VALKEY_URL) \ 251 + is not set" 252 + .to_string(), 253 + ); 254 + } 255 + } 256 + "ripple" => {} 257 + other => { 258 + errors.push(format!( 259 + "cache.backend must be \"ripple\" or \"valkey\", got \"{other}\"" 260 + )); 261 + } 262 + } 263 + 264 + if errors.is_empty() { 265 + Ok(()) 266 + } else { 267 + Err(ConfigError { errors }) 268 + } 269 + } 270 + 271 + fn validate_sso_provider(&self, prefix: &str, p: &SsoProviderConfig, errors: &mut Vec<String>) { 272 + if p.enabled { 273 + if p.client_id.is_none() { 274 + errors.push(format!( 275 + "{prefix}.client_id is required when {prefix}.enabled = true" 276 + )); 277 + } 278 + if p.client_secret.is_none() { 279 + errors.push(format!( 280 + "{prefix}.client_secret is required when {prefix}.enabled = true" 281 + )); 282 + } 283 + } 284 + } 285 + 286 + fn validate_sso_discord(&self, errors: &mut Vec<String>) { 287 + let p = &self.sso.discord; 288 + if p.enabled { 289 + if p.client_id.is_none() { 290 + errors.push( 291 + "sso.discord.client_id is required when sso.discord.enabled = true".to_string(), 292 + ); 293 + } 294 + if p.client_secret.is_none() { 295 + errors.push( 296 + "sso.discord.client_secret is required when sso.discord.enabled = true" 297 + .to_string(), 298 + ); 299 + } 300 + } 301 + } 302 + 303 + fn validate_sso_with_issuer( 304 + &self, 305 + prefix: &str, 306 + p: &SsoProviderWithIssuerConfig, 307 + errors: &mut Vec<String>, 308 + ) { 309 + if p.enabled { 310 + if p.client_id.is_none() { 311 + errors.push(format!( 312 + "{prefix}.client_id is required when {prefix}.enabled = true" 313 + )); 314 + } 315 + if p.client_secret.is_none() { 316 + errors.push(format!( 317 + "{prefix}.client_secret is required when {prefix}.enabled = true" 318 + )); 319 + } 320 + if p.issuer.is_none() { 321 + errors.push(format!( 322 + "{prefix}.issuer is required when {prefix}.enabled = true" 323 + )); 324 + } 325 + } 326 + } 327 + 328 + fn validate_sso_apple(&self, errors: &mut Vec<String>) { 329 + let p = &self.sso.apple; 330 + if p.enabled { 331 + if p.client_id.is_none() { 332 + errors.push( 333 + "sso.apple.client_id is required when sso.apple.enabled = true".to_string(), 334 + ); 335 + } 336 + if p.team_id.is_none() { 337 + errors.push( 338 + "sso.apple.team_id is required when sso.apple.enabled = true".to_string(), 339 + ); 340 + } 341 + if p.key_id.is_none() { 342 + errors 343 + .push("sso.apple.key_id is required when sso.apple.enabled = true".to_string()); 344 + } 345 + if p.private_key.is_none() { 346 + errors.push( 347 + "sso.apple.private_key is required when sso.apple.enabled = true".to_string(), 348 + ); 349 + } 350 + } 351 + } 352 + } 353 + 354 + // --------------------------------------------------------------------------- 355 + // Server 356 + // --------------------------------------------------------------------------- 357 + 358 + #[derive(Debug, Config)] 359 + pub struct ServerConfig { 360 + /// Public hostname of the PDS (e.g. `pds.example.com`). 361 + #[config(env = "PDS_HOSTNAME")] 362 + pub hostname: String, 363 + 364 + /// Address to bind the HTTP server to. 365 + #[config(env = "SERVER_HOST", default = "127.0.0.1")] 366 + pub host: String, 367 + 368 + /// Port to bind the HTTP server to. 369 + #[config(env = "SERVER_PORT", default = 3000)] 370 + pub port: u16, 371 + 372 + /// List of domains for user handles. 373 + /// Defaults to the PDS hostname when not set. 374 + #[config(env = "PDS_USER_HANDLE_DOMAINS", parse_env = split_comma_list)] 375 + pub user_handle_domains: Option<Vec<String>>, 376 + 377 + /// List of domains available for user registration. 378 + /// Defaults to the PDS hostname when not set. 379 + #[config(env = "AVAILABLE_USER_DOMAINS", parse_env = split_comma_list)] 380 + pub available_user_domains: Option<Vec<String>>, 381 + 382 + /// Enable PDS-hosted did:web identities. Hosting did:web requires a 383 + /// long-term commitment to serve DID documents; opt-in only. 384 + #[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)] 385 + pub enable_pds_hosted_did_web: bool, 386 + 387 + /// When set to true, skip age-assurance birthday prompt for all accounts. 388 + #[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)] 389 + pub age_assurance_override: bool, 390 + 391 + /// Require an invite code for new account registration. 392 + #[config(env = "INVITE_CODE_REQUIRED", default = true)] 393 + pub invite_code_required: bool, 394 + 395 + /// Allow HTTP (non-TLS) proxy requests. Only useful during development. 396 + #[config(env = "ALLOW_HTTP_PROXY", default = false)] 397 + pub allow_http_proxy: bool, 398 + 399 + /// Disable all rate limiting. Should only be used in testing. 400 + #[config(env = "DISABLE_RATE_LIMITING", default = false)] 401 + pub disable_rate_limiting: bool, 402 + 403 + /// List of additional banned words for handle validation. 404 + #[config(env = "PDS_BANNED_WORDS", parse_env = split_comma_list)] 405 + pub banned_words: Option<Vec<String>>, 406 + 407 + /// URL to a privacy policy page. 408 + #[config(env = "PRIVACY_POLICY_URL")] 409 + pub privacy_policy_url: Option<String>, 410 + 411 + /// URL to terms of service page. 412 + #[config(env = "TERMS_OF_SERVICE_URL")] 413 + pub terms_of_service_url: Option<String>, 414 + 415 + /// Operator contact email address. 416 + #[config(env = "CONTACT_EMAIL")] 417 + pub contact_email: Option<String>, 418 + 419 + /// Maximum allowed blob size in bytes (default 10 GiB). 420 + #[config(env = "MAX_BLOB_SIZE", default = 10_737_418_240u64)] 421 + pub max_blob_size: u64, 422 + } 423 + 424 + impl ServerConfig { 425 + /// The public HTTPS URL for this PDS. 426 + pub fn public_url(&self) -> String { 427 + format!("https://{}", self.hostname) 428 + } 429 + 430 + /// Hostname without port suffix (e.g. `pds.example.com` from 431 + /// `pds.example.com:443`). 432 + pub fn hostname_without_port(&self) -> &str { 433 + self.hostname.split(':').next().unwrap_or(&self.hostname) 434 + } 435 + 436 + /// Returns the extra banned words list, or an empty vec when unset. 437 + pub fn banned_word_list(&self) -> Vec<String> { 438 + self.banned_words.clone().unwrap_or_default() 439 + } 440 + 441 + /// Returns the available user domains, falling back to `[hostname_without_port]`. 442 + pub fn available_user_domain_list(&self) -> Vec<String> { 443 + self.available_user_domains 444 + .clone() 445 + .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) 446 + } 447 + 448 + /// Returns the user handle domains, falling back to `[hostname_without_port]`. 449 + pub fn user_handle_domain_list(&self) -> Vec<String> { 450 + self.user_handle_domains 451 + .clone() 452 + .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) 453 + } 454 + } 455 + 456 + #[derive(Debug, Config)] 457 + pub struct DatabaseConfig { 458 + /// PostgreSQL connection URL. 459 + #[config(env = "DATABASE_URL")] 460 + pub url: String, 461 + 462 + /// Maximum number of connections in the pool. 463 + #[config(env = "DATABASE_MAX_CONNECTIONS", default = 100)] 464 + pub max_connections: u32, 465 + 466 + /// Minimum number of idle connections kept in the pool. 467 + #[config(env = "DATABASE_MIN_CONNECTIONS", default = 10)] 468 + pub min_connections: u32, 469 + 470 + /// Timeout in seconds when acquiring a connection from the pool. 471 + #[config(env = "DATABASE_ACQUIRE_TIMEOUT_SECS", default = 10)] 472 + pub acquire_timeout_secs: u64, 473 + } 474 + 475 + #[derive(Config)] 476 + pub struct SecretsConfig { 477 + /// Secret used for signing JWTs. Must be at least 32 characters in 478 + /// production. 479 + #[config(env = "JWT_SECRET")] 480 + pub jwt_secret: Option<String>, 481 + 482 + /// Secret used for DPoP proof validation. Must be at least 32 characters 483 + /// in production. 484 + #[config(env = "DPOP_SECRET")] 485 + pub dpop_secret: Option<String>, 486 + 487 + /// Master key used for key-encryption and HKDF derivation. Must be at 488 + /// least 32 characters in production. 489 + #[config(env = "MASTER_KEY")] 490 + pub master_key: Option<String>, 491 + 492 + /// PLC rotation key (DID key). If not set, user-level keys are used. 493 + #[config(env = "PLC_ROTATION_KEY")] 494 + pub plc_rotation_key: Option<String>, 495 + 496 + /// Allow insecure/test secrets. NEVER enable in production. 497 + #[config(env = "TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", default = false)] 498 + pub allow_insecure: bool, 499 + } 500 + 501 + impl std::fmt::Debug for SecretsConfig { 502 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 503 + f.debug_struct("SecretsConfig") 504 + .field( 505 + "jwt_secret", 506 + &self.jwt_secret.as_ref().map(|_| "[REDACTED]"), 507 + ) 508 + .field( 509 + "dpop_secret", 510 + &self.dpop_secret.as_ref().map(|_| "[REDACTED]"), 511 + ) 512 + .field( 513 + "master_key", 514 + &self.master_key.as_ref().map(|_| "[REDACTED]"), 515 + ) 516 + .field( 517 + "plc_rotation_key", 518 + &self.plc_rotation_key.as_ref().map(|_| "[REDACTED]"), 519 + ) 520 + .field("allow_insecure", &self.allow_insecure) 521 + .finish() 522 + } 523 + } 524 + 525 + impl SecretsConfig { 526 + /// Resolve the JWT secret, falling back to an insecure default if 527 + /// `allow_insecure` is true. 528 + pub fn jwt_secret_or_default(&self) -> String { 529 + self.jwt_secret.clone().unwrap_or_else(|| { 530 + if cfg!(test) || self.allow_insecure { 531 + "test-jwt-secret-not-for-production".to_string() 532 + } else { 533 + panic!( 534 + "JWT_SECRET must be set in production. \ 535 + Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." 536 + ); 537 + } 538 + }) 539 + } 540 + 541 + /// Resolve the DPoP secret, falling back to an insecure default if 542 + /// `allow_insecure` is true. 543 + pub fn dpop_secret_or_default(&self) -> String { 544 + self.dpop_secret.clone().unwrap_or_else(|| { 545 + if cfg!(test) || self.allow_insecure { 546 + "test-dpop-secret-not-for-production".to_string() 547 + } else { 548 + panic!( 549 + "DPOP_SECRET must be set in production. \ 550 + Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." 551 + ); 552 + } 553 + }) 554 + } 555 + 556 + /// Resolve the master key, falling back to an insecure default if 557 + /// `allow_insecure` is true. 558 + pub fn master_key_or_default(&self) -> String { 559 + self.master_key.clone().unwrap_or_else(|| { 560 + if cfg!(test) || self.allow_insecure { 561 + "test-master-key-not-for-production".to_string() 562 + } else { 563 + panic!( 564 + "MASTER_KEY must be set in production. \ 565 + Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=true for development/testing." 566 + ); 567 + } 568 + }) 569 + } 570 + } 571 + 572 + #[derive(Debug, Config)] 573 + pub struct StorageConfig { 574 + /// Storage backend: `filesystem` or `s3`. 575 + #[config(env = "BLOB_STORAGE_BACKEND", default = "filesystem")] 576 + pub backend: String, 577 + 578 + /// Path on disk for the filesystem blob backend. 579 + #[config(env = "BLOB_STORAGE_PATH", default = "/var/lib/tranquil-pds/blobs")] 580 + pub path: String, 581 + 582 + /// S3 bucket name for blob storage. 583 + #[config(env = "S3_BUCKET")] 584 + pub s3_bucket: Option<String>, 585 + 586 + /// Custom S3 endpoint URL (for MinIO, R2, etc.). 587 + #[config(env = "S3_ENDPOINT")] 588 + pub s3_endpoint: Option<String>, 589 + } 590 + 591 + #[derive(Debug, Config)] 592 + pub struct BackupConfig { 593 + /// Enable automatic backups. 594 + #[config(env = "BACKUP_ENABLED", default = true)] 595 + pub enabled: bool, 596 + 597 + /// Backup storage backend: `filesystem` or `s3`. 598 + #[config(env = "BACKUP_STORAGE_BACKEND", default = "filesystem")] 599 + pub backend: String, 600 + 601 + /// Path on disk for the filesystem backup backend. 602 + #[config(env = "BACKUP_STORAGE_PATH", default = "/var/lib/tranquil-pds/backups")] 603 + pub path: String, 604 + 605 + /// S3 bucket name for backups. 606 + #[config(env = "BACKUP_S3_BUCKET")] 607 + pub s3_bucket: Option<String>, 608 + 609 + /// Number of backup revisions to keep per account. 610 + #[config(env = "BACKUP_RETENTION_COUNT", default = 7)] 611 + pub retention_count: u32, 612 + 613 + /// Seconds between backup runs. 614 + #[config(env = "BACKUP_INTERVAL_SECS", default = 86400)] 615 + pub interval_secs: u64, 616 + } 617 + 618 + #[derive(Debug, Config)] 619 + pub struct CacheConfig { 620 + /// Cache backend: `ripple` (default, built-in gossip) or `valkey`. 621 + #[config(env = "CACHE_BACKEND", default = "ripple")] 622 + pub backend: String, 623 + 624 + /// Valkey / Redis connection URL. Required when `backend = "valkey"`. 625 + #[config(env = "VALKEY_URL")] 626 + pub valkey_url: Option<String>, 627 + 628 + #[config(nested)] 629 + pub ripple: RippleCacheConfig, 630 + } 631 + 632 + #[derive(Debug, Config)] 633 + pub struct PlcConfig { 634 + /// Base URL of the PLC directory. 635 + #[config(env = "PLC_DIRECTORY_URL", default = "https://plc.directory")] 636 + pub directory_url: String, 637 + 638 + /// HTTP request timeout in seconds. 639 + #[config(env = "PLC_TIMEOUT_SECS", default = 10)] 640 + pub timeout_secs: u64, 641 + 642 + /// TCP connect timeout in seconds. 643 + #[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)] 644 + pub connect_timeout_secs: u64, 645 + 646 + /// Seconds to cache DID documents in memory. 647 + #[config(env = "DID_CACHE_TTL_SECS", default = 300)] 648 + pub did_cache_ttl_secs: u64, 649 + } 650 + 651 + #[derive(Debug, Config)] 652 + pub struct FirehoseConfig { 653 + /// Size of the in-memory broadcast buffer for firehose events. 654 + #[config(env = "FIREHOSE_BUFFER_SIZE", default = 10000)] 655 + pub buffer_size: usize, 656 + 657 + /// How many hours of historical events to replay for cursor-based 658 + /// firehose connections. 659 + #[config(env = "FIREHOSE_BACKFILL_HOURS", default = 72)] 660 + pub backfill_hours: i64, 661 + 662 + /// Maximum number of lagged events before disconnecting a slow consumer. 663 + #[config(env = "FIREHOSE_MAX_LAG", default = 5000)] 664 + pub max_lag: u64, 665 + 666 + /// List of relay / crawler notification URLs. 667 + #[config(env = "CRAWLERS", parse_env = split_comma_list)] 668 + pub crawlers: Option<Vec<String>>, 669 + } 670 + 671 + impl FirehoseConfig { 672 + /// Returns the list of crawler URLs, falling back to `["https://bsky.network"]` 673 + /// when none are configured. 674 + pub fn crawler_list(&self) -> Vec<String> { 675 + self.crawlers 676 + .clone() 677 + .unwrap_or_else(|| vec!["https://bsky.network".to_string()]) 678 + } 679 + } 680 + 681 + #[derive(Debug, Config)] 682 + pub struct EmailConfig { 683 + /// Sender email address. When unset, email sending is disabled. 684 + #[config(env = "MAIL_FROM_ADDRESS")] 685 + pub from_address: Option<String>, 686 + 687 + /// Display name used in the `From` header. 688 + #[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")] 689 + pub from_name: String, 690 + 691 + /// Path to the `sendmail` binary. 692 + #[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")] 693 + pub sendmail_path: String, 694 + } 695 + 696 + #[derive(Debug, Config)] 697 + pub struct DiscordConfig { 698 + /// Discord bot token. When unset, Discord integration is disabled. 699 + #[config(env = "DISCORD_BOT_TOKEN")] 700 + pub bot_token: Option<String>, 701 + } 702 + 703 + #[derive(Debug, Config)] 704 + pub struct TelegramConfig { 705 + /// Telegram bot token. When unset, Telegram integration is disabled. 706 + #[config(env = "TELEGRAM_BOT_TOKEN")] 707 + pub bot_token: Option<String>, 708 + 709 + /// Secret token for incoming webhook verification. 710 + #[config(env = "TELEGRAM_WEBHOOK_SECRET")] 711 + pub webhook_secret: Option<String>, 712 + } 713 + 714 + #[derive(Debug, Config)] 715 + pub struct SignalConfig { 716 + /// Path to the `signal-cli` binary. 717 + #[config(env = "SIGNAL_CLI_PATH", default = "/usr/local/bin/signal-cli")] 718 + pub cli_path: String, 719 + 720 + /// Sender phone number. When unset, Signal integration is disabled. 721 + #[config(env = "SIGNAL_SENDER_NUMBER")] 722 + pub sender_number: Option<String>, 723 + } 724 + 725 + #[derive(Debug, Config)] 726 + pub struct NotificationConfig { 727 + /// Polling interval in milliseconds for the comms queue. 728 + #[config(env = "NOTIFICATION_POLL_INTERVAL_MS", default = 1000)] 729 + pub poll_interval_ms: u64, 730 + 731 + /// Number of notifications to process per batch. 732 + #[config(env = "NOTIFICATION_BATCH_SIZE", default = 100)] 733 + pub batch_size: i64, 734 + } 735 + 736 + #[derive(Debug, Config)] 737 + pub struct SsoConfig { 738 + #[config(nested)] 739 + pub github: SsoProviderConfig, 740 + 741 + #[config(nested)] 742 + pub discord: SsoDiscordProviderConfig, 743 + 744 + #[config(nested)] 745 + pub google: SsoProviderConfig, 746 + 747 + #[config(nested)] 748 + pub gitlab: SsoProviderWithIssuerConfig, 749 + 750 + #[config(nested)] 751 + pub oidc: SsoProviderWithIssuerConfig, 752 + 753 + #[config(nested)] 754 + pub apple: SsoAppleConfig, 755 + } 756 + 757 + // Generic SSO provider (GitHub, Google) 758 + #[derive(Debug, Config)] 759 + pub struct SsoProviderConfig { 760 + #[config(default = false)] 761 + pub enabled: bool, 762 + pub client_id: Option<String>, 763 + pub client_secret: Option<String>, 764 + pub display_name: Option<String>, 765 + } 766 + 767 + // SSO provider with custom env prefixes for Discord 768 + // (since the nested TOML key is `sso.discord` but env vars are `SSO_DISCORD_*`) 769 + #[derive(Debug, Config)] 770 + pub struct SsoDiscordProviderConfig { 771 + #[config(default = false)] 772 + pub enabled: bool, 773 + pub client_id: Option<String>, 774 + pub client_secret: Option<String>, 775 + pub display_name: Option<String>, 776 + } 777 + 778 + // SSO providers that require an issuer URL (GitLab, OIDC) 779 + #[derive(Debug, Config)] 780 + pub struct SsoProviderWithIssuerConfig { 781 + #[config(default = false)] 782 + pub enabled: bool, 783 + pub client_id: Option<String>, 784 + pub client_secret: Option<String>, 785 + pub issuer: Option<String>, 786 + pub display_name: Option<String>, 787 + } 788 + 789 + #[derive(Debug, Config)] 790 + pub struct SsoAppleConfig { 791 + #[config(env = "SSO_APPLE_ENABLED", default = false)] 792 + pub enabled: bool, 793 + 794 + #[config(env = "SSO_APPLE_CLIENT_ID")] 795 + pub client_id: Option<String>, 796 + 797 + #[config(env = "SSO_APPLE_TEAM_ID")] 798 + pub team_id: Option<String>, 799 + 800 + #[config(env = "SSO_APPLE_KEY_ID")] 801 + pub key_id: Option<String>, 802 + 803 + #[config(env = "SSO_APPLE_PRIVATE_KEY")] 804 + pub private_key: Option<String>, 805 + } 806 + 807 + #[derive(Debug, Config)] 808 + pub struct ModerationConfig { 809 + /// External report-handling service URL. 810 + #[config(env = "REPORT_SERVICE_URL")] 811 + pub report_service_url: Option<String>, 812 + 813 + /// DID of the external report-handling service. 814 + #[config(env = "REPORT_SERVICE_DID")] 815 + pub report_service_did: Option<String>, 816 + } 817 + 818 + #[derive(Debug, Config)] 819 + pub struct ImportConfig { 820 + /// Whether the PDS accepts repo imports. 821 + #[config(env = "ACCEPTING_REPO_IMPORTS", default = true)] 822 + pub accepting: bool, 823 + 824 + /// Maximum allowed import archive size in bytes (default 1 GiB). 825 + #[config(env = "MAX_IMPORT_SIZE", default = 1_073_741_824)] 826 + pub max_size: u64, 827 + 828 + /// Maximum number of blocks allowed in an import. 829 + #[config(env = "MAX_IMPORT_BLOCKS", default = 500000)] 830 + pub max_blocks: u64, 831 + 832 + /// Skip CAR verification during import. Only for development/debugging. 833 + #[config(env = "SKIP_IMPORT_VERIFICATION", default = false)] 834 + pub skip_verification: bool, 835 + } 836 + 837 + /// Parse a comma-separated environment variable into a `Vec<String>`, 838 + /// trimming whitespace and dropping empty entries. 839 + /// 840 + /// Signature matches confique's `parse_env` expectation: `fn(&str) -> Result<T, E>`. 841 + fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible> { 842 + Ok(value 843 + .split(',') 844 + .map(|item| item.trim().to_string()) 845 + .filter(|item| !item.is_empty()) 846 + .collect()) 847 + } 848 + 849 + #[derive(Debug, Config)] 850 + pub struct RippleCacheConfig { 851 + /// Address to bind the Ripple gossip protocol listener. 852 + #[config(env = "RIPPLE_BIND", default = "0.0.0.0:0")] 853 + pub bind_addr: String, 854 + 855 + /// List of seed peer addresses. 856 + #[config(env = "RIPPLE_PEERS", parse_env = split_comma_list)] 857 + pub peers: Option<Vec<String>>, 858 + 859 + /// Unique machine identifier. Auto-derived from hostname when not set. 860 + #[config(env = "RIPPLE_MACHINE_ID")] 861 + pub machine_id: Option<u64>, 862 + 863 + /// Gossip protocol interval in milliseconds. 864 + #[config(env = "RIPPLE_GOSSIP_INTERVAL_MS", default = 200)] 865 + pub gossip_interval_ms: u64, 866 + 867 + /// Maximum cache size in megabytes. 868 + #[config(env = "RIPPLE_CACHE_MAX_MB", default = 256)] 869 + pub cache_max_mb: usize, 870 + } 871 + 872 + #[derive(Debug, Config)] 873 + pub struct ScheduledConfig { 874 + /// Interval in seconds between scheduled delete checks. 875 + #[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)] 876 + pub delete_check_interval_secs: u64, 877 + } 878 + 879 + /// Generate a TOML configuration template with all available options, 880 + /// defaults, and documentation comments. 881 + pub fn template() -> String { 882 + confique::toml::template::<TranquilConfig>(confique::toml::FormatOptions::default()) 883 + }
+2
crates/tranquil-infra/Cargo.toml
··· 5 5 license.workspace = true 6 6 7 7 [dependencies] 8 + tranquil-config = { workspace = true } 9 + 8 10 async-trait = { workspace = true } 9 11 bytes = { workspace = true } 10 12 futures = { workspace = true }
+4 -6
crates/tranquil-infra/src/lib.rs
··· 45 45 } 46 46 47 47 pub fn backup_retention_count() -> u32 { 48 - std::env::var("BACKUP_RETENTION_COUNT") 49 - .ok() 50 - .and_then(|v| v.parse().ok()) 48 + tranquil_config::try_get() 49 + .map(|c| c.backup.retention_count) 51 50 .unwrap_or(7) 52 51 } 53 52 54 53 pub fn backup_interval_secs() -> u64 { 55 - std::env::var("BACKUP_INTERVAL_SECS") 56 - .ok() 57 - .and_then(|v| v.parse().ok()) 54 + tranquil_config::try_get() 55 + .map(|c| c.backup.interval_secs) 58 56 .unwrap_or(86400) 59 57 } 60 58
+2
crates/tranquil-pds/Cargo.toml
··· 6 6 7 7 [dependencies] 8 8 tranquil-types = { workspace = true } 9 + tranquil-config = { workspace = true } 9 10 tranquil-crypto = { workspace = true } 10 11 tranquil-storage = { workspace = true } 11 12 tranquil-cache = { workspace = true } ··· 29 30 bytes = { workspace = true } 30 31 chrono = { workspace = true } 31 32 cid = { workspace = true } 33 + clap = { workspace = true } 32 34 dotenvy = { workspace = true } 33 35 ed25519-dalek = { workspace = true } 34 36 futures = { workspace = true }
+1 -2
crates/tranquil-pds/src/api/admin/account/email.rs
··· 2 2 use crate::auth::{Admin, Auth}; 3 3 use crate::state::AppState; 4 4 use crate::types::Did; 5 - use crate::util::pds_hostname; 6 5 use axum::{ 7 6 Json, 8 7 extract::State, ··· 45 44 46 45 let email = user.email.ok_or(ApiError::NoEmail)?; 47 46 let (user_id, handle) = (user.id, user.handle); 48 - let hostname = pds_hostname(); 47 + let hostname = &tranquil_config::get().server.hostname; 49 48 let subject = input 50 49 .subject 51 50 .clone()
+1 -2
crates/tranquil-pds/src/api/admin/account/update.rs
··· 3 3 use crate::auth::{Admin, Auth}; 4 4 use crate::state::AppState; 5 5 use crate::types::{Did, Handle, PlainPassword}; 6 - use crate::util::pds_hostname_without_port; 7 6 use axum::{ 8 7 Json, 9 8 extract::State, ··· 70 69 { 71 70 return Err(ApiError::InvalidHandle(None)); 72 71 } 73 - let hostname_for_handles = pds_hostname_without_port(); 72 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 74 73 let handle = if !input_handle.contains('.') { 75 74 format!("{}.{}", input_handle, hostname_for_handles) 76 75 } else {
+8 -6
crates/tranquil-pds/src/api/delegation.rs
··· 8 8 use crate::rate_limit::{AccountCreationLimit, RateLimited}; 9 9 use crate::state::AppState; 10 10 use crate::types::{Did, Handle}; 11 - use crate::util::{pds_hostname, pds_hostname_without_port}; 12 11 use axum::{ 13 12 Json, 14 13 extract::{Query, State}, ··· 435 434 Err(response) => return Ok(response), 436 435 }; 437 436 438 - let hostname = pds_hostname(); 439 - let hostname_for_handles = pds_hostname_without_port(); 437 + let hostname = &tranquil_config::get().server.hostname; 438 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 440 439 let pds_suffix = format!(".{}", hostname_for_handles); 441 440 442 441 let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { ··· 475 474 Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()), 476 475 } 477 476 } else { 478 - let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); 477 + let invite_required = tranquil_config::get().server.invite_code_required; 479 478 if invite_required { 480 479 return Ok(ApiError::InviteCodeRequired.into_response()); 481 480 } ··· 497 496 } 498 497 }; 499 498 500 - let rotation_key = std::env::var("PLC_ROTATION_KEY") 501 - .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key)); 499 + let rotation_key = tranquil_config::get() 500 + .secrets 501 + .plc_rotation_key 502 + .clone() 503 + .unwrap_or_else(|| crate::plc::signing_key_to_did_key(&signing_key)); 502 504 503 505 let genesis_result = match crate::plc::create_genesis_operation( 504 506 &signing_key,
+2 -2
crates/tranquil-pds/src/api/discord_webhook.rs
··· 12 12 13 13 use crate::comms::comms_repo; 14 14 use crate::state::AppState; 15 - use crate::util::{discord_public_key, pds_hostname}; 15 + use crate::util::discord_public_key; 16 16 17 17 #[derive(Deserialize)] 18 18 struct Interaction { ··· 185 185 user_id, 186 186 tranquil_db_traits::CommsChannel::Discord, 187 187 &discord_user_id, 188 - pds_hostname(), 188 + &tranquil_config::get().server.hostname, 189 189 ) 190 190 .await 191 191 {
+22 -15
crates/tranquil-pds/src/api/identity/account.rs
··· 6 6 use crate::rate_limit::{AccountCreationLimit, RateLimited}; 7 7 use crate::state::AppState; 8 8 use crate::types::{Did, Handle, PlainPassword}; 9 - use crate::util::{pds_hostname, pds_hostname_without_port}; 10 9 use crate::validation::validate_password; 11 10 use axum::{ 12 11 Json, ··· 141 140 } 142 141 } 143 142 144 - let hostname_for_validation = pds_hostname_without_port(); 143 + let hostname_for_validation = tranquil_config::get().server.hostname_without_port(); 145 144 let pds_suffix = format!(".{}", hostname_for_validation); 146 145 147 146 let validated_short_handle = if !input.handle.contains('.') ··· 233 232 }, 234 233 }) 235 234 }; 236 - let hostname = pds_hostname(); 237 - let hostname_for_handles = pds_hostname_without_port(); 235 + let hostname = &tranquil_config::get().server.hostname; 236 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 238 237 let pds_endpoint = format!("https://{}", hostname); 239 238 let suffix = format!(".{}", hostname_for_handles); 240 239 let handle = if input.handle.ends_with(&suffix) { ··· 326 325 ) 327 326 .into_response(); 328 327 } else { 329 - let rotation_key = std::env::var("PLC_ROTATION_KEY") 330 - .unwrap_or_else(|_| signing_key_to_did_key(&signing_key)); 328 + let rotation_key = tranquil_config::get() 329 + .secrets 330 + .plc_rotation_key 331 + .clone() 332 + .unwrap_or_else(|| signing_key_to_did_key(&signing_key)); 331 333 let genesis_result = match create_genesis_operation( 332 334 &signing_key, 333 335 &rotation_key, ··· 359 361 genesis_result.did 360 362 } 361 363 } else { 362 - let rotation_key = std::env::var("PLC_ROTATION_KEY") 363 - .unwrap_or_else(|_| signing_key_to_did_key(&signing_key)); 364 + let rotation_key = tranquil_config::get() 365 + .secrets 366 + .plc_rotation_key 367 + .clone() 368 + .unwrap_or_else(|| signing_key_to_did_key(&signing_key)); 364 369 let genesis_result = match create_genesis_operation( 365 370 &signing_key, 366 371 &rotation_key, ··· 473 478 error!("Error creating session: {:?}", e); 474 479 return ApiError::InternalError(None).into_response(); 475 480 } 476 - let hostname = pds_hostname(); 481 + let hostname = &tranquil_config::get().server.hostname; 477 482 let verification_required = if let Some(ref user_email) = email { 478 483 let token = crate::auth::verification_token::generate_migration_token( 479 484 &did_typed, user_email, ··· 543 548 return ApiError::HandleTaken.into_response(); 544 549 } 545 550 546 - let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); 551 + let invite_code_required = tranquil_config::get().server.invite_code_required; 547 552 if invite_code_required 548 553 && input 549 554 .invite_code ··· 632 637 let rev_str = rev.as_ref().to_string(); 633 638 let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; 634 639 635 - let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| { 636 - json!({ 640 + let birthdate_pref = if tranquil_config::get().server.age_assurance_override { 641 + Some(json!({ 637 642 "$type": "app.bsky.actor.defs#personalDetailsPref", 638 643 "birthDate": "1998-05-06T00:00:00.000Z" 639 - }) 640 - }); 644 + })) 645 + } else { 646 + None 647 + }; 641 648 642 649 let preferred_comms_channel = verification_channel; 643 650 ··· 748 755 warn!("Failed to create default profile for {}: {}", did, e); 749 756 } 750 757 } 751 - let hostname = pds_hostname(); 758 + let hostname = &tranquil_config::get().server.hostname; 752 759 if !is_migration { 753 760 if let Some(ref recipient) = verification_recipient { 754 761 let verification_token = crate::auth::verification_token::generate_signup_token(
+10 -10
crates/tranquil-pds/src/api/identity/did.rs
··· 6 6 }; 7 7 use crate::state::AppState; 8 8 use crate::types::Handle; 9 - use crate::util::{get_header_str, pds_hostname, pds_hostname_without_port}; 9 + use crate::util::get_header_str; 10 10 use axum::{ 11 11 Json, 12 12 extract::{Path, Query, State}, ··· 122 122 } 123 123 124 124 pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response { 125 - let hostname = pds_hostname(); 126 - let hostname_without_port = pds_hostname_without_port(); 125 + let hostname = &tranquil_config::get().server.hostname; 126 + let hostname_without_port = tranquil_config::get().server.hostname_without_port(); 127 127 let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname); 128 128 let host_without_port = host_header.split(':').next().unwrap_or(host_header); 129 129 if host_without_port != hostname_without_port ··· 275 275 } 276 276 277 277 pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response { 278 - let hostname = pds_hostname(); 279 - let hostname_for_handles = pds_hostname_without_port(); 278 + let hostname = &tranquil_config::get().server.hostname; 279 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 280 280 let current_handle = format!("{}.{}", handle, hostname_for_handles); 281 281 let current_handle_typed: Handle = match current_handle.parse() { 282 282 Ok(h) => h, ··· 571 571 ApiError::AuthenticationFailed(Some("OAuth tokens cannot get DID credentials".into())) 572 572 })?; 573 573 574 - let hostname = pds_hostname(); 574 + let hostname = &tranquil_config::get().server.hostname; 575 575 let pds_endpoint = format!("https://{}", hostname); 576 576 let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes) 577 577 .map_err(|_| ApiError::InternalError(None))?; ··· 579 579 let rotation_keys = if auth.did.starts_with("did:web:") { 580 580 vec![] 581 581 } else { 582 - let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") { 583 - Ok(key) => key, 584 - Err(_) => { 582 + let server_rotation_key = match &tranquil_config::get().secrets.plc_rotation_key { 583 + Some(key) => key.clone(), 584 + None => { 585 585 warn!( 586 586 "PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation" 587 587 ); ··· 675 675 "Inappropriate language in handle".into(), 676 676 ))); 677 677 } 678 - let hostname_for_handles = pds_hostname_without_port(); 678 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 679 679 let suffix = format!(".{}", hostname_for_handles); 680 680 let is_service_domain = 681 681 crate::handle::is_service_domain_handle(&new_handle, hostname_for_handles);
+1 -2
crates/tranquil-pds/src/api/identity/plc/request.rs
··· 2 2 use crate::api::error::{ApiError, DbResultExt}; 3 3 use crate::auth::{Auth, Permissive}; 4 4 use crate::state::AppState; 5 - use crate::util::pds_hostname; 6 5 use axum::{ 7 6 extract::State, 8 7 response::{IntoResponse, Response}, ··· 41 40 .await 42 41 .log_db_err("creating PLC token")?; 43 42 44 - let hostname = pds_hostname(); 43 + let hostname = &tranquil_config::get().server.hostname; 45 44 if let Err(e) = crate::comms::comms_repo::enqueue_plc_operation( 46 45 state.user_repo.as_ref(), 47 46 state.infra_repo.as_ref(),
+6 -4
crates/tranquil-pds/src/api/identity/plc/submit.rs
··· 4 4 use crate::circuit_breaker::with_circuit_breaker; 5 5 use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation}; 6 6 use crate::state::AppState; 7 - use crate::util::pds_hostname; 8 7 use axum::{ 9 8 Json, 10 9 extract::State, ··· 42 41 .map_err(|e| ApiError::InvalidRequest(format!("Invalid operation: {}", e)))?; 43 42 44 43 let op = &input.operation; 45 - let hostname = pds_hostname(); 44 + let hostname = &tranquil_config::get().server.hostname; 46 45 let public_url = format!("https://{}", hostname); 47 46 let user = state 48 47 .user_repo ··· 70 69 })?; 71 70 72 71 let user_did_key = signing_key_to_did_key(&signing_key); 73 - let server_rotation_key = 74 - std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone()); 72 + let server_rotation_key = tranquil_config::get() 73 + .secrets 74 + .plc_rotation_key 75 + .clone() 76 + .unwrap_or_else(|| user_did_key.clone()); 75 77 if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) { 76 78 let has_server_key = rotation_keys 77 79 .iter()
+3 -2
crates/tranquil-pds/src/api/moderation/mod.rs
··· 69 69 } 70 70 71 71 fn get_report_service_config() -> Option<ReportServiceConfig> { 72 - let url = std::env::var("REPORT_SERVICE_URL").ok()?; 73 - let did = std::env::var("REPORT_SERVICE_DID").ok()?; 72 + let cfg = tranquil_config::get(); 73 + let url = cfg.moderation.report_service_url.clone()?; 74 + let did = cfg.moderation.report_service_did.clone()?; 74 75 if url.is_empty() || did.is_empty() { 75 76 return None; 76 77 }
+2 -3
crates/tranquil-pds/src/api/notification_prefs.rs
··· 1 1 use crate::api::error::ApiError; 2 2 use crate::auth::{Active, Auth}; 3 3 use crate::state::AppState; 4 - use crate::util::pds_hostname; 5 4 use axum::{ 6 5 Json, 7 6 extract::State, ··· 148 147 149 148 match channel { 150 149 CommsChannel::Email => { 151 - let hostname = pds_hostname(); 150 + let hostname = &tranquil_config::get().server.hostname; 152 151 let handle_str = handle.unwrap_or("user"); 153 152 crate::comms::comms_repo::enqueue_email_update( 154 153 state.infra_repo.as_ref(), ··· 167 166 })?; 168 167 } 169 168 _ => { 170 - let hostname = pds_hostname(); 169 + let hostname = &tranquil_config::get().server.hostname; 171 170 let encoded_token = urlencoding::encode(&formatted_token); 172 171 let encoded_identifier = urlencoding::encode(identifier); 173 172 let verify_link = format!(
+1 -1
crates/tranquil-pds/src/api/proxy.rs
··· 168 168 } 169 169 170 170 // If the age assurance override is set and this is an age assurance call then we dont want to proxy even if the client requests it 171 - if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() 171 + if tranquil_config::get().server.age_assurance_override 172 172 && (path.ends_with("app.bsky.ageassurance.getState") 173 173 || path.ends_with("app.bsky.unspecced.getAgeAssuranceState")) 174 174 {
+1 -1
crates/tranquil-pds/src/api/proxy_client.rs
··· 63 63 let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?; 64 64 let scheme = parsed.scheme(); 65 65 if scheme != "https" { 66 - let allow_http = std::env::var("ALLOW_HTTP_PROXY").is_ok() 66 + let allow_http = tranquil_config::get().server.allow_http_proxy 67 67 || url.starts_with("http://127.0.0.1") 68 68 || url.starts_with("http://localhost"); 69 69 if !allow_http {
+2 -2
crates/tranquil-pds/src/api/repo/blob.rs
··· 3 3 use crate::delegation::DelegationActionType; 4 4 use crate::state::AppState; 5 5 use crate::types::{CidLink, Did}; 6 - use crate::util::{get_header_str, get_max_blob_size}; 6 + use crate::util::get_header_str; 7 7 use axum::body::Body; 8 8 use axum::{ 9 9 Json, ··· 89 89 .ok_or(ApiError::InternalError(None))?; 90 90 91 91 let temp_key = format!("temp/{}", uuid::Uuid::new_v4()); 92 - let max_size = u64::try_from(get_max_blob_size()).unwrap_or(u64::MAX); 92 + let max_size = tranquil_config::get().server.max_blob_size; 93 93 94 94 let body_stream = body.into_data_stream(); 95 95 let mapped_stream =
+5 -16
crates/tranquil-pds/src/api/repo/import.rs
··· 18 18 use tracing::{debug, error, info, warn}; 19 19 use tranquil_types::{AtUri, CidLink}; 20 20 21 - const DEFAULT_MAX_IMPORT_SIZE: usize = 1024 * 1024 * 1024; 22 - const DEFAULT_MAX_BLOCKS: usize = 500000; 23 - 24 21 pub async fn import_repo( 25 22 State(state): State<AppState>, 26 23 auth: Auth<NotTakendown>, 27 24 body: Bytes, 28 25 ) -> Result<Response, ApiError> { 29 - let accepting_imports = std::env::var("ACCEPTING_REPO_IMPORTS") 30 - .map(|v| v != "false" && v != "0") 31 - .unwrap_or(true); 26 + let accepting_imports = tranquil_config::get().import.accepting; 32 27 if !accepting_imports { 33 28 return Err(ApiError::InvalidRequest( 34 29 "Service is not accepting repo imports".into(), 35 30 )); 36 31 } 37 - let max_size: usize = std::env::var("MAX_IMPORT_SIZE") 38 - .ok() 39 - .and_then(|s| s.parse().ok()) 40 - .unwrap_or(DEFAULT_MAX_IMPORT_SIZE); 32 + let max_size = tranquil_config::get().import.max_size as usize; 41 33 if body.len() > max_size { 42 34 return Err(ApiError::PayloadTooLarge(format!( 43 35 "Import size exceeds limit of {} bytes", ··· 108 100 commit_did, did 109 101 ))); 110 102 } 111 - let skip_verification = crate::util::parse_env_bool("SKIP_IMPORT_VERIFICATION"); 103 + let skip_verification = tranquil_config::get().import.skip_verification; 112 104 let is_migration = user.deactivated_at.is_some(); 113 105 if skip_verification { 114 106 warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)"); ··· 196 188 } 197 189 } 198 190 } 199 - let max_blocks: usize = std::env::var("MAX_IMPORT_BLOCKS") 200 - .ok() 201 - .and_then(|s| s.parse().ok()) 202 - .unwrap_or(DEFAULT_MAX_BLOCKS); 191 + let max_blocks = tranquil_config::get().import.max_blocks as usize; 203 192 let _write_lock = state.repo_write_locks.lock(user_id).await; 204 193 match apply_import( 205 194 &state.repo_repo, ··· 324 313 { 325 314 warn!("Failed to sequence import event: {:?}", e); 326 315 } 327 - if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() { 316 + if tranquil_config::get().server.age_assurance_override { 328 317 let birthdate_pref = json!({ 329 318 "$type": "app.bsky.actor.defs#personalDetailsPref", 330 319 "birthDate": "1998-05-06T00:00:00.000Z"
+1 -2
crates/tranquil-pds/src/api/repo/meta.rs
··· 1 1 use crate::api::error::ApiError; 2 2 use crate::state::AppState; 3 3 use crate::types::AtIdentifier; 4 - use crate::util::pds_hostname_without_port; 5 4 use axum::{ 6 5 Json, 7 6 extract::{Query, State}, ··· 19 18 State(state): State<AppState>, 20 19 Query(input): Query<DescribeRepoInput>, 21 20 ) -> Response { 22 - let hostname_for_handles = pds_hostname_without_port(); 21 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 23 22 let user_row = if input.repo.is_did() { 24 23 let did: crate::types::Did = match input.repo.as_str().parse() { 25 24 Ok(d) => d,
+2 -3
crates/tranquil-pds/src/api/repo/record/read.rs
··· 2 2 use crate::api::error::ApiError; 3 3 use crate::state::AppState; 4 4 use crate::types::{AtIdentifier, Nsid, Rkey}; 5 - use crate::util::pds_hostname_without_port; 6 5 use axum::{ 7 6 Json, 8 7 extract::{Query, State}, ··· 60 59 _headers: HeaderMap, 61 60 Query(input): Query<GetRecordInput>, 62 61 ) -> Response { 63 - let hostname_for_handles = pds_hostname_without_port(); 62 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 64 63 let user_id_opt = if input.repo.is_did() { 65 64 let did: crate::types::Did = match input.repo.as_str().parse() { 66 65 Ok(d) => d, ··· 159 158 State(state): State<AppState>, 160 159 Query(input): Query<ListRecordsInput>, 161 160 ) -> Response { 162 - let hostname_for_handles = pds_hostname_without_port(); 161 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 163 162 let user_id_opt = if input.repo.is_did() { 164 163 let did: crate::types::Did = match input.repo.as_str().parse() { 165 164 Ok(d) => d,
+3 -4
crates/tranquil-pds/src/api/server/account_status.rs
··· 5 5 use crate::plc::PlcClient; 6 6 use crate::state::AppState; 7 7 use crate::types::PlainPassword; 8 - use crate::util::pds_hostname; 9 8 use axum::{ 10 9 Json, 11 10 extract::State, ··· 131 130 did: &crate::types::Did, 132 131 with_retry: bool, 133 132 ) -> Result<(), ApiError> { 134 - let hostname = pds_hostname(); 133 + let hostname = &tranquil_config::get().server.hostname; 135 134 let expected_endpoint = format!("https://{}", hostname); 136 135 137 136 if did.as_str().starts_with("did:plc:") { ··· 201 200 .await 202 201 .map_err(ApiError::InvalidRequest)?; 203 202 204 - let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok(); 203 + let server_rotation_key = tranquil_config::get().secrets.plc_rotation_key.clone(); 205 204 if let Some(ref expected_rotation_key) = server_rotation_key { 206 205 let rotation_keys = doc_data 207 206 .get("rotationKeys") ··· 552 551 .create_deletion_request(&confirmation_token, session_mfa.did(), expires_at) 553 552 .await 554 553 .log_db_err("creating deletion token")?; 555 - let hostname = pds_hostname(); 554 + let hostname = &tranquil_config::get().server.hostname; 556 555 if let Err(e) = crate::comms::comms_repo::enqueue_account_deletion( 557 556 state.user_repo.as_ref(), 558 557 state.infra_repo.as_ref(),
+3 -4
crates/tranquil-pds/src/api/server/email.rs
··· 3 3 use crate::auth::{Auth, NotTakendown}; 4 4 use crate::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit}; 5 5 use crate::state::AppState; 6 - use crate::util::pds_hostname; 7 6 use axum::{ 8 7 Json, 9 8 extract::State, ··· 105 104 } 106 105 } 107 106 108 - let hostname = pds_hostname(); 107 + let hostname = &tranquil_config::get().server.hostname; 109 108 if let Err(e) = crate::comms::comms_repo::enqueue_short_token_email( 110 109 state.user_repo.as_ref(), 111 110 state.infra_repo.as_ref(), ··· 367 366 ); 368 367 let formatted_token = 369 368 crate::auth::verification_token::format_token_for_display(&verification_token); 370 - let hostname = pds_hostname(); 369 + let hostname = &tranquil_config::get().server.hostname; 371 370 if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( 372 371 state.user_repo.as_ref(), 373 372 state.infra_repo.as_ref(), ··· 531 530 532 531 info!(did = %did, "Email update authorized via link click"); 533 532 534 - let hostname = pds_hostname(); 533 + let hostname = &tranquil_config::get().server.hostname; 535 534 let redirect_url = format!( 536 535 "https://{}/app/verify?type=email-authorize-success", 537 536 hostname
+1 -2
crates/tranquil-pds/src/api/server/invite.rs
··· 3 3 use crate::auth::{Admin, Auth, NotTakendown}; 4 4 use crate::state::AppState; 5 5 use crate::types::Did; 6 - use crate::util::pds_hostname; 7 6 use axum::{ 8 7 Json, 9 8 extract::State, ··· 26 25 } 27 26 28 27 fn gen_invite_code() -> String { 29 - let hostname = pds_hostname(); 28 + let hostname = &tranquil_config::get().server.hostname; 30 29 let hostname_prefix = hostname.replace('.', "-"); 31 30 format!("{}-{}", hostname_prefix, gen_random_token()) 32 31 }
+13 -15
crates/tranquil-pds/src/api/server/meta.rs
··· 1 1 use crate::state::AppState; 2 - use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_bot_username}; 2 + use crate::util::{discord_app_id, discord_bot_username, telegram_bot_username}; 3 3 use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; 4 4 use serde_json::json; 5 5 6 6 fn get_available_comms_channels() -> Vec<tranquil_db_traits::CommsChannel> { 7 7 use tranquil_db_traits::CommsChannel; 8 + let cfg = tranquil_config::get(); 8 9 let mut channels = vec![CommsChannel::Email]; 9 - if std::env::var("DISCORD_BOT_TOKEN").is_ok() { 10 + if cfg.discord.bot_token.is_some() { 10 11 channels.push(CommsChannel::Discord); 11 12 } 12 - if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() { 13 + if cfg.telegram.bot_token.is_some() { 13 14 channels.push(CommsChannel::Telegram); 14 15 } 15 - if std::env::var("SIGNAL_CLI_PATH").is_ok() && std::env::var("SIGNAL_SENDER_NUMBER").is_ok() { 16 + if cfg.signal.sender_number.is_some() { 16 17 channels.push(CommsChannel::Signal); 17 18 } 18 19 channels ··· 26 27 ) 27 28 } 28 29 pub fn is_self_hosted_did_web_enabled() -> bool { 29 - std::env::var("ENABLE_SELF_HOSTED_DID_WEB") 30 - .map(|v| v != "false" && v != "0") 31 - .unwrap_or(true) 30 + tranquil_config::get().server.enable_pds_hosted_did_web 32 31 } 33 32 34 33 pub async fn describe_server() -> impl IntoResponse { 35 - let pds_hostname = pds_hostname(); 36 - let domains_str = 37 - std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.to_string()); 38 - let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect(); 39 - let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); 40 - let privacy_policy = std::env::var("PRIVACY_POLICY_URL").ok(); 41 - let terms_of_service = std::env::var("TERMS_OF_SERVICE_URL").ok(); 42 - let contact_email = std::env::var("CONTACT_EMAIL").ok(); 34 + let cfg = tranquil_config::get(); 35 + let pds_hostname = &cfg.server.hostname; 36 + let domains = cfg.server.available_user_domain_list(); 37 + let invite_code_required = cfg.server.invite_code_required; 38 + let privacy_policy = cfg.server.privacy_policy_url.clone(); 39 + let terms_of_service = cfg.server.terms_of_service_url.clone(); 40 + let contact_email = cfg.server.contact_email.clone(); 43 41 let mut links = serde_json::Map::new(); 44 42 if let Some(pp) = privacy_policy { 45 43 links.insert("privacyPolicy".to_string(), json!(pp));
+1 -2
crates/tranquil-pds/src/api/server/migration.rs
··· 2 2 use crate::api::error::DbResultExt; 3 3 use crate::auth::{Active, Auth}; 4 4 use crate::state::AppState; 5 - use crate::util::pds_hostname; 6 5 use axum::{ 7 6 Json, 8 7 extract::State, ··· 147 146 } 148 147 149 148 async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_json::Value { 150 - let hostname = pds_hostname(); 149 + let hostname = &tranquil_config::get().server.hostname; 151 150 152 151 let user = match state.user_repo.get_user_for_did_doc_build(did).await { 153 152 Ok(Some(row)) => row,
+16 -12
crates/tranquil-pds/src/api/server/passkey_account.rs
··· 24 24 use crate::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited}; 25 25 use crate::state::AppState; 26 26 use crate::types::{Did, Handle, PlainPassword}; 27 - use crate::util::{pds_hostname, pds_hostname_without_port}; 28 27 use crate::validation::validate_password; 29 28 30 29 fn generate_setup_token() -> String { ··· 113 112 .map(|d| d.starts_with("did:web:")) 114 113 .unwrap_or(false); 115 114 116 - let hostname = pds_hostname(); 117 - let hostname_for_handles = pds_hostname_without_port(); 115 + let hostname = &tranquil_config::get().server.hostname; 116 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 118 117 let pds_suffix = format!(".{}", hostname_for_handles); 119 118 120 119 let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { ··· 153 152 Err(_) => return ApiError::InvalidInviteCode.into_response(), 154 153 } 155 154 } else { 156 - let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); 155 + let invite_required = tranquil_config::get().server.invite_code_required; 157 156 if invite_required { 158 157 return ApiError::InviteCodeRequired.into_response(); 159 158 } ··· 309 308 .into_response(); 310 309 } 311 310 } else { 312 - let rotation_key = std::env::var("PLC_ROTATION_KEY") 313 - .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key)); 311 + let rotation_key = tranquil_config::get() 312 + .secrets 313 + .plc_rotation_key 314 + .clone() 315 + .unwrap_or_else(|| crate::plc::signing_key_to_did_key(&secret_key)); 314 316 315 317 let genesis_result = match crate::plc::create_genesis_operation( 316 318 &secret_key, ··· 401 403 }; 402 404 let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; 403 405 404 - let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| { 405 - json!({ 406 + let birthdate_pref = if tranquil_config::get().server.age_assurance_override { 407 + Some(json!({ 406 408 "$type": "app.bsky.actor.defs#personalDetailsPref", 407 409 "birthDate": "1998-05-06T00:00:00.000Z" 408 - }) 409 - }); 410 + })) 411 + } else { 412 + None 413 + }; 410 414 411 415 let handle_typed: Handle = match handle.parse() { 412 416 Ok(h) => h, ··· 820 824 _rate_limit: RateLimited<PasswordResetLimit>, 821 825 Json(input): Json<RequestPasskeyRecoveryInput>, 822 826 ) -> Response { 823 - let hostname_for_handles = pds_hostname_without_port(); 827 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 824 828 let identifier = input.email.trim().to_lowercase(); 825 829 let identifier = identifier.strip_prefix('@').unwrap_or(&identifier); 826 830 let normalized_handle = ··· 855 859 return ApiError::InternalError(None).into_response(); 856 860 } 857 861 858 - let hostname = pds_hostname(); 862 + let hostname = &tranquil_config::get().server.hostname; 859 863 let recovery_url = format!( 860 864 "https://{}/app/recover-passkey?did={}&token={}", 861 865 hostname,
+2 -3
crates/tranquil-pds/src/api/server/password.rs
··· 7 7 use crate::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit}; 8 8 use crate::state::AppState; 9 9 use crate::types::PlainPassword; 10 - use crate::util::{pds_hostname, pds_hostname_without_port}; 11 10 use crate::validation::validate_password; 12 11 use axum::{ 13 12 Json, ··· 38 37 if identifier.is_empty() { 39 38 return ApiError::InvalidRequest("email or handle is required".into()).into_response(); 40 39 } 41 - let hostname_for_handles = pds_hostname_without_port(); 40 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 42 41 let normalized = identifier.to_lowercase(); 43 42 let normalized = normalized.strip_prefix('@').unwrap_or(&normalized); 44 43 let is_email_lookup = normalized.contains('@'); ··· 78 77 error!("DB error setting reset code: {:?}", e); 79 78 return ApiError::InternalError(None).into_response(); 80 79 } 81 - let hostname = pds_hostname(); 80 + let hostname = &tranquil_config::get().server.hostname; 82 81 if let Err(e) = crate::comms::comms_repo::enqueue_password_reset( 83 82 state.user_repo.as_ref(), 84 83 state.infra_repo.as_ref(),
+9 -10
crates/tranquil-pds/src/api/server/session.rs
··· 7 7 use crate::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit}; 8 8 use crate::state::AppState; 9 9 use crate::types::{AccountState, Did, Handle, PlainPassword}; 10 - use crate::util::{pds_hostname, pds_hostname_without_port}; 11 10 use axum::{ 12 11 Json, 13 12 extract::State, ··· 66 65 "create_session called with identifier: {}", 67 66 input.identifier 68 67 ); 69 - let pds_host = pds_hostname(); 70 - let hostname_for_handles = pds_hostname_without_port(); 68 + let pds_host = &tranquil_config::get().server.hostname; 69 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 71 70 let normalized_identifier = 72 - NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles); 71 + NormalizedLoginIdentifier::normalize(&input.identifier, &hostname_for_handles); 73 72 info!( 74 73 "Normalized identifier: {} -> {}", 75 74 input.identifier, normalized_identifier ··· 182 181 return ApiError::LegacyLoginBlocked.into_response(); 183 182 } 184 183 Ok(crate::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => { 185 - let hostname = pds_hostname(); 184 + let hostname = &tranquil_config::get().server.hostname; 186 185 if let Err(e) = crate::comms::comms_repo::enqueue_2fa_code( 187 186 state.user_repo.as_ref(), 188 187 state.infra_repo.as_ref(), ··· 286 285 ip = %client_ip, 287 286 "Legacy login on TOTP-enabled account - sending notification" 288 287 ); 289 - let hostname = pds_hostname(); 288 + let hostname = &tranquil_config::get().server.hostname; 290 289 if let Err(e) = crate::comms::comms_repo::enqueue_legacy_login( 291 290 state.user_repo.as_ref(), 292 291 state.infra_repo.as_ref(), ··· 341 340 let preferred_channel_verified = row 342 341 .channel_verification 343 342 .is_verified(row.preferred_comms_channel); 344 - let pds_hostname = pds_hostname(); 343 + let pds_hostname = &tranquil_config::get().server.hostname; 345 344 let handle = full_handle(&row.handle, pds_hostname); 346 345 let account_state = AccountState::from_db_fields( 347 346 row.deactivated_at, ··· 545 544 let preferred_channel_verified = u 546 545 .channel_verification 547 546 .is_verified(u.preferred_comms_channel); 548 - let pds_hostname = pds_hostname(); 547 + let pds_hostname = &tranquil_config::get().server.hostname; 549 548 let handle = full_handle(&u.handle, pds_hostname); 550 549 let account_state = 551 550 AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None); ··· 707 706 return ApiError::InternalError(None).into_response(); 708 707 } 709 708 710 - let hostname = pds_hostname(); 709 + let hostname = &tranquil_config::get().server.hostname; 711 710 if let Err(e) = crate::comms::comms_repo::enqueue_welcome( 712 711 state.user_repo.as_ref(), 713 712 state.infra_repo.as_ref(), ··· 777 776 let formatted_token = 778 777 crate::auth::verification_token::format_token_for_display(&verification_token); 779 778 780 - let hostname = pds_hostname(); 779 + let hostname = &tranquil_config::get().server.hostname; 781 780 if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( 782 781 state.user_repo.as_ref(), 783 782 state.infra_repo.as_ref(),
+1 -2
crates/tranquil-pds/src/api/server/totp.rs
··· 9 9 use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message}; 10 10 use crate::state::AppState; 11 11 use crate::types::PlainPassword; 12 - use crate::util::pds_hostname; 13 12 use axum::{ 14 13 Json, 15 14 extract::State, ··· 52 51 .log_db_err("fetching handle")? 53 52 .ok_or(ApiError::AccountNotFound)?; 54 53 55 - let hostname = pds_hostname(); 54 + let hostname = &tranquil_config::get().server.hostname; 56 55 let uri = generate_totp_uri(&secret, &handle, hostname); 57 56 58 57 let qr_code = generate_qr_png_base64(&secret, &handle, hostname).map_err(|e| {
+1 -2
crates/tranquil-pds/src/api/server/verify_email.rs
··· 5 5 use tracing::{info, warn}; 6 6 7 7 use crate::state::AppState; 8 - use crate::util::pds_hostname; 9 8 10 9 #[derive(Deserialize)] 11 10 #[serde(rename_all = "camelCase")] ··· 71 70 return Ok(Json(ResendMigrationVerificationOutput { sent: true })); 72 71 } 73 72 74 - let hostname = pds_hostname(); 73 + let hostname = &tranquil_config::get().server.hostname; 75 74 let token = crate::auth::verification_token::generate_migration_token(&user.did, &email); 76 75 let formatted_token = crate::auth::verification_token::format_token_for_display(&token); 77 76
+2 -3
crates/tranquil-pds/src/api/server/verify_token.rs
··· 1 1 use crate::api::error::{ApiError, DbResultExt}; 2 2 use crate::comms::comms_repo; 3 3 use crate::types::Did; 4 - use crate::util::pds_hostname; 5 4 use axum::{Json, extract::State}; 6 5 use serde::{Deserialize, Serialize}; 7 6 use tracing::{info, warn}; ··· 162 161 user_id, 163 162 channel, 164 163 &recipient, 165 - pds_hostname(), 164 + &tranquil_config::get().server.hostname, 166 165 ) 167 166 .await 168 167 { ··· 260 259 user.id, 261 260 channel, 262 261 &recipient, 263 - pds_hostname(), 262 + &tranquil_config::get().server.hostname, 264 263 ) 265 264 .await 266 265 {
+4 -5
crates/tranquil-pds/src/api/telegram_webhook.rs
··· 8 8 9 9 use crate::comms::comms_repo; 10 10 use crate::state::AppState; 11 - use crate::util::pds_hostname; 12 11 13 12 #[derive(Deserialize)] 14 13 struct TelegramUpdate { ··· 32 31 headers: HeaderMap, 33 32 body: String, 34 33 ) -> impl IntoResponse { 35 - let expected_secret = match std::env::var("TELEGRAM_WEBHOOK_SECRET") { 36 - Ok(s) => s, 37 - Err(_) => { 34 + let expected_secret = match &tranquil_config::get().telegram.webhook_secret { 35 + Some(s) => s.clone(), 36 + None => { 38 37 warn!("Telegram webhook called but TELEGRAM_WEBHOOK_SECRET is not configured"); 39 38 return StatusCode::FORBIDDEN; 40 39 } ··· 88 87 user_id, 89 88 tranquil_db_traits::CommsChannel::Telegram, 90 89 &from.id.to_string(), 91 - pds_hostname(), 90 + &tranquil_config::get().server.hostname, 92 91 ) 93 92 .await 94 93 {
+3 -6
crates/tranquil-pds/src/appview/mod.rs
··· 64 64 65 65 impl DidResolver { 66 66 pub fn new() -> Self { 67 - let cache_ttl_secs: u64 = std::env::var("DID_CACHE_TTL_SECS") 68 - .ok() 69 - .and_then(|v| v.parse().ok()) 70 - .unwrap_or(300); 67 + let cfg = tranquil_config::get(); 68 + let cache_ttl_secs = cfg.plc.did_cache_ttl_secs; 71 69 72 - let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") 73 - .unwrap_or_else(|_| "https://plc.directory".to_string()); 70 + let plc_directory_url = cfg.plc.directory_url.clone(); 74 71 75 72 let client = Client::builder() 76 73 .timeout(Duration::from_secs(10))
+2 -4
crates/tranquil-pds/src/auth/service.rs
··· 1 - use crate::util::pds_hostname; 2 1 use base64::Engine as _; 3 2 use base64::engine::general_purpose::URL_SAFE_NO_PAD; 4 3 use chrono::Utc; ··· 146 145 147 146 impl ServiceTokenVerifier { 148 147 pub fn new() -> Self { 149 - let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") 150 - .unwrap_or_else(|_| "https://plc.directory".to_string()); 148 + let plc_directory_url = tranquil_config::get().plc.directory_url.clone(); 151 149 152 - let pds_hostname = pds_hostname(); 150 + let pds_hostname = &tranquil_config::get().server.hostname; 153 151 let pds_did: Did = format!("did:web:{}", pds_hostname) 154 152 .parse() 155 153 .expect("PDS hostname produces a valid DID");
+1 -7
crates/tranquil-pds/src/auth/verification_token.rs
··· 61 61 62 62 fn derive_verification_key() -> [u8; 32] { 63 63 use hkdf::Hkdf; 64 - let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| { 65 - if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() { 66 - "test-master-key-not-for-production".to_string() 67 - } else { 68 - panic!("MASTER_KEY must be set"); 69 - } 70 - }); 64 + let master_key = tranquil_config::get().secrets.master_key_or_default(); 71 65 let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes()); 72 66 let mut key = [0u8; 32]; 73 67 hk.expand(b"tranquil-pds-verification-token-v1", &mut key)
+3 -8
crates/tranquil-pds/src/comms/service.rs
··· 21 21 22 22 impl CommsService { 23 23 pub fn new(infra_repo: Arc<dyn InfraRepository>) -> Self { 24 - let poll_interval_ms: u64 = std::env::var("NOTIFICATION_POLL_INTERVAL_MS") 25 - .ok() 26 - .and_then(|v| v.parse().ok()) 27 - .unwrap_or(1000); 28 - let batch_size: i64 = std::env::var("NOTIFICATION_BATCH_SIZE") 29 - .ok() 30 - .and_then(|v| v.parse().ok()) 31 - .unwrap_or(100); 24 + let cfg = tranquil_config::get(); 25 + let poll_interval_ms = cfg.notifications.poll_interval_ms; 26 + let batch_size = cfg.notifications.batch_size; 32 27 Self { 33 28 infra_repo, 34 29 senders: HashMap::new(),
+4 -48
crates/tranquil-pds/src/config.rs
··· 48 48 impl AuthConfig { 49 49 pub fn init() -> &'static Self { 50 50 CONFIG.get_or_init(|| { 51 - let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| { 52 - if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() { 53 - "test-jwt-secret-not-for-production".to_string() 54 - } else { 55 - panic!( 56 - "JWT_SECRET environment variable must be set in production. \ 57 - Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing." 58 - ); 59 - } 60 - }); 61 - 62 - let dpop_secret = std::env::var("DPOP_SECRET").unwrap_or_else(|_| { 63 - if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() { 64 - "test-dpop-secret-not-for-production".to_string() 65 - } else { 66 - panic!( 67 - "DPOP_SECRET environment variable must be set in production. \ 68 - Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing." 69 - ); 70 - } 71 - }); 72 - 73 - if jwt_secret.len() < 32 74 - && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() 75 - { 76 - panic!("JWT_SECRET must be at least 32 characters"); 77 - } 51 + let secrets = &tranquil_config::get().secrets; 78 52 79 - if dpop_secret.len() < 32 80 - && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() 81 - { 82 - panic!("DPOP_SECRET must be at least 32 characters"); 83 - } 53 + let jwt_secret = secrets.jwt_secret_or_default(); 54 + let dpop_secret = secrets.dpop_secret_or_default(); 84 55 85 56 let mut hasher = Sha256::new(); 86 57 hasher.update(b"oauth-signing-key-derivation:"); ··· 114 85 let kid_hash = kid_hasher.finalize(); 115 86 let signing_key_id = URL_SAFE_NO_PAD.encode(&kid_hash[..8]); 116 87 117 - let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| { 118 - if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() { 119 - "test-master-key-not-for-production".to_string() 120 - } else { 121 - panic!( 122 - "MASTER_KEY environment variable must be set in production. \ 123 - Set TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 for development/testing." 124 - ); 125 - } 126 - }); 127 - 128 - if master_key.len() < 32 129 - && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() 130 - { 131 - panic!("MASTER_KEY must be at least 32 characters"); 132 - } 88 + let master_key = secrets.master_key_or_default(); 133 89 134 90 let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes()); 135 91 let mut key_encryption_key = [0u8; 32];
+3 -9
crates/tranquil-pds/src/crawlers.rs
··· 1 1 use crate::circuit_breaker::CircuitBreaker; 2 2 use crate::sync::firehose::SequencedEvent; 3 - use crate::util::pds_hostname; 4 3 use reqwest::Client; 5 4 use std::sync::Arc; 6 5 use std::sync::atomic::{AtomicU64, Ordering}; ··· 42 41 self 43 42 } 44 43 45 - pub fn from_env() -> Option<Self> { 46 - let hostname = pds_hostname(); 44 + pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> { 45 + let hostname = &cfg.server.hostname; 47 46 if hostname == "localhost" { 48 47 return None; 49 48 } 50 49 51 - let crawler_urls: Vec<String> = std::env::var("CRAWLERS") 52 - .unwrap_or_default() 53 - .split(',') 54 - .filter(|s| !s.is_empty()) 55 - .map(|s| s.trim().to_string()) 56 - .collect(); 50 + let crawler_urls = cfg.firehose.crawler_list(); 57 51 58 52 if crawler_urls.is_empty() { 59 53 return None;
+2 -4
crates/tranquil-pds/src/handle/mod.rs
··· 87 87 } 88 88 } 89 89 90 - pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool { 90 + pub fn is_service_domain_handle(handle: &str, _hostname: &str) -> bool { 91 91 if !handle.contains('.') { 92 92 return true; 93 93 } 94 - let service_domains: Vec<String> = std::env::var("PDS_SERVICE_HANDLE_DOMAINS") 95 - .map(|s| s.split(',').map(|d| d.trim().to_string()).collect()) 96 - .unwrap_or_else(|_| vec![hostname.to_string()]); 94 + let service_domains = tranquil_config::get().server.user_handle_domain_list(); 97 95 service_domains 98 96 .iter() 99 97 .any(|domain| handle.ends_with(&format!(".{}", domain)) || handle == domain)
+3 -1
crates/tranquil-pds/src/lib.rs
··· 658 658 post(api::discord_webhook::handle_discord_webhook) 659 659 .layer(DefaultBodyLimit::max(64 * 1024)), 660 660 ) 661 - .layer(DefaultBodyLimit::max(util::get_max_blob_size())) 661 + .layer(DefaultBodyLimit::max( 662 + tranquil_config::get().server.max_blob_size as usize, 663 + )) 662 664 .layer(axum::middleware::map_response(rewrite_422_to_400)) 663 665 .layer(middleware::from_fn(metrics::metrics_middleware)) 664 666 .layer(
+92 -22
crates/tranquil-pds/src/main.rs
··· 1 + use clap::{Parser, Subcommand}; 1 2 use std::net::SocketAddr; 3 + use std::path::PathBuf; 2 4 use std::process::ExitCode; 3 5 use std::sync::Arc; 4 6 use tokio_util::sync::CancellationToken; ··· 18 20 }; 19 21 use tranquil_pds::state::AppState; 20 22 23 + #[derive(Parser)] 24 + #[command(name = "tranquil-pds", version = BUILD_VERSION, about = "Tranquil AT Protocol PDS")] 25 + struct Cli { 26 + /// Path to a TOML configuration file (also settable via TRANQUIL_PDS_CONFIG env var) 27 + #[arg(short, long, value_name = "FILE", env = "TRANQUIL_PDS_CONFIG")] 28 + config: Option<PathBuf>, 29 + 30 + #[command(subcommand)] 31 + command: Option<Command>, 32 + } 33 + 34 + #[derive(Subcommand)] 35 + enum Command { 36 + /// Validate the configuration and exit 37 + Validate { 38 + /// Skip validation of secrets and database URL (useful when secrets 39 + /// are provided at runtime via environment variables / secret files) 40 + #[arg(long)] 41 + ignore_secrets: bool, 42 + }, 43 + /// Print a TOML configuration template to stdout 44 + ConfigTemplate, 45 + } 46 + 21 47 #[tokio::main] 22 48 async fn main() -> ExitCode { 23 49 dotenvy::dotenv().ok(); 50 + 51 + let cli = Cli::parse(); 52 + 53 + // Handle subcommands that don't need full startup 54 + if let Some(command) = &cli.command { 55 + return match command { 56 + Command::ConfigTemplate => { 57 + print!("{}", tranquil_config::template()); 58 + ExitCode::SUCCESS 59 + } 60 + Command::Validate { ignore_secrets } => { 61 + let config = match tranquil_config::load(cli.config.as_ref()) { 62 + Ok(c) => c, 63 + Err(e) => { 64 + eprintln!("Failed to load configuration: {e:#}"); 65 + return ExitCode::FAILURE; 66 + } 67 + }; 68 + match config.validate(*ignore_secrets) { 69 + Ok(()) => { 70 + println!("Configuration is valid."); 71 + ExitCode::SUCCESS 72 + } 73 + Err(e) => { 74 + eprint!("{e}"); 75 + ExitCode::FAILURE 76 + } 77 + } 78 + } 79 + }; 80 + } 81 + 24 82 tracing_subscriber::fmt::init(); 83 + 84 + let config = match tranquil_config::load(cli.config.as_ref()) { 85 + Ok(c) => c, 86 + Err(e) => { 87 + error!("Failed to load configuration: {e:#}"); 88 + return ExitCode::FAILURE; 89 + } 90 + }; 91 + 92 + if let Err(e) = config.validate(false) { 93 + error!("{e}"); 94 + return ExitCode::FAILURE; 95 + } 96 + 97 + tranquil_config::init(config); 98 + 25 99 tranquil_pds::metrics::init_metrics(); 26 100 27 101 match run().await { ··· 66 140 let mut comms_service = CommsService::new(state.infra_repo.clone()); 67 141 let mut deferred_discord_endpoint: Option<(DiscordSender, String, String)> = None; 68 142 69 - if let Some(email_sender) = EmailSender::from_env() { 143 + let cfg = tranquil_config::get(); 144 + 145 + if let Some(email_sender) = EmailSender::from_config(cfg) { 70 146 info!("Email comms enabled"); 71 147 comms_service = comms_service.register_sender(email_sender); 72 148 } else { 73 149 warn!("Email comms disabled (MAIL_FROM_ADDRESS not set)"); 74 150 } 75 151 76 - if let Some(discord_sender) = DiscordSender::from_env() { 152 + if let Some(discord_sender) = DiscordSender::from_config(cfg) { 77 153 info!("Discord comms enabled"); 78 154 match discord_sender.resolve_bot_username().await { 79 155 Ok(username) => { ··· 96 172 Some(public_key) => { 97 173 tranquil_pds::util::set_discord_public_key(public_key); 98 174 info!("Discord Ed25519 public key loaded"); 99 - let hostname = std::env::var("PDS_HOSTNAME") 100 - .unwrap_or_else(|_| "localhost".to_string()); 175 + let hostname = &tranquil_config::get().server.hostname; 101 176 let webhook_url = format!("https://{}/webhook/discord", hostname); 102 177 match discord_sender.register_slash_command(&app_id).await { 103 178 Ok(()) => info!("Discord /start slash command registered"), ··· 118 193 comms_service = comms_service.register_sender(discord_sender); 119 194 } 120 195 121 - if let Some(telegram_sender) = TelegramSender::from_env() { 122 - let secret_token = match std::env::var("TELEGRAM_WEBHOOK_SECRET") { 123 - Ok(s) => s, 124 - Err(_) => { 125 - return Err( 126 - "TELEGRAM_BOT_TOKEN is set but TELEGRAM_WEBHOOK_SECRET is missing. Both are required for secure Telegram integration.".into() 127 - ); 128 - } 129 - }; 196 + if let Some(telegram_sender) = TelegramSender::from_config(cfg) { 197 + // Safe to unwrap: validated in TranquilConfig::validate() 198 + let secret_token = tranquil_config::get() 199 + .telegram 200 + .webhook_secret 201 + .clone() 202 + .expect("telegram.webhook_secret checked during config validation"); 130 203 info!("Telegram comms enabled"); 131 204 match telegram_sender.resolve_bot_username().await { 132 205 Ok(username) => { 133 206 info!(bot_username = %username, "Resolved Telegram bot username"); 134 207 tranquil_pds::util::set_telegram_bot_username(username); 135 - let hostname = 136 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 208 + let hostname = tranquil_config::get().server.hostname.clone(); 137 209 let webhook_url = format!("https://{}/webhook/telegram", hostname); 138 210 match telegram_sender 139 211 .set_webhook(&webhook_url, Some(&secret_token)) ··· 150 222 comms_service = comms_service.register_sender(telegram_sender); 151 223 } 152 224 153 - if let Some(signal_sender) = SignalSender::from_env() { 225 + if let Some(signal_sender) = SignalSender::from_config(cfg) { 154 226 info!("Signal comms enabled"); 155 227 comms_service = comms_service.register_sender(signal_sender); 156 228 } 157 229 158 230 let comms_handle = tokio::spawn(comms_service.run(shutdown.clone())); 159 231 160 - let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() { 232 + let crawlers_handle = if let Some(crawlers) = Crawlers::from_config(cfg) { 161 233 let crawlers = Arc::new( 162 234 crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone()), 163 235 ); ··· 197 269 198 270 let app = tranquil_pds::app(state); 199 271 200 - let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 201 - let port: u16 = std::env::var("SERVER_PORT") 202 - .ok() 203 - .and_then(|p| p.parse().ok()) 204 - .unwrap_or(3000); 272 + let cfg = tranquil_config::get(); 273 + let host = &cfg.server.host; 274 + let port = cfg.server.port; 205 275 206 276 let addr: SocketAddr = format!("{}:{}", host, port) 207 277 .parse()
+1 -8
crates/tranquil-pds/src/moderation/mod.rs
··· 34 34 } 35 35 36 36 fn get_extra_banned_words() -> &'static Vec<String> { 37 - EXTRA_BANNED_WORDS.get_or_init(|| { 38 - std::env::var("PDS_BANNED_WORDS") 39 - .unwrap_or_default() 40 - .split(',') 41 - .map(|s| s.trim().to_lowercase()) 42 - .filter(|s| !s.is_empty()) 43 - .collect() 44 - }) 37 + EXTRA_BANNED_WORDS.get_or_init(|| tranquil_config::get().server.banned_word_list()) 45 38 } 46 39 47 40 fn strip_trailing_digits(s: &str) -> &str {
+18 -18
crates/tranquil-pds/src/oauth/endpoints/authorize.rs
··· 10 10 }; 11 11 use crate::state::AppState; 12 12 use crate::types::{Did, Handle, PlainPassword}; 13 - use crate::util::{extract_client_ip, pds_hostname, pds_hostname_without_port}; 13 + use crate::util::extract_client_ip; 14 14 use axum::{ 15 15 Json, 16 16 extract::{Query, State}, ··· 253 253 254 254 if let Some(ref login_hint) = request_data.parameters.login_hint { 255 255 tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation"); 256 - let hostname_for_handles = pds_hostname_without_port(); 257 - let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles); 256 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 257 + let normalized = NormalizedLoginIdentifier::normalize(login_hint, &hostname_for_handles); 258 258 tracing::info!(normalized = %normalized, "Normalized login_hint"); 259 259 260 260 match state ··· 526 526 url_encode(error_msg) 527 527 )) 528 528 }; 529 - let hostname_for_handles = pds_hostname_without_port(); 529 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 530 530 let normalized_username = 531 - NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles); 531 + NormalizedLoginIdentifier::normalize(&form.username, &hostname_for_handles); 532 532 tracing::debug!( 533 533 original_username = %form.username, 534 534 normalized_username = %normalized_username, 535 - pds_hostname = %pds_hostname(), 535 + pds_hostname = %tranquil_config::get().server.hostname, 536 536 "Normalized username for lookup" 537 537 ); 538 538 let user = match state ··· 677 677 .await 678 678 { 679 679 Ok(challenge) => { 680 - let hostname = pds_hostname(); 680 + let hostname = &tranquil_config::get().server.hostname; 681 681 if let Err(e) = enqueue_2fa_code( 682 682 state.user_repo.as_ref(), 683 683 state.infra_repo.as_ref(), ··· 992 992 .await 993 993 { 994 994 Ok(challenge) => { 995 - let hostname = pds_hostname(); 995 + let hostname = &tranquil_config::get().server.hostname; 996 996 if let Err(e) = enqueue_2fa_code( 997 997 state.user_repo.as_ref(), 998 998 state.infra_repo.as_ref(), ··· 1116 1116 '?' 1117 1117 }; 1118 1118 redirect_url.push(separator); 1119 - let pds_host = pds_hostname(); 1119 + let pds_host = &tranquil_config::get().server.hostname; 1120 1120 redirect_url.push_str(&format!( 1121 1121 "iss={}", 1122 1122 url_encode(&format!("https://{}", pds_host)) ··· 1134 1134 state: Option<&str>, 1135 1135 response_mode: Option<&str>, 1136 1136 ) -> String { 1137 - let pds_host = pds_hostname(); 1137 + let pds_host = &tranquil_config::get().server.hostname; 1138 1138 let mut url = format!( 1139 1139 "https://{}/oauth/authorize/redirect?redirect_uri={}&code={}", 1140 1140 pds_host, ··· 1991 1991 State(state): State<AppState>, 1992 1992 Query(query): Query<CheckPasskeysQuery>, 1993 1993 ) -> Response { 1994 - let hostname_for_handles = pds_hostname_without_port(); 1994 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 1995 1995 let bare_identifier = 1996 - BareLoginIdentifier::from_identifier(&query.identifier, hostname_for_handles); 1996 + BareLoginIdentifier::from_identifier(&query.identifier, &hostname_for_handles); 1997 1997 1998 1998 let user = state 1999 1999 .user_repo ··· 2023 2023 State(state): State<AppState>, 2024 2024 Query(query): Query<CheckPasskeysQuery>, 2025 2025 ) -> Response { 2026 - let hostname_for_handles = pds_hostname_without_port(); 2026 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 2027 2027 let normalized_identifier = 2028 - NormalizedLoginIdentifier::normalize(&query.identifier, hostname_for_handles); 2028 + NormalizedLoginIdentifier::normalize(&query.identifier, &hostname_for_handles); 2029 2029 2030 2030 let user = state 2031 2031 .user_repo ··· 2131 2131 .into_response(); 2132 2132 } 2133 2133 2134 - let hostname_for_handles = pds_hostname_without_port(); 2134 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 2135 2135 let normalized_username = 2136 - NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles); 2136 + NormalizedLoginIdentifier::normalize(&form.identifier, &hostname_for_handles); 2137 2137 2138 2138 let user = match state 2139 2139 .user_repo ··· 2602 2602 .await 2603 2603 { 2604 2604 Ok(challenge) => { 2605 - let hostname = pds_hostname(); 2605 + let hostname = &tranquil_config::get().server.hostname; 2606 2606 if let Err(e) = enqueue_2fa_code( 2607 2607 state.user_repo.as_ref(), 2608 2608 state.infra_repo.as_ref(), ··· 2881 2881 headers: HeaderMap, 2882 2882 Json(form): Json<AuthorizePasskeySubmit>, 2883 2883 ) -> Response { 2884 - let pds_hostname = pds_hostname(); 2884 + let pds_hostname = &tranquil_config::get().server.hostname; 2885 2885 let passkey_finish_request_id = RequestId::from(form.request_uri.clone()); 2886 2886 2887 2887 let request_data = match state
+2 -3
crates/tranquil-pds/src/oauth/endpoints/metadata.rs
··· 1 1 use crate::oauth::jwks::{JwkSet, create_jwk_set}; 2 2 use crate::state::AppState; 3 - use crate::util::pds_hostname; 4 3 use axum::{Json, extract::State}; 5 4 use serde::{Deserialize, Serialize}; 6 5 ··· 58 57 pub async fn oauth_protected_resource( 59 58 State(_state): State<AppState>, 60 59 ) -> Json<ProtectedResourceMetadata> { 61 - let pds_hostname = pds_hostname(); 60 + let pds_hostname = &tranquil_config::get().server.hostname; 62 61 let public_url = format!("https://{}", pds_hostname); 63 62 Json(ProtectedResourceMetadata { 64 63 resource: public_url.clone(), ··· 72 71 pub async fn oauth_authorization_server( 73 72 State(_state): State<AppState>, 74 73 ) -> Json<AuthorizationServerMetadata> { 75 - let pds_hostname = pds_hostname(); 74 + let pds_hostname = &tranquil_config::get().server.hostname; 76 75 let issuer = format!("https://{}", pds_hostname); 77 76 Json(AuthorizationServerMetadata { 78 77 issuer: issuer.clone(),
+2 -3
crates/tranquil-pds/src/oauth/endpoints/token/grants.rs
··· 12 12 verify_client_auth, 13 13 }; 14 14 use crate::state::AppState; 15 - use crate::util::pds_hostname; 16 15 use axum::Json; 17 16 use axum::http::{HeaderMap, Method}; 18 17 use chrono::{Duration, Utc}; ··· 101 100 let dpop_jkt = if let Some(proof) = &dpop_proof { 102 101 let config = AuthConfig::get(); 103 102 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 104 - let pds_hostname = pds_hostname(); 103 + let pds_hostname = &tranquil_config::get().server.hostname; 105 104 let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 106 105 let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?; 107 106 if !state ··· 348 347 let dpop_jkt = if let Some(proof) = &dpop_proof { 349 348 let config = AuthConfig::get(); 350 349 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 351 - let pds_hostname = pds_hostname(); 350 + let pds_hostname = &tranquil_config::get().server.hostname; 352 351 let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 353 352 let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?; 354 353 if !state
+1 -2
crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs
··· 1 1 use crate::config::AuthConfig; 2 2 use crate::oauth::OAuthError; 3 - use crate::util::pds_hostname; 4 3 use base64::Engine; 5 4 use base64::engine::general_purpose::URL_SAFE_NO_PAD; 6 5 use chrono::Utc; ··· 52 51 ) -> Result<String, OAuthError> { 53 52 use serde_json::json; 54 53 let jti = uuid::Uuid::new_v4().to_string(); 55 - let pds_hostname = pds_hostname(); 54 + let pds_hostname = &tranquil_config::get().server.hostname; 56 55 let issuer = format!("https://{}", pds_hostname); 57 56 let now = Utc::now().timestamp(); 58 57 let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS;
+1 -2
crates/tranquil-pds/src/oauth/endpoints/token/introspect.rs
··· 2 2 use crate::oauth::OAuthError; 3 3 use crate::rate_limit::{OAuthIntrospectLimit, OAuthRateLimited}; 4 4 use crate::state::AppState; 5 - use crate::util::pds_hostname; 6 5 use axum::extract::State; 7 6 use axum::http::StatusCode; 8 7 use axum::{Form, Json}; ··· 112 111 if token_data.expires_at < Utc::now() { 113 112 return Ok(Json(inactive_response)); 114 113 } 115 - let pds_hostname = pds_hostname(); 114 + let pds_hostname = &tranquil_config::get().server.hostname; 116 115 let issuer = format!("https://{}", pds_hostname); 117 116 Ok(Json(IntrospectResponse { 118 117 active: true,
+4 -12
crates/tranquil-pds/src/plc/mod.rs
··· 124 124 } 125 125 126 126 pub fn with_cache(base_url: Option<String>, cache: Option<Arc<dyn Cache>>) -> Self { 127 - let base_url = base_url.unwrap_or_else(|| { 128 - std::env::var("PLC_DIRECTORY_URL") 129 - .unwrap_or_else(|_| "https://plc.directory".to_string()) 130 - }); 131 - let timeout_secs: u64 = std::env::var("PLC_TIMEOUT_SECS") 132 - .ok() 133 - .and_then(|v| v.parse().ok()) 134 - .unwrap_or(10); 135 - let connect_timeout_secs: u64 = std::env::var("PLC_CONNECT_TIMEOUT_SECS") 136 - .ok() 137 - .and_then(|v| v.parse().ok()) 138 - .unwrap_or(5); 127 + let cfg = tranquil_config::get(); 128 + let base_url = base_url.unwrap_or_else(|| cfg.plc.directory_url.clone()); 129 + let timeout_secs = cfg.plc.timeout_secs; 130 + let connect_timeout_secs = cfg.plc.connect_timeout_secs; 139 131 let client = Client::builder() 140 132 .timeout(Duration::from_secs(timeout_secs)) 141 133 .connect_timeout(Duration::from_secs(connect_timeout_secs))
+2 -6
crates/tranquil-pds/src/scheduled.rs
··· 438 438 sso_repo: Arc<dyn SsoRepository>, 439 439 shutdown: CancellationToken, 440 440 ) { 441 - let check_interval = Duration::from_secs( 442 - std::env::var("SCHEDULED_DELETE_CHECK_INTERVAL_SECS") 443 - .ok() 444 - .and_then(|s| s.parse().ok()) 445 - .unwrap_or(3600), 446 - ); 441 + let check_interval = 442 + Duration::from_secs(tranquil_config::get().scheduled.delete_check_interval_secs); 447 443 448 444 info!( 449 445 check_interval_secs = check_interval.as_secs(),
+87 -73
crates/tranquil-pds/src/sso/config.rs
··· 1 - use crate::util::pds_hostname; 2 1 use std::sync::OnceLock; 3 2 use tranquil_db_traits::SsoProviderType; 4 3 ··· 34 33 impl SsoConfig { 35 34 pub fn init() -> &'static Self { 36 35 SSO_CONFIG.get_or_init(|| { 37 - let github = Self::load_provider("GITHUB", false); 38 - let discord = Self::load_provider("DISCORD", false); 39 - let google = Self::load_provider("GOOGLE", false); 40 - let gitlab = Self::load_provider("GITLAB", true); 41 - let oidc = Self::load_provider("OIDC", true); 42 - let apple = Self::load_apple_provider(); 43 - 44 - let config = SsoConfig { 45 - github, 46 - discord, 47 - google, 48 - gitlab, 49 - oidc, 50 - apple, 36 + let sso = &tranquil_config::get().sso; 37 + let config = SsoConfig { 38 + github: Self::provider_from_config( 39 + sso.github.enabled, 40 + sso.github.client_id.as_deref(), 41 + sso.github.client_secret.as_deref(), 42 + None, 43 + sso.github.display_name.as_deref(), 44 + "GITHUB", 45 + false, 46 + ), 47 + discord: Self::provider_from_config( 48 + sso.discord.enabled, 49 + sso.discord.client_id.as_deref(), 50 + sso.discord.client_secret.as_deref(), 51 + None, 52 + sso.discord.display_name.as_deref(), 53 + "DISCORD", 54 + false, 55 + ), 56 + google: Self::provider_from_config( 57 + sso.google.enabled, 58 + sso.google.client_id.as_deref(), 59 + sso.google.client_secret.as_deref(), 60 + None, 61 + sso.google.display_name.as_deref(), 62 + "GOOGLE", 63 + false, 64 + ), 65 + gitlab: Self::provider_from_config( 66 + sso.gitlab.enabled, 67 + sso.gitlab.client_id.as_deref(), 68 + sso.gitlab.client_secret.as_deref(), 69 + sso.gitlab.issuer.as_deref(), 70 + sso.gitlab.display_name.as_deref(), 71 + "GITLAB", 72 + true, 73 + ), 74 + oidc: Self::provider_from_config( 75 + sso.oidc.enabled, 76 + sso.oidc.client_id.as_deref(), 77 + sso.oidc.client_secret.as_deref(), 78 + sso.oidc.issuer.as_deref(), 79 + sso.oidc.display_name.as_deref(), 80 + "OIDC", 81 + true, 82 + ), 83 + apple: Self::apple_from_config(&sso.apple), 51 84 }; 52 85 53 86 if config.is_any_enabled() { 54 - let hostname = pds_hostname(); 87 + let hostname = &tranquil_config::get().server.hostname; 55 88 if hostname.is_empty() || hostname == "localhost" { 56 89 panic!( 57 90 "PDS_HOSTNAME must be set to a valid hostname when SSO is enabled. \ ··· 72 105 }) 73 106 } 74 107 75 - pub fn get_redirect_uri() -> &'static str { 76 - SSO_REDIRECT_URI 77 - .get() 78 - .map(|s| s.as_str()) 79 - .expect("SSO redirect URI not initialized - call SsoConfig::init() first") 80 - } 81 - 82 - fn load_provider(name: &str, needs_issuer: bool) -> Option<ProviderConfig> { 83 - let enabled = crate::util::parse_env_bool(&format!("SSO_{}_ENABLED", name)); 84 - 108 + fn provider_from_config( 109 + enabled: bool, 110 + client_id: Option<&str>, 111 + client_secret: Option<&str>, 112 + issuer: Option<&str>, 113 + display_name: Option<&str>, 114 + name: &str, 115 + needs_issuer: bool, 116 + ) -> Option<ProviderConfig> { 85 117 if !enabled { 86 118 return None; 87 119 } 88 - 89 - let client_id = std::env::var(format!("SSO_{}_CLIENT_ID", name)).ok()?; 90 - let client_secret = std::env::var(format!("SSO_{}_CLIENT_SECRET", name)).ok()?; 91 - 92 - if client_id.is_empty() || client_secret.is_empty() { 93 - tracing::warn!( 94 - "SSO_{} enabled but missing client_id or client_secret", 95 - name 96 - ); 97 - return None; 98 - } 120 + let client_id = client_id.filter(|s| !s.is_empty())?; 121 + let client_secret = client_secret.filter(|s| !s.is_empty())?; 99 122 100 - let issuer = if needs_issuer { 101 - let issuer_val = std::env::var(format!("SSO_{}_ISSUER", name)).ok(); 102 - if issuer_val.is_none() || issuer_val.as_ref().map(|s| s.is_empty()).unwrap_or(true) { 123 + if needs_issuer { 124 + let issuer_val = issuer.filter(|s| !s.is_empty()); 125 + if issuer_val.is_none() { 103 126 tracing::warn!("SSO_{} requires ISSUER but none provided", name); 104 127 return None; 105 128 } 106 - issuer_val 107 - } else { 108 - None 109 - }; 110 - 111 - let display_name = std::env::var(format!("SSO_{}_NAME", name)).ok(); 129 + } 112 130 113 131 Some(ProviderConfig { 114 - client_id, 115 - client_secret, 116 - issuer, 117 - display_name, 132 + client_id: client_id.to_string(), 133 + client_secret: client_secret.to_string(), 134 + issuer: issuer.map(|s| s.to_string()), 135 + display_name: display_name.map(|s| s.to_string()), 118 136 }) 119 137 } 120 138 121 - fn load_apple_provider() -> Option<AppleProviderConfig> { 122 - let enabled = crate::util::parse_env_bool("SSO_APPLE_ENABLED"); 123 - 124 - if !enabled { 139 + fn apple_from_config(cfg: &tranquil_config::SsoAppleConfig) -> Option<AppleProviderConfig> { 140 + if !cfg.enabled { 125 141 return None; 126 142 } 127 - 128 - let client_id = std::env::var("SSO_APPLE_CLIENT_ID").ok()?; 129 - let team_id = std::env::var("SSO_APPLE_TEAM_ID").ok()?; 130 - let key_id = std::env::var("SSO_APPLE_KEY_ID").ok()?; 131 - let private_key_pem = std::env::var("SSO_APPLE_PRIVATE_KEY").ok()?; 143 + let client_id = cfg.client_id.as_deref().filter(|s| !s.is_empty())?; 144 + let team_id = cfg.team_id.as_deref().filter(|s| !s.is_empty())?; 145 + let key_id = cfg.key_id.as_deref().filter(|s| !s.is_empty())?; 146 + let private_key_pem = cfg.private_key.as_deref().filter(|s| !s.is_empty())?; 132 147 133 - if client_id.is_empty() { 134 - tracing::warn!("SSO_APPLE enabled but missing CLIENT_ID"); 135 - return None; 136 - } 137 - if team_id.is_empty() || team_id.len() != 10 { 148 + if team_id.len() != 10 { 138 149 tracing::warn!("SSO_APPLE enabled but TEAM_ID is invalid (must be 10 characters)"); 139 150 return None; 140 151 } 141 - if key_id.is_empty() { 142 - tracing::warn!("SSO_APPLE enabled but missing KEY_ID"); 143 - return None; 144 - } 145 - if private_key_pem.is_empty() || !private_key_pem.contains("PRIVATE KEY") { 152 + if !private_key_pem.contains("PRIVATE KEY") { 146 153 tracing::warn!("SSO_APPLE enabled but PRIVATE_KEY is invalid"); 147 154 return None; 148 155 } 149 156 150 157 Some(AppleProviderConfig { 151 - client_id, 152 - team_id, 153 - key_id, 154 - private_key_pem, 158 + client_id: client_id.to_string(), 159 + team_id: team_id.to_string(), 160 + key_id: key_id.to_string(), 161 + private_key_pem: private_key_pem.to_string(), 155 162 }) 163 + } 164 + 165 + pub fn get_redirect_uri() -> &'static str { 166 + SSO_REDIRECT_URI 167 + .get() 168 + .map(|s| s.as_str()) 169 + .expect("SSO redirect URI not initialized - call SsoConfig::init() first") 156 170 } 157 171 158 172 pub fn get() -> &'static Self {
+16 -12
crates/tranquil-pds/src/sso/endpoints.rs
··· 18 18 check_user_rate_limit_with_message, 19 19 }; 20 20 use crate::state::AppState; 21 - use crate::util::{pds_hostname, pds_hostname_without_port}; 22 21 23 22 fn generate_state() -> String { 24 23 use rand::RngCore; ··· 773 772 } 774 773 }; 775 774 776 - let hostname_for_handles = pds_hostname_without_port(); 775 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 777 776 let full_handle = format!("{}.{}", validated, hostname_for_handles); 778 777 let handle_typed: crate::types::Handle = match full_handle.parse() { 779 778 Ok(h) => h, ··· 856 855 .await? 857 856 .ok_or(ApiError::SsoSessionExpired)?; 858 857 859 - let hostname = pds_hostname(); 860 - let hostname_for_handles = pds_hostname_without_port(); 858 + let hostname = &tranquil_config::get().server.hostname; 859 + let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 861 860 862 861 let handle = match crate::api::validation::validate_short_handle(&input.handle) { 863 862 Ok(h) => format!("{}.{}", h, hostname_for_handles), ··· 948 947 Err(_) => return Err(ApiError::InvalidInviteCode), 949 948 } 950 949 } else { 951 - let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); 950 + let invite_required = tranquil_config::get().server.invite_code_required; 952 951 if invite_required { 953 952 return Err(ApiError::InviteCodeRequired); 954 953 } ··· 1006 1005 d.to_string() 1007 1006 } 1008 1007 _ => { 1009 - let rotation_key = std::env::var("PLC_ROTATION_KEY") 1010 - .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key)); 1008 + let rotation_key = tranquil_config::get() 1009 + .secrets 1010 + .plc_rotation_key 1011 + .clone() 1012 + .unwrap_or_else(|| crate::plc::signing_key_to_did_key(&signing_key)); 1011 1013 1012 1014 let genesis_result = match crate::plc::create_genesis_operation( 1013 1015 &signing_key, ··· 1085 1087 1086 1088 let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; 1087 1089 1088 - let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| { 1089 - json!({ 1090 + let birthdate_pref = if tranquil_config::get().server.age_assurance_override { 1091 + Some(json!({ 1090 1092 "$type": "app.bsky.actor.defs#personalDetailsPref", 1091 1093 "birthDate": "1998-05-06T00:00:00.000Z" 1092 - }) 1093 - }); 1094 + })) 1095 + } else { 1096 + None 1097 + }; 1094 1098 1095 1099 let create_input = tranquil_db_traits::CreateSsoAccountInput { 1096 1100 handle: handle_typed.clone(), ··· 1299 1303 return Err(ApiError::InternalError(None)); 1300 1304 } 1301 1305 1302 - let hostname = pds_hostname(); 1306 + let hostname = &tranquil_config::get().server.hostname; 1303 1307 if let Err(e) = crate::comms::comms_repo::enqueue_welcome( 1304 1308 state.user_repo.as_ref(), 1305 1309 state.infra_repo.as_ref(),
+12 -28
crates/tranquil-pds/src/state.rs
··· 1 1 use crate::appview::DidResolver; 2 2 use crate::auth::webauthn::WebAuthnConfig; 3 - use crate::cache::{Cache, DistributedRateLimiter, create_cache}; 3 + use crate::cache::{create_cache, Cache, DistributedRateLimiter}; 4 4 use crate::circuit_breaker::CircuitBreakers; 5 5 use crate::config::AuthConfig; 6 6 use crate::rate_limit::RateLimiters; 7 7 use crate::repo::PostgresBlockStore; 8 8 use crate::repo_write_lock::RepoWriteLocks; 9 9 use crate::sso::{SsoConfig, SsoManager}; 10 - use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage}; 10 + use crate::storage::{create_backup_storage, create_blob_storage, BackupStorage, BlobStorage}; 11 11 use crate::sync::firehose::SequencedEvent; 12 - use crate::util::pds_hostname; 13 12 use sqlx::PgPool; 14 13 use std::error::Error; 14 + use std::sync::atomic::{AtomicBool, Ordering}; 15 15 use std::sync::Arc; 16 - use std::sync::atomic::{AtomicBool, Ordering}; 17 16 use tokio::sync::broadcast; 18 17 use tokio_util::sync::CancellationToken; 19 18 use tranquil_db::{ ··· 25 24 static RATE_LIMITING_DISABLED: AtomicBool = AtomicBool::new(false); 26 25 27 26 pub fn init_rate_limit_override() { 28 - let disabled = std::env::var("DISABLE_RATE_LIMITING").is_ok(); 27 + let disabled = tranquil_config::get().server.disable_rate_limiting; 29 28 RATE_LIMITING_DISABLED.store(disabled, Ordering::Relaxed); 30 29 if disabled { 31 - tracing::warn!("rate limiting is DISABLED via DISABLE_RATE_LIMITING env var"); 30 + tracing::warn!("rate limiting is DISABLED via configuration"); 32 31 } 33 32 } 34 33 ··· 205 204 206 205 impl AppState { 207 206 pub async fn new(shutdown: CancellationToken) -> Result<Self, Box<dyn Error>> { 208 - let database_url = std::env::var("DATABASE_URL") 209 - .map_err(|_| "DATABASE_URL environment variable must be set")?; 210 - 211 - let max_connections: u32 = std::env::var("DATABASE_MAX_CONNECTIONS") 212 - .ok() 213 - .and_then(|v| v.parse().ok()) 214 - .unwrap_or(100); 215 - 216 - let min_connections: u32 = std::env::var("DATABASE_MIN_CONNECTIONS") 217 - .ok() 218 - .and_then(|v| v.parse().ok()) 219 - .unwrap_or(10); 220 - 221 - let acquire_timeout_secs: u64 = std::env::var("DATABASE_ACQUIRE_TIMEOUT_SECS") 222 - .ok() 223 - .and_then(|v| v.parse().ok()) 224 - .unwrap_or(10); 207 + let cfg = tranquil_config::get(); 208 + let database_url = &cfg.database.url; 209 + let max_connections = cfg.database.max_connections; 210 + let min_connections = cfg.database.min_connections; 211 + let acquire_timeout_secs = cfg.database.acquire_timeout_secs; 225 212 226 213 tracing::info!( 227 214 "Configuring database pool: max={}, min={}, acquire_timeout={}s", ··· 257 244 let blob_store = create_blob_storage().await; 258 245 let backup_storage = create_backup_storage().await; 259 246 260 - let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE") 261 - .ok() 262 - .and_then(|v| v.parse().ok()) 263 - .unwrap_or(10000); 247 + let firehose_buffer_size = tranquil_config::get().firehose.buffer_size; 264 248 265 249 let (firehose_tx, _) = broadcast::channel(firehose_buffer_size); 266 250 let rate_limiters = Arc::new(RateLimiters::new()); ··· 271 255 let sso_config = SsoConfig::init(); 272 256 let sso_manager = SsoManager::from_config(sso_config); 273 257 let webauthn_config = Arc::new( 274 - WebAuthnConfig::new(pds_hostname()) 258 + WebAuthnConfig::new(&tranquil_config::get().server.hostname) 275 259 .expect("Failed to create WebAuthn config at startup"), 276 260 ); 277 261
+2 -8
crates/tranquil-pds/src/sync/subscribe_repos.rs
··· 59 59 } 60 60 61 61 fn get_backfill_hours() -> i64 { 62 - std::env::var("FIREHOSE_BACKFILL_HOURS") 63 - .ok() 64 - .and_then(|v| v.parse().ok()) 65 - .unwrap_or(72) 62 + tranquil_config::get().firehose.backfill_hours 66 63 } 67 64 68 65 async fn handle_socket_inner( ··· 204 201 } 205 202 } 206 203 } 207 - let max_lag_before_disconnect: u64 = std::env::var("FIREHOSE_MAX_LAG") 208 - .ok() 209 - .and_then(|v| v.parse().ok()) 210 - .unwrap_or(5000); 204 + let max_lag_before_disconnect: u64 = tranquil_config::get().firehose.max_lag; 211 205 loop { 212 206 tokio::select! { 213 207 result = rx.recv() => {
+1 -2
crates/tranquil-pds/src/sync/verify.rs
··· 145 145 } 146 146 147 147 async fn resolve_plc_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> { 148 - let plc_url = std::env::var("PLC_DIRECTORY_URL") 149 - .unwrap_or_else(|_| "https://plc.directory".to_string()); 148 + let plc_url = tranquil_config::get().plc.directory_url.clone(); 150 149 let url = format!("{}/{}", plc_url, urlencoding::encode(did)); 151 150 let response = self 152 151 .http_client
+5 -31
crates/tranquil-pds/src/util.rs
··· 9 9 use std::sync::OnceLock; 10 10 11 11 const BASE32_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz234567"; 12 - const DEFAULT_MAX_BLOB_SIZE: usize = 10 * 1024 * 1024 * 1024; 13 12 14 - static MAX_BLOB_SIZE: OnceLock<usize> = OnceLock::new(); 15 - static PDS_HOSTNAME: OnceLock<String> = OnceLock::new(); 16 - static PDS_HOSTNAME_WITHOUT_PORT: OnceLock<String> = OnceLock::new(); 17 13 static DISCORD_BOT_USERNAME: OnceLock<String> = OnceLock::new(); 18 14 static DISCORD_PUBLIC_KEY: OnceLock<ed25519_dalek::VerifyingKey> = OnceLock::new(); 19 15 static DISCORD_APP_ID: OnceLock<String> = OnceLock::new(); 20 16 static TELEGRAM_BOT_USERNAME: OnceLock<String> = OnceLock::new(); 21 - 22 - pub fn get_max_blob_size() -> usize { 23 - *MAX_BLOB_SIZE.get_or_init(|| { 24 - std::env::var("MAX_BLOB_SIZE") 25 - .ok() 26 - .and_then(|s| s.parse().ok()) 27 - .unwrap_or(DEFAULT_MAX_BLOB_SIZE) 28 - }) 29 - } 30 17 31 18 pub fn generate_token_code() -> String { 32 19 generate_token_code_parts(2, 5) ··· 109 96 .unwrap_or_else(|| "unknown".to_string()) 110 97 } 111 98 112 - pub fn pds_hostname() -> &'static str { 113 - PDS_HOSTNAME 114 - .get_or_init(|| std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())) 115 - } 116 - 117 - pub fn pds_hostname_without_port() -> &'static str { 118 - PDS_HOSTNAME_WITHOUT_PORT.get_or_init(|| { 119 - let hostname = pds_hostname(); 120 - hostname.split(':').next().unwrap_or(hostname).to_string() 121 - }) 122 - } 123 - 124 99 pub fn set_discord_bot_username(username: String) { 125 100 DISCORD_BOT_USERNAME.set(username).ok(); 126 101 } ··· 154 129 } 155 130 156 131 pub fn parse_env_bool(key: &str) -> bool { 132 + // Check the config system first, then fall back to env var for dynamic 133 + // SSO keys that are not in the static config struct. 157 134 std::env::var(key) 158 135 .map(|v| v == "true" || v == "1") 159 136 .unwrap_or(false) 160 - } 161 - 162 - pub fn pds_public_url() -> String { 163 - format!("https://{}", pds_hostname()) 164 137 } 165 138 166 139 pub fn build_full_url(path: &str) -> String { 140 + let cfg = tranquil_config::get(); 167 141 let normalized_path = if !path.starts_with("/xrpc/") 168 142 && (path.starts_with("/com.atproto.") 169 143 || path.starts_with("/app.bsky.") 170 144 || path.starts_with("/_")) 171 145 { 172 - format!("/xrpc{}", path) 146 + format!("/xrpc{path}") 173 147 } else { 174 148 path.to_string() 175 149 }; 176 - format!("{}{}", pds_public_url(), normalized_path) 150 + format!("{}{normalized_path}", cfg.server.public_url()) 177 151 } 178 152 179 153 pub fn json_to_ipld(value: &JsonValue) -> Ipld {
+1
crates/tranquil-ripple/Cargo.toml
··· 5 5 license.workspace = true 6 6 7 7 [dependencies] 8 + tranquil-config = { workspace = true } 8 9 tranquil-infra = { workspace = true } 9 10 10 11 async-trait = { workspace = true }
+23 -42
crates/tranquil-ripple/src/config.rs
··· 15 15 pub cache_max_bytes: usize, 16 16 } 17 17 18 - fn parse_env_with_warning<T: std::str::FromStr>(var_name: &str, raw: &str) -> Option<T> { 19 - match raw.parse::<T>() { 20 - Ok(v) => Some(v), 21 - Err(_) => { 22 - tracing::warn!( 23 - var = var_name, 24 - value = raw, 25 - "invalid env var value, using default" 26 - ); 27 - None 28 - } 29 - } 30 - } 18 + impl RippleConfig { 19 + pub fn from_config() -> Result<Self, RippleConfigError> { 20 + let ripple = &tranquil_config::get().cache.ripple; 31 21 32 - impl RippleConfig { 33 - pub fn from_env() -> Result<Self, RippleConfigError> { 34 - let bind_addr: SocketAddr = std::env::var("RIPPLE_BIND") 35 - .unwrap_or_else(|_| "0.0.0.0:0".into()) 22 + let bind_addr: SocketAddr = ripple 23 + .bind_addr 36 24 .parse() 37 25 .map_err(|e| RippleConfigError::InvalidAddr(format!("{e}")))?; 38 26 39 - let seed_peers: Vec<SocketAddr> = std::env::var("RIPPLE_PEERS") 40 - .unwrap_or_default() 41 - .split(',') 27 + let seed_peers: Vec<SocketAddr> = ripple 28 + .peers 29 + .as_deref() 30 + .unwrap_or(&[]) 31 + .iter() 42 32 .filter(|s| !s.trim().is_empty()) 43 33 .map(|s| { 44 34 s.trim() ··· 47 37 }) 48 38 .collect::<Result<Vec<_>, _>>()?; 49 39 50 - let machine_id: u64 = std::env::var("RIPPLE_MACHINE_ID") 51 - .ok() 52 - .and_then(|v| parse_env_with_warning::<u64>("RIPPLE_MACHINE_ID", &v)) 53 - .unwrap_or_else(|| { 54 - let host_str = std::fs::read_to_string("/etc/hostname") 55 - .map(|s| s.trim().to_string()) 56 - .unwrap_or_else(|_| format!("pid-{}", std::process::id())); 57 - let input = format!("{host_str}:{bind_addr}:{}", std::process::id()); 58 - fnv1a(input.as_bytes()) 59 - }); 60 - 61 - let gossip_interval_ms: u64 = std::env::var("RIPPLE_GOSSIP_INTERVAL_MS") 62 - .ok() 63 - .and_then(|v| parse_env_with_warning::<u64>("RIPPLE_GOSSIP_INTERVAL_MS", &v)) 64 - .unwrap_or(200) 65 - .max(50); 40 + let machine_id = ripple.machine_id.unwrap_or_else(|| { 41 + let host_str = std::fs::read_to_string("/etc/hostname") 42 + .map(|s| s.trim().to_string()) 43 + .unwrap_or_else(|_| format!("pid-{}", std::process::id())); 44 + let input = format!("{host_str}:{bind_addr}:{}", std::process::id()); 45 + fnv1a(input.as_bytes()) 46 + }); 66 47 67 - let cache_max_mb: usize = std::env::var("RIPPLE_CACHE_MAX_MB") 68 - .ok() 69 - .and_then(|v| parse_env_with_warning::<usize>("RIPPLE_CACHE_MAX_MB", &v)) 70 - .unwrap_or(256) 71 - .clamp(1, 16_384); 48 + let gossip_interval_ms = ripple.gossip_interval_ms.max(50); 72 49 73 - let cache_max_bytes = cache_max_mb.saturating_mul(1024).saturating_mul(1024); 50 + let cache_max_bytes = ripple 51 + .cache_max_mb 52 + .clamp(1, 16_384) 53 + .saturating_mul(1024) 54 + .saturating_mul(1024); 74 55 75 56 Ok(Self { 76 57 bind_addr,
+1
crates/tranquil-storage/Cargo.toml
··· 9 9 s3 = ["dep:aws-config", "dep:aws-sdk-s3"] 10 10 11 11 [dependencies] 12 + tranquil-config = { workspace = true } 12 13 tranquil-infra = { workspace = true } 13 14 14 15 async-trait = { workspace = true }
+34 -34
crates/tranquil-storage/src/lib.rs
··· 121 121 122 122 impl S3BlobStorage { 123 123 pub async fn new() -> Self { 124 - let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set"); 124 + let cfg = tranquil_config::get(); 125 + let bucket = cfg 126 + .storage 127 + .s3_bucket 128 + .clone() 129 + .expect("storage.s3_bucket (S3_BUCKET) must be set"); 125 130 let client = create_s3_client().await; 126 131 Self { client, bucket } 127 132 } ··· 140 145 .load() 141 146 .await; 142 147 143 - std::env::var("S3_ENDPOINT").ok().map_or_else( 144 - || Client::new(&config), 145 - |endpoint| { 146 - let s3_config = aws_sdk_s3::config::Builder::from(&config) 147 - .endpoint_url(endpoint) 148 - .force_path_style(true) 149 - .build(); 150 - Client::from_conf(s3_config) 151 - }, 152 - ) 148 + tranquil_config::get() 149 + .storage 150 + .s3_endpoint 151 + .as_deref() 152 + .map_or_else( 153 + || Client::new(&config), 154 + |endpoint| { 155 + let s3_config = aws_sdk_s3::config::Builder::from(&config) 156 + .endpoint_url(endpoint) 157 + .force_path_style(true) 158 + .build(); 159 + Client::from_conf(s3_config) 160 + }, 161 + ) 153 162 } 154 163 155 164 pub struct S3BackupStorage { ··· 159 168 160 169 impl S3BackupStorage { 161 170 pub async fn new() -> Option<Self> { 162 - let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?; 171 + let bucket = tranquil_config::get().backup.s3_bucket.clone()?; 163 172 let client = create_s3_client().await; 164 173 Some(Self { client, bucket }) 165 174 } ··· 499 508 }) 500 509 } 501 510 502 - pub async fn from_env() -> Result<Self, StorageError> { 503 - let path = std::env::var("BLOB_STORAGE_PATH") 504 - .map_err(|_| StorageError::Other("BLOB_STORAGE_PATH not set".into()))?; 505 - Self::new(path).await 506 - } 507 - 508 511 fn resolve_path(&self, key: &str) -> Result<PathBuf, StorageError> { 509 512 validate_key(key)?; 510 513 Ok(split_cid_path(key).map_or_else( ··· 649 652 }) 650 653 } 651 654 652 - pub async fn from_env() -> Result<Self, StorageError> { 653 - let path = std::env::var("BACKUP_STORAGE_PATH") 654 - .map_err(|_| StorageError::Other("BACKUP_STORAGE_PATH not set".into()))?; 655 - Self::new(path).await 656 - } 657 - 658 655 fn resolve_path(&self, key: &str) -> Result<PathBuf, StorageError> { 659 656 validate_key(key)?; 660 657 Ok(self.base_path.join(key)) ··· 701 698 } 702 699 703 700 pub async fn create_blob_storage() -> Arc<dyn BlobStorage> { 704 - let backend = std::env::var("BLOB_STORAGE_BACKEND").unwrap_or_else(|_| "filesystem".into()); 701 + let cfg = tranquil_config::get(); 702 + let backend = &cfg.storage.backend; 705 703 706 704 match backend.as_str() { 707 705 #[cfg(feature = "s3")] ··· 718 716 } 719 717 _ => { 720 718 tracing::info!("Initializing filesystem blob storage"); 721 - FilesystemBlobStorage::from_env() 719 + let path = cfg.storage.path.clone(); 720 + FilesystemBlobStorage::new(path) 722 721 .await 723 722 .unwrap_or_else(|e| { 724 723 panic!( ··· 733 732 } 734 733 735 734 pub async fn create_backup_storage() -> Option<Arc<dyn BackupStorage>> { 736 - let enabled = std::env::var("BACKUP_ENABLED") 737 - .map(|v| v != "false" && v != "0") 738 - .unwrap_or(true); 735 + let cfg = tranquil_config::get(); 739 736 740 - if !enabled { 741 - tracing::info!("Backup storage disabled via BACKUP_ENABLED=false"); 737 + if !cfg.backup.enabled { 738 + tracing::info!("Backup storage disabled via config"); 742 739 return None; 743 740 } 744 741 745 - let backend = std::env::var("BACKUP_STORAGE_BACKEND").unwrap_or_else(|_| "filesystem".into()); 742 + let backend = &cfg.backup.backend; 746 743 747 744 match backend.as_str() { 748 745 #[cfg(feature = "s3")] ··· 767 764 ); 768 765 None 769 766 } 770 - _ => FilesystemBackupStorage::from_env().await.map_or_else( 767 + _ => { 768 + let path = cfg.backup.path.clone(); 769 + FilesystemBackupStorage::new(path).await.map_or_else( 771 770 |e| { 772 771 tracing::error!( 773 772 "Failed to initialize filesystem backup storage: {}. \ ··· 781 780 tracing::info!("Initialized filesystem backup storage"); 782 781 Some(Arc::new(storage) as Arc<dyn BackupStorage>) 783 782 }, 784 - ), 783 + ) 784 + } 785 785 } 786 786 } 787 787
+1
docker-compose.prod.yaml
··· 17 17 MASTER_KEY: "${MASTER_KEY:?MASTER_KEY is required (min 32 chars)}" 18 18 CRAWLERS: "${CRAWLERS:-https://bsky.network}" 19 19 volumes: 20 + - ./config.toml:/etc/tranquil-pds/config.toml:ro 20 21 - blob_data:/var/lib/tranquil/blobs 21 22 - backup_data:/var/lib/tranquil/backups 22 23 depends_on:
+1
docker-compose.yaml
··· 11 11 environment: 12 12 DATABASE_URL: postgres://postgres:postgres@db:5432/pds 13 13 volumes: 14 + - ./config.toml:/etc/tranquil-pds/config.toml:ro 14 15 - blob_data:/var/lib/tranquil/blobs 15 16 - backup_data:/var/lib/tranquil/backups 16 17 depends_on:
+20 -18
docs/install-containers.md
··· 18 18 If you just want to get running quickly: 19 19 20 20 ```sh 21 - cp .env.example .env 21 + cp example.toml config.toml 22 22 ``` 23 23 24 - Edit `.env` with your values. Generate secrets with `openssl rand -base64 48`. 24 + Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`. 25 25 26 26 Build and start: 27 27 ```sh ··· 59 59 ```sh 60 60 podman run -d --name tranquil-pds \ 61 61 --network=host \ 62 - --env-file /etc/tranquil-pds/tranquil-pds.env \ 62 + -v /etc/tranquil-pds/config.toml:/etc/tranquil-pds/config.toml:ro,Z \ 63 63 -v /var/lib/tranquil:/var/lib/tranquil:Z \ 64 64 tranquil-pds:latest 65 65 ``` ··· 113 113 mkdir -p /srv/tranquil-pds/{postgres,blobs,backups,certs,acme,config} 114 114 ``` 115 115 116 - ## Create an environment file 116 + ## Create a configuration file 117 117 118 118 ```bash 119 - cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env 120 - chmod 600 /srv/tranquil-pds/config/tranquil-pds.env 119 + cp /opt/tranquil-pds/example.toml /srv/tranquil-pds/config/config.toml 120 + chmod 600 /srv/tranquil-pds/config/config.toml 121 121 ``` 122 122 123 - Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with: 123 + Edit `/srv/tranquil-pds/config/config.toml` and fill in your values. Generate secrets with: 124 124 ```bash 125 125 openssl rand -base64 48 126 126 ``` 127 + 128 + > **Note:** Every config option can also be set via environment variables 129 + > (see comments in `example.toml`). Environment variables always take 130 + > precedence over the config file. 127 131 128 132 ## Install quadlet definitions 129 133 ··· 157 161 ## Create podman secrets 158 162 159 163 ```bash 160 - source /srv/tranquil-pds/config/tranquil-pds.env 161 164 echo "$DB_PASSWORD" | podman secret create tranquil-pds-db-password - 162 165 ``` 163 166 ··· 264 267 podman build -t tranquil-pds-frontend:latest ./frontend 265 268 ``` 266 269 267 - ## Create an environment file 270 + ## Create a configuration file 268 271 269 272 ```sh 270 - cp /opt/tranquil-pds/.env.example /srv/tranquil-pds/config/tranquil-pds.env 271 - chmod 600 /srv/tranquil-pds/config/tranquil-pds.env 273 + cp /opt/tranquil-pds/example.toml /srv/tranquil-pds/config/config.toml 274 + chmod 600 /srv/tranquil-pds/config/config.toml 272 275 ``` 273 276 274 - Edit `/srv/tranquil-pds/config/tranquil-pds.env` and fill in your values. Generate secrets with: 277 + Edit `/srv/tranquil-pds/config/config.toml` and fill in your values. Generate secrets with: 275 278 ```sh 276 279 openssl rand -base64 48 277 280 ``` 281 + 282 + > **Note:** Every config option can also be set via environment variables 283 + > (see comments in `example.toml`). Environment variables always take 284 + > precedence over the config file. 278 285 279 286 ## Set up compose and nginx 280 287 ··· 308 315 after firewall 309 316 } 310 317 start_pre() { 311 - set -a 312 - . /srv/tranquil-pds/config/tranquil-pds.env 313 - set +a 318 + checkpath -d /srv/tranquil-pds 314 319 } 315 320 stop() { 316 321 ebegin "Stopping ${name}" 317 322 cd /srv/tranquil-pds 318 - set -a 319 - . /srv/tranquil-pds/config/tranquil-pds.env 320 - set +a 321 323 podman-compose -f /srv/tranquil-pds/docker-compose.yml down 322 324 eend $? 323 325 }
+14 -5
docs/install-debian.md
··· 73 73 74 74 ```bash 75 75 mkdir -p /etc/tranquil-pds 76 - cp /opt/tranquil-pds/.env.example /etc/tranquil-pds/tranquil-pds.env 77 - chmod 600 /etc/tranquil-pds/tranquil-pds.env 76 + cp /opt/tranquil-pds/example.toml /etc/tranquil-pds/config.toml 77 + chmod 600 /etc/tranquil-pds/config.toml 78 78 ``` 79 79 80 - Edit `/etc/tranquil-pds/tranquil-pds.env` and fill in your values. Generate secrets with: 80 + Edit `/etc/tranquil-pds/config.toml` and fill in your values. Generate secrets with: 81 81 ```bash 82 82 openssl rand -base64 48 83 + ``` 84 + 85 + > **Note:** Every config option can also be set via environment variables 86 + > (see comments in `example.toml`). Environment variables always take 87 + > precedence over the config file. You can also pass the config file path 88 + > via the `TRANQUIL_PDS_CONFIG` env var instead of `--config`. 89 + 90 + You can validate your configuration before starting the service: 91 + ```bash 92 + /usr/local/bin/tranquil-pds --config /etc/tranquil-pds/config.toml validate 83 93 ``` 84 94 85 95 ## Install frontend files ··· 105 115 Type=simple 106 116 User=tranquil-pds 107 117 Group=tranquil-pds 108 - EnvironmentFile=/etc/tranquil-pds/tranquil-pds.env 109 - ExecStart=/usr/local/bin/tranquil-pds 118 + ExecStart=/usr/local/bin/tranquil-pds --config /etc/tranquil-pds/config.toml 110 119 Restart=always 111 120 RestartSec=5 112 121 ProtectSystem=strict
+4 -1
docs/install-kubernetes.md
··· 9 9 You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com`. User handles are served as subdomains. 10 10 11 11 The container image expects: 12 + - A TOML config file mounted at `/etc/tranquil-pds/config.toml` (or passed via `--config`) 12 13 - `DATABASE_URL` - postgres connection string 13 14 - `BLOB_STORAGE_PATH` - path to blob storage (mount a PV here) 14 15 - `BACKUP_STORAGE_PATH` - path for repo backups (optional but recommended) 15 16 - `PDS_HOSTNAME` - your PDS hostname (without protocol) 16 17 - `JWT_SECRET`, `DPOP_SECRET`, `MASTER_KEY` - generate with `openssl rand -base64 48` 17 18 - `CRAWLERS` - typically `https://bsky.network` 18 - and more, check the .env.example. 19 + 20 + and more, check the example.toml for all options. Environment variables can override any TOML value. 21 + You can also point to a config file via the `TRANQUIL_PDS_CONFIG` env var. 19 22 20 23 Health check: `GET /xrpc/_health` 21 24
+509
example.toml
··· 1 + [server] 2 + # Public hostname of the PDS (e.g. `pds.example.com`). 3 + # 4 + # Can also be specified via environment variable `PDS_HOSTNAME`. 5 + # 6 + # Required! This value must be specified. 7 + #hostname = 8 + 9 + # Address to bind the HTTP server to. 10 + # 11 + # Can also be specified via environment variable `SERVER_HOST`. 12 + # 13 + # Default value: "127.0.0.1" 14 + #host = "127.0.0.1" 15 + 16 + # Port to bind the HTTP server to. 17 + # 18 + # Can also be specified via environment variable `SERVER_PORT`. 19 + # 20 + # Default value: 3000 21 + #port = 3000 22 + 23 + # List of domains for user handles. 24 + # Defaults to the PDS hostname when not set. 25 + # 26 + # Can also be specified via environment variable `PDS_USER_HANDLE_DOMAINS`. 27 + #user_handle_domains = 28 + 29 + # List of domains available for user registration. 30 + # Defaults to the PDS hostname when not set. 31 + # 32 + # Can also be specified via environment variable `AVAILABLE_USER_DOMAINS`. 33 + #available_user_domains = 34 + 35 + # Enable PDS-hosted did:web identities. Hosting did:web requires a 36 + # long-term commitment to serve DID documents; opt-in only. 37 + # 38 + # Can also be specified via environment variable `ENABLE_PDS_HOSTED_DID_WEB`. 39 + # 40 + # Default value: false 41 + #enable_pds_hosted_did_web = false 42 + 43 + # When set to true, skip age-assurance birthday prompt for all accounts. 44 + # 45 + # Can also be specified via environment variable `PDS_AGE_ASSURANCE_OVERRIDE`. 46 + # 47 + # Default value: false 48 + #age_assurance_override = false 49 + 50 + # Require an invite code for new account registration. 51 + # 52 + # Can also be specified via environment variable `INVITE_CODE_REQUIRED`. 53 + # 54 + # Default value: true 55 + #invite_code_required = true 56 + 57 + # Allow HTTP (non-TLS) proxy requests. Only useful during development. 58 + # 59 + # Can also be specified via environment variable `ALLOW_HTTP_PROXY`. 60 + # 61 + # Default value: false 62 + #allow_http_proxy = false 63 + 64 + # Disable all rate limiting. Should only be used in testing. 65 + # 66 + # Can also be specified via environment variable `DISABLE_RATE_LIMITING`. 67 + # 68 + # Default value: false 69 + #disable_rate_limiting = false 70 + 71 + # List of additional banned words for handle validation. 72 + # 73 + # Can also be specified via environment variable `PDS_BANNED_WORDS`. 74 + #banned_words = 75 + 76 + # URL to a privacy policy page. 77 + # 78 + # Can also be specified via environment variable `PRIVACY_POLICY_URL`. 79 + #privacy_policy_url = 80 + 81 + # URL to terms of service page. 82 + # 83 + # Can also be specified via environment variable `TERMS_OF_SERVICE_URL`. 84 + #terms_of_service_url = 85 + 86 + # Operator contact email address. 87 + # 88 + # Can also be specified via environment variable `CONTACT_EMAIL`. 89 + #contact_email = 90 + 91 + # Maximum allowed blob size in bytes (default 10 GiB). 92 + # 93 + # Can also be specified via environment variable `MAX_BLOB_SIZE`. 94 + # 95 + # Default value: 10737418240 96 + #max_blob_size = 10737418240 97 + 98 + [database] 99 + # PostgreSQL connection URL. 100 + # 101 + # Can also be specified via environment variable `DATABASE_URL`. 102 + # 103 + # Required! This value must be specified. 104 + #url = 105 + 106 + # Maximum number of connections in the pool. 107 + # 108 + # Can also be specified via environment variable `DATABASE_MAX_CONNECTIONS`. 109 + # 110 + # Default value: 100 111 + #max_connections = 100 112 + 113 + # Minimum number of idle connections kept in the pool. 114 + # 115 + # Can also be specified via environment variable `DATABASE_MIN_CONNECTIONS`. 116 + # 117 + # Default value: 10 118 + #min_connections = 10 119 + 120 + # Timeout in seconds when acquiring a connection from the pool. 121 + # 122 + # Can also be specified via environment variable `DATABASE_ACQUIRE_TIMEOUT_SECS`. 123 + # 124 + # Default value: 10 125 + #acquire_timeout_secs = 10 126 + 127 + [secrets] 128 + # Secret used for signing JWTs. Must be at least 32 characters in 129 + # production. 130 + # 131 + # Can also be specified via environment variable `JWT_SECRET`. 132 + #jwt_secret = 133 + 134 + # Secret used for DPoP proof validation. Must be at least 32 characters 135 + # in production. 136 + # 137 + # Can also be specified via environment variable `DPOP_SECRET`. 138 + #dpop_secret = 139 + 140 + # Master key used for key-encryption and HKDF derivation. Must be at 141 + # least 32 characters in production. 142 + # 143 + # Can also be specified via environment variable `MASTER_KEY`. 144 + #master_key = 145 + 146 + # PLC rotation key (DID key). If not set, user-level keys are used. 147 + # 148 + # Can also be specified via environment variable `PLC_ROTATION_KEY`. 149 + #plc_rotation_key = 150 + 151 + # Allow insecure/test secrets. NEVER enable in production. 152 + # 153 + # Can also be specified via environment variable `TRANQUIL_PDS_ALLOW_INSECURE_SECRETS`. 154 + # 155 + # Default value: false 156 + #allow_insecure = false 157 + 158 + [storage] 159 + # Storage backend: `filesystem` or `s3`. 160 + # 161 + # Can also be specified via environment variable `BLOB_STORAGE_BACKEND`. 162 + # 163 + # Default value: "filesystem" 164 + #backend = "filesystem" 165 + 166 + # Path on disk for the filesystem blob backend. 167 + # 168 + # Can also be specified via environment variable `BLOB_STORAGE_PATH`. 169 + # 170 + # Default value: "/var/lib/tranquil-pds/blobs" 171 + #path = "/var/lib/tranquil-pds/blobs" 172 + 173 + # S3 bucket name for blob storage. 174 + # 175 + # Can also be specified via environment variable `S3_BUCKET`. 176 + #s3_bucket = 177 + 178 + # Custom S3 endpoint URL (for MinIO, R2, etc.). 179 + # 180 + # Can also be specified via environment variable `S3_ENDPOINT`. 181 + #s3_endpoint = 182 + 183 + [backup] 184 + # Enable automatic backups. 185 + # 186 + # Can also be specified via environment variable `BACKUP_ENABLED`. 187 + # 188 + # Default value: true 189 + #enabled = true 190 + 191 + # Backup storage backend: `filesystem` or `s3`. 192 + # 193 + # Can also be specified via environment variable `BACKUP_STORAGE_BACKEND`. 194 + # 195 + # Default value: "filesystem" 196 + #backend = "filesystem" 197 + 198 + # Path on disk for the filesystem backup backend. 199 + # 200 + # Can also be specified via environment variable `BACKUP_STORAGE_PATH`. 201 + # 202 + # Default value: "/var/lib/tranquil-pds/backups" 203 + #path = "/var/lib/tranquil-pds/backups" 204 + 205 + # S3 bucket name for backups. 206 + # 207 + # Can also be specified via environment variable `BACKUP_S3_BUCKET`. 208 + #s3_bucket = 209 + 210 + # Number of backup revisions to keep per account. 211 + # 212 + # Can also be specified via environment variable `BACKUP_RETENTION_COUNT`. 213 + # 214 + # Default value: 7 215 + #retention_count = 7 216 + 217 + # Seconds between backup runs. 218 + # 219 + # Can also be specified via environment variable `BACKUP_INTERVAL_SECS`. 220 + # 221 + # Default value: 86400 222 + #interval_secs = 86400 223 + 224 + [cache] 225 + # Cache backend: `ripple` (default, built-in gossip) or `valkey`. 226 + # 227 + # Can also be specified via environment variable `CACHE_BACKEND`. 228 + # 229 + # Default value: "ripple" 230 + #backend = "ripple" 231 + 232 + # Valkey / Redis connection URL. Required when `backend = "valkey"`. 233 + # 234 + # Can also be specified via environment variable `VALKEY_URL`. 235 + #valkey_url = 236 + 237 + [cache.ripple] 238 + # Address to bind the Ripple gossip protocol listener. 239 + # 240 + # Can also be specified via environment variable `RIPPLE_BIND`. 241 + # 242 + # Default value: "0.0.0.0:0" 243 + #bind_addr = "0.0.0.0:0" 244 + 245 + # List of seed peer addresses. 246 + # 247 + # Can also be specified via environment variable `RIPPLE_PEERS`. 248 + #peers = 249 + 250 + # Unique machine identifier. Auto-derived from hostname when not set. 251 + # 252 + # Can also be specified via environment variable `RIPPLE_MACHINE_ID`. 253 + #machine_id = 254 + 255 + # Gossip protocol interval in milliseconds. 256 + # 257 + # Can also be specified via environment variable `RIPPLE_GOSSIP_INTERVAL_MS`. 258 + # 259 + # Default value: 200 260 + #gossip_interval_ms = 200 261 + 262 + # Maximum cache size in megabytes. 263 + # 264 + # Can also be specified via environment variable `RIPPLE_CACHE_MAX_MB`. 265 + # 266 + # Default value: 256 267 + #cache_max_mb = 256 268 + 269 + [plc] 270 + # Base URL of the PLC directory. 271 + # 272 + # Can also be specified via environment variable `PLC_DIRECTORY_URL`. 273 + # 274 + # Default value: "https://plc.directory" 275 + #directory_url = "https://plc.directory" 276 + 277 + # HTTP request timeout in seconds. 278 + # 279 + # Can also be specified via environment variable `PLC_TIMEOUT_SECS`. 280 + # 281 + # Default value: 10 282 + #timeout_secs = 10 283 + 284 + # TCP connect timeout in seconds. 285 + # 286 + # Can also be specified via environment variable `PLC_CONNECT_TIMEOUT_SECS`. 287 + # 288 + # Default value: 5 289 + #connect_timeout_secs = 5 290 + 291 + # Seconds to cache DID documents in memory. 292 + # 293 + # Can also be specified via environment variable `DID_CACHE_TTL_SECS`. 294 + # 295 + # Default value: 300 296 + #did_cache_ttl_secs = 300 297 + 298 + [firehose] 299 + # Size of the in-memory broadcast buffer for firehose events. 300 + # 301 + # Can also be specified via environment variable `FIREHOSE_BUFFER_SIZE`. 302 + # 303 + # Default value: 10000 304 + #buffer_size = 10000 305 + 306 + # How many hours of historical events to replay for cursor-based 307 + # firehose connections. 308 + # 309 + # Can also be specified via environment variable `FIREHOSE_BACKFILL_HOURS`. 310 + # 311 + # Default value: 72 312 + #backfill_hours = 72 313 + 314 + # Maximum number of lagged events before disconnecting a slow consumer. 315 + # 316 + # Can also be specified via environment variable `FIREHOSE_MAX_LAG`. 317 + # 318 + # Default value: 5000 319 + #max_lag = 5000 320 + 321 + # List of relay / crawler notification URLs. 322 + # 323 + # Can also be specified via environment variable `CRAWLERS`. 324 + #crawlers = 325 + 326 + [email] 327 + # Sender email address. When unset, email sending is disabled. 328 + # 329 + # Can also be specified via environment variable `MAIL_FROM_ADDRESS`. 330 + #from_address = 331 + 332 + # Display name used in the `From` header. 333 + # 334 + # Can also be specified via environment variable `MAIL_FROM_NAME`. 335 + # 336 + # Default value: "Tranquil PDS" 337 + #from_name = "Tranquil PDS" 338 + 339 + # Path to the `sendmail` binary. 340 + # 341 + # Can also be specified via environment variable `SENDMAIL_PATH`. 342 + # 343 + # Default value: "/usr/sbin/sendmail" 344 + #sendmail_path = "/usr/sbin/sendmail" 345 + 346 + [discord] 347 + # Discord bot token. When unset, Discord integration is disabled. 348 + # 349 + # Can also be specified via environment variable `DISCORD_BOT_TOKEN`. 350 + #bot_token = 351 + 352 + [telegram] 353 + # Telegram bot token. When unset, Telegram integration is disabled. 354 + # 355 + # Can also be specified via environment variable `TELEGRAM_BOT_TOKEN`. 356 + #bot_token = 357 + 358 + # Secret token for incoming webhook verification. 359 + # 360 + # Can also be specified via environment variable `TELEGRAM_WEBHOOK_SECRET`. 361 + #webhook_secret = 362 + 363 + [signal] 364 + # Path to the `signal-cli` binary. 365 + # 366 + # Can also be specified via environment variable `SIGNAL_CLI_PATH`. 367 + # 368 + # Default value: "/usr/local/bin/signal-cli" 369 + #cli_path = "/usr/local/bin/signal-cli" 370 + 371 + # Sender phone number. When unset, Signal integration is disabled. 372 + # 373 + # Can also be specified via environment variable `SIGNAL_SENDER_NUMBER`. 374 + #sender_number = 375 + 376 + [notifications] 377 + # Polling interval in milliseconds for the comms queue. 378 + # 379 + # Can also be specified via environment variable `NOTIFICATION_POLL_INTERVAL_MS`. 380 + # 381 + # Default value: 1000 382 + #poll_interval_ms = 1000 383 + 384 + # Number of notifications to process per batch. 385 + # 386 + # Can also be specified via environment variable `NOTIFICATION_BATCH_SIZE`. 387 + # 388 + # Default value: 100 389 + #batch_size = 100 390 + 391 + [sso] 392 + [sso.github] 393 + # Default value: false 394 + #enabled = false 395 + 396 + #client_id = 397 + 398 + #client_secret = 399 + 400 + #display_name = 401 + 402 + [sso.discord] 403 + # Default value: false 404 + #enabled = false 405 + 406 + #client_id = 407 + 408 + #client_secret = 409 + 410 + #display_name = 411 + 412 + [sso.google] 413 + # Default value: false 414 + #enabled = false 415 + 416 + #client_id = 417 + 418 + #client_secret = 419 + 420 + #display_name = 421 + 422 + [sso.gitlab] 423 + # Default value: false 424 + #enabled = false 425 + 426 + #client_id = 427 + 428 + #client_secret = 429 + 430 + #issuer = 431 + 432 + #display_name = 433 + 434 + [sso.oidc] 435 + # Default value: false 436 + #enabled = false 437 + 438 + #client_id = 439 + 440 + #client_secret = 441 + 442 + #issuer = 443 + 444 + #display_name = 445 + 446 + [sso.apple] 447 + # Can also be specified via environment variable `SSO_APPLE_ENABLED`. 448 + # Default value: false 449 + #enabled = false 450 + 451 + # Can also be specified via environment variable `SSO_APPLE_CLIENT_ID`. 452 + #client_id = 453 + 454 + # Can also be specified via environment variable `SSO_APPLE_TEAM_ID`. 455 + #team_id = 456 + 457 + # Can also be specified via environment variable `SSO_APPLE_KEY_ID`. 458 + #key_id = 459 + 460 + # Can also be specified via environment variable `SSO_APPLE_PRIVATE_KEY`. 461 + #private_key = 462 + 463 + [moderation] 464 + # External report-handling service URL. 465 + # 466 + # Can also be specified via environment variable `REPORT_SERVICE_URL`. 467 + #report_service_url = 468 + 469 + # DID of the external report-handling service. 470 + # 471 + # Can also be specified via environment variable `REPORT_SERVICE_DID`. 472 + #report_service_did = 473 + 474 + [import] 475 + # Whether the PDS accepts repo imports. 476 + # 477 + # Can also be specified via environment variable `ACCEPTING_REPO_IMPORTS`. 478 + # 479 + # Default value: true 480 + #accepting = true 481 + 482 + # Maximum allowed import archive size in bytes (default 1 GiB). 483 + # 484 + # Can also be specified via environment variable `MAX_IMPORT_SIZE`. 485 + # 486 + # Default value: 1073741824 487 + #max_size = 1073741824 488 + 489 + # Maximum number of blocks allowed in an import. 490 + # 491 + # Can also be specified via environment variable `MAX_IMPORT_BLOCKS`. 492 + # 493 + # Default value: 500000 494 + #max_blocks = 500000 495 + 496 + # Skip CAR verification during import. Only for development/debugging. 497 + # 498 + # Can also be specified via environment variable `SKIP_IMPORT_VERIFICATION`. 499 + # 500 + # Default value: false 501 + #skip_verification = false 502 + 503 + [scheduled] 504 + # Interval in seconds between scheduled delete checks. 505 + # 506 + # Can also be specified via environment variable `SCHEDULED_DELETE_CHECK_INTERVAL_SECS`. 507 + # 508 + # Default value: 3600 509 + #delete_check_interval_secs = 3600