diff --git a/nixos/doc/manual/release-notes/rl-2405.section.md b/nixos/doc/manual/release-notes/rl-2405.section.md index 7afc14347f5c..763cb1df3202 100644 --- a/nixos/doc/manual/release-notes/rl-2405.section.md +++ b/nixos/doc/manual/release-notes/rl-2405.section.md @@ -138,6 +138,8 @@ The pre-existing [services.ankisyncd](#opt-services.ankisyncd.enable) has been m - [Scrutiny](https://github.com/AnalogJ/scrutiny), a S.M.A.R.T monitoring tool for hard disks with a web frontend. +- [davis](https://github.com/tchapi/davis), a simple CardDav and CalDav server inspired by Baïkal. Available as [services.davis]($opt-services-davis.enable). + - [systemd-lock-handler](https://git.sr.ht/~whynothugo/systemd-lock-handler/), a bridge between logind D-Bus events and systemd targets. Available as [services.systemd-lock-handler.enable](#opt-services.systemd-lock-handler.enable). - [wastebin](https://github.com/matze/wastebin), a pastebin server written in rust. Available as [services.wastebin](#opt-services.wastebin.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 9cbc421239ba..9a1bfe94a405 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1309,6 +1309,7 @@ ./services/web-apps/cloudlog.nix ./services/web-apps/code-server.nix ./services/web-apps/convos.nix + ./services/web-apps/davis.nix ./services/web-apps/dex.nix ./services/web-apps/discourse.nix ./services/web-apps/documize.nix diff --git a/nixos/modules/services/web-apps/davis.md b/nixos/modules/services/web-apps/davis.md new file mode 100644 index 000000000000..9775d8221b5b --- /dev/null +++ b/nixos/modules/services/web-apps/davis.md @@ -0,0 +1,32 @@ +# Davis {#module-services-davis} + +[Davis](https://github.com/tchapi/davis/) is a caldav and carrddav server. It +has a simple, fully translatable admin interface for sabre/dav based on Symfony +5 and Bootstrap 5, initially inspired by Baïkal. + +## Basic Usage {#module-services-davis-basic-usage} + +At first, an application secret is needed, this can be generated with: +```ShellSession +$ cat /dev/urandom | tr -dc a-zA-Z0-9 | fold -w 48 | head -n 1 +``` + +After that, `davis` can be deployed like this: +``` +{ + services.davis = { + enable = true; + hostname = "davis.example.com"; + mail = { + dsn = "smtp://username@example.com:25"; + inviteFromAddress = "davis@example.com"; + }; + adminLogin = "admin"; + adminPasswordFile = "/run/secrets/davis-admin-password"; + appSecretFile = "/run/secrets/davis-app-secret"; + nginx = {}; + }; +} +``` + +This deploys Davis using a sqlite database running out of `/var/lib/davis`. diff --git a/nixos/modules/services/web-apps/davis.nix b/nixos/modules/services/web-apps/davis.nix new file mode 100644 index 000000000000..325ede38d2a1 --- /dev/null +++ b/nixos/modules/services/web-apps/davis.nix @@ -0,0 +1,554 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.davis; + db = cfg.database; + mail = cfg.mail; + + mysqlLocal = db.createLocally && db.driver == "mysql"; + pgsqlLocal = db.createLocally && db.driver == "postgresql"; + + user = cfg.user; + group = cfg.group; + + isSecret = v: lib.isAttrs v && v ? _secret && (lib.isString v._secret || builtins.isPath v._secret); + davisEnvVars = lib.generators.toKeyValue { + mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" { + mkValueString = + v: + if builtins.isInt v then + toString v + else if lib.isString v then + "\"${v}\"" + else if true == v then + "true" + else if false == v then + "false" + else if null == v then + "" + else if isSecret v then + if (lib.isString v._secret) then + builtins.hashString "sha256" v._secret + else + builtins.hashString "sha256" (builtins.readFile v._secret) + else + throw "unsupported type ${builtins.typeOf v}: ${(lib.generators.toPretty { }) v}"; + }; + }; + secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config); + mkSecretReplacement = file: '' + replace-secret ${ + lib.escapeShellArgs [ + ( + if (lib.isString file) then + builtins.hashString "sha256" file + else + builtins.hashString "sha256" (builtins.readFile file) + ) + file + "${cfg.dataDir}/.env.local" + ] + } + ''; + secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths; + filteredConfig = lib.converge (lib.filterAttrsRecursive ( + _: v: + !lib.elem v [ + { } + null + ] + )) cfg.config; + davisEnv = pkgs.writeText "davis.env" (davisEnvVars filteredConfig); +in +{ + options.services.davis = { + enable = lib.mkEnableOption (lib.mdDoc "Davis is a caldav and carddav server"); + + user = lib.mkOption { + default = "davis"; + description = lib.mdDoc "User davis runs as."; + type = lib.types.str; + }; + + group = lib.mkOption { + default = "davis"; + description = lib.mdDoc "Group davis runs as."; + type = lib.types.str; + }; + + package = lib.mkPackageOption pkgs "davis" { }; + + dataDir = lib.mkOption { + type = lib.types.path; + default = "/var/lib/davis"; + description = lib.mdDoc '' + Davis data directory. + ''; + }; + + hostname = lib.mkOption { + type = lib.types.str; + example = "davis.yourdomain.org"; + description = lib.mdDoc '' + Domain of the host to serve davis under. You may want to change it if you + run Davis on a different URL than davis.yourdomain. + ''; + }; + + config = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.nullOr ( + lib.types.either + (lib.types.oneOf [ + lib.types.bool + lib.types.int + lib.types.port + lib.types.path + lib.types.str + ]) + ( + lib.types.submodule { + options = { + _secret = lib.mkOption { + type = lib.types.nullOr ( + lib.types.oneOf [ + lib.types.str + lib.types.path + ] + ); + description = lib.mdDoc '' + The path to a file containing the value the + option should be set to in the final + configuration file. + ''; + }; + }; + } + ) + ) + ); + default = { }; + + example = ''''; + description = lib.mdDoc ''''; + }; + + adminLogin = lib.mkOption { + type = lib.types.str; + default = "root"; + description = lib.mdDoc '' + Username for the admin account. + ''; + }; + adminPasswordFile = lib.mkOption { + type = lib.types.path; + description = lib.mdDoc '' + The full path to a file that contains the admin's password. Must be + readable by the user. + ''; + example = "/run/secrets/davis-admin-pass"; + }; + + appSecretFile = lib.mkOption { + type = lib.types.path; + description = lib.mdDoc '' + A file containing the Symfony APP_SECRET - Its value should be a series + of characters, numbers and symbols chosen randomly and the recommended + length is around 32 characters. Can be generated with cat + /dev/urandom | tr -dc a-zA-Z0-9 | fold -w 48 | head -n 1. + ''; + example = "/run/secrets/davis-appsecret"; + }; + + database = { + driver = lib.mkOption { + type = lib.types.enum [ + "sqlite" + "postgresql" + "mysql" + ]; + default = "sqlite"; + description = lib.mdDoc "Database type, required in all circumstances."; + }; + urlFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/secrets/davis-db-url"; + description = lib.mdDoc '' + A file containing the database connection url. If set then it + overrides all other database settings (except driver). This is + mandatory if you want to use an external database, that is when + `services.davis.database.createLocally` is `false`. + ''; + }; + name = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = "davis"; + description = lib.mdDoc "Database name, only used when the databse is created locally."; + }; + createLocally = lib.mkOption { + type = lib.types.bool; + default = true; + description = lib.mdDoc "Create the database and database user locally."; + }; + }; + + mail = { + dsn = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = lib.mdDoc "Mail DSN for sending emails. Mutually exclusive with `services.davis.mail.dsnFile`."; + example = "smtp://username:password@example.com:25"; + }; + dsnFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/run/secrets/davis-mail-dsn"; + description = lib.mdDoc "A file containing the mail DSN for sending emails. Mutually exclusive with `servies.davis.mail.dsn`."; + }; + inviteFromAddress = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = lib.mdDoc "Email address to send invitations from."; + example = "no-reply@dav.example.com"; + }; + }; + + nginx = lib.mkOption { + type = lib.types.submodule ( + lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) { } + ); + default = null; + example = '' + { + serverAliases = [ + "dav.''${config.networking.domain}" + ]; + # To enable encryption and let let's encrypt take care of certificate + forceSSL = true; + enableACME = true; + } + ''; + description = lib.mdDoc '' + With this option, you can customize the nginx virtualHost settings. + ''; + }; + + poolConfig = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.oneOf [ + lib.types.str + lib.types.int + lib.types.bool + ] + ); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = lib.mdDoc '' + Options for the davis PHP pool. See the documentation on php-fpm.conf + for details on configuration directives. + ''; + }; + }; + + config = + let + defaultServiceConfig = { + ReadWritePaths = "${cfg.dataDir}"; + User = user; + UMask = 77; + DeviceAllow = ""; + LockPersonality = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@resources" + "~@privileged" + ]; + WorkingDirectory = "${cfg.package}/"; + }; + in + lib.mkIf cfg.enable { + assertions = [ + { + assertion = db.createLocally -> db.urlFile == null; + message = "services.davis.database.urlFile must be unset if services.davis.database.createLocally is set true."; + } + { + assertion = db.createLocally || db.urlFile != null; + message = "One of services.davis.database.urlFile or services.davis.database.createLocally must be set."; + } + { + assertion = (mail.dsn != null) != (mail.dsnFile != null); + message = "One of (and only one of) services.davis.mail.dsn or services.davis.mail.dsnFile must be set."; + } + ]; + services.davis.config = + { + APP_ENV = "prod"; + CACHE_DIR = "${cfg.dataDir}/var/cache"; + # note: we do not need the log dir (we log to stdout/journald), by davis/symfony will try to create it, and the default value is one in the nix-store + # so we set it to a path under dataDir to avoid something like: Unable to create the "logs" directory (/nix/store/5cfskz0ybbx37s1161gjn5klwb5si1zg-davis-4.4.1/var/log). + LOG_DIR = "${cfg.dataDir}/var/log"; + LOG_FILE_PATH = "/dev/stdout"; + DATABASE_DRIVER = db.driver; + INVITE_FROM_ADDRESS = mail.inviteFromAddress; + APP_SECRET._secret = cfg.appSecretFile; + ADMIN_LOGIN = cfg.adminLogin; + ADMIN_PASSWORD._secret = cfg.adminPasswordFile; + APP_TIMEZONE = config.time.timeZone; + WEBDAV_ENABLED = false; + CALDAV_ENABLED = true; + CARDDAV_ENABLED = true; + } + // (if mail.dsn != null then { MAILER_DSN = mail.dsn; } else { MAILER_DSN._secret = mail.dsnFile; }) + // ( + if db.createLocally then + { + DATABASE_URL = + if db.driver == "sqlite" then + "sqlite:///${cfg.dataDir}/davis.db" # note: sqlite needs 4 slashes for an absolute path + else if + pgsqlLocal + # note: davis expects a non-standard postgres uri (due to the underlying doctrine library) + # specifically the charset query parameter, and the dummy hostname which is overriden by the host query parameter + then + "postgres://${user}@localhost/${db.name}?host=/run/postgresql&charset=UTF-8" + else if mysqlLocal then + "mysql://${user}@localhost/${db.name}?socket=/run/mysqld/mysqld.sock" + else + null; + } + else + { DATABASE_URL._secret = db.urlFile; } + ); + + users = { + users = lib.mkIf (user == "davis") { + davis = { + description = "Davis service user"; + group = cfg.group; + isSystemUser = true; + home = cfg.dataDir; + }; + }; + groups = lib.mkIf (group == "davis") { davis = { }; }; + }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0710 ${user} ${group} - -" + "d ${cfg.dataDir}/var 0700 ${user} ${group} - -" + "d ${cfg.dataDir}/var/log 0700 ${user} ${group} - -" + "d ${cfg.dataDir}/var/cache 0700 ${user} ${group} - -" + ]; + + services.phpfpm.pools.davis = { + inherit user group; + phpOptions = '' + log_errors = on + ''; + phpEnv = { + ENV_DIR = "${cfg.dataDir}"; + CACHE_DIR = "${cfg.dataDir}/var/cache"; + #LOG_DIR = "${cfg.dataDir}/var/log"; + }; + settings = + { + "listen.mode" = "0660"; + "pm" = "dynamic"; + "pm.max_children" = 256; + "pm.start_servers" = 10; + "pm.min_spare_servers" = 5; + "pm.max_spare_servers" = 20; + } + // ( + if cfg.nginx != null then + { + "listen.owner" = config.services.nginx.user; + "listen.group" = config.services.nginx.group; + } + else + { } + ) + // cfg.poolConfig; + }; + + # Reading the user-provided secret files requires root access + systemd.services.davis-env-setup = { + description = "Setup davis environment"; + before = [ + "phpfpm-davis.service" + "davis-db-migrate.service" + ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.replace-secret ]; + restartTriggers = [ + cfg.package + davisEnv + ]; + script = '' + # error handling + set -euo pipefail + # create .env file with the upstream values + install -T -m 0600 -o ${user} ${cfg.package}/env-upstream "${cfg.dataDir}/.env" + # create .env.local file with the user-provided values + install -T -m 0600 -o ${user} ${davisEnv} "${cfg.dataDir}/.env.local" + ${secretReplacements} + ''; + }; + + systemd.services.davis-db-migrate = { + description = "Migrate davis database"; + before = [ "phpfpm-davis.service" ]; + after = + lib.optional mysqlLocal "mysql.service" + ++ lib.optional pgsqlLocal "postgresql.service" + ++ [ "davis-env-setup.service" ]; + requires = + lib.optional mysqlLocal "mysql.service" + ++ lib.optional pgsqlLocal "postgresql.service" + ++ [ "davis-env-setup.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = defaultServiceConfig // { + Type = "oneshot"; + RemainAfterExit = true; + Environment = [ + "ENV_DIR=${cfg.dataDir}" + "CACHE_DIR=${cfg.dataDir}/var/cache" + "LOG_DIR=${cfg.dataDir}/var/log" + ]; + EnvironmentFile = "${cfg.dataDir}/.env.local"; + }; + restartTriggers = [ + cfg.package + davisEnv + ]; + script = '' + set -euo pipefail + ${cfg.package}/bin/console cache:clear --no-debug + ${cfg.package}/bin/console cache:warmup --no-debug + ${cfg.package}/bin/console doctrine:migrations:migrate + ''; + }; + + systemd.services.phpfpm-davis.after = [ + "davis-env-setup.service" + "davis-db-migrate.service" + ]; + systemd.services.phpfpm-davis.requires = [ + "davis-env-setup.service" + "davis-db-migrate.service" + ] ++ lib.optional mysqlLocal "mysql.service" ++ lib.optional pgsqlLocal "postgresql.service"; + systemd.services.phpfpm-davis.serviceConfig.ReadWritePaths = [ cfg.dataDir ]; + + services.nginx = lib.mkIf (cfg.nginx != null) { + enable = lib.mkDefault true; + virtualHosts = { + "${cfg.hostname}" = lib.mkMerge [ + cfg.nginx + { + root = lib.mkForce "${cfg.package}/public"; + extraConfig = '' + charset utf-8; + index index.php; + ''; + locations = { + "/" = { + extraConfig = '' + try_files $uri $uri/ /index.php$is_args$args; + ''; + }; + "~* ^/.well-known/(caldav|carddav)$" = { + extraConfig = '' + return 302 $http_x_forwarded_proto://$host/dav/; + ''; + }; + "~ ^(.+\.php)(.*)$" = { + extraConfig = '' + try_files $fastcgi_script_name =404; + include ${config.services.nginx.package}/conf/fastcgi_params; + include ${config.services.nginx.package}/conf/fastcgi.conf; + fastcgi_pass unix:${config.services.phpfpm.pools.davis.socket}; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_split_path_info ^(.+\.php)(.*)$; + fastcgi_param X-Forwarded-Proto $http_x_forwarded_proto; + fastcgi_param X-Forwarded-Port $http_x_forwarded_port; + ''; + }; + "~ /(\\.ht)" = { + extraConfig = '' + deny all; + return 404; + ''; + }; + }; + } + ]; + }; + }; + + services.mysql = lib.mkIf mysqlLocal { + enable = true; + package = lib.mkDefault pkgs.mariadb; + ensureDatabases = [ db.name ]; + ensureUsers = [ + { + name = user; + ensurePermissions = { + "${db.name}.*" = "ALL PRIVILEGES"; + }; + } + ]; + }; + + services.postgresql = lib.mkIf pgsqlLocal { + enable = true; + ensureDatabases = [ db.name ]; + ensureUsers = [ + { + name = user; + ensureDBOwnership = true; + } + ]; + }; + }; + + meta = { + doc = ./davis.md; + maintainers = pkgs.davis.meta.maintainers; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 204775c5d444..80edf70ee11c 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -233,6 +233,7 @@ in { croc = handleTest ./croc.nix {}; darling = handleTest ./darling.nix {}; dae = handleTest ./dae.nix {}; + davis = handleTest ./davis.nix {}; dconf = handleTest ./dconf.nix {}; deconz = handleTest ./deconz.nix {}; deepin = handleTest ./deepin.nix {}; diff --git a/nixos/tests/davis.nix b/nixos/tests/davis.nix new file mode 100644 index 000000000000..68958cee7a43 --- /dev/null +++ b/nixos/tests/davis.nix @@ -0,0 +1,59 @@ +import ./make-test-python.nix ( + { lib, pkgs, ... }: + + { + name = "davis"; + + meta.maintainers = pkgs.davis.meta.maintainers; + + nodes.machine = + { config, ... }: + { + virtualisation = { + memorySize = 512; + }; + + services.davis = { + enable = true; + hostname = "davis.example.com"; + database = { + driver = "postgresql"; + }; + mail = { + dsnFile = "${pkgs.writeText "davisMailDns" "smtp://username:password@example.com:25"}"; + inviteFromAddress = "dav@example.com"; + }; + adminLogin = "admin"; + appSecretFile = "${pkgs.writeText "davisAppSecret" "52882ef142066e09ab99ce816ba72522e789505caba224"}"; + adminPasswordFile = "${pkgs.writeText "davisAdminPass" "nixos"}"; + nginx = { }; + }; + }; + + testScript = '' + start_all() + machine.wait_for_unit("postgresql.service") + machine.wait_for_unit("davis-env-setup.service") + machine.wait_for_unit("davis-db-migrate.service") + machine.wait_for_unit("nginx.service") + machine.wait_for_unit("phpfpm-davis.service") + + with subtest("welcome screen loads"): + machine.succeed( + "curl -sSfL --resolve davis.example.com:80:127.0.0.1 http://davis.example.com/ | grep 'Davis'" + ) + + with subtest("login works"): + csrf_token = machine.succeed( + "curl -c /tmp/cookies -sSfL --resolve davis.example.com:80:127.0.0.1 http://davis.example.com/login | grep '_csrf_token' | sed -E 's,.*value=\"(.*)\".*,\\1,g'" + ) + r = machine.succeed( + f"curl -b /tmp/cookies --resolve davis.example.com:80:127.0.0.1 http://davis.example.com/login -X POST -F username=admin -F password=nixos -F _csrf_token={csrf_token.strip()} -D headers" + ) + print(r) + machine.succeed( + "[[ $(grep -i 'location: ' headers | cut -d: -f2- | xargs echo) == /dashboard* ]]" + ) + ''; + } +)