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
+507 -147
Interdiff #0 โ†’ #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"; 23 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; 24 + 24 25 settingsToEnv = settings: let 25 26 raw = { 26 27 SERVER_HOST = settings.server.host; ··· 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 + ]; 639 691 }; 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 - ]; 648 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"]; 699 + }; 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 + }; 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) 59 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 --- 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'") 70 111 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 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}" 151 + 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 }
+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;

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)

lewis.moe submitted #1
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