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}