diff --git a/nixos/doc/manual/release-notes/rl-2411.section.md b/nixos/doc/manual/release-notes/rl-2411.section.md index c4de4128b040..ac585131ea85 100644 --- a/nixos/doc/manual/release-notes/rl-2411.section.md +++ b/nixos/doc/manual/release-notes/rl-2411.section.md @@ -48,6 +48,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). - [Gotenberg](https://gotenberg.dev), an API server for converting files to PDFs that can be used alongside Paperless-ngx. Available as [services.gotenberg](options.html#opt-services.gotenberg). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index e028704c5d0f..090236780abf 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1378,6 +1378,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 fca814512e08..2e37a984a34e 100644 --- a/nixos/modules/rename.nix +++ b/nixos/modules/rename.nix @@ -117,7 +117,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..770eefc00739 --- /dev/null +++ b/nixos/modules/services/web-apps/cryptpad.nix @@ -0,0 +1,293 @@ +{ + 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" + ]; + DynamicUser = true; + Environment = [ + "CRYPTPAD_CONFIG=${cryptpadConfigFile}" + "HOME=%S/cryptpad" + ]; + ExecStart = lib.getExe cfg.package; + Restart = "always"; + StateDirectory = "cryptpad"; + WorkingDirectory = "%S/cryptpad"; + # security way too many numerous options, from the systemd-analyze security output + # at end of test: block everything except + # - SystemCallFiters=@resources is required for node + # - MemoryDenyWriteExecute for node JIT + # - RestrictAddressFamilies=~AF_(INET|INET6) / PrivateNetwork to bind to sockets + # - IPAddressDeny likewise allow localhost if binding to localhost or any otherwise + # - PrivateUsers somehow service doesn't start with that + # - DeviceAllow (char-rtc r added by ProtectClock) + AmbientCapabilities = ""; + CapabilityBoundingSet = ""; + DeviceAllow = ""; + LockPersonality = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RuntimeDirectoryMode = "700"; + SocketBindAllow = [ + "tcp:${builtins.toString cfg.settings.httpPort}" + "tcp:${builtins.toString cfg.settings.websocketPort}" + ]; + SocketBindDeny = [ "any" ]; + StateDirectoryMode = "0700"; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@pkey" + "@system-service" + "~@chown" + "~@keyring" + "~@memlock" + "~@privileged" + "~@resources" + "~@setuid" + "~@timer" + ]; + UMask = "0077"; + }; + confinement = { + enable = true; + binSh = null; + mode = "chroot-only"; + }; + }; + } + # block external network access if not phoning home and + # binding to localhost (default) + (mkIf + ( + cfg.settings.blockDailyCheck + && (builtins.elem cfg.settings.httpAddress [ + "127.0.0.1" + "::1" + ]) + ) + { + systemd.services.cryptpad = { + serviceConfig = { + IPAddressAllow = [ "localhost" ]; + IPAddressDeny = [ "any" ]; + }; + }; + } + ) + # .. conversely allow DNS & TLS if telemetry is explicitly enabled + (mkIf (!cfg.settings.blockDailyCheck) { + systemd.services.cryptpad = { + serviceConfig = { + BindReadOnlyPaths = [ + "-/etc/resolv.conf" + "-/run/systemd" + "/etc/hosts" + "/etc/ssl/certs/ca-certificates.crt" + ]; + }; + }; + }) + + (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 cc31fbade123..f485b6a77844 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..9d6af15f5f86 --- /dev/null +++ b/nixos/tests/cryptpad.nix @@ -0,0 +1,71 @@ +{ 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"); + + # for future improvements + machine.log(machine.execute("systemd-analyze security cryptpad.service")[1]) + ''; +} diff --git a/pkgs/by-name/cr/cryptpad/0001-env.js-fix-httpSafePort-handling.patch b/pkgs/by-name/cr/cryptpad/0001-env.js-fix-httpSafePort-handling.patch new file mode 100644 index 000000000000..838ee033871f --- /dev/null +++ b/pkgs/by-name/cr/cryptpad/0001-env.js-fix-httpSafePort-handling.patch @@ -0,0 +1,56 @@ +From 4bf0be64fe51a9c9fd9e410ada15251378b743bf Mon Sep 17 00:00:00 2001 +From: Dominique Martinet +Date: Sat, 26 Aug 2023 09:28:59 +0900 +Subject: [PATCH] env.js: fix httpSafePort handling + +It has been clarified that this is only a dev option that should not be +used in production, but setting the value in config was still ignored, +so fix the init code to consider the config value and make it clear that +this port is not bound if safeOrigin is set. +--- + config/config.example.js | 3 ++- + lib/env.js | 5 +++-- + 2 files changed, 5 insertions(+), 3 deletions(-) + +diff --git a/config/config.example.js b/config/config.example.js +index 7c8184c6c2f6..77263643c354 100644 +--- a/config/config.example.js ++++ b/config/config.example.js +@@ -89,8 +89,9 @@ module.exports = { + + /* httpSafePort purpose is to emulate another origin for the sandbox when + * you don't have two domains at hand (i.e. when httpSafeOrigin not defined). +- * It is meant to be used only in case where you are working on a local ++ * It is meant to be used only in case where you are working on a local + * development instance. The default value is your httpPort + 1. ++ * Setting this to 0 or setting httpSafeOrigin disables this listener. + * + */ + //httpSafePort: 3001, +diff --git a/lib/env.js b/lib/env.js +index d3748750f21e..f0660cba3e11 100644 +--- a/lib/env.js ++++ b/lib/env.js +@@ -74,8 +74,9 @@ module.exports.create = function (config) { + + if (typeof(config.httpSafeOrigin) !== 'string') { + NO_SANDBOX = true; +- if (typeof(config.httpSafePort) !== 'number') { httpSafePort = httpPort + 1; } + httpSafeOrigin = deriveSandboxOrigin(httpUnsafeOrigin, httpSafePort); ++ // only set if httpSafeOrigin isn't set. ++ httpSafePort = isValidPort(config.httpSafePort) ? config.httpSafePort : (httpPort + 1); + } else { + httpSafeOrigin = canonicalizeOrigin(config.httpSafeOrigin); + } +@@ -115,7 +116,7 @@ module.exports.create = function (config) { + permittedEmbedders: typeof(permittedEmbedders) === 'string' && permittedEmbedders? permittedEmbedders: httpSafeOrigin, + + removeDonateButton: config.removeDonateButton, +- httpPort: isValidPort(config.httpPort)? config.httpPort: 3000, ++ httpPort: httpPort, + httpAddress: typeof(config.httpAddress) === 'string'? config.httpAddress: 'localhost', + websocketPath: config.externalWebsocketURL, + logIP: config.logIP, +-- +2.45.2 + diff --git a/pkgs/by-name/cr/cryptpad/package.nix b/pkgs/by-name/cr/cryptpad/package.nix new file mode 100644 index 000000000000..f2137c19bac4 --- /dev/null +++ b/pkgs/by-name/cr/cryptpad/package.nix @@ -0,0 +1,135 @@ +{ + buildNpmPackage, + fetchFromGitHub, + lib, + makeBinaryWrapper, + nixosTests, + nodejs, + rdfind, +}: + +let + version = "2024.6.0"; + # nix version of install-onlyoffice.sh + # a later version could rebuild from sdkjs/web-apps as per + # https://github.com/cryptpad/onlyoffice-builds/blob/main/build.sh + onlyoffice_build = + rev: hash: + fetchFromGitHub { + inherit rev hash; + owner = "cryptpad"; + repo = "onlyoffice-builds"; + }; + onlyoffice_install = oo: '' + oo_dir="$out_cryptpad/www/common/onlyoffice/dist/${oo.subdir}" + cp -a "${onlyoffice_build oo.rev oo.hash}/." "$oo_dir" + chmod -R +w "$oo_dir" + echo "${oo.rev}" > "$oo_dir/.commit" + ''; + onlyoffice_versions = [ + { + subdir = "v1"; + rev = "4f370beb"; + hash = "sha256-TE/99qOx4wT2s0op9wi+SHwqTPYq/H+a9Uus9Zj4iSY="; + } + { + subdir = "v2b"; + rev = "d9da72fd"; + hash = "sha256-SiRDRc2vnLwCVnvtk+C8PKw7IeuSzHBaJmZHogRe3hQ="; + } + { + subdir = "v4"; + rev = "6ebc6938"; + hash = "sha256-eto1+8Tk/s3kbUCpbUh8qCS8EOq700FYG1/KiHyynaA="; + } + { + subdir = "v5"; + rev = "88a356f0"; + hash = "sha256-8j1rlAyHlKx6oAs2pIhjPKcGhJFj6ZzahOcgenyeOCc="; + } + { + subdir = "v6"; + rev = "abd8a309"; + hash = "sha256-BZdExj2q/bqUD3k9uluOot2dlrWKA+vpad49EdgXKww="; + } + { + subdir = "v7"; + rev = "9d8b914a"; + hash = "sha256-M+rPJ/Xo2olhqB5ViynGRaesMLLfG/1ltUoLnepMPnM="; + } + ]; + +in +buildNpmPackage { + inherit version; + pname = "cryptpad"; + + src = fetchFromGitHub { + owner = "cryptpad"; + repo = "cryptpad"; + rev = version; + hash = "sha256-huIhhnjatkaVfm1zDeqi88EX/nAUBQ0onPNOwn7hrX4="; + }; + + npmDepsHash = "sha256-Oh1fBvP7OXC+VDiH3D+prHmi8pRrxld06n30sqw5apY="; + + nativeBuildInputs = [ + makeBinaryWrapper + rdfind + ]; + + patches = [ + # fix httpSafePort setting + # https://github.com/cryptpad/cryptpad/pull/1571 + ./0001-env.js-fix-httpSafePort-handling.patch + ]; + + # cryptpad build tries to write in cache dir + makeCacheWritable = true; + + # 'npm build run' (scripts/build.js) generates a customize directory, but: + # - that is not installed by npm install + # - it embeds values from config into the directory, so needs to be + # run before starting the server (it's just a few quick replaces) + # Skip it here. + dontNpmBuild = true; + + postInstall = '' + out_cryptpad="$out/lib/node_modules/cryptpad" + + # 'npm run install:components' (scripts/copy-component.js) copies + # required node modules to www/component in the build tree... + # Move to install directory manually. + npm run install:components + mv www/components "$out_cryptpad/www/" + + # install OnlyOffice (install-onlyoffice.sh without network) + mkdir -p "$out_cryptpad/www/common/onlyoffice/dist" + ${lib.concatMapStringsSep "\n" onlyoffice_install onlyoffice_versions} + rdfind -makehardlinks true -makeresultsfile false "$out_cryptpad/www/common/onlyoffice/dist" + + # cryptpad assumes it runs in the source directory and also outputs + # its state files there, which is not exactly great for us. + # There are relative paths everywhere so just substituing source paths + # is difficult and will likely break on a future update, instead we + # make links to the required source directories before running. + # The build.js step populates 'customize' from customize.dist and config; + # one would normally want to re-run it after modifying config but since it + # would overwrite user modifications only run it if there is no customize + # directory. + makeWrapper "${lib.getExe nodejs}" "$out/bin/cryptpad" \ + --add-flags "$out_cryptpad/server.js" \ + --run "for d in customize.dist lib www; do ln -sf \"$out_cryptpad/\$d\" .; done" \ + --run "if ! [ -d customize ]; then \"${lib.getExe nodejs}\" \"$out_cryptpad/scripts/build.js\"; fi" + ''; + + passthru.tests.cryptpad = nixosTests.cryptpad; + + meta = { + description = "Collaborative office suite, end-to-end encrypted and open-source."; + homepage = "https://cryptpad.org/"; + license = lib.licenses.agpl3Plus; + mainProgram = "cryptpad"; + maintainers = with lib.maintainers; [ martinetd ]; + }; +}