at 25.11-pre 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 } // 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}