at master 14 kB view raw
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}