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}