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}