nixos/davis: init

Changed files
+649
nixos
doc
manual
release-notes
modules
services
web-apps
tests
+2
nixos/doc/manual/release-notes/rl-2405.section.md
···
- [Scrutiny](https://github.com/AnalogJ/scrutiny), a S.M.A.R.T monitoring tool for hard disks with a web frontend.
- [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).
···
- [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).
+1
nixos/modules/module-list.nix
···
./services/web-apps/cloudlog.nix
./services/web-apps/code-server.nix
./services/web-apps/convos.nix
./services/web-apps/dex.nix
./services/web-apps/discourse.nix
./services/web-apps/documize.nix
···
./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
+32
nixos/modules/services/web-apps/davis.md
···
···
+
# 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`.
+554
nixos/modules/services/web-apps/davis.nix
···
···
+
{
+
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;
+
};
+
}
+1
nixos/tests/all-tests.nix
···
croc = handleTest ./croc.nix {};
darling = handleTest ./darling.nix {};
dae = handleTest ./dae.nix {};
dconf = handleTest ./dconf.nix {};
deconz = handleTest ./deconz.nix {};
deepin = handleTest ./deepin.nix {};
···
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 {};
+59
nixos/tests/davis.nix
···
···
+
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 '<title>Davis</title>'"
+
)
+
+
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* ]]"
+
)
+
'';
+
}
+
)