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 } // cfg.poolConfig;
248 })
249 ) eachSite;
250
251 }
252
253 {
254 systemd.tmpfiles.rules = flatten (
255 mapAttrsToList (hostName: cfg: [
256 "d '${stateDir hostName}' 0770 ${user} ${webserver.group} - -"
257 ]) eachSite
258 );
259
260 systemd.services = mkMerge [
261 (mapAttrs' (
262 hostName: cfg:
263 (nameValuePair "kimai-init-${hostName}" {
264 wantedBy = [ "multi-user.target" ];
265 before = [ "phpfpm-kimai-${hostName}.service" ];
266 after = optional cfg.database.createLocally "mysql.service";
267 script =
268 let
269 envFile = "${stateDir hostName}/.env";
270 appSecretFile = "${stateDir hostName}/.app_secret";
271 mysql = "${config.services.mysql.package}/bin/mysql";
272
273 dbUser = cfg.database.user;
274 dbPwd = if cfg.database.passwordFile != null then ":$(cat ${cfg.database.passwordFile})" else "";
275 dbHost = cfg.database.host;
276 dbPort = toString cfg.database.port;
277 dbName = cfg.database.name;
278 dbCharset = cfg.database.charset;
279 dbUnixSocket = if cfg.database.socket != null then "&unixSocket=${cfg.database.socket}" else "";
280 # Note: serverVersion is a shell variable. See below.
281 dbUri =
282 "mysql://${dbUser}${dbPwd}@${dbHost}:${dbPort}"
283 + "/${dbName}?charset=${dbCharset}"
284 + "&serverVersion=$serverVersion${dbUnixSocket}";
285 in
286 ''
287 set -eu
288
289 serverVersion=${
290 if !cfg.database.createLocally then
291 cfg.database.serverVersion
292 else
293 # Obtain MySQL version string dynamically from the running
294 # instance. Doctrine ORM's doc said it should be possible to
295 # autodetect this, however Kimai's doc insists that it has to
296 # be set.
297 # https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#mysql
298 # https://stackoverflow.com/q/9558867
299 "$(${mysql} --silent --skip-column-names --execute 'SELECT VERSION();')"
300 }
301
302 # Create .env file containing DATABASE_URL and other default
303 # variables. Set umask to make sure .env is not readable by
304 # unrelated users.
305 oldUmask=$(umask)
306 umask 177
307
308 if ! [ -e ${appSecretFile} ]; then
309 tr -dc A-Za-z0-9 </dev/urandom | head -c 20 >${appSecretFile}
310 fi
311
312 cat >${envFile} <<EOF
313 DATABASE_URL=${dbUri}
314 MAILER_FROM=kimai@example.com
315 MAILER_URL=null://null
316 APP_ENV=prod
317 APP_SECRET=$(cat ${appSecretFile})
318 CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?\$
319 EOF
320
321 umask $oldUmask
322
323 # Ensure that our local.yaml is valid (see kimai:reload command).
324 ${pkg hostName cfg}/bin/console lint:yaml --parse-tags \
325 ${pkg hostName cfg}/share/php/kimai/config
326
327 # Before running any further console commands, clear cache. This
328 # avoids errors due to old cache getting used with new version
329 # of Kimai.
330 ${pkg hostName cfg}/bin/console cache:clear --env=prod
331 # Then, run kimai:install to ensure database is created or updated.
332 # Note that kimai:update is an alias to kimai:install.
333 ${pkg hostName cfg}/bin/console kimai:install --no-cache
334 # Finally, warm up cache.
335 ${pkg hostName cfg}/bin/console cache:warmup --env=prod
336 '';
337
338 serviceConfig = {
339 Type = "oneshot";
340 User = user;
341 Group = webserver.group;
342 EnvironmentFile = [ cfg.environmentFile ];
343 };
344 })
345 ) eachSite)
346
347 (mapAttrs' (
348 hostName: cfg:
349 (nameValuePair "phpfpm-kimai-${hostName}" {
350 serviceConfig = {
351 EnvironmentFile = [ cfg.environmentFile ];
352 };
353 })
354 ) eachSite)
355
356 (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
357 "${cfg.webserver}".after = [ "mysql.service" ];
358 })
359 ];
360
361 users.users.${user} = {
362 group = webserver.group;
363 isSystemUser = true;
364 };
365 }
366
367 (mkIf (cfg.webserver == "nginx") {
368 services.nginx = {
369 enable = true;
370 virtualHosts = mapAttrs (hostName: cfg: {
371 serverName = mkDefault hostName;
372 root = "${pkg hostName cfg}/share/php/kimai/public";
373 extraConfig = ''
374 index index.php;
375 '';
376 locations = {
377 "/" = {
378 priority = 200;
379 extraConfig = ''
380 try_files $uri /index.php$is_args$args;
381 '';
382 };
383 "~ ^/index\\.php(/|$)" = {
384 priority = 500;
385 extraConfig = ''
386 fastcgi_split_path_info ^(.+\.php)(/.+)$;
387 fastcgi_pass unix:${config.services.phpfpm.pools."kimai-${hostName}".socket};
388 fastcgi_index index.php;
389 include "${config.services.nginx.package}/conf/fastcgi.conf";
390 fastcgi_param PATH_INFO $fastcgi_path_info;
391 fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
392 # Mitigate https://httpoxy.org/ vulnerabilities
393 fastcgi_param HTTP_PROXY "";
394 fastcgi_intercept_errors off;
395 fastcgi_buffer_size 16k;
396 fastcgi_buffers 4 16k;
397 fastcgi_connect_timeout 300;
398 fastcgi_send_timeout 300;
399 fastcgi_read_timeout 300;
400 '';
401 };
402 "~ \\.php$" = {
403 priority = 800;
404 extraConfig = ''
405 return 404;
406 '';
407 };
408 };
409 }) eachSite;
410 };
411 })
412
413 ]);
414}