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