1{ config, lib, pkgs, ... }:
2
3let
4 cfg = config.services.librenms;
5 settingsFormat = pkgs.formats.json {};
6 configJson = settingsFormat.generate "librenms-config.json" cfg.settings;
7
8 package = pkgs.librenms.override {
9 logDir = cfg.logDir;
10 dataDir = cfg.dataDir;
11 };
12
13 phpOptions = ''
14 log_errors = on
15 post_max_size = 100M
16 upload_max_filesize = 100M
17 date.timezone = "${config.time.timeZone}"
18 '';
19 phpIni = pkgs.runCommand "php.ini" {
20 inherit (package) phpPackage;
21 inherit phpOptions;
22 preferLocalBuild = true;
23 passAsFile = [ "phpOptions" ];
24 } ''
25 cat $phpPackage/etc/php.ini $phpOptionsPath > $out
26 '';
27
28 artisanWrapper = pkgs.writeShellScriptBin "librenms-artisan" ''
29 cd ${package}
30 sudo=exec
31 if [[ "$USER" != ${cfg.user} ]]; then
32 sudo='exec /run/wrappers/bin/sudo -u ${cfg.user}'
33 fi
34 $sudo ${package}/artisan $*
35 '';
36
37 lnmsWrapper = pkgs.writeShellScriptBin "lnms" ''
38 cd ${package}
39 exec ${package}/lnms $*
40 '';
41
42 configFile = pkgs.writeText "config.php" ''
43 <?php
44 $new_config = json_decode(file_get_contents("${cfg.dataDir}/config.json"), true);
45 $config = ($config == null) ? $new_config : array_merge($config, $new_config);
46
47 ${lib.optionalString (cfg.extraConfig != null) cfg.extraConfig}
48 '';
49
50in {
51 options.services.librenms = with lib; {
52 enable = mkEnableOption "LibreNMS network monitoring system";
53
54 user = mkOption {
55 type = types.str;
56 default = "librenms";
57 description = ''
58 Name of the LibreNMS user.
59 '';
60 };
61
62 group = mkOption {
63 type = types.str;
64 default = "librenms";
65 description = ''
66 Name of the LibreNMS group.
67 '';
68 };
69
70 hostname = mkOption {
71 type = types.str;
72 default = config.networking.fqdnOrHostName;
73 defaultText = literalExpression "config.networking.fqdnOrHostName";
74 description = ''
75 The hostname to serve LibreNMS on.
76 '';
77 };
78
79 pollerThreads = mkOption {
80 type = types.int;
81 default = 16;
82 description = ''
83 Amount of threads of the cron-poller.
84 '';
85 };
86
87 enableOneMinutePolling = mkOption {
88 type = types.bool;
89 default = false;
90 description = ''
91 Enables the [1-Minute Polling](https://docs.librenms.org/Support/1-Minute-Polling/).
92 Changing this option will automatically convert your existing rrd files.
93 '';
94 };
95
96 useDistributedPollers = mkOption {
97 type = types.bool;
98 default = false;
99 description = ''
100 Enables (distributed pollers)[https://docs.librenms.org/Extensions/Distributed-Poller/]
101 for this LibreNMS instance. This will enable a local `rrdcached` and `memcached` server.
102
103 To use this feature, make sure to configure your firewall that the distributed pollers
104 can reach the local `mysql`, `rrdcached` and `memcached` ports.
105 '';
106 };
107
108 distributedPoller = {
109 enable = mkOption {
110 type = types.bool;
111 default = false;
112 description = ''
113 Configure this LibreNMS instance as a (distributed poller)[https://docs.librenms.org/Extensions/Distributed-Poller/].
114 This will disable all web features and just configure the poller features.
115 Use the `mysql` database of your main LibreNMS instance in the database settings.
116 '';
117 };
118
119 name = mkOption {
120 type = types.nullOr types.str;
121 default = null;
122 description = ''
123 Custom name of this poller.
124 '';
125 };
126
127 group = mkOption {
128 type = types.str;
129 default = "0";
130 example = "1,2";
131 description = ''
132 Group(s) of this poller.
133 '';
134 };
135
136 distributedBilling = mkOption {
137 type = types.bool;
138 default = false;
139 description = ''
140 Enable distributed billing on this poller.
141 '';
142 };
143
144 memcachedHost = mkOption {
145 type = types.str;
146 description = ''
147 Hostname or IP of the `memcached` server.
148 '';
149 };
150
151 memcachedPort = mkOption {
152 type = types.port;
153 default = 11211;
154 description = ''
155 Port of the `memcached` server.
156 '';
157 };
158
159 rrdcachedHost = mkOption {
160 type = types.str;
161 description = ''
162 Hostname or IP of the `rrdcached` server.
163 '';
164 };
165
166 rrdcachedPort = mkOption {
167 type = types.port;
168 default = 42217;
169 description = ''
170 Port of the `memcached` server.
171 '';
172 };
173 };
174
175 poolConfig = mkOption {
176 type = with types; attrsOf (oneOf [ str int bool ]);
177 default = {
178 "pm" = "dynamic";
179 "pm.max_children" = 32;
180 "pm.start_servers" = 2;
181 "pm.min_spare_servers" = 2;
182 "pm.max_spare_servers" = 4;
183 "pm.max_requests" = 500;
184 };
185 description = ''
186 Options for the LibreNMS PHP pool. See the documentation on `php-fpm.conf`
187 for details on configuration directives.
188 '';
189 };
190
191 nginx = mkOption {
192 type = types.submodule (
193 recursiveUpdate
194 (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {}
195 );
196 default = { };
197 example = literalExpression ''
198 {
199 serverAliases = [
200 "librenms.''${config.networking.domain}"
201 ];
202 # To enable encryption and let let's encrypt take care of certificate
203 forceSSL = true;
204 enableACME = true;
205 # To set the LibreNMS virtualHost as the default virtualHost;
206 default = true;
207 }
208 '';
209 description = ''
210 With this option, you can customize the nginx virtualHost settings.
211 '';
212 };
213
214 dataDir = mkOption {
215 type = types.path;
216 default = "/var/lib/librenms";
217 description = ''
218 Path of the LibreNMS state directory.
219 '';
220 };
221
222 logDir = mkOption {
223 type = types.path;
224 default = "/var/log/librenms";
225 description = ''
226 Path of the LibreNMS logging directory.
227 '';
228 };
229
230 database = {
231 createLocally = mkOption {
232 type = types.bool;
233 default = false;
234 description = ''
235 Whether to create a local database automatically.
236 '';
237 };
238
239 host = mkOption {
240 default = "localhost";
241 description = ''
242 Hostname or IP of the MySQL/MariaDB server.
243 '';
244 };
245
246 port = mkOption {
247 type = types.port;
248 default = 3306;
249 description = ''
250 Port of the MySQL/MariaDB server.
251 '';
252 };
253
254 database = mkOption {
255 type = types.str;
256 default = "librenms";
257 description = ''
258 Name of the database on the MySQL/MariaDB server.
259 '';
260 };
261
262 username = mkOption {
263 type = types.str;
264 default = "librenms";
265 description = ''
266 Name of the user on the MySQL/MariaDB server.
267 '';
268 };
269
270 passwordFile = mkOption {
271 type = types.path;
272 example = "/run/secrets/mysql.pass";
273 description = ''
274 A file containing the password for the user of the MySQL/MariaDB server.
275 Must be readable for the LibreNMS user.
276 '';
277 };
278 };
279
280 environmentFile = mkOption {
281 type = types.nullOr types.str;
282 default = null;
283 description = ''
284 File containing env-vars to be substituted into the final config. Useful for secrets.
285 Does not apply to settings defined in `extraConfig`.
286 '';
287 };
288
289 settings = mkOption {
290 type = types.submodule {
291 freeformType = settingsFormat.type;
292 options = {};
293 };
294 description = ''
295 Attrset of the LibreNMS configuration.
296 See https://docs.librenms.org/Support/Configuration/ for reference.
297 All possible options are listed [here](https://github.com/librenms/librenms/blob/master/misc/config_definitions.json).
298 See https://docs.librenms.org/Extensions/Authentication/ for setting other authentication methods.
299 '';
300 default = { };
301 example = {
302 base_url = "/librenms/";
303 top_devices = true;
304 top_ports = false;
305 };
306 };
307
308 extraConfig = mkOption {
309 type = types.nullOr types.str;
310 default = null;
311 description = ''
312 Additional config for LibreNMS that will be appended to the `config.php`. See
313 https://github.com/librenms/librenms/blob/master/misc/config_definitions.json
314 for possible options. Useful if you want to use PHP-Functions in your config.
315 '';
316 };
317 };
318
319 config = lib.mkIf cfg.enable {
320 assertions = [
321 {
322 assertion = config.time.timeZone != null;
323 message = "You must set `time.timeZone` to use the LibreNMS module.";
324 }
325 {
326 assertion = cfg.database.createLocally -> cfg.database.host == "localhost";
327 message = "The database host must be \"localhost\" if services.librenms.database.createLocally is set to true.";
328 }
329 {
330 assertion = !(cfg.useDistributedPollers && cfg.distributedPoller.enable);
331 message = "The LibreNMS instance can't be a distributed poller and a full instance at the same time.";
332 }
333 ];
334
335 users.users.${cfg.user} = {
336 group = "${cfg.group}";
337 isSystemUser = true;
338 };
339
340 users.groups.${cfg.group} = { };
341
342 services.librenms.settings = {
343 # basic configs
344 "user" = cfg.user;
345 "own_hostname" = cfg.hostname;
346 "base_url" = lib.mkDefault "/";
347 "auth_mechanism" = lib.mkDefault "mysql";
348
349 # disable auto update function (won't work with NixOS)
350 "update" = false;
351
352 # enable fast ping by default
353 "ping_rrd_step" = 60;
354
355 # one minute polling
356 "rrd.step" = if cfg.enableOneMinutePolling then 60 else 300;
357 "rrd.heartbeat" = if cfg.enableOneMinutePolling then 120 else 600;
358 } // (lib.optionalAttrs cfg.distributedPoller.enable {
359 "distributed_poller" = true;
360 "distributed_poller_name" = lib.mkIf (cfg.distributedPoller.name != null) cfg.distributedPoller.name;
361 "distributed_poller_group" = cfg.distributedPoller.group;
362 "distributed_billing" = cfg.distributedPoller.distributedBilling;
363 "distributed_poller_memcached_host" = cfg.distributedPoller.memcachedHost;
364 "distributed_poller_memcached_port" = cfg.distributedPoller.memcachedPort;
365 "rrdcached" = "${cfg.distributedPoller.rrdcachedHost}:${toString cfg.distributedPoller.rrdcachedPort}";
366 }) // (lib.optionalAttrs cfg.useDistributedPollers {
367 "distributed_poller" = true;
368 # still enable a local poller with distributed polling
369 "distributed_poller_group" = lib.mkDefault "0";
370 "distributed_billing" = lib.mkDefault true;
371 "distributed_poller_memcached_host" = "localhost";
372 "distributed_poller_memcached_port" = 11211;
373 "rrdcached" = "localhost:42217";
374 });
375
376 services.memcached = lib.mkIf cfg.useDistributedPollers {
377 enable = true;
378 listen = "0.0.0.0";
379 };
380
381 systemd.services.rrdcached = lib.mkIf cfg.useDistributedPollers {
382 description = "rrdcached";
383 after = [ "librenms-setup.service" ];
384 wantedBy = [ "multi-user.target" ];
385 serviceConfig = {
386 Type = "forking";
387 User = cfg.user;
388 Group = cfg.group;
389 LimitNOFILE = 16384;
390 RuntimeDirectory = "rrdcached";
391 PidFile = "/run/rrdcached/rrdcached.pid";
392 # rrdcached params from https://docs.librenms.org/Extensions/Distributed-Poller/#config-sample
393 ExecStart = "${pkgs.rrdtool}/bin/rrdcached -l 0:42217 -R -j ${cfg.dataDir}/rrdcached-journal/ -F -b ${cfg.dataDir}/rrd -B -w 1800 -z 900 -p /run/rrdcached/rrdcached.pid";
394 };
395 };
396
397 services.mysql = lib.mkIf cfg.database.createLocally {
398 enable = true;
399 package = lib.mkDefault pkgs.mariadb;
400 settings.mysqld = {
401 innodb_file_per_table = 1;
402 lower_case_table_names = 0;
403 } // (lib.optionalAttrs cfg.useDistributedPollers {
404 bind-address = "0.0.0.0";
405 });
406 ensureDatabases = [ cfg.database.database ];
407 ensureUsers = [
408 {
409 name = cfg.database.username;
410 ensurePermissions = {
411 "${cfg.database.database}.*" = "ALL PRIVILEGES";
412 };
413 }
414 ];
415 initialScript = lib.mkIf cfg.useDistributedPollers (pkgs.writeText "mysql-librenms-init" ''
416 CREATE USER IF NOT EXISTS '${cfg.database.username}'@'%';
417 GRANT ALL PRIVILEGES ON ${cfg.database.database}.* TO '${cfg.database.username}'@'%';
418 '');
419 };
420
421 services.nginx = lib.mkIf (!cfg.distributedPoller.enable) {
422 enable = true;
423 virtualHosts."${cfg.hostname}" = lib.mkMerge [
424 cfg.nginx
425 {
426 root = lib.mkForce "${package}/html";
427 locations."/" = {
428 index = "index.php";
429 tryFiles = "$uri $uri/ /index.php?$query_string";
430 };
431 locations."~ .php$".extraConfig = ''
432 fastcgi_pass unix:${config.services.phpfpm.pools."librenms".socket};
433 fastcgi_split_path_info ^(.+\.php)(/.+)$;
434 '';
435 }
436 ];
437 };
438
439 services.phpfpm.pools.librenms = lib.mkIf (!cfg.distributedPoller.enable) {
440 user = cfg.user;
441 group = cfg.group;
442 inherit (package) phpPackage;
443 inherit phpOptions;
444 settings = {
445 "listen.mode" = "0660";
446 "listen.owner" = config.services.nginx.user;
447 "listen.group" = config.services.nginx.group;
448 } // cfg.poolConfig;
449 };
450
451 systemd.services.librenms-scheduler = {
452 description = "LibreNMS Scheduler";
453 path = [ pkgs.unixtools.whereis ];
454 serviceConfig = {
455 Type = "oneshot";
456 WorkingDirectory = package;
457 User = cfg.user;
458 Group = cfg.group;
459 ExecStart = "${artisanWrapper}/bin/librenms-artisan schedule:run";
460 };
461 };
462
463 systemd.timers.librenms-scheduler = {
464 description = "LibreNMS Scheduler";
465 wantedBy = [ "timers.target" ];
466 timerConfig = {
467 OnCalendar = "minutely";
468 AccuracySec = "1second";
469 };
470 };
471
472 systemd.services.librenms-setup = {
473 description = "Preparation tasks for LibreNMS";
474 before = [ "phpfpm-librenms.service" ];
475 after = [ "systemd-tmpfiles-setup.service" ]
476 ++ (lib.optional (cfg.database.host == "localhost") "mysql.service");
477 wantedBy = [ "multi-user.target" ];
478 restartTriggers = [ package configFile ];
479 path = [ pkgs.mariadb pkgs.unixtools.whereis pkgs.gnused ];
480 serviceConfig = {
481 Type = "oneshot";
482 RemainAfterExit = true;
483 EnvironmentFile = lib.mkIf (cfg.environmentFile != null) [ cfg.environmentFile ];
484 User = cfg.user;
485 Group = cfg.group;
486 ExecStartPre = lib.mkIf cfg.database.createLocally [ "!${pkgs.writeShellScript "librenms-db-init" ''
487 DB_PASSWORD=$(cat ${cfg.database.passwordFile} | tr -d '\n')
488 echo "ALTER USER '${cfg.database.username}'@'localhost' IDENTIFIED BY '$DB_PASSWORD';" | ${pkgs.mariadb}/bin/mysql
489 ${lib.optionalString cfg.useDistributedPollers ''
490 echo "ALTER USER '${cfg.database.username}'@'%' IDENTIFIED BY '$DB_PASSWORD';" | ${pkgs.mariadb}/bin/mysql
491 ''}
492 ''}"];
493 };
494 script = ''
495 set -euo pipefail
496
497 # config setup
498 ln -sf ${configFile} ${cfg.dataDir}/config.php
499 ${pkgs.envsubst}/bin/envsubst -i ${configJson} -o ${cfg.dataDir}/config.json
500 export PHPRC=${phpIni}
501
502 if [[ ! -s ${cfg.dataDir}/.env ]]; then
503 # init .env file
504 echo "APP_KEY=" > ${cfg.dataDir}/.env
505 ${artisanWrapper}/bin/librenms-artisan key:generate --ansi
506 ${artisanWrapper}/bin/librenms-artisan webpush:vapid
507 echo "" >> ${cfg.dataDir}/.env
508 echo -n "NODE_ID=" >> ${cfg.dataDir}/.env
509 ${package.phpPackage}/bin/php -r "echo uniqid();" >> ${cfg.dataDir}/.env
510 echo "" >> ${cfg.dataDir}/.env
511 else
512 # .env file already exists --> only update database and cache config
513 ${pkgs.gnused}/bin/sed -i /^DB_/d ${cfg.dataDir}/.env
514 ${pkgs.gnused}/bin/sed -i /^CACHE_DRIVER/d ${cfg.dataDir}/.env
515 fi
516 ${lib.optionalString (cfg.useDistributedPollers || cfg.distributedPoller.enable) ''
517 echo "CACHE_DRIVER=memcached" >> ${cfg.dataDir}/.env
518 ''}
519 echo "DB_HOST=${cfg.database.host}" >> ${cfg.dataDir}/.env
520 echo "DB_PORT=${toString cfg.database.port}" >> ${cfg.dataDir}/.env
521 echo "DB_DATABASE=${cfg.database.database}" >> ${cfg.dataDir}/.env
522 echo "DB_USERNAME=${cfg.database.username}" >> ${cfg.dataDir}/.env
523 echo -n "DB_PASSWORD=" >> ${cfg.dataDir}/.env
524 cat ${cfg.database.passwordFile} >> ${cfg.dataDir}/.env
525
526 # clear cache after update
527 OLD_VERSION=$(cat ${cfg.dataDir}/version)
528 if [[ $OLD_VERSION != "${package.version}" ]]; then
529 rm -r ${cfg.dataDir}/cache/*
530 echo "${package.version}" > ${cfg.dataDir}/version
531 fi
532
533 # convert rrd files when the oneMinutePolling option is changed
534 OLD_ENABLED=$(cat ${cfg.dataDir}/one_minute_enabled)
535 if [[ $OLD_ENABLED != "${lib.boolToString cfg.enableOneMinutePolling}" ]]; then
536 ${package}/scripts/rrdstep.php -h all
537 echo "${lib.boolToString cfg.enableOneMinutePolling}" > ${cfg.dataDir}/one_minute_enabled
538 fi
539
540 # migrate db
541 ${artisanWrapper}/bin/librenms-artisan migrate --force --no-interaction
542 '';
543 };
544
545 programs.mtr.enable = true;
546
547 services.logrotate = {
548 enable = true;
549 settings."${cfg.logDir}/librenms.log" = {
550 su = "${cfg.user} ${cfg.group}";
551 create = "0640 ${cfg.user} ${cfg.group}";
552 rotate = 6;
553 frequency = "weekly";
554 compress = true;
555 delaycompress = true;
556 missingok = true;
557 notifempty = true;
558 };
559 };
560
561 services.cron = {
562 enable = true;
563 systemCronJobs = let
564 env = "PHPRC=${phpIni}";
565 in [
566 # based on crontab provided by LibreNMS
567 "33 */6 * * * ${cfg.user} ${env} ${package}/cronic ${package}/discovery-wrapper.py 1"
568 "*/5 * * * * ${cfg.user} ${env} ${package}/discovery.php -h new >> /dev/null 2>&1"
569
570 "${if cfg.enableOneMinutePolling then "*" else "*/5"} * * * * ${cfg.user} ${env} ${package}/cronic ${package}/poller-wrapper.py ${toString cfg.pollerThreads}"
571 "* * * * * ${cfg.user} ${env} ${package}/alerts.php >> /dev/null 2>&1"
572
573 "*/5 * * * * ${cfg.user} ${env} ${package}/poll-billing.php >> /dev/null 2>&1"
574 "01 * * * * ${cfg.user} ${env} ${package}/billing-calculate.php >> /dev/null 2>&1"
575 "*/5 * * * * ${cfg.user} ${env} ${package}/check-services.php >> /dev/null 2>&1"
576
577 # extra: fast ping
578 "* * * * * ${cfg.user} ${env} ${package}/ping.php >> /dev/null 2>&1"
579
580 # daily.sh tasks are split to exclude update
581 "19 0 * * * ${cfg.user} ${env} ${package}/daily.sh cleanup >> /dev/null 2>&1"
582 "19 0 * * * ${cfg.user} ${env} ${package}/daily.sh notifications >> /dev/null 2>&1"
583 "19 0 * * * ${cfg.user} ${env} ${package}/daily.sh peeringdb >> /dev/null 2>&1"
584 "19 0 * * * ${cfg.user} ${env} ${package}/daily.sh mac_oui >> /dev/null 2>&1"
585 ];
586 };
587
588 security.wrappers = {
589 fping = {
590 setuid = true;
591 owner = "root";
592 group = "root";
593 source = "${pkgs.fping}/bin/fping";
594 };
595 };
596
597 environment.systemPackages = [ artisanWrapper lnmsWrapper ];
598
599 systemd.tmpfiles.rules = [
600 "d ${cfg.logDir} 0750 ${cfg.user} ${cfg.group} - -"
601 "f ${cfg.logDir}/librenms.log 0640 ${cfg.user} ${cfg.group} - -"
602 "d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} - -"
603 "f ${cfg.dataDir}/.env 0600 ${cfg.user} ${cfg.group} - -"
604 "f ${cfg.dataDir}/version 0600 ${cfg.user} ${cfg.group} - -"
605 "f ${cfg.dataDir}/one_minute_enabled 0600 ${cfg.user} ${cfg.group} - -"
606 "f ${cfg.dataDir}/config.json 0600 ${cfg.user} ${cfg.group} - -"
607 "d ${cfg.dataDir}/storage 0700 ${cfg.user} ${cfg.group} - -"
608 "d ${cfg.dataDir}/storage/app 0700 ${cfg.user} ${cfg.group} - -"
609 "d ${cfg.dataDir}/storage/debugbar 0700 ${cfg.user} ${cfg.group} - -"
610 "d ${cfg.dataDir}/storage/framework 0700 ${cfg.user} ${cfg.group} - -"
611 "d ${cfg.dataDir}/storage/framework/cache 0700 ${cfg.user} ${cfg.group} - -"
612 "d ${cfg.dataDir}/storage/framework/sessions 0700 ${cfg.user} ${cfg.group} - -"
613 "d ${cfg.dataDir}/storage/framework/views 0700 ${cfg.user} ${cfg.group} - -"
614 "d ${cfg.dataDir}/storage/logs 0700 ${cfg.user} ${cfg.group} - -"
615 "d ${cfg.dataDir}/rrd 0700 ${cfg.user} ${cfg.group} - -"
616 "d ${cfg.dataDir}/cache 0700 ${cfg.user} ${cfg.group} - -"
617 ] ++ lib.optionals cfg.useDistributedPollers [
618 "d ${cfg.dataDir}/rrdcached-journal 0700 ${cfg.user} ${cfg.group} - -"
619 ];
620
621 };
622
623 meta.maintainers = lib.teams.wdz.members;
624}