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

feat: nix module #16

merged opened by lewis.moe targeting main from feat/nix-module

Added a module and a test file to test it out! Tested locally on qemu

Labels

None yet.

assignee

None yet.

Participants 2
Referenced by
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3me4wjqzoiv22
+726 -2328
Interdiff #5 โ†’ #6
default.nix

This file has not been changed.

docs/install-debian.md

This patch was likely rebased, as context lines do not match.

flake.nix

This file has not been changed.

+69 -82
module.nix
··· 1 - self: 2 - { 1 + self: { 3 2 lib, 4 3 pkgs, 5 4 config, 6 5 ... 7 - }: 8 - let 6 + }: let 9 7 cfg = config.services.tranquil-pds; 10 8 11 9 inherit (lib) types mkOption; 12 10 13 - backendUrl = "http://127.0.0.1:${toString cfg.settings.SERVER_PORT}"; 11 + settingsFormat = pkgs.formats.toml { }; 14 12 13 + backendUrl = "http://127.0.0.1:${toString cfg.settings.server.port}"; 14 + 15 15 useACME = cfg.nginx.enableACME && cfg.nginx.useACMEHost == null; 16 16 hasSSL = useACME || cfg.nginx.useACMEHost != null; 17 - in 18 - { 17 + in { 19 18 _class = "nixos"; 20 19 21 20 options.services.tranquil-pds = { ··· 100 99 101 100 settings = mkOption { 102 101 type = types.submodule { 103 - freeformType = types.attrsOf ( 104 - types.nullOr ( 105 - types.oneOf [ 106 - types.str 107 - types.path 108 - types.int 109 - ] 110 - ) 111 - ); 102 + freeformType = settingsFormat.type; 112 103 113 104 options = { 114 - SERVER_HOST = mkOption { 115 - type = types.str; 116 - default = "127.0.0.1"; 117 - description = "Host for tranquil-pds to listen on"; 118 - }; 105 + server = { 106 + host = mkOption { 107 + type = types.str; 108 + default = "127.0.0.1"; 109 + description = "Host for tranquil-pds to listen on"; 110 + }; 119 111 120 - SERVER_PORT = mkOption { 121 - type = types.int; 122 - default = 3000; 123 - description = "Port for tranquil-pds to listen on"; 124 - }; 112 + port = mkOption { 113 + type = types.int; 114 + default = 3000; 115 + description = "Port for tranquil-pds to listen on"; 116 + }; 125 117 126 - PDS_HOSTNAME = mkOption { 127 - type = types.nullOr types.str; 128 - default = null; 129 - example = "pds.example.com"; 130 - description = "The public-facing hostname of the PDS"; 131 - }; 118 + hostname = mkOption { 119 + type = types.str; 120 + default = ""; 121 + example = "pds.example.com"; 122 + description = "The public-facing hostname of the PDS"; 123 + }; 132 124 133 - BLOB_STORAGE_PATH = mkOption { 134 - type = types.path; 135 - default = "/var/lib/tranquil-pds/blobs"; 136 - description = "Directory for storing blobs"; 125 + max_blob_size = mkOption { 126 + type = types.int; 127 + default = 10737418240; # 10 GiB 128 + description = "Maximum allowed blob size in bytes."; 129 + }; 137 130 }; 138 131 139 - BACKUP_STORAGE_PATH = mkOption { 140 - type = types.path; 141 - default = "/var/lib/tranquil-pds/backups"; 142 - description = "Directory for storing backups"; 132 + storage = { 133 + path = mkOption { 134 + type = types.path; 135 + default = "/var/lib/tranquil-pds/blobs"; 136 + description = "Directory for storing blobs"; 137 + }; 143 138 }; 144 139 145 - MAIL_FROM_ADDRESS = mkOption { 146 - type = types.nullOr types.str; 147 - default = null; 148 - description = "Email address to use in the From header when sending emails."; 140 + backup = { 141 + path = mkOption { 142 + type = types.path; 143 + default = "/var/lib/tranquil-pds/backups"; 144 + description = "Directory for storing backups"; 145 + }; 149 146 }; 150 147 151 - SENDMAIL_PATH = mkOption { 152 - type = types.path; 153 - default = lib.getExe pkgs.system-sendmail; 154 - description = "Path to the sendmail executable to use for sending emails."; 148 + email = { 149 + sendmail_path = mkOption { 150 + type = types.path; 151 + default = lib.getExe pkgs.system-sendmail; 152 + description = "Path to the sendmail executable to use for sending emails."; 153 + }; 155 154 }; 156 155 157 - SIGNAL_SENDER_NUMBER = mkOption { 158 - type = types.nullOr types.str; 159 - default = null; 160 - description = "Phone number (in international format) to use for sending Signal notifications."; 156 + signal = { 157 + cli_path = mkOption { 158 + type = types.path; 159 + default = lib.getExe pkgs.signal-cli; 160 + description = "Path to the signal-cli executable to use for sending Signal notifications."; 161 + }; 161 162 }; 162 - 163 - SIGNAL_CLI_PATH = mkOption { 164 - type = types.path; 165 - default = lib.getExe pkgs.signal-cli; 166 - description = "Path to the signal-cli executable to use for sending Signal notifications."; 167 - }; 168 - 169 - MAX_BLOB_SIZE = mkOption { 170 - type = types.int; 171 - default = 10737418240; # 10 GiB 172 - description = "Maximum allowed blob size in bytes."; 173 - }; 174 163 }; 175 164 }; 176 165 177 166 description = '' 178 - Environment variables to set for the service. Secrets should be 167 + Configuration options to set for the service. Secrets should be 179 168 specified using {option}`environmentFile`. 180 169 181 - Refer to <https://tangled.org/tranquil.farm/tranquil-pds/blob/main/.env.example> 182 - available environment variables. 170 + Refer to <https://tangled.org/tranquil.farm/tranquil-pds/blob/main/example.toml> 171 + for available configuration options. 183 172 ''; 184 173 }; 185 174 }; ··· 198 187 ]; 199 188 }; 200 189 201 - services.tranquil-pds.settings.DATABASE_URL = lib.mkDefault "postgresql:///${cfg.user}?host=/run/postgresql"; 190 + services.tranquil-pds.settings.database.url = 191 + lib.mkDefault "postgresql:///${cfg.user}?host=/run/postgresql"; 202 192 203 193 systemd.services.tranquil-pds = { 204 194 requires = [ "postgresql.service" ]; ··· 210 200 services.nginx = { 211 201 enable = true; 212 202 213 - virtualHosts.${cfg.settings.PDS_HOSTNAME} = { 214 - serverAliases = [ "*.${cfg.settings.PDS_HOSTNAME}" ]; 203 + virtualHosts.${cfg.settings.server.hostname} = { 204 + serverAliases = [ "*.${cfg.settings.server.hostname}" ]; 215 205 forceSSL = hasSSL; 216 206 enableACME = useACME; 217 207 useACMEHost = cfg.nginx.useACMEHost; 218 208 219 209 root = lib.mkIf (cfg.frontend.package != null) cfg.frontend.package; 220 210 221 - extraConfig = "client_max_body_size ${toString cfg.settings.MAX_BLOB_SIZE};"; 211 + extraConfig = "client_max_body_size ${toString cfg.settings.server.max_blob_size};"; 222 212 223 213 locations = lib.mkMerge [ 224 214 { ··· 321 311 lib.genAttrs 322 312 [ 323 313 cfg.dataDir 324 - cfg.settings.BLOB_STORAGE_PATH 325 - cfg.settings.BACKUP_STORAGE_PATH 314 + cfg.settings.storage.path 315 + cfg.settings.backup.path 326 316 ] 327 317 (_: { 328 318 d = { ··· 331 321 }; 332 322 }); 333 323 324 + environment.etc = { 325 + "tranquil-pds/config.toml".source = settingsFormat.generate "tranquil-pds.toml" cfg.settings; 326 + }; 327 + 334 328 systemd.services.tranquil-pds = { 335 329 description = "Tranquil PDS - AT Protocol Personal Data Server"; 336 330 after = [ "network-online.target" ]; ··· 348 342 StateDirectory = "tranquil-pds"; 349 343 350 344 EnvironmentFile = cfg.environmentFiles; 351 - Environment = lib.mapAttrsToList (k: v: "${k}=${if builtins.isInt v then toString v else v}") ( 352 - lib.filterAttrs (k: v: 353 - if k == "SENDMAIL_PATH" then cfg.settings.MAIL_FROM_ADDRESS != null 354 - else if k == "SIGNAL_CLI_PATH" then cfg.settings.SIGNAL_SENDER_NUMBER != null 355 - else v != null 356 - ) cfg.settings 357 - ); 358 345 359 346 NoNewPrivileges = true; 360 347 ProtectSystem = "strict"; ··· 377 364 RemoveIPC = true; 378 365 379 366 ReadWritePaths = [ 380 - cfg.settings.BLOB_STORAGE_PATH 381 - cfg.settings.BACKUP_STORAGE_PATH 367 + cfg.settings.storage.path 368 + cfg.settings.backup.path 382 369 ]; 383 370 }; 384 371 };
test.nix

This file has not been changed.

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

History

8 rounds 13 comments
sign up or login to add to the discussion
6 commits
expand
feat: nix module
fix: better defaults, add in pg & nginx
feat: actual good config from Isabel
fix: set env vars of paths for sendmail & signal from nix pkg not manual
refactor(nix): toml conf
fix: minor format cleanup and description fixing in the module
expand 0 comments
pull request successfully merged
6 commits
expand
feat: nix module
fix: better defaults, add in pg & nginx
feat: actual good config from Isabel
fix: set env vars of paths for sendmail & signal from nix pkg not manual
refactor(nix): toml conf
fix: minor format cleanup and description fixing in the module
expand 0 comments
4 commits
expand
feat: nix module
fix: better defaults, add in pg & nginx
feat: actual good config from Isabel
fix: set env vars of paths for sendmail & signal from nix pkg not manual
expand 0 comments
3 commits
expand
feat: nix module
fix: better defaults, add in pg & nginx
feat: actual good config from Isabel
expand 0 comments
4 commits
expand
feat: nix module
fix: better defaults, add in pg & nginx
fix: bulk type safety improvements, added a couple of tests
feat: actual good config from Isabel
expand 0 comments
2 commits
expand
feat: nix module
fix: better defaults, add in pg & nginx
expand 5 comments

Generally much better! I'll go through with a more fine-toothed comb at some point, but one immediate comment is that you should probably be using lib.mkEnableOption rather than lib.mkOption for your enable options - it's more idiomatic.

I'd also be interested in seeing your default paths for sendmail and signal moved up (and the options can be made no longer nullOr in that case too) - remember that in Nix nothing is evaluated unless you actually look at it, so as long as you check that email/signal are enabled when you place those options into the environment file (e.g. with mkIf) then this still needn't cause a dependency when it's not desirable...

https://search.nixos.org/packages?channel=25.11&show=system-sendmail&query=sendmail

^ You should probably also consider using system-sendmail which looks in a few places rather than your own best-guess sendmail path

The starred alias in nginx should really be tied to availableUserDomains rather than the hostname

I'm not a huge fan of some of the nginx stuff you're doing - recommendedProxySettings, etc. are good settings but you're leaking config outside the module which might change stuff for other modules. It might be better to enable recommendedProxySettings per-location and then setup whatever settings for the other categories you feel work best for tranquil in a lower scope...

...on a similar note, openFirewall tends to default to false to avoid unknowingly opening ports in firewalls that you didn't mean to

openFirewall tends to default to false

(I think there's one place in nixpkgs this isn't true - and it's with SSH to avoid making stuff unreachable unexpectedly)

7 commits
expand
feat: nix module
feat: fun handle resolution during migration
feat: initial in-house cache distribution
feat: cache locks less
fix: smaller docker img
fix: removed some bs
fix: better defaults, add in pg & nginx
expand 1 comment

Sorry, just updating to main

lewis.moe submitted #0
1 commit
expand
feat: nix module
expand 7 comments

Is there any reason the package doesn't seem to default to packages.tranquil-pds from this flake? I think it might be kinda nice if it did... (though I guess it would mean you have to have a reference to the flake in your module - that shouldn't really be much of a problem though)

What's the default for settings.storage.blobPath - is it actually null? what happens if I leave the blob backend as filesystem and then this as null? Could we default it in terms of dataDir (as I suspect it must really be) so that it's easier to introspect or could we put what this'll be in the description of the option

Why is backup.backend set in the test file - given it has the same value as the default and backups aren't enabled by default (or are they? they're set to null?) ... is this a mistake or something else? blobBackend is also set to its default but this is a little more understandable incase the default were to change/etc.

I also wonder if backup.enabled should be called backup.enable and should be a mkEnableOption rather than what you are doing here...... it seems quite unidiomatic

You're using optional types in a bunch of places. I'm going to assume that they are being set as overrides to some other config that is secretly elsewhere (e.g. plc.directoryUrl)... it would be much more idiomatic to have these required and have the true defaults in nix

Can you default sendmailPath (and signal CLI path) to whatever it should be from nixpkgs? I think that'd be nice :) - you need to make sure these options don't get evaluated unless mail or signal is enabled respectively

Thanks for all the comments! I am no nix connoisseur and i'll read through carefully and try to fix