at 25.11-pre 19 kB view raw
1{ 2 config, 3 lib, 4 pkgs, 5 ... 6}: 7 8with lib; 9 10let 11 cfg = config.services.snipe-it; 12 snipe-it = pkgs.snipe-it.override { 13 dataDir = cfg.dataDir; 14 }; 15 db = cfg.database; 16 mail = cfg.mail; 17 18 user = cfg.user; 19 group = cfg.group; 20 21 tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME; 22 23 inherit (snipe-it.passthru) phpPackage; 24 25 # shell script for local administration 26 artisan = 27 (pkgs.writeScriptBin "snipe-it" '' 28 #! ${pkgs.runtimeShell} 29 cd "${snipe-it}/share/php/snipe-it" 30 sudo=exec 31 if [[ "$USER" != ${user} ]]; then 32 sudo='exec /run/wrappers/bin/sudo -u ${user}' 33 fi 34 $sudo ${phpPackage}/bin/php artisan $* 35 '').overrideAttrs 36 (old: { 37 meta = old.meta // { 38 mainProgram = "snipe-it"; 39 }; 40 }); 41in 42{ 43 options.services.snipe-it = { 44 45 enable = mkEnableOption "snipe-it, a free open source IT asset/license management system"; 46 47 user = mkOption { 48 default = "snipeit"; 49 description = "User snipe-it runs as."; 50 type = types.str; 51 }; 52 53 group = mkOption { 54 default = "snipeit"; 55 description = "Group snipe-it runs as."; 56 type = types.str; 57 }; 58 59 appKeyFile = mkOption { 60 description = '' 61 A file containing the Laravel APP_KEY - a 32 character long, 62 base64 encoded key used for encryption where needed. Can be 63 generated with `head -c 32 /dev/urandom | base64`. 64 ''; 65 example = "/run/keys/snipe-it/appkey"; 66 type = types.path; 67 }; 68 69 hostName = lib.mkOption { 70 type = lib.types.str; 71 default = config.networking.fqdnOrHostName; 72 defaultText = lib.literalExpression "config.networking.fqdnOrHostName"; 73 example = "snipe-it.example.com"; 74 description = '' 75 The hostname to serve Snipe-IT on. 76 ''; 77 }; 78 79 appURL = mkOption { 80 description = '' 81 The root URL that you want to host Snipe-IT on. All URLs in Snipe-IT will be generated using this value. 82 If you change this in the future you may need to run a command to update stored URLs in the database. 83 Command example: `snipe-it snipe-it:update-url https://old.example.com https://new.example.com` 84 ''; 85 default = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostName}"; 86 defaultText = '' 87 http''${lib.optionalString tlsEnabled "s"}://''${cfg.hostName} 88 ''; 89 example = "https://example.com"; 90 type = types.str; 91 }; 92 93 dataDir = mkOption { 94 description = "snipe-it data directory"; 95 default = "/var/lib/snipe-it"; 96 type = types.path; 97 }; 98 99 database = { 100 host = mkOption { 101 type = types.str; 102 default = "localhost"; 103 description = "Database host address."; 104 }; 105 port = mkOption { 106 type = types.port; 107 default = 3306; 108 description = "Database host port."; 109 }; 110 name = mkOption { 111 type = types.str; 112 default = "snipeit"; 113 description = "Database name."; 114 }; 115 user = mkOption { 116 type = types.str; 117 default = user; 118 defaultText = literalExpression "user"; 119 description = "Database username."; 120 }; 121 passwordFile = mkOption { 122 type = with types; nullOr path; 123 default = null; 124 example = "/run/keys/snipe-it/dbpassword"; 125 description = '' 126 A file containing the password corresponding to 127 {option}`database.user`. 128 ''; 129 }; 130 createLocally = mkOption { 131 type = types.bool; 132 default = false; 133 description = "Create the database and database user locally."; 134 }; 135 }; 136 137 mail = { 138 driver = mkOption { 139 type = types.enum [ 140 "smtp" 141 "sendmail" 142 ]; 143 default = "smtp"; 144 description = "Mail driver to use."; 145 }; 146 host = mkOption { 147 type = types.str; 148 default = "localhost"; 149 description = "Mail host address."; 150 }; 151 port = mkOption { 152 type = types.port; 153 default = 1025; 154 description = "Mail host port."; 155 }; 156 encryption = mkOption { 157 type = 158 with types; 159 nullOr (enum [ 160 "tls" 161 "ssl" 162 ]); 163 default = null; 164 description = "SMTP encryption mechanism to use."; 165 }; 166 user = mkOption { 167 type = with types; nullOr str; 168 default = null; 169 example = "snipeit"; 170 description = "Mail username."; 171 }; 172 passwordFile = mkOption { 173 type = with types; nullOr path; 174 default = null; 175 example = "/run/keys/snipe-it/mailpassword"; 176 description = '' 177 A file containing the password corresponding to 178 {option}`mail.user`. 179 ''; 180 }; 181 backupNotificationAddress = mkOption { 182 type = types.str; 183 default = "backup@example.com"; 184 description = "Email Address to send Backup Notifications to."; 185 }; 186 from = { 187 name = mkOption { 188 type = types.str; 189 default = "Snipe-IT Asset Management"; 190 description = "Mail \"from\" name."; 191 }; 192 address = mkOption { 193 type = types.str; 194 default = "mail@example.com"; 195 description = "Mail \"from\" address."; 196 }; 197 }; 198 replyTo = { 199 name = mkOption { 200 type = types.str; 201 default = "Snipe-IT Asset Management"; 202 description = "Mail \"reply-to\" name."; 203 }; 204 address = mkOption { 205 type = types.str; 206 default = "mail@example.com"; 207 description = "Mail \"reply-to\" address."; 208 }; 209 }; 210 }; 211 212 maxUploadSize = mkOption { 213 type = types.str; 214 default = "18M"; 215 example = "1G"; 216 description = "The maximum size for uploads (e.g. images)."; 217 }; 218 219 poolConfig = mkOption { 220 type = 221 with types; 222 attrsOf (oneOf [ 223 str 224 int 225 bool 226 ]); 227 default = { 228 "pm" = "dynamic"; 229 "pm.max_children" = 32; 230 "pm.start_servers" = 2; 231 "pm.min_spare_servers" = 2; 232 "pm.max_spare_servers" = 4; 233 "pm.max_requests" = 500; 234 }; 235 description = '' 236 Options for the snipe-it PHP pool. See the documentation on `php-fpm.conf` 237 for details on configuration directives. 238 ''; 239 }; 240 241 nginx = mkOption { 242 type = types.submodule ( 243 recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) { } 244 ); 245 default = { }; 246 example = literalExpression '' 247 { 248 serverAliases = [ 249 "snipe-it.''${config.networking.domain}" 250 ]; 251 # To enable encryption and let let's encrypt take care of certificate 252 forceSSL = true; 253 enableACME = true; 254 } 255 ''; 256 description = '' 257 With this option, you can customize the nginx virtualHost settings. 258 ''; 259 }; 260 261 config = mkOption { 262 type = 263 with types; 264 attrsOf ( 265 nullOr ( 266 either 267 (oneOf [ 268 bool 269 int 270 port 271 path 272 str 273 ]) 274 (submodule { 275 options = { 276 _secret = mkOption { 277 type = nullOr (oneOf [ 278 str 279 path 280 ]); 281 description = '' 282 The path to a file containing the value the 283 option should be set to in the final 284 configuration file. 285 ''; 286 }; 287 }; 288 }) 289 ) 290 ); 291 default = { }; 292 example = literalExpression '' 293 { 294 ALLOWED_IFRAME_HOSTS = "https://example.com"; 295 WKHTMLTOPDF = "''${pkgs.wkhtmltopdf}/bin/wkhtmltopdf"; 296 AUTH_METHOD = "oidc"; 297 OIDC_NAME = "MyLogin"; 298 OIDC_DISPLAY_NAME_CLAIMS = "name"; 299 OIDC_CLIENT_ID = "snipe-it"; 300 OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"}; 301 OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm"; 302 OIDC_ISSUER_DISCOVER = true; 303 } 304 ''; 305 description = '' 306 Snipe-IT configuration options to set in the 307 {file}`.env` file. 308 Refer to <https://snipe-it.readme.io/docs/configuration> 309 for details on supported values. 310 311 Settings containing secret data should be set to an attribute 312 set containing the attribute `_secret` - a 313 string pointing to a file containing the value the option 314 should be set to. See the example to get a better picture of 315 this: in the resulting {file}`.env` file, the 316 `OIDC_CLIENT_SECRET` key will be set to the 317 contents of the {file}`/run/keys/oidc_secret` 318 file. 319 ''; 320 }; 321 }; 322 323 config = mkIf cfg.enable { 324 325 assertions = [ 326 { 327 assertion = db.createLocally -> db.user == user; 328 message = "services.snipe-it.database.user must be set to ${user} if services.snipe-it.database.createLocally is set true."; 329 } 330 { 331 assertion = db.createLocally -> db.passwordFile == null; 332 message = "services.snipe-it.database.passwordFile cannot be specified if services.snipe-it.database.createLocally is set to true."; 333 } 334 ]; 335 336 environment.systemPackages = [ artisan ]; 337 338 services.snipe-it.config = { 339 APP_ENV = "production"; 340 APP_KEY._secret = cfg.appKeyFile; 341 APP_URL = cfg.appURL; 342 DB_HOST = db.host; 343 DB_PORT = db.port; 344 DB_DATABASE = db.name; 345 DB_USERNAME = db.user; 346 DB_PASSWORD._secret = db.passwordFile; 347 MAIL_DRIVER = mail.driver; 348 MAIL_FROM_NAME = mail.from.name; 349 MAIL_FROM_ADDR = mail.from.address; 350 MAIL_REPLYTO_NAME = mail.from.name; 351 MAIL_REPLYTO_ADDR = mail.from.address; 352 MAIL_BACKUP_NOTIFICATION_ADDRESS = mail.backupNotificationAddress; 353 MAIL_HOST = mail.host; 354 MAIL_PORT = mail.port; 355 MAIL_USERNAME = mail.user; 356 MAIL_ENCRYPTION = mail.encryption; 357 MAIL_PASSWORD._secret = mail.passwordFile; 358 APP_SERVICES_CACHE = "/run/snipe-it/cache/services.php"; 359 APP_PACKAGES_CACHE = "/run/snipe-it/cache/packages.php"; 360 APP_CONFIG_CACHE = "/run/snipe-it/cache/config.php"; 361 APP_ROUTES_CACHE = "/run/snipe-it/cache/routes-v7.php"; 362 APP_EVENTS_CACHE = "/run/snipe-it/cache/events.php"; 363 SECURE_COOKIES = tlsEnabled; 364 }; 365 366 services.mysql = mkIf db.createLocally { 367 enable = true; 368 package = mkDefault pkgs.mariadb; 369 ensureDatabases = [ db.name ]; 370 ensureUsers = [ 371 { 372 name = db.user; 373 ensurePermissions = { 374 "${db.name}.*" = "ALL PRIVILEGES"; 375 }; 376 } 377 ]; 378 }; 379 380 services.phpfpm.pools.snipe-it = { 381 inherit user group phpPackage; 382 phpOptions = '' 383 post_max_size = ${cfg.maxUploadSize} 384 upload_max_filesize = ${cfg.maxUploadSize} 385 ''; 386 settings = { 387 "listen.mode" = "0660"; 388 "listen.owner" = user; 389 "listen.group" = group; 390 } // cfg.poolConfig; 391 }; 392 393 services.nginx = { 394 enable = mkDefault true; 395 virtualHosts."${cfg.hostName}" = mkMerge [ 396 cfg.nginx 397 { 398 root = mkForce "${snipe-it}/share/php/snipe-it/public"; 399 extraConfig = optionalString ( 400 cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME 401 ) "fastcgi_param HTTPS on;"; 402 locations = { 403 "/" = { 404 index = "index.php"; 405 extraConfig = ''try_files $uri $uri/ /index.php?$query_string;''; 406 }; 407 "~ \\.php$" = { 408 extraConfig = '' 409 try_files $uri $uri/ /index.php?$query_string; 410 include ${config.services.nginx.package}/conf/fastcgi_params; 411 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 412 fastcgi_param REDIRECT_STATUS 200; 413 fastcgi_pass unix:${config.services.phpfpm.pools."snipe-it".socket}; 414 ${optionalString ( 415 cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME 416 ) "fastcgi_param HTTPS on;"} 417 ''; 418 }; 419 "~ \\.(js|css|gif|png|ico|jpg|jpeg)$" = { 420 extraConfig = "expires 365d;"; 421 }; 422 }; 423 } 424 ]; 425 }; 426 427 systemd.services.snipe-it-setup = { 428 description = "Preparation tasks for snipe-it"; 429 before = [ "phpfpm-snipe-it.service" ]; 430 after = optional db.createLocally "mysql.service"; 431 wantedBy = [ "multi-user.target" ]; 432 serviceConfig = { 433 Type = "oneshot"; 434 RemainAfterExit = true; 435 User = user; 436 WorkingDirectory = snipe-it; 437 RuntimeDirectory = "snipe-it/cache"; 438 RuntimeDirectoryMode = "0700"; 439 }; 440 path = [ 441 pkgs.replace-secret 442 artisan 443 ]; 444 script = 445 let 446 isSecret = v: isAttrs v && v ? _secret && (isString v._secret || builtins.isPath v._secret); 447 snipeITEnvVars = lib.generators.toKeyValue { 448 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" { 449 mkValueString = 450 v: 451 with builtins; 452 if isInt v then 453 toString v 454 else if isString v then 455 "\"${v}\"" 456 else if true == v then 457 "true" 458 else if false == v then 459 "false" 460 else if isSecret v then 461 if (isString v._secret) then 462 hashString "sha256" v._secret 463 else 464 hashString "sha256" (builtins.readFile v._secret) 465 else 466 throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty { }) v}"; 467 }; 468 }; 469 secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config); 470 mkSecretReplacement = file: '' 471 replace-secret ${ 472 escapeShellArgs [ 473 ( 474 if (isString file) then 475 builtins.hashString "sha256" file 476 else 477 builtins.hashString "sha256" (builtins.readFile file) 478 ) 479 file 480 "${cfg.dataDir}/.env" 481 ] 482 } 483 ''; 484 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths; 485 filteredConfig = lib.converge (lib.filterAttrsRecursive ( 486 _: v: 487 !elem v [ 488 { } 489 null 490 ] 491 )) cfg.config; 492 snipeITEnv = pkgs.writeText "snipeIT.env" (snipeITEnvVars filteredConfig); 493 in 494 '' 495 # error handling 496 set -euo pipefail 497 498 # set permissions 499 umask 077 500 501 # create .env file 502 install -T -m 0600 -o ${user} ${snipeITEnv} "${cfg.dataDir}/.env" 503 504 # replace secrets 505 ${secretReplacements} 506 507 # prepend `base64:` if it does not exist in APP_KEY 508 if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then 509 sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env" 510 fi 511 512 # pruge and rebuild caches 513 ${lib.getExe artisan} optimize:clear 514 ${lib.getExe artisan} optimize 515 516 # migrate db 517 ${lib.getExe artisan} migrate --force 518 519 # A placeholder file for invalid barcodes 520 invalid_barcode_location="${cfg.dataDir}/public/uploads/barcodes/invalid_barcode.gif" 521 if [ ! -e "$invalid_barcode_location" ]; then 522 cp ${snipe-it}/share/snipe-it/invalid_barcode.gif "$invalid_barcode_location" 523 fi 524 ''; 525 }; 526 527 systemd.tmpfiles.rules = [ 528 "d ${cfg.dataDir} 0710 ${user} ${group} - -" 529 "d ${cfg.dataDir}/bootstrap 0750 ${user} ${group} - -" 530 "d ${cfg.dataDir}/bootstrap/cache 0750 ${user} ${group} - -" 531 "d ${cfg.dataDir}/public 0750 ${user} ${group} - -" 532 "d ${cfg.dataDir}/public/uploads 0750 ${user} ${group} - -" 533 "d ${cfg.dataDir}/public/uploads/accessories 0750 ${user} ${group} - -" 534 "d ${cfg.dataDir}/public/uploads/assets 0750 ${user} ${group} - -" 535 "d ${cfg.dataDir}/public/uploads/avatars 0750 ${user} ${group} - -" 536 "d ${cfg.dataDir}/public/uploads/barcodes 0750 ${user} ${group} - -" 537 "d ${cfg.dataDir}/public/uploads/categories 0750 ${user} ${group} - -" 538 "d ${cfg.dataDir}/public/uploads/companies 0750 ${user} ${group} - -" 539 "d ${cfg.dataDir}/public/uploads/components 0750 ${user} ${group} - -" 540 "d ${cfg.dataDir}/public/uploads/consumables 0750 ${user} ${group} - -" 541 "d ${cfg.dataDir}/public/uploads/departments 0750 ${user} ${group} - -" 542 "d ${cfg.dataDir}/public/uploads/locations 0750 ${user} ${group} - -" 543 "d ${cfg.dataDir}/public/uploads/manufacturers 0750 ${user} ${group} - -" 544 "d ${cfg.dataDir}/public/uploads/models 0750 ${user} ${group} - -" 545 "d ${cfg.dataDir}/public/uploads/suppliers 0750 ${user} ${group} - -" 546 "d ${cfg.dataDir}/storage 0700 ${user} ${group} - -" 547 "d ${cfg.dataDir}/storage/app 0700 ${user} ${group} - -" 548 "d ${cfg.dataDir}/storage/fonts 0700 ${user} ${group} - -" 549 "d ${cfg.dataDir}/storage/framework 0700 ${user} ${group} - -" 550 "d ${cfg.dataDir}/storage/framework/cache 0700 ${user} ${group} - -" 551 "d ${cfg.dataDir}/storage/framework/sessions 0700 ${user} ${group} - -" 552 "d ${cfg.dataDir}/storage/framework/views 0700 ${user} ${group} - -" 553 "d ${cfg.dataDir}/storage/logs 0700 ${user} ${group} - -" 554 "d ${cfg.dataDir}/storage/uploads 0700 ${user} ${group} - -" 555 "d ${cfg.dataDir}/storage/private_uploads 0700 ${user} ${group} - -" 556 ]; 557 558 users = { 559 users = mkIf (user == "snipeit") { 560 snipeit = { 561 inherit group; 562 isSystemUser = true; 563 }; 564 "${config.services.nginx.user}".extraGroups = [ group ]; 565 }; 566 groups = mkIf (group == "snipeit") { 567 snipeit = { }; 568 }; 569 }; 570 571 }; 572 573 meta.maintainers = with maintainers; [ yayayayaka ]; 574}