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

fix: better defaults, add in pg & nginx

+507 -147
+8
docs/install-debian.md
··· 175 175 proxy_request_buffering off; 176 176 } 177 177 178 + location = /oauth/client-metadata.json { 179 + root /var/www/tranquil-pds; 180 + default_type application/json; 181 + sub_filter_once off; 182 + sub_filter_types application/json; 183 + sub_filter '__PDS_HOSTNAME__' $host; 184 + } 185 + 178 186 location /oauth/ { 179 187 proxy_pass http://127.0.0.1:3000; 180 188 proxy_http_version 1.1;
+1 -1
flake.nix
··· 30 30 default = pkgs.callPackage ./shell.nix {}; 31 31 }); 32 32 33 - nixosModules.default = import ./module.nix; 33 + nixosModules.default = import ./module.nix self; 34 34 35 35 checks.x86_64-linux.integration = import ./test.nix { 36 36 pkgs = nixpkgs.legacyPackages.x86_64-linux;
+285 -118
module.nix
··· 1 - { 1 + self: { 2 2 config, 3 3 lib, 4 4 pkgs, ··· 8 8 9 9 optionalStr = lib.types.nullOr lib.types.str; 10 10 optionalInt = lib.types.nullOr lib.types.int; 11 - optionalBool = lib.types.nullOr lib.types.bool; 12 11 optionalPath = lib.types.nullOr lib.types.str; 13 - optionalPort = lib.types.nullOr lib.types.port; 14 12 15 13 filterNulls = lib.filterAttrs (_: v: v != null); 16 14 17 15 boolToStr = b: 18 - if b == true 16 + if b 19 17 then "true" 20 - else if b == false 21 - then "false" 22 - else null; 18 + else "false"; 19 + 20 + backendUrl = "http://127.0.0.1:${toString cfg.settings.server.port}"; 21 + 22 + useACME = cfg.nginx.enableACME && cfg.nginx.useACMEHost == null; 23 + hasSSL = useACME || cfg.nginx.useACMEHost != null; 23 24 24 25 settingsToEnv = settings: let 25 26 raw = { ··· 38 39 AWS_REGION = settings.storage.awsRegion; 39 40 S3_BUCKET = settings.storage.s3Bucket; 40 41 41 - BACKUP_ENABLED = boolToStr settings.backup.enabled; 42 + BACKUP_ENABLED = boolToStr settings.backup.enable; 42 43 BACKUP_STORAGE_BACKEND = settings.backup.backend; 43 44 BACKUP_STORAGE_PATH = settings.backup.path; 44 45 BACKUP_S3_BUCKET = settings.backup.s3Bucket; ··· 94 95 PDS_AGE_ASSURANCE_OVERRIDE = boolToStr settings.misc.ageAssuranceOverride; 95 96 ALLOW_HTTP_PROXY = boolToStr settings.misc.allowHttpProxy; 96 97 97 - SSO_GITHUB_ENABLED = boolToStr settings.sso.github.enabled; 98 + SSO_GITHUB_ENABLED = boolToStr settings.sso.github.enable; 98 99 SSO_GITHUB_CLIENT_ID = settings.sso.github.clientId; 99 100 100 - SSO_DISCORD_ENABLED = boolToStr settings.sso.discord.enabled; 101 + SSO_DISCORD_ENABLED = boolToStr settings.sso.discord.enable; 101 102 SSO_DISCORD_CLIENT_ID = settings.sso.discord.clientId; 102 103 103 - SSO_GOOGLE_ENABLED = boolToStr settings.sso.google.enabled; 104 + SSO_GOOGLE_ENABLED = boolToStr settings.sso.google.enable; 104 105 SSO_GOOGLE_CLIENT_ID = settings.sso.google.clientId; 105 106 106 - SSO_GITLAB_ENABLED = boolToStr settings.sso.gitlab.enabled; 107 + SSO_GITLAB_ENABLED = boolToStr settings.sso.gitlab.enable; 107 108 SSO_GITLAB_CLIENT_ID = settings.sso.gitlab.clientId; 108 109 SSO_GITLAB_ISSUER = settings.sso.gitlab.issuer; 109 110 110 - SSO_OIDC_ENABLED = boolToStr settings.sso.oidc.enabled; 111 + SSO_OIDC_ENABLED = boolToStr settings.sso.oidc.enable; 111 112 SSO_OIDC_CLIENT_ID = settings.sso.oidc.clientId; 112 113 SSO_OIDC_ISSUER = settings.sso.oidc.issuer; 113 114 SSO_OIDC_NAME = settings.sso.oidc.name; 114 115 115 - SSO_APPLE_ENABLED = boolToStr settings.sso.apple.enabled; 116 + SSO_APPLE_ENABLED = boolToStr settings.sso.apple.enable; 116 117 SSO_APPLE_CLIENT_ID = settings.sso.apple.clientId; 117 118 SSO_APPLE_TEAM_ID = settings.sso.apple.teamId; 118 119 SSO_APPLE_KEY_ID = settings.sso.apple.keyId; ··· 123 124 options.services.tranquil-pds = { 124 125 enable = lib.mkEnableOption "tranquil-pds AT Protocol personal data server"; 125 126 126 - package = lib.mkPackageOption pkgs "tranquil-pds" {}; 127 + package = lib.mkOption { 128 + type = lib.types.package; 129 + default = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-pds; 130 + defaultText = lib.literalExpression "self.packages.\${pkgs.stdenv.hostPlatform.system}.tranquil-pds"; 131 + description = "The tranquil-pds package to use"; 132 + }; 127 133 128 134 user = lib.mkOption { 129 135 type = lib.types.str; ··· 155 161 ''; 156 162 }; 157 163 164 + database.createLocally = lib.mkOption { 165 + type = lib.types.bool; 166 + default = false; 167 + description = '' 168 + Create the postgres database and user on the local host. 169 + ''; 170 + }; 171 + 172 + frontend.package = lib.mkOption { 173 + type = lib.types.nullOr lib.types.package; 174 + default = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-frontend; 175 + defaultText = lib.literalExpression "self.packages.\${pkgs.stdenv.hostPlatform.system}.tranquil-frontend"; 176 + description = "Frontend package to serve via nginx (set null to disable frontend)"; 177 + }; 178 + 179 + nginx = { 180 + enable = lib.mkEnableOption "nginx reverse proxy for tranquil-pds"; 181 + 182 + enableACME = lib.mkOption { 183 + type = lib.types.bool; 184 + default = true; 185 + description = "Enable ACME for the pds domain"; 186 + }; 187 + 188 + useACMEHost = lib.mkOption { 189 + type = lib.types.nullOr lib.types.str; 190 + default = null; 191 + description = '' 192 + Use a pre-configured ACME certificate instead of generating one. 193 + Set this to the cert name from security.acme.certs for wildcard setups. 194 + REMEMBER: Handle subdomains (*.pds.example.com) require a wildcard cert via DNS-01. 195 + ''; 196 + }; 197 + 198 + openFirewall = lib.mkOption { 199 + type = lib.types.bool; 200 + default = true; 201 + description = "Open ports 80 and 443 in the firewall"; 202 + }; 203 + }; 204 + 158 205 settings = { 159 206 server = { 160 207 host = lib.mkOption { ··· 182 229 }; 183 230 184 231 maxConnections = lib.mkOption { 185 - type = optionalInt; 186 - default = null; 232 + type = lib.types.int; 233 + default = 100; 187 234 description = "Maximum database connections"; 188 235 }; 189 236 190 237 minConnections = lib.mkOption { 191 - type = optionalInt; 192 - default = null; 238 + type = lib.types.int; 239 + default = 10; 193 240 description = "Minimum database connections"; 194 241 }; 195 242 196 243 acquireTimeoutSecs = lib.mkOption { 197 - type = optionalInt; 198 - default = null; 244 + type = lib.types.int; 245 + default = 10; 199 246 description = "Connection acquire timeout in seconds"; 200 247 }; 201 248 }; ··· 208 255 }; 209 256 210 257 blobPath = lib.mkOption { 211 - type = optionalPath; 212 - default = null; 258 + type = lib.types.str; 259 + default = "${cfg.dataDir}/blobs"; 260 + defaultText = lib.literalExpression ''"''${cfg.dataDir}/blobs"''; 213 261 description = "Path for filesystem blob storage"; 214 262 }; 215 263 ··· 233 281 }; 234 282 235 283 backup = { 236 - enabled = lib.mkOption { 237 - type = optionalBool; 238 - default = null; 239 - description = "Enable automatic repo backups"; 240 - }; 284 + enable = lib.mkEnableOption "automatic repo backups"; 241 285 242 286 backend = lib.mkOption { 243 287 type = lib.types.enum ["filesystem" "s3"]; ··· 246 290 }; 247 291 248 292 path = lib.mkOption { 249 - type = optionalPath; 250 - default = null; 293 + type = lib.types.str; 294 + default = "${cfg.dataDir}/backups"; 295 + defaultText = lib.literalExpression ''"''${cfg.dataDir}/backups"''; 251 296 description = "Path for filesystem backup storage"; 252 297 }; 253 298 ··· 258 303 }; 259 304 260 305 retentionCount = lib.mkOption { 261 - type = optionalInt; 262 - default = null; 306 + type = lib.types.int; 307 + default = 7; 263 308 description = "Number of backups to retain"; 264 309 }; 265 310 266 311 intervalSecs = lib.mkOption { 267 - type = optionalInt; 268 - default = null; 312 + type = lib.types.int; 313 + default = 86400; 269 314 description = "Backup interval in seconds"; 270 315 }; 271 316 }; ··· 280 325 281 326 security = { 282 327 allowInsecureSecrets = lib.mkOption { 283 - type = optionalBool; 284 - default = null; 328 + type = lib.types.bool; 329 + default = false; 285 330 description = "Allow default/weak secrets (development only, NEVER in production ofc)"; 286 331 }; 287 332 }; 288 333 289 334 plc = { 290 335 directoryUrl = lib.mkOption { 291 - type = optionalStr; 292 - default = null; 336 + type = lib.types.str; 337 + default = "https://plc.directory"; 293 338 description = "PLC directory URL"; 294 339 }; 295 340 296 341 timeoutSecs = lib.mkOption { 297 - type = optionalInt; 298 - default = null; 342 + type = lib.types.int; 343 + default = 10; 299 344 description = "PLC request timeout in seconds"; 300 345 }; 301 346 302 347 connectTimeoutSecs = lib.mkOption { 303 - type = optionalInt; 304 - default = null; 348 + type = lib.types.int; 349 + default = 5; 305 350 description = "PLC connection timeout in seconds"; 306 351 }; 307 352 ··· 314 359 315 360 did = { 316 361 cacheTtlSecs = lib.mkOption { 317 - type = optionalInt; 318 - default = null; 362 + type = lib.types.int; 363 + default = 300; 319 364 description = "DID document cache TTL in seconds"; 320 365 }; 321 366 }; ··· 330 375 331 376 firehose = { 332 377 bufferSize = lib.mkOption { 333 - type = optionalInt; 334 - default = null; 378 + type = lib.types.int; 379 + default = 10000; 335 380 description = "Firehose broadcast channel buffer size"; 336 381 }; 337 382 ··· 344 389 345 390 notifications = { 346 391 batchSize = lib.mkOption { 347 - type = optionalInt; 348 - default = null; 392 + type = lib.types.int; 393 + default = 100; 349 394 description = "Notification queue batch size"; 350 395 }; 351 396 352 397 pollIntervalMs = lib.mkOption { 353 - type = optionalInt; 354 - default = null; 398 + type = lib.types.int; 399 + default = 1000; 355 400 description = "Notification queue poll interval in ms"; 356 401 }; 357 402 ··· 388 433 389 434 limits = { 390 435 maxBlobSize = lib.mkOption { 391 - type = optionalInt; 392 - default = null; 436 + type = lib.types.int; 437 + default = 10737418240; 393 438 description = "Maximum blob size in bytes"; 394 439 }; 395 440 }; 396 441 397 442 import = { 398 443 accepting = lib.mkOption { 399 - type = optionalBool; 400 - default = null; 444 + type = lib.types.bool; 445 + default = true; 401 446 description = "Accept repository imports"; 402 447 }; 403 448 404 449 maxSize = lib.mkOption { 405 - type = optionalInt; 406 - default = null; 450 + type = lib.types.int; 451 + default = 1073741824; 407 452 description = "Maximum import size in bytes"; 408 453 }; 409 454 410 455 maxBlocks = lib.mkOption { 411 - type = optionalInt; 412 - default = null; 456 + type = lib.types.int; 457 + default = 500000; 413 458 description = "Maximum blocks per import"; 414 459 }; 415 460 416 461 skipVerification = lib.mkOption { 417 - type = optionalBool; 418 - default = null; 462 + type = lib.types.bool; 463 + default = false; 419 464 description = "Skip verification during import (testing only)"; 420 465 }; 421 466 }; 422 467 423 468 registration = { 424 469 inviteCodeRequired = lib.mkOption { 425 - type = optionalBool; 426 - default = null; 470 + type = lib.types.bool; 471 + default = false; 427 472 description = "Require invite codes for registration"; 428 473 }; 429 474 ··· 434 479 }; 435 480 436 481 enableSelfHostedDidWeb = lib.mkOption { 437 - type = optionalBool; 438 - default = null; 482 + type = lib.types.bool; 483 + default = true; 439 484 description = "Enable self-hosted did:web identities"; 440 485 }; 441 486 }; ··· 462 507 463 508 rateLimiting = { 464 509 disable = lib.mkOption { 465 - type = optionalBool; 466 - default = null; 510 + type = lib.types.bool; 511 + default = false; 467 512 description = "Disable rate limiting (testing only, NEVER in production you naughty!)"; 468 513 }; 469 514 }; 470 515 471 516 scheduling = { 472 517 deleteCheckIntervalSecs = lib.mkOption { 473 - type = optionalInt; 474 - default = null; 518 + type = lib.types.int; 519 + default = 3600; 475 520 description = "Scheduled deletion check interval in seconds"; 476 521 }; 477 522 }; ··· 492 537 493 538 misc = { 494 539 ageAssuranceOverride = lib.mkOption { 495 - type = optionalBool; 496 - default = null; 540 + type = lib.types.bool; 541 + default = false; 497 542 description = "Override age assurance checks"; 498 543 }; 499 544 500 545 allowHttpProxy = lib.mkOption { 501 - type = optionalBool; 502 - default = null; 546 + type = lib.types.bool; 547 + default = false; 503 548 description = "Allow HTTP for proxy requests (development only)"; 504 549 }; 505 550 }; 506 551 507 552 sso = { 508 553 github = { 509 - enabled = lib.mkOption { 510 - type = optionalBool; 511 - default = null; 554 + enable = lib.mkOption { 555 + type = lib.types.bool; 556 + default = false; 512 557 description = "Enable GitHub SSO"; 513 558 }; 514 559 ··· 520 565 }; 521 566 522 567 discord = { 523 - enabled = lib.mkOption { 524 - type = optionalBool; 525 - default = null; 568 + enable = lib.mkOption { 569 + type = lib.types.bool; 570 + default = false; 526 571 description = "Enable Discord SSO"; 527 572 }; 528 573 ··· 534 579 }; 535 580 536 581 google = { 537 - enabled = lib.mkOption { 538 - type = optionalBool; 539 - default = null; 582 + enable = lib.mkOption { 583 + type = lib.types.bool; 584 + default = false; 540 585 description = "Enable Google SSO"; 541 586 }; 542 587 ··· 548 593 }; 549 594 550 595 gitlab = { 551 - enabled = lib.mkOption { 552 - type = optionalBool; 553 - default = null; 596 + enable = lib.mkOption { 597 + type = lib.types.bool; 598 + default = false; 554 599 description = "Enable GitLab SSO"; 555 600 }; 556 601 ··· 568 613 }; 569 614 570 615 oidc = { 571 - enabled = lib.mkOption { 572 - type = optionalBool; 573 - default = null; 616 + enable = lib.mkOption { 617 + type = lib.types.bool; 618 + default = false; 574 619 description = "Enable generic OIDC SSO"; 575 620 }; 576 621 ··· 594 639 }; 595 640 596 641 apple = { 597 - enabled = lib.mkOption { 598 - type = optionalBool; 599 - default = null; 642 + enable = lib.mkOption { 643 + type = lib.types.bool; 644 + default = false; 600 645 description = "Enable Apple Sign-in"; 601 646 }; 602 647 ··· 622 667 }; 623 668 }; 624 669 625 - config = let 626 - effectiveBlobPath = 627 - if cfg.settings.storage.blobPath != null 628 - then cfg.settings.storage.blobPath 629 - else "${cfg.dataDir}/blobs"; 630 - effectiveBackupPath = 631 - if cfg.settings.backup.path != null 632 - then cfg.settings.backup.path 633 - else "${cfg.dataDir}/backups"; 634 - envVars = 635 - (settingsToEnv cfg.settings) 636 - // { 637 - BLOB_STORAGE_PATH = effectiveBlobPath; 638 - BACKUP_STORAGE_PATH = effectiveBackupPath; 670 + config = lib.mkIf cfg.enable (lib.mkMerge [ 671 + (lib.mkIf (cfg.settings.notifications.mailFromAddress != null) { 672 + services.tranquil-pds.settings.notifications.sendmailPath = 673 + lib.mkDefault "/run/wrappers/bin/sendmail"; 674 + }) 675 + 676 + (lib.mkIf (cfg.settings.notifications.signalSenderNumber != null) { 677 + services.tranquil-pds.settings.notifications.signalCliPath = 678 + lib.mkDefault (lib.getExe pkgs.signal-cli); 679 + }) 680 + 681 + (lib.mkIf cfg.database.createLocally { 682 + services.postgresql = { 683 + enable = true; 684 + ensureDatabases = [cfg.user]; 685 + ensureUsers = [ 686 + { 687 + name = cfg.user; 688 + ensureDBOwnership = true; 689 + } 690 + ]; 691 + }; 692 + 693 + services.tranquil-pds.settings.database.url = 694 + lib.mkDefault "postgresql:///${cfg.user}?host=/run/postgresql"; 695 + 696 + systemd.services.tranquil-pds = { 697 + requires = ["postgresql.service"]; 698 + after = ["postgresql.service"]; 639 699 }; 640 - in 641 - lib.mkIf cfg.enable { 642 - assertions = [ 643 - { 644 - assertion = config.systemd.enable or true; 645 - message = "services.tranquil-pds requires systemd"; 646 - } 647 - ]; 700 + }) 701 + 702 + (lib.mkIf cfg.nginx.enable (lib.mkMerge [ 703 + { 704 + services.nginx = { 705 + enable = true; 706 + recommendedProxySettings = lib.mkDefault true; 707 + recommendedTlsSettings = lib.mkDefault true; 708 + recommendedGzipSettings = lib.mkDefault true; 709 + recommendedOptimisation = lib.mkDefault true; 710 + 711 + virtualHosts.${cfg.settings.server.pdsHostname} = { 712 + serverAliases = ["*.${cfg.settings.server.pdsHostname}"]; 713 + forceSSL = hasSSL; 714 + enableACME = useACME; 715 + useACMEHost = cfg.nginx.useACMEHost; 716 + 717 + root = lib.mkIf (cfg.frontend.package != null) "${cfg.frontend.package}"; 718 + 719 + extraConfig = "client_max_body_size ${toString cfg.settings.limits.maxBlobSize};"; 720 + 721 + locations = let 722 + proxyLocations = { 723 + "/xrpc/" = { 724 + proxyPass = backendUrl; 725 + proxyWebsockets = true; 726 + extraConfig = '' 727 + proxy_read_timeout 86400; 728 + proxy_send_timeout 86400; 729 + proxy_buffering off; 730 + proxy_request_buffering off; 731 + ''; 732 + }; 733 + 734 + "/oauth/" = { 735 + proxyPass = backendUrl; 736 + extraConfig = '' 737 + proxy_read_timeout 300; 738 + proxy_send_timeout 300; 739 + ''; 740 + }; 741 + 742 + "/.well-known/" = { 743 + proxyPass = backendUrl; 744 + }; 745 + 746 + "/webhook/" = { 747 + proxyPass = backendUrl; 748 + }; 648 749 750 + "= /metrics" = { 751 + proxyPass = backendUrl; 752 + }; 753 + 754 + "= /health" = { 755 + proxyPass = backendUrl; 756 + }; 757 + 758 + "= /robots.txt" = { 759 + proxyPass = backendUrl; 760 + }; 761 + 762 + "= /logo" = { 763 + proxyPass = backendUrl; 764 + }; 765 + 766 + "~ ^/u/[^/]+/did\\.json$" = { 767 + proxyPass = backendUrl; 768 + }; 769 + }; 770 + 771 + frontendLocations = lib.optionalAttrs (cfg.frontend.package != null) { 772 + "= /oauth/client-metadata.json" = { 773 + root = "${cfg.frontend.package}"; 774 + extraConfig = '' 775 + default_type application/json; 776 + sub_filter_once off; 777 + sub_filter_types application/json; 778 + sub_filter '__PDS_HOSTNAME__' $host; 779 + ''; 780 + }; 781 + 782 + "/assets/" = { 783 + extraConfig = '' 784 + expires 1y; 785 + add_header Cache-Control "public, immutable"; 786 + ''; 787 + tryFiles = "$uri =404"; 788 + }; 789 + 790 + "/app/" = { 791 + tryFiles = "$uri $uri/ /index.html"; 792 + }; 793 + 794 + "= /" = { 795 + tryFiles = "/homepage.html /index.html"; 796 + }; 797 + 798 + "/" = { 799 + tryFiles = "$uri $uri/ /index.html"; 800 + priority = 9999; 801 + }; 802 + }; 803 + in 804 + proxyLocations // frontendLocations; 805 + }; 806 + }; 807 + } 808 + 809 + (lib.mkIf cfg.nginx.openFirewall { 810 + networking.firewall.allowedTCPPorts = [80 443]; 811 + }) 812 + ])) 813 + 814 + { 649 815 users.users.${cfg.user} = { 650 816 isSystemUser = true; 651 817 inherit (cfg) group; ··· 656 822 657 823 systemd.tmpfiles.rules = [ 658 824 "d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -" 659 - "d ${effectiveBlobPath} 0750 ${cfg.user} ${cfg.group} -" 660 - "d ${effectiveBackupPath} 0750 ${cfg.user} ${cfg.group} -" 825 + "d ${cfg.settings.storage.blobPath} 0750 ${cfg.user} ${cfg.group} -" 826 + "d ${cfg.settings.backup.path} 0750 ${cfg.user} ${cfg.group} -" 661 827 ]; 662 828 663 829 systemd.services.tranquil-pds = { ··· 666 832 wants = ["network.target"]; 667 833 wantedBy = ["multi-user.target"]; 668 834 669 - environment = envVars; 835 + environment = settingsToEnv cfg.settings; 670 836 671 837 serviceConfig = { 672 838 Type = "exec"; ··· 698 864 RemoveIPC = true; 699 865 700 866 ReadWritePaths = [ 701 - effectiveBlobPath 702 - effectiveBackupPath 867 + cfg.settings.storage.blobPath 868 + cfg.settings.backup.path 703 869 ]; 704 870 }; 705 871 }; 706 - }; 872 + } 873 + ]); 707 874 }
+213 -28
test.nix
··· 13 13 }: { 14 14 imports = [self.nixosModules.default]; 15 15 16 - services.postgresql = { 17 - enable = true; 18 - ensureDatabases = ["tranquil"]; 19 - ensureUsers = [ 20 - { 21 - name = "tranquil"; 22 - ensureDBOwnership = true; 23 - } 24 - ]; 25 - authentication = '' 26 - local all all trust 27 - host all all 127.0.0.1/32 trust 28 - host all all ::1/128 trust 29 - ''; 30 - }; 31 - 32 16 services.tranquil-pds = { 33 17 enable = true; 34 - package = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-pds; 18 + database.createLocally = true; 35 19 secretsFile = pkgs.writeText "tranquil-secrets" '' 36 20 JWT_SECRET=test-jwt-secret-must-be-32-chars-long 37 21 DPOP_SECRET=test-dpop-secret-must-be-32-chars-long 38 22 MASTER_KEY=test-master-key-must-be-32-chars-long 39 23 ''; 40 24 25 + nginx = { 26 + enable = true; 27 + enableACME = false; 28 + }; 29 + 41 30 settings = { 42 31 server.pdsHostname = "test.local"; 43 32 server.host = "0.0.0.0"; 44 33 45 - database.url = "postgres://tranquil@localhost/tranquil"; 46 - 47 34 storage.blobBackend = "filesystem"; 48 - backup.backend = "filesystem"; 35 + rateLimiting.disable = true; 36 + security.allowInsecureSecrets = true; 49 37 }; 50 38 }; 51 - 52 - networking.firewall.allowedTCPPorts = [3000]; 53 39 }; 54 40 55 41 testScript = '' 42 + import json 43 + 56 44 server.wait_for_unit("postgresql.service") 57 45 server.wait_for_unit("tranquil-pds.service") 46 + server.wait_for_unit("nginx.service") 58 47 server.wait_for_open_port(3000) 48 + server.wait_for_open_port(80) 49 + 50 + def xrpc(method, endpoint, *, headers=None, data=None, raw_body=None, via="nginx"): 51 + host_header = "-H 'Host: test.local'" if via == "nginx" else "" 52 + base = "http://localhost" if via == "nginx" else "http://localhost:3000" 53 + url = f"{base}/xrpc/{endpoint}" 54 + 55 + parts = ["curl", "-sf", "-X", method, host_header] 56 + if headers: 57 + parts.extend(f"-H '{k}: {v}'" for k, v in headers.items()) 58 + if data is not None: 59 + parts.append("-H 'Content-Type: application/json'") 60 + parts.append(f"-d '{json.dumps(data)}'") 61 + if raw_body: 62 + parts.append(f"--data-binary @{raw_body}") 63 + parts.append(f"'{url}'") 64 + 65 + return server.succeed(" ".join(parts)) 66 + 67 + def xrpc_json(method, endpoint, **kwargs): 68 + return json.loads(xrpc(method, endpoint, **kwargs)) 69 + 70 + def xrpc_status(endpoint, *, headers=None, via="nginx"): 71 + host_header = "-H 'Host: test.local'" if via == "nginx" else "" 72 + base = "http://localhost" if via == "nginx" else "http://localhost:3000" 73 + url = f"{base}/xrpc/{endpoint}" 74 + 75 + parts = ["curl", "-s", "-o", "/dev/null", "-w", "'%{http_code}'", host_header] 76 + if headers: 77 + parts.extend(f"-H '{k}: {v}'" for k, v in headers.items()) 78 + parts.append(f"'{url}'") 79 + 80 + return server.succeed(" ".join(parts)).strip() 81 + 82 + def http_status(path, *, host="test.local", via="nginx"): 83 + base = "http://localhost" if via == "nginx" else "http://localhost:3000" 84 + return server.succeed( 85 + f"curl -s -o /dev/null -w '%{{http_code}}' -H 'Host: {host}' '{base}{path}'" 86 + ).strip() 87 + 88 + def http_get(path, *, host="test.local"): 89 + return server.succeed( 90 + f"curl -sf -H 'Host: {host}' 'http://localhost{path}'" 91 + ) 92 + 93 + def http_header(path, header, *, host="test.local"): 94 + return server.succeed( 95 + f"curl -sI -H 'Host: {host}' 'http://localhost{path}'" 96 + f" | grep -i '^{header}:'" 97 + ).strip() 98 + 99 + # --- testing that stuff is up in general --- 59 100 60 101 with subtest("service is running"): 61 102 status = server.succeed("systemctl is-active tranquil-pds") 62 103 assert "active" in status 63 104 64 - with subtest("blob storage directory exists"): 105 + with subtest("data directories exist"): 65 106 server.succeed("test -d /var/lib/tranquil-pds/blobs") 66 107 server.succeed("test -d /var/lib/tranquil-pds/backups") 67 108 68 - with subtest("healthcheck responds"): 69 - server.succeed("curl -sf http://localhost:3000/xrpc/_health") 109 + with subtest("postgres database created"): 110 + server.succeed("sudo -u tranquil-pds psql -d tranquil-pds -c 'SELECT 1'") 111 + 112 + with subtest("healthcheck via backend"): 113 + xrpc("GET", "_health", via="backend") 114 + 115 + with subtest("healthcheck via nginx"): 116 + xrpc("GET", "_health") 117 + 118 + with subtest("describeServer"): 119 + desc = xrpc_json("GET", "com.atproto.server.describeServer") 120 + assert "availableUserDomains" in desc 121 + assert "did" in desc 122 + assert desc.get("inviteCodeRequired") == False 123 + 124 + with subtest("nginx serves frontend"): 125 + result = server.succeed("curl -sf -H 'Host: test.local' http://localhost/") 126 + assert "<html" in result.lower() or "<!" in result 127 + 128 + with subtest("well-known proxied"): 129 + code = http_status("/.well-known/atproto-did") 130 + assert code != "502" and code != "504", f"well-known proxy broken: {code}" 131 + 132 + with subtest("health endpoint proxied"): 133 + code = http_status("/health") 134 + assert code != "404" and code != "502", f"/health not proxied: {code}" 135 + 136 + with subtest("robots.txt proxied"): 137 + code = http_status("/robots.txt") 138 + assert code != "404" and code != "502", f"/robots.txt not proxied: {code}" 139 + 140 + with subtest("metrics endpoint proxied"): 141 + code = http_status("/metrics") 142 + assert code != "502", f"/metrics not proxied: {code}" 143 + 144 + with subtest("oauth path proxied"): 145 + code = http_status("/oauth/.well-known/openid-configuration") 146 + assert code != "502" and code != "504", f"oauth proxy broken: {code}" 147 + 148 + with subtest("subdomain routing works"): 149 + code = http_status("/xrpc/_health", host="alice.test.local") 150 + assert code == "200", f"subdomain routing failed: {code}" 70 151 71 - with subtest("describeServer returns valid response"): 72 - result = server.succeed("curl -sf http://localhost:3000/xrpc/com.atproto.server.describeServer") 73 - assert "availableUserDomains" in result 152 + with subtest("client-metadata.json served with host substitution"): 153 + meta_raw = http_get("/oauth/client-metadata.json") 154 + meta = json.loads(meta_raw) 155 + assert "client_id" in meta, f"no client_id in client-metadata: {meta}" 156 + assert "test.local" in meta_raw, "host substitution did not apply" 157 + 158 + with subtest("static assets location exists"): 159 + code = http_status("/assets/nonexistent.js") 160 + assert code == "404", f"expected 404 for missing asset, got {code}" 161 + 162 + with subtest("spa fallback works"): 163 + code = http_status("/app/some/deep/route") 164 + assert code == "200", f"SPA fallback broken: {code}" 165 + 166 + with subtest("firewall ports open"): 167 + server.succeed("ss -tlnp | grep ':80 '") 168 + server.succeed("ss -tlnp | grep ':3000 '") 169 + 170 + # --- test little bit of an account lifecycle --- 171 + 172 + with subtest("create account"): 173 + account = xrpc_json("POST", "com.atproto.server.createAccount", data={ 174 + "handle": "alice.test.local", 175 + "password": "NixOS-Test-Pass-99!", 176 + "email": "alice@test.local", 177 + "didType": "web", 178 + }) 179 + assert "accessJwt" in account, f"no accessJwt: {account}" 180 + assert "did" in account, f"no did: {account}" 181 + access_token = account["accessJwt"] 182 + did = account["did"] 183 + assert did.startswith("did:web:"), f"expected did:web, got {did}" 184 + 185 + with subtest("mark account verified"): 186 + server.succeed( 187 + f"sudo -u tranquil-pds psql -d tranquil-pds " 188 + f"-c \"UPDATE users SET email_verified = true WHERE did = '{did}'\"" 189 + ) 190 + 191 + auth = {"Authorization": f"Bearer {access_token}"} 192 + 193 + with subtest("get session"): 194 + session = xrpc_json("GET", "com.atproto.server.getSession", headers=auth) 195 + assert session["did"] == did 196 + assert session["handle"] == "alice.test.local" 197 + 198 + with subtest("create record"): 199 + created = xrpc_json("POST", "com.atproto.repo.createRecord", headers=auth, data={ 200 + "repo": did, 201 + "collection": "app.bsky.feed.post", 202 + "record": { 203 + "$type": "app.bsky.feed.post", 204 + "text": "hello from lewis silly nix integration test", 205 + "createdAt": "2025-01-01T00:00:00.000Z", 206 + }, 207 + }) 208 + assert "uri" in created, f"no uri: {created}" 209 + assert "cid" in created, f"no cid: {created}" 210 + record_uri = created["uri"] 211 + record_cid = created["cid"] 212 + rkey = record_uri.split("/")[-1] 213 + 214 + with subtest("read record back"): 215 + fetched = xrpc_json( 216 + "GET", 217 + f"com.atproto.repo.getRecord?repo={did}&collection=app.bsky.feed.post&rkey={rkey}", 218 + ) 219 + assert fetched["uri"] == record_uri 220 + assert fetched["cid"] == record_cid 221 + assert fetched["value"]["text"] == "hello from lewis silly nix integration test" 222 + 223 + with subtest("upload blob"): 224 + server.succeed("dd if=/dev/urandom bs=1024 count=4 of=/tmp/testblob.bin 2>/dev/null") 225 + blob_resp = xrpc_json( 226 + "POST", 227 + "com.atproto.repo.uploadBlob", 228 + headers={**auth, "Content-Type": "application/octet-stream"}, 229 + raw_body="/tmp/testblob.bin", 230 + ) 231 + assert "blob" in blob_resp, f"no blob: {blob_resp}" 232 + blob_ref = blob_resp["blob"] 233 + assert blob_ref["size"] == 4096 234 + 235 + with subtest("export repo as car"): 236 + server.succeed( 237 + f"curl -sf -H 'Host: test.local' " 238 + f"-o /tmp/repo.car " 239 + f"'http://localhost/xrpc/com.atproto.sync.getRepo?did={did}'" 240 + ) 241 + size = int(server.succeed("stat -c%s /tmp/repo.car").strip()) 242 + assert size > 0, "exported car is empty" 243 + 244 + with subtest("delete record"): 245 + xrpc_json("POST", "com.atproto.repo.deleteRecord", headers=auth, data={ 246 + "repo": did, 247 + "collection": "app.bsky.feed.post", 248 + "rkey": rkey, 249 + }) 250 + 251 + with subtest("deleted record gone"): 252 + code = xrpc_status( 253 + f"com.atproto.repo.getRecord?repo={did}&collection=app.bsky.feed.post&rkey={rkey}", 254 + ) 255 + assert code != "200", f"expected non-200 for deleted record, got {code}" 256 + 257 + with subtest("service still healthy after lifecycle"): 258 + xrpc("GET", "_health") 74 259 ''; 75 260 }