1{
2 config,
3 pkgs,
4 lib,
5 ...
6}:
7
8with lib;
9
10let
11 cfg = config.services.kimai;
12 eachSite = cfg.sites;
13 user = "kimai";
14 webserver = config.services.${cfg.webserver};
15 stateDir = hostName: "/var/lib/kimai/${hostName}";
16
17 pkg =
18 hostName: cfg:
19 pkgs.stdenv.mkDerivation rec {
20 pname = "kimai-${hostName}";
21 src = cfg.package;
22 version = src.version;
23
24 installPhase = ''
25 mkdir -p $out
26 cp -r * $out/
27
28 # Symlink .env file. This will be dynamically created at the service
29 # startup.
30 ln -sf ${stateDir hostName}/.env $out/share/php/kimai/.env
31
32 # Symlink the var/ folder
33 # TODO: we may have to symlink individual folders if we want to also
34 # manage plugins from Nix.
35 rm -rf $out/share/php/kimai/var
36 ln -s ${stateDir hostName} $out/share/php/kimai/var
37
38 # Symlink local.yaml.
39 ln -s ${kimaiConfig hostName cfg} $out/share/php/kimai/config/packages/local.yaml
40 '';
41 };
42
43 kimaiConfig =
44 hostName: cfg:
45 pkgs.writeTextFile {
46 name = "kimai-config-${hostName}.yaml";
47 text = generators.toYAML { } cfg.settings;
48 };
49
50 siteOpts =
51 {
52 lib,
53 name,
54 config,
55 ...
56 }:
57 {
58 options = {
59 package = mkPackageOption pkgs "kimai" { };
60
61 database = {
62 host = mkOption {
63 type = types.str;
64 default = "localhost";
65 description = "Database host address.";
66 };
67
68 port = mkOption {
69 type = types.port;
70 default = 3306;
71 description = "Database host port.";
72 };
73
74 name = mkOption {
75 type = types.str;
76 default = "kimai";
77 description = "Database name.";
78 };
79
80 user = mkOption {
81 type = types.str;
82 default = "kimai";
83 description = "Database user.";
84 };
85
86 passwordFile = mkOption {
87 type = types.nullOr types.path;
88 default = null;
89 example = "/run/keys/kimai-dbpassword";
90 description = ''
91 A file containing the password corresponding to
92 {option}`database.user`.
93 '';
94 };
95
96 socket = mkOption {
97 type = types.nullOr types.path;
98 default = null;
99 defaultText = literalExpression "/run/mysqld/mysqld.sock";
100 description = "Path to the unix socket file to use for authentication.";
101 };
102
103 charset = mkOption {
104 type = types.str;
105 default = "utf8mb4";
106 description = "Database charset.";
107 };
108
109 serverVersion = mkOption {
110 type = types.nullOr types.str;
111 default = null;
112 description = ''
113 MySQL *exact* version string. Not used if `createdLocally` is set,
114 but must be set otherwise. See
115 <https://www.kimai.org/documentation/installation.html#column-table_name-in-where-clause-is-ambiguous>
116 for how to set this value, especially if you're using MariaDB.
117 '';
118 };
119
120 createLocally = mkOption {
121 type = types.bool;
122 default = true;
123 description = "Create the database and database user locally.";
124 };
125 };
126
127 poolConfig = mkOption {
128 type =
129 with types;
130 attrsOf (oneOf [
131 str
132 int
133 bool
134 ]);
135 default = {
136 "pm" = "dynamic";
137 "pm.max_children" = 32;
138 "pm.start_servers" = 2;
139 "pm.min_spare_servers" = 2;
140 "pm.max_spare_servers" = 4;
141 "pm.max_requests" = 500;
142 };
143 description = ''
144 Options for the Kimai PHP pool. See the documentation on `php-fpm.conf`
145 for details on configuration directives.
146 '';
147 };
148
149 settings = mkOption {
150 type = types.attrsOf types.anything;
151 default = { };
152 description = ''
153 Structural Kimai's local.yaml configuration.
154 Refer to <https://www.kimai.org/documentation/local-yaml.html#localyaml>
155 for details.
156 '';
157 example = literalExpression ''
158 {
159 kimai = {
160 timesheet = {
161 rounding = {
162 default = {
163 begin = 15;
164 end = 15;
165 };
166 };
167 };
168 };
169 }
170 '';
171 };
172
173 environmentFile = mkOption {
174 type = types.nullOr types.path;
175 default = null;
176 example = "/run/secrets/kimai.env";
177 description = ''
178 Securely pass environment variabels to Kimai. This can be used to
179 set other environement variables such as MAILER_URL.
180 '';
181 };
182 };
183 };
184in
185{
186 # interface
187 options = {
188 services.kimai = {
189 sites = mkOption {
190 type = types.attrsOf (types.submodule siteOpts);
191 default = { };
192 description = "Specification of one or more Kimai sites to serve";
193 };
194
195 webserver = mkOption {
196 type = types.enum [ "nginx" ];
197 default = "nginx";
198 description = ''
199 The webserver to configure for the PHP frontend.
200
201 At the moment, only `nginx` is supported. PRs are welcome for support
202 for other web servers.
203 '';
204 };
205 };
206 };
207
208 # implementation
209 config = mkIf (eachSite != { }) (mkMerge [
210 {
211
212 assertions =
213 (mapAttrsToList (hostName: cfg: {
214 assertion = cfg.database.createLocally -> cfg.database.user == user;
215 message = ''services.kimai.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned'';
216 }) eachSite)
217 ++ (mapAttrsToList (hostName: cfg: {
218 assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
219 message = ''services.kimai.sites."${hostName}".database.passwordFile cannot be specified if services.kimai.sites."${hostName}".database.createLocally is set to true.'';
220 }) eachSite)
221 ++ (mapAttrsToList (hostName: cfg: {
222 assertion = !cfg.database.createLocally -> cfg.database.serverVersion != null;
223 message = ''services.kimai.sites."${hostName}".database.serverVersion must be specified if services.kimai.sites."${hostName}".database.createLocally is set to false.'';
224 }) eachSite);
225
226 services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
227 enable = true;
228 package = mkDefault pkgs.mariadb;
229 ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite;
230 ensureUsers = mapAttrsToList (hostName: cfg: {
231 name = cfg.database.user;
232 ensurePermissions = {
233 "${cfg.database.name}.*" = "ALL PRIVILEGES";
234 };
235 }) eachSite;
236 };
237
238 services.phpfpm.pools = mapAttrs' (
239 hostName: cfg:
240 (nameValuePair "kimai-${hostName}" {
241 phpPackage = cfg.package.php;
242 inherit user;
243 group = webserver.group;
244 settings = {
245 "listen.owner" = webserver.user;
246 "listen.group" = webserver.group;
247 }
248 // cfg.poolConfig;
249 })
250 ) eachSite;
251
252 }
253
254 {
255 systemd.tmpfiles.rules = flatten (
256 mapAttrsToList (hostName: cfg: [
257 "d '${stateDir hostName}' 0770 ${user} ${webserver.group} - -"
258 ]) eachSite
259 );
260
261 systemd.services = mkMerge [
262 (mapAttrs' (
263 hostName: cfg:
264 (nameValuePair "kimai-init-${hostName}" {
265 wantedBy = [ "multi-user.target" ];
266 before = [ "phpfpm-kimai-${hostName}.service" ];
267 after = optional cfg.database.createLocally "mysql.service";
268 script =
269 let
270 envFile = "${stateDir hostName}/.env";
271 appSecretFile = "${stateDir hostName}/.app_secret";
272 mysql = "${config.services.mysql.package}/bin/mysql";
273
274 dbUser = cfg.database.user;
275 dbPwd = if cfg.database.passwordFile != null then ":$(cat ${cfg.database.passwordFile})" else "";
276 dbHost = cfg.database.host;
277 dbPort = toString cfg.database.port;
278 dbName = cfg.database.name;
279 dbCharset = cfg.database.charset;
280 dbUnixSocket = if cfg.database.socket != null then "&unixSocket=${cfg.database.socket}" else "";
281 # Note: serverVersion is a shell variable. See below.
282 dbUri =
283 "mysql://${dbUser}${dbPwd}@${dbHost}:${dbPort}"
284 + "/${dbName}?charset=${dbCharset}"
285 + "&serverVersion=$serverVersion${dbUnixSocket}";
286 in
287 ''
288 set -eu
289
290 serverVersion=${
291 if !cfg.database.createLocally then
292 cfg.database.serverVersion
293 else
294 # Obtain MySQL version string dynamically from the running
295 # instance. Doctrine ORM's doc said it should be possible to
296 # autodetect this, however Kimai's doc insists that it has to
297 # be set.
298 # https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#mysql
299 # https://stackoverflow.com/q/9558867
300 "$(${mysql} --silent --skip-column-names --execute 'SELECT VERSION();')"
301 }
302
303 # Create .env file containing DATABASE_URL and other default
304 # variables. Set umask to make sure .env is not readable by
305 # unrelated users.
306 oldUmask=$(umask)
307 umask 177
308
309 if ! [ -e ${appSecretFile} ]; then
310 tr -dc A-Za-z0-9 </dev/urandom | head -c 20 >${appSecretFile}
311 fi
312
313 cat >${envFile} <<EOF
314 DATABASE_URL=${dbUri}
315 MAILER_FROM=kimai@example.com
316 MAILER_URL=null://null
317 APP_ENV=prod
318 APP_SECRET=$(cat ${appSecretFile})
319 CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?\$
320 EOF
321
322 umask $oldUmask
323
324 # Ensure that our local.yaml is valid (see kimai:reload command).
325 ${pkg hostName cfg}/bin/console lint:yaml --parse-tags \
326 ${pkg hostName cfg}/share/php/kimai/config
327
328 # Before running any further console commands, clear cache. This
329 # avoids errors due to old cache getting used with new version
330 # of Kimai.
331 ${pkg hostName cfg}/bin/console cache:clear --env=prod
332 # Then, run kimai:install to ensure database is created or updated.
333 # Note that kimai:update is an alias to kimai:install.
334 ${pkg hostName cfg}/bin/console kimai:install --no-cache
335 # Finally, warm up cache.
336 ${pkg hostName cfg}/bin/console cache:warmup --env=prod
337 '';
338
339 serviceConfig = {
340 Type = "oneshot";
341 User = user;
342 Group = webserver.group;
343 EnvironmentFile = [ cfg.environmentFile ];
344 };
345 })
346 ) eachSite)
347
348 (mapAttrs' (
349 hostName: cfg:
350 (nameValuePair "phpfpm-kimai-${hostName}" {
351 serviceConfig = {
352 EnvironmentFile = [ cfg.environmentFile ];
353 };
354 })
355 ) eachSite)
356
357 (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
358 "${cfg.webserver}".after = [ "mysql.service" ];
359 })
360 ];
361
362 users.users.${user} = {
363 group = webserver.group;
364 isSystemUser = true;
365 };
366 }
367
368 (mkIf (cfg.webserver == "nginx") {
369 services.nginx = {
370 enable = true;
371 virtualHosts = mapAttrs (hostName: cfg: {
372 serverName = mkDefault hostName;
373 root = "${pkg hostName cfg}/share/php/kimai/public";
374 extraConfig = ''
375 index index.php;
376 '';
377 locations = {
378 "/" = {
379 priority = 200;
380 extraConfig = ''
381 try_files $uri /index.php$is_args$args;
382 '';
383 };
384 "~ ^/index\\.php(/|$)" = {
385 priority = 500;
386 extraConfig = ''
387 fastcgi_split_path_info ^(.+\.php)(/.+)$;
388 fastcgi_pass unix:${config.services.phpfpm.pools."kimai-${hostName}".socket};
389 fastcgi_index index.php;
390 include "${config.services.nginx.package}/conf/fastcgi.conf";
391 fastcgi_param PATH_INFO $fastcgi_path_info;
392 fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
393 # Mitigate https://httpoxy.org/ vulnerabilities
394 fastcgi_param HTTP_PROXY "";
395 fastcgi_intercept_errors off;
396 fastcgi_buffer_size 16k;
397 fastcgi_buffers 4 16k;
398 fastcgi_connect_timeout 300;
399 fastcgi_send_timeout 300;
400 fastcgi_read_timeout 300;
401 '';
402 };
403 "~ \\.php$" = {
404 priority = 800;
405 extraConfig = ''
406 return 404;
407 '';
408 };
409 };
410 }) eachSite;
411 };
412 })
413
414 ]);
415}