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

refactor: toml config #24

merged opened by isabelroses.com targeting main

most certainly a improvement but almost certainly going to have some errors

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:qxichs7jsycphrsmbujwqbfb/sh.tangled.repo.pull/3meorfx6aki22
+2165 -598
Diff #3
+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
··· 104 104 "libc", 105 105 ] 106 106 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 + 107 157 [[package]] 108 158 name = "anyhow" 109 159 version = "1.0.100" ··· 1216 1266 "inout", 1217 1267 ] 1218 1268 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 + 1219 1309 [[package]] 1220 1310 name = "cmake" 1221 1311 version = "0.1.57" ··· 1240 1330 source = "registry+https://github.com/rust-lang/crates.io-index" 1241 1331 checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" 1242 1332 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 + 1243 1339 [[package]] 1244 1340 name = "combine" 1245 1341 version = "4.6.7" ··· 1280 1376 "crossbeam-utils", 1281 1377 ] 1282 1378 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 + 1283 1402 [[package]] 1284 1403 name = "const-oid" 1285 1404 version = "0.9.6" ··· 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", ··· 3059 3178 "unsigned-varint 0.7.2", 3060 3179 ] 3061 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" 3186 + 3062 3187 [[package]] 3063 3188 name = "itertools" 3064 3189 version = "0.14.0" ··· 3715 3840 source = "registry+https://github.com/rust-lang/crates.io-index" 3716 3841 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 3717 3842 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 + 3718 3849 [[package]] 3719 3850 name = "opaque-debug" 3720 3851 version = "0.3.1" ··· 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 ] ··· 4909 5040 "syn 2.0.111", 4910 5041 ] 4911 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", 5050 + ] 5051 + 4912 5052 [[package]] 4913 5053 name = "serde_urlencoded" 4914 5054 version = "0.7.1" ··· 5708 5848 "tokio", 5709 5849 ] 5710 5850 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 + 5711 5890 [[package]] 5712 5891 name = "tonic" 5713 5892 version = "0.14.2" ··· 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", 5941 6123 ] 5942 6124 6125 + [[package]] 6126 + name = "tranquil-config" 6127 + version = "0.2.1" 6128 + dependencies = [ 6129 + "confique", 6130 + "serde", 6131 + ] 6132 + 5943 6133 [[package]] 5944 6134 name = "tranquil-crypto" 5945 6135 version = "0.2.1" ··· 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 ] ··· 6356 6551 source = "registry+https://github.com/rust-lang/crates.io-index" 6357 6552 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 6358 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" 6559 + 6359 6560 [[package]] 6360 6561 name = "uuid" 6361 6562 version = "1.19.0" ··· 6930 7131 source = "registry+https://github.com/rust-lang/crates.io-index" 6931 7132 checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 6932 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" 7139 + 6933 7140 [[package]] 6934 7141 name = "winreg" 6935 7142 version = "0.50.0"
+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 - }); 51 + let secrets = &tranquil_config::get().secrets; 61 52 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 - } 78 - 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 } 120 + let client_id = client_id.filter(|s| !s.is_empty())?; 121 + let client_secret = client_secret.filter(|s| !s.is_empty())?; 88 122 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 - } 99 - 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 } 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())?; 127 147 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()?; 132 - 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 }) 156 163 } 157 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") 170 + } 171 + 158 172 pub fn get() -> &'static Self { 159 173 SSO_CONFIG.get_or_init(SsoConfig::default) 160 174 }
+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; 15 - use std::sync::Arc; 16 14 use std::sync::atomic::{AtomicBool, Ordering}; 15 + use std::sync::Arc; 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 17 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 - 31 18 pub fn generate_token_code() -> String { 32 19 generate_token_code_parts(2, 5) 33 20 } ··· 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 137 } 161 138 162 - pub fn pds_public_url() -> String { 163 - format!("https://{}", pds_hostname()) 164 - } 165 - 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 - } 31 - 32 18 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()) 19 + pub fn from_config() -> Result<Self, RippleConfigError> { 20 + let ripple = &tranquil_config::get().cache.ripple; 21 + 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 - }); 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 + }); 60 47 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); 48 + let gossip_interval_ms = ripple.gossip_interval_ms.max(50); 66 49 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); 72 - 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 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. 131 + 128 132 ## Install quadlet definitions 129 133 130 134 Copy the quadlet files from the repository: ··· 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 ``` 278 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. 285 + 279 286 ## Set up compose and nginx 280 287 281 288 Copy the production compose and nginx configs: ··· 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 83 ``` 84 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 93 + ``` 94 + 85 95 ## Install frontend files 86 96 87 97 ```bash ··· 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

History

4 rounds 2 comments
sign up or login to add to the discussion
1 commit
expand
c66fe45e
refactor: toml config
expand 0 comments
pull request successfully merged
1 commit
expand
1b32bf4b
refactor: toml config
expand 0 comments
1 commit
expand
21b7ee92
refactor: toml config
expand 1 comment

oookay finally got a chance to try this out and noticed some usability mehs

the blob storage paths and the backup storage paths should have default values. doesnt make much sense to me to not have that.

validate should probably have a --ignore-secrets flag or similar? since most people will probably want to set the secrets in an env file and not in the config file. necessary for the nix module too if we want that to validate the config during build

anywhere possible errors from trying to load the config are printed to the user should also have some more handling to actually print the error properly. as https://github.com/LukasKalbertodt/confique/blob/main/src/error.rs#L9 mentions just printing it doesnt actually do that which ends up giving very cryptic and non-specific messages to the user just saying loading the config failed without giving proper reasons why. just doing e:# seemed fine for now for me locally. in the future we'll probably want to walk the sources properly but it can wait

1 commit
expand
13867bba
refactor: toml config
expand 1 comment

overall looks really good! i only really have some nitpicks about naming and default values.

server.service_handle_domains should be server.user_handle_domains imo. i never quite liked the "service handle domain" name. its confusing imo. and describeServer calls them user domains so i think we should align with that

server.enable_self_hosted_did_web makes it sound like its the opposite of what it is. should be server.enable_pds_hosted_did_web instead. also imo we should default this to false? given the consequences of having this enabled it should be opt-in imo

server.invite_code_required should probably default to true? thats what ref impl does and i think thats sensible

crawlers.urls should be firehose.crawlers imo. i think it makes sense to keep it with the rest of the sync related config (or have a layer of nesting more and have sync.crawlers and sync.firehose. sounds messy tho)

all of ripple.* should probably go under cache.ripple.* (as well as add a cache.backend option) since ripple is an in-house in-process replacement for valkey

also perhaps make it clear in the docs that you can set config options with env vars too? + the config cli flag and env var

idk how i feel about the config static being a OnceLock and not a LazyLock and all the panicing with init() and get(). but i understand getting it to work with a LazyLock is annoying due to the fallibility of config loading. probably going to explore how to handle that in the future. not going to block this PR on that