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