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 <pol.dellaiera@protonmail.com>
Co-authored-by: Michael Smith <shmitty@protonmail.com>
This commit is contained in:
Dominique Martinet 2024-07-19 21:45:19 +09:00
parent 5f020a4166
commit b846e8762f
6 changed files with 287 additions and 1 deletions

View File

@ -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).

View File

@ -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

View File

@ -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.")

View File

@ -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://<domain>/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;
};
};
}
];
};
})
]);
}

View File

@ -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 {};

68
nixos/tests/cryptpad.nix Normal file
View File

@ -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");
'';
}