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

isabelroses.com submitted #0
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