summaryrefslogtreecommitdiffstats
path: root/nixos/modules/services/web-apps
diff options
context:
space:
mode:
authorCasey Link <unnamedrambler@gmail.com>2024-03-25 15:32:01 +0100
committerCasey Link <unnamedrambler@gmail.com>2024-04-01 12:55:29 +0200
commit17d73dc9a13e3ec159b38860ce0eba708a1c4c83 (patch)
treeac9293c7aa6d81db403fb54c2daf4b1397107ee6 /nixos/modules/services/web-apps
parentafaaa840c1d13e1ee1713b70a052bd68eef0b711 (diff)
nixos/davis: init
Diffstat (limited to 'nixos/modules/services/web-apps')
-rw-r--r--nixos/modules/services/web-apps/davis.md32
-rw-r--r--nixos/modules/services/web-apps/davis.nix554
2 files changed, 586 insertions, 0 deletions
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 <code>cat
+ /dev/urandom | tr -dc a-zA-Z0-9 | fold -w 48 | head -n 1</code>.
+ '';
+ 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 <literal>php-fpm.conf</literal>
+ 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;
+ };
+}