From b846e8762f2c0560bee8512badbe559ae6863e1d Mon Sep 17 00:00:00 2001 From: Dominique Martinet Date: Fri, 19 Jul 2024 21:45:19 +0900 Subject: [PATCH] nixos/cryptpad: init This is a full rewrite independent of the previously removed cryptpad module, managing cryptpad's config in RFC0042 along with a shiny test. Upstream cryptpad provides two nginx configs, with many optimizations and complex settings; this uses the easier variant for now but improvements (e.g. serving blocks and js files directly through nginx) should be possible with a bit of work and care about http headers. the /checkup page of cryptpad passes all tests except HSTS, we don't seem to have any nginx config with HSTS enabled in nixpkgs so leave this as is for now. Co-authored-by: Pol Dellaiera Co-authored-by: Michael Smith --- .../manual/release-notes/rl-2411.section.md | 2 + nixos/modules/module-list.nix | 1 + nixos/modules/rename.nix | 1 - nixos/modules/services/web-apps/cryptpad.nix | 215 ++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/cryptpad.nix | 68 ++++++ 6 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 nixos/modules/services/web-apps/cryptpad.nix create mode 100644 nixos/tests/cryptpad.nix diff --git a/nixos/doc/manual/release-notes/rl-2411.section.md b/nixos/doc/manual/release-notes/rl-2411.section.md index 7e1127ba6cb1..511b75b79c90 100644 --- a/nixos/doc/manual/release-notes/rl-2411.section.md +++ b/nixos/doc/manual/release-notes/rl-2411.section.md @@ -32,6 +32,8 @@ - [Localsend](https://localsend.org/), an open source cross-platform alternative to AirDrop. Available as [programs.localsend](#opt-programs.localsend.enable). +- [cryptpad](https://cryptpad.org/), a privacy-oriented collaborative platform (docs/drive/etc), has been added back. Available as [services.cryptpad](#opt-services.cryptpad.enable). + - [realm](https://github.com/zhboner/realm), a simple, high performance relay server written in rust. Available as [services.realm.enable](#opt-services.realm.enable). - [Playerctld](https://github.com/altdesktop/playerctl), a daemon to track media player activity. Available as [services.playerctld](option.html#opt-services.playerctld). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 3019b23bc870..4e9251ce4d5a 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1371,6 +1371,7 @@ ./services/web-apps/convos.nix ./services/web-apps/crabfit.nix ./services/web-apps/davis.nix + ./services/web-apps/cryptpad.nix ./services/web-apps/dex.nix ./services/web-apps/discourse.nix ./services/web-apps/documize.nix diff --git a/nixos/modules/rename.nix b/nixos/modules/rename.nix index f5000cbbb767..6617ace6cee6 100644 --- a/nixos/modules/rename.nix +++ b/nixos/modules/rename.nix @@ -116,7 +116,6 @@ in (mkRemovedOptionModule [ "services" "virtuoso" ] "The corresponding package was removed from nixpkgs.") (mkRemovedOptionModule [ "services" "openfire" ] "The corresponding package was removed from nixpkgs.") (mkRemovedOptionModule [ "services" "riak" ] "The corresponding package was removed from nixpkgs.") - (mkRemovedOptionModule [ "services" "cryptpad" ] "The corresponding package was removed from nixpkgs.") (mkRemovedOptionModule [ "services" "rtsp-simple-server" ] "Package has been completely rebranded by upstream as mediamtx, and thus the service and the package were renamed in NixOS as well.") (mkRemovedOptionModule [ "services" "prayer" ] "The corresponding package was removed from nixpkgs.") (mkRemovedOptionModule [ "services" "restya-board" ] "The corresponding package was removed from nixpkgs.") diff --git a/nixos/modules/services/web-apps/cryptpad.nix b/nixos/modules/services/web-apps/cryptpad.nix new file mode 100644 index 000000000000..6850c4089a4c --- /dev/null +++ b/nixos/modules/services/web-apps/cryptpad.nix @@ -0,0 +1,215 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.cryptpad; + + inherit (lib) + mkIf + mkMerge + mkOption + strings + types + ; + + # The Cryptpad configuration file isn't JSON, but a JavaScript source file that assigns a JSON value + # to a variable. + cryptpadConfigFile = builtins.toFile "cryptpad_config.js" '' + module.exports = ${builtins.toJSON cfg.settings} + ''; + + # Derive domain names for Nginx configuration from Cryptpad configuration + mainDomain = strings.removePrefix "https://" cfg.settings.httpUnsafeOrigin; + sandboxDomain = + if cfg.settings.httpSafeOrigin == null then + mainDomain + else + strings.removePrefix "https://" cfg.settings.httpSafeOrigin; + +in +{ + options.services.cryptpad = { + enable = lib.mkEnableOption "cryptpad"; + + package = lib.mkPackageOption pkgs "cryptpad" { }; + + configureNginx = mkOption { + description = '' + Configure Nginx as a reverse proxy for Cryptpad. + Note that this makes some assumptions on your setup, and sets settings that will + affect other virtualHosts running on your Nginx instance, if any. + Alternatively you can configure a reverse-proxy of your choice. + ''; + type = types.bool; + default = false; + }; + + settings = mkOption { + description = '' + Cryptpad configuration settings. + See https://github.com/cryptpad/cryptpad/blob/main/config/config.example.js for a more extensive + reference documentation. + Test your deployed instance through `https:///checkup/`. + ''; + type = types.submodule { + freeformType = (pkgs.formats.json { }).type; + options = { + httpUnsafeOrigin = mkOption { + type = types.str; + example = "https://cryptpad.example.com"; + default = ""; + description = "This is the URL that users will enter to load your instance"; + }; + httpSafeOrigin = mkOption { + type = types.nullOr types.str; + example = "https://cryptpad-ui.example.com. Apparently optional but recommended."; + description = "Cryptpad sandbox URL"; + }; + httpAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Address on which the Node.js server should listen"; + }; + httpPort = mkOption { + type = types.int; + default = 3000; + description = "Port on which the Node.js server should listen"; + }; + websocketPort = mkOption { + type = types.int; + default = 3003; + description = "Port for the websocket that needs to be separate"; + }; + maxWorkers = mkOption { + type = types.nullOr types.int; + default = null; + description = "Number of child processes, defaults to number of cores available"; + }; + adminKeys = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "List of public signing keys of users that can access the admin panel"; + example = [ "[cryptpad-user1@my.awesome.website/YZgXQxKR0Rcb6r6CmxHPdAGLVludrAF2lEnkbx1vVOo=]" ]; + }; + logToStdout = mkOption { + type = types.bool; + default = true; + description = "Controls whether log output should go to stdout of the systemd service"; + }; + logLevel = mkOption { + type = types.str; + default = "info"; + description = "Controls log level"; + }; + blockDailyCheck = mkOption { + type = types.bool; + default = true; + description = '' + Disable telemetry. This setting is only effective if the 'Disable server telemetry' + setting in the admin menu has been untouched, and will be ignored by cryptpad once + that option is set either way. + Note that due to the service confinement, just enabling the option in the admin + menu will not be able to resolve DNS and fail; this setting must be set as well. + ''; + }; + installMethod = mkOption { + type = types.str; + default = "nixos"; + description = '' + Install method is listed in telemetry if you agree to it through the consentToContact + setting in the admin panel. + ''; + }; + }; + }; + }; + }; + + config = mkIf cfg.enable (mkMerge [ + { + systemd.services.cryptpad = { + description = "Cryptpad service"; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ]; + serviceConfig = { + BindReadOnlyPaths = [ + cryptpadConfigFile + # apparently needs proc for workers management + "/proc" + "/dev/urandom" + ] ++ (if ! cfg.settings.blockDailyCheck then [ + # allow DNS & TLS if telemetry is explicitly enabled + "-/etc/resolv.conf" + "-/run/systemd" + "/etc/hosts" + "/etc/ssl/certs/ca-certificates.crt" + ] else []); + DynamicUser = true; + Environment = [ + "CRYPTPAD_CONFIG=${cryptpadConfigFile}" + "HOME=%S/cryptpad" + ]; + ExecStart = lib.getExe cfg.package; + PrivateTmp = true; + Restart = "always"; + StateDirectory = "cryptpad"; + WorkingDirectory = "%S/cryptpad"; + }; + confinement = { + enable = true; + binSh = null; + mode = "chroot-only"; + }; + }; + } + + (mkIf cfg.configureNginx { + assertions = [ + { + assertion = cfg.settings.httpUnsafeOrigin != ""; + message = "services.cryptpad.settings.httpUnsafeOrigin is required"; + } + { + assertion = strings.hasPrefix "https://" cfg.settings.httpUnsafeOrigin; + message = "services.cryptpad.settings.httpUnsafeOrigin must start with https://"; + } + { + assertion = + cfg.settings.httpSafeOrigin == null || strings.hasPrefix "https://" cfg.settings.httpSafeOrigin; + message = "services.cryptpad.settings.httpSafeOrigin must start with https:// (or be unset)"; + } + ]; + services.nginx = { + enable = true; + recommendedTlsSettings = true; + recommendedProxySettings = true; + recommendedOptimisation = true; + recommendedGzipSettings = true; + + virtualHosts = mkMerge [ + { + "${mainDomain}" = { + serverAliases = lib.optionals (cfg.settings.httpSafeOrigin != null) [ sandboxDomain ]; + enableACME = lib.mkDefault true; + forceSSL = true; + locations."/" = { + proxyPass = "http://${cfg.settings.httpAddress}:${builtins.toString cfg.settings.httpPort}"; + extraConfig = '' + client_max_body_size 150m; + ''; + }; + locations."/cryptpad_websocket" = { + proxyPass = "http://${cfg.settings.httpAddress}:${builtins.toString cfg.settings.websocketPort}"; + proxyWebsockets = true; + }; + }; + } + ]; + }; + }) + ]); +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 29eb36ab1f28..07eb4543df9d 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -235,6 +235,7 @@ in { couchdb = handleTest ./couchdb.nix {}; crabfit = handleTest ./crabfit.nix {}; cri-o = handleTestOn ["aarch64-linux" "x86_64-linux"] ./cri-o.nix {}; + cryptpad = runTest ./cryptpad.nix; cups-pdf = handleTest ./cups-pdf.nix {}; curl-impersonate = handleTest ./curl-impersonate.nix {}; custom-ca = handleTest ./custom-ca.nix {}; diff --git a/nixos/tests/cryptpad.nix b/nixos/tests/cryptpad.nix new file mode 100644 index 000000000000..21a2f8b583b2 --- /dev/null +++ b/nixos/tests/cryptpad.nix @@ -0,0 +1,68 @@ +{ pkgs, ... }: +let + certs = pkgs.runCommand "cryptpadSelfSignedCerts" { buildInputs = [ pkgs.openssl ]; } '' + mkdir -p $out + cd $out + openssl req -x509 -newkey rsa:4096 \ + -keyout key.pem -out cert.pem -nodes -days 3650 \ + -subj '/CN=cryptpad.localhost' \ + -addext 'subjectAltName = DNS.1:cryptpad.localhost, DNS.2:cryptpad-sandbox.localhost' + ''; + # data sniffed from cryptpad's /checkup network trace, seems to be re-usable + test_write_data = pkgs.writeText "cryptpadTestData" '' + {"command":"WRITE_BLOCK","content":{"publicKey":"O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=","signature":"aXcM9SMO59lwA7q7HbYB+AnzymmxSyy/KhkG/cXIBVzl8v+kkPWXmFuWhcuKfRF8yt3Zc3ktIsHoFyuyDSAwAA==","ciphertext":"AFwCIfBHKdFzDKjMg4cu66qlJLpP+6Yxogbl3o9neiQou5P8h8yJB8qgnQ=="},"publicKey":"O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik=","nonce":"bitSbJMNSzOsg98nEzN80a231PCkBQeH"} + ''; +in +{ + name = "cryptpad"; + meta = with pkgs.lib.maintainers; { + maintainers = [ martinetd ]; + }; + + nodes.machine = { + services.cryptpad = { + enable = true; + configureNginx = true; + settings = { + httpUnsafeOrigin = "https://cryptpad.localhost"; + httpSafeOrigin = "https://cryptpad-sandbox.localhost"; + }; + }; + services.nginx = { + virtualHosts."cryptpad.localhost" = { + enableACME = false; + sslCertificate = "${certs}/cert.pem"; + sslCertificateKey = "${certs}/key.pem"; + }; + }; + security = { + pki.certificateFiles = [ "${certs}/cert.pem" ]; + }; + }; + + testScript = '' + machine.wait_for_unit("cryptpad.service") + machine.wait_for_unit("nginx.service") + machine.wait_for_open_port(3000) + + # test home page + machine.succeed("curl --fail https://cryptpad.localhost -o /tmp/cryptpad_home.html") + machine.succeed("grep -F 'CryptPad: Collaboration suite' /tmp/cryptpad_home.html") + + # test scripts/build.js actually generated customize content from config + machine.succeed("grep -F 'meta property=\"og:url\" content=\"https://cryptpad.localhost/index.html' /tmp/cryptpad_home.html") + + # make sure child pages are accessible (e.g. check nginx try_files paths) + machine.succeed( + "grep -oE '/(customize|components)[^\"]*' /tmp/cryptpad_home.html" + " | while read -r page; do" + " curl -O --fail https://cryptpad.localhost$page || exit;" + " done") + + # test some API (e.g. check cryptpad main process) + machine.succeed("curl --fail -d @${test_write_data} -H 'Content-Type: application/json' https://cryptpad.localhost/api/auth") + + # test telemetry has been disabled + machine.fail("journalctl -u cryptpad | grep TELEMETRY"); + ''; +}