1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7# TODO: This is not secure, have a look at the file docs/security.txt inside
8# the project sources.
9let
10 cfg = config.power.ups;
11 defaultPort = 3493;
12
13 envVars = {
14 NUT_CONFPATH = "/etc/nut";
15 NUT_STATEPATH = "/var/lib/nut";
16 };
17
18 nutFormat = {
19
20 type =
21 with lib.types;
22 let
23
24 singleAtom =
25 nullOr (oneOf [
26 bool
27 int
28 float
29 str
30 ])
31 // {
32 description = "atom (null, bool, int, float or string)";
33 };
34
35 in
36 attrsOf (oneOf [
37 singleAtom
38 (listOf (nonEmptyListOf singleAtom))
39 ]);
40
41 generate =
42 name: value:
43 let
44 normalizedValue = lib.mapAttrs (
45 key: val:
46 if lib.isList val then
47 lib.forEach val (elem: if lib.isList elem then elem else [ elem ])
48 else if val == null then
49 [ ]
50 else
51 [ [ val ] ]
52 ) value;
53
54 mkValueString = lib.concatMapStringsSep " " (
55 v:
56 let
57 str = lib.generators.mkValueStringDefault { } v;
58 in
59 # Quote the value if it has spaces and isn't already quoted.
60 if (lib.hasInfix " " str) && !(lib.hasPrefix "\"" str && lib.hasSuffix "\"" str) then
61 "\"${str}\""
62 else
63 str
64 );
65
66 in
67 pkgs.writeText name (
68 lib.generators.toKeyValue {
69 mkKeyValue = lib.generators.mkKeyValueDefault { inherit mkValueString; } " ";
70 listsAsDuplicateKeys = true;
71 } normalizedValue
72 );
73
74 };
75
76 installSecrets =
77 source: target: owner: secrets:
78 pkgs.writeShellScript "installSecrets.sh" ''
79 install -m0600 -o${owner} -D ${source} "${target}"
80 ${lib.concatLines (
81 lib.forEach secrets (name: ''
82 ${pkgs.replace-secret}/bin/replace-secret \
83 '@${name}@' \
84 "$CREDENTIALS_DIRECTORY/${name}" \
85 "${target}"
86 '')
87 )}
88 chmod u-w "${target}"
89 '';
90
91 upsmonConf = nutFormat.generate "upsmon.conf" cfg.upsmon.settings;
92
93 upsdUsers = pkgs.writeText "upsd.users" (
94 let
95 # This looks like INI, but it's not quite because the
96 # 'upsmon' option lacks a '='. See: man upsd.users
97 userConfig =
98 name: user:
99 lib.concatStringsSep "\n " (
100 lib.concatLists [
101 [
102 "[${name}]"
103 "password = \"@upsdusers_password_${name}@\""
104 ]
105 (lib.optional (user.upsmon != null) "upsmon ${user.upsmon}")
106 (lib.forEach user.actions (action: "actions = ${action}"))
107 (lib.forEach user.instcmds (instcmd: "instcmds = ${instcmd}"))
108 ]
109 );
110 in
111 lib.concatStringsSep "\n\n" (lib.mapAttrsToList userConfig cfg.users)
112 );
113
114 upsOptions =
115 { name, config, ... }:
116 {
117 options = {
118 # This can be inferred from the UPS model by looking at
119 # /nix/store/nut/share/driver.list
120 driver = lib.mkOption {
121 type = lib.types.str;
122 description = ''
123 Specify the program to run to talk to this UPS. apcsmart,
124 bestups, and sec are some examples.
125 '';
126 };
127
128 port = lib.mkOption {
129 type = lib.types.str;
130 description = ''
131 The serial port to which your UPS is connected. /dev/ttyS0 is
132 usually the first port on Linux boxes, for example.
133 '';
134 };
135
136 shutdownOrder = lib.mkOption {
137 default = 0;
138 type = lib.types.int;
139 description = ''
140 When you have multiple UPSes on your system, you usually need to
141 turn them off in a certain order. upsdrvctl shuts down all the
142 0s, then the 1s, 2s, and so on. To exclude a UPS from the
143 shutdown sequence, set this to -1.
144 '';
145 };
146
147 maxStartDelay = lib.mkOption {
148 default = null;
149 type = lib.types.uniq (lib.types.nullOr lib.types.int);
150 description = ''
151 This can be set as a global variable above your first UPS
152 definition and it can also be set in a UPS section. This value
153 controls how long upsdrvctl will wait for the driver to finish
154 starting. This keeps your system from getting stuck due to a
155 broken driver or UPS.
156 '';
157 };
158
159 description = lib.mkOption {
160 default = "";
161 type = lib.types.str;
162 description = ''
163 Description of the UPS.
164 '';
165 };
166
167 directives = lib.mkOption {
168 default = [ ];
169 type = lib.types.listOf lib.types.str;
170 description = ''
171 List of configuration directives for this UPS.
172 '';
173 };
174
175 summary = lib.mkOption {
176 default = "";
177 type = lib.types.lines;
178 description = ''
179 Lines which would be added inside ups.conf for handling this UPS.
180 '';
181 };
182
183 };
184
185 config = {
186 directives = lib.mkOrder 10 (
187 [
188 "driver = ${config.driver}"
189 "port = ${config.port}"
190 ''desc = "${config.description}"''
191 "sdorder = ${toString config.shutdownOrder}"
192 ]
193 ++ (lib.optional (config.maxStartDelay != null) "maxstartdelay = ${toString config.maxStartDelay}")
194 );
195
196 summary = lib.concatStringsSep "\n " ([ "[${name}]" ] ++ config.directives);
197 };
198 };
199
200 listenOptions = {
201 options = {
202 address = lib.mkOption {
203 type = lib.types.str;
204 description = ''
205 Address of the interface for `upsd` to listen on.
206 See `man upsd.conf` for details.
207 '';
208 };
209
210 port = lib.mkOption {
211 type = lib.types.port;
212 default = defaultPort;
213 description = ''
214 TCP port for `upsd` to listen on.
215 See `man upsd.conf` for details.
216 '';
217 };
218 };
219 };
220
221 upsdOptions = {
222 options = {
223 enable = lib.mkOption {
224 type = lib.types.bool;
225 defaultText = lib.literalMD "`true` if `mode` is one of `standalone`, `netserver`";
226 description = "Whether to enable `upsd`.";
227 };
228
229 listen = lib.mkOption {
230 type = with lib.types; listOf (submodule listenOptions);
231 default = [ ];
232 example = [
233 {
234 address = "192.168.50.1";
235 }
236 {
237 address = "::1";
238 port = 5923;
239 }
240 ];
241 description = ''
242 Address of the interface for `upsd` to listen on.
243 See `man upsd` for details`.
244 '';
245 };
246
247 extraConfig = lib.mkOption {
248 type = lib.types.lines;
249 default = "";
250 description = ''
251 Additional lines to add to `upsd.conf`.
252 '';
253 };
254 };
255
256 config = {
257 enable = lib.mkDefault (
258 lib.elem cfg.mode [
259 "standalone"
260 "netserver"
261 ]
262 );
263 };
264 };
265
266 monitorOptions =
267 { name, config, ... }:
268 {
269 options = {
270 system = lib.mkOption {
271 type = lib.types.str;
272 default = name;
273 description = ''
274 Identifier of the UPS to monitor, in this form: `<upsname>[@<hostname>[:<port>]]`
275 See `upsmon.conf` for details.
276 '';
277 };
278
279 powerValue = lib.mkOption {
280 type = lib.types.int;
281 default = 1;
282 description = ''
283 Number of power supplies that the UPS feeds on this system.
284 See `upsmon.conf` for details.
285 '';
286 };
287
288 user = lib.mkOption {
289 type = lib.types.str;
290 description = ''
291 Username from `upsd.users` for accessing this UPS.
292 See `upsmon.conf` for details.
293 '';
294 };
295
296 passwordFile = lib.mkOption {
297 type = lib.types.str;
298 defaultText = lib.literalMD "power.ups.users.\${user}.passwordFile";
299 description = ''
300 The full path to a file containing the password from
301 `upsd.users` for accessing this UPS. The password file
302 is read on service start.
303 See `upsmon.conf` for details.
304 '';
305 };
306
307 type = lib.mkOption {
308 type = lib.types.str;
309 default = "master";
310 description = ''
311 The relationship with `upsd`.
312 See `upsmon.conf` for details.
313 '';
314 };
315 };
316
317 config = {
318 passwordFile = lib.mkDefault cfg.users.${config.user}.passwordFile;
319 };
320 };
321
322 upsmonOptions = {
323 options = {
324 enable = lib.mkOption {
325 type = lib.types.bool;
326 defaultText = lib.literalMD "`true` if `mode` is one of `standalone`, `netserver`, `netclient`";
327 description = "Whether to enable `upsmon`.";
328 };
329
330 user = lib.mkOption {
331 type = lib.types.str;
332 default = "nutmon";
333 description = ''
334 User to run `upsmon` as. `upsmon.conf` will have its owner set to this
335 user. If not specified, a default user will be created.
336 '';
337 };
338 group = lib.mkOption {
339 type = lib.types.str;
340 default = "nutmon";
341 description = ''
342 Group for the default `nutmon` user. If the default user is created
343 and this is not specified, a default group will be created.
344 '';
345 };
346
347 monitor = lib.mkOption {
348 type = with lib.types; attrsOf (submodule monitorOptions);
349 default = { };
350 description = ''
351 Set of UPS to monitor. See `man upsmon.conf` for details.
352 '';
353 };
354
355 settings = lib.mkOption {
356 type = nutFormat.type;
357 default = { };
358 defaultText = lib.literalMD ''
359 {
360 MINSUPPLIES = 1;
361 MONITOR = <generated from config.power.ups.upsmon.monitor>
362 NOTIFYCMD = "''${pkgs.nut}/bin/upssched";
363 POWERDOWNFLAG = "/run/killpower";
364 SHUTDOWNCMD = "''${pkgs.systemd}/bin/shutdown now";
365 }
366 '';
367 description = "Additional settings to add to `upsmon.conf`.";
368 example = lib.literalMD ''
369 {
370 MINSUPPLIES = 2;
371 NOTIFYFLAG = [
372 [ "ONLINE" "SYSLOG+EXEC" ]
373 [ "ONBATT" "SYSLOG+EXEC" ]
374 ];
375 }
376 '';
377 };
378 };
379
380 config = {
381 enable = lib.mkDefault (
382 lib.elem cfg.mode [
383 "standalone"
384 "netserver"
385 "netclient"
386 ]
387 );
388 settings = {
389 MINSUPPLIES = lib.mkDefault 1;
390 MONITOR = lib.flip lib.mapAttrsToList cfg.upsmon.monitor (
391 name: monitor: with monitor; [
392 system
393 powerValue
394 user
395 "\"@upsmon_password_${name}@\""
396 type
397 ]
398 );
399 NOTIFYCMD = lib.mkDefault "${pkgs.nut}/bin/upssched";
400 POWERDOWNFLAG = lib.mkDefault "/run/killpower";
401 SHUTDOWNCMD = lib.mkDefault "${pkgs.systemd}/bin/shutdown now";
402 };
403 };
404 };
405
406 userOptions = {
407 options = {
408 passwordFile = lib.mkOption {
409 type = lib.types.str;
410 description = ''
411 The full path to a file that contains the user's (clear text)
412 password. The password file is read on service start.
413 '';
414 };
415
416 actions = lib.mkOption {
417 type = with lib.types; listOf str;
418 default = [ ];
419 description = ''
420 Allow the user to do certain things with upsd.
421 See `man upsd.users` for details.
422 '';
423 };
424
425 instcmds = lib.mkOption {
426 type = with lib.types; listOf str;
427 default = [ ];
428 description = ''
429 Let the user initiate specific instant commands. Use "ALL" to grant all commands automatically. For the full list of what your UPS supports, use "upscmd -l".
430 See `man upsd.users` for details.
431 '';
432 };
433
434 upsmon = lib.mkOption {
435 type =
436 with lib.types;
437 nullOr (enum [
438 "primary"
439 "secondary"
440 ]);
441 default = null;
442 description = ''
443 Add the necessary actions for a upsmon process to work.
444 See `man upsd.users` for details.
445 '';
446 };
447 };
448 };
449
450in
451
452{
453 options = {
454 # powerManagement.powerDownCommands
455
456 power.ups = {
457 enable = lib.mkEnableOption ''
458 support for Power Devices, such as Uninterruptible Power
459 Supplies, Power Distribution Units and Solar Controllers
460 '';
461
462 mode = lib.mkOption {
463 default = "standalone";
464 type = lib.types.enum [
465 "none"
466 "standalone"
467 "netserver"
468 "netclient"
469 ];
470 description = ''
471 The MODE determines which part of the NUT is to be started, and
472 which configuration files must be modified.
473
474 The values of MODE can be:
475
476 - none: NUT is not configured, or use the Integrated Power
477 Management, or use some external system to startup NUT
478 components. So nothing is to be started.
479
480 - standalone: This mode address a local only configuration, with 1
481 UPS protecting the local system. This implies to start the 3 NUT
482 layers (driver, upsd and upsmon) and the matching configuration
483 files. This mode can also address UPS redundancy.
484
485 - netserver: same as for the standalone configuration, but also
486 need some more ACLs and possibly a specific LISTEN directive in
487 upsd.conf. Since this MODE is opened to the network, a special
488 care should be applied to security concerns.
489
490 - netclient: this mode only requires upsmon.
491 '';
492 };
493
494 schedulerRules = lib.mkOption {
495 example = "/etc/nixos/upssched.conf";
496 type = lib.types.str;
497 description = ''
498 File which contains the rules to handle UPS events.
499 '';
500 };
501
502 openFirewall = lib.mkOption {
503 type = lib.types.bool;
504 default = false;
505 description = ''
506 Open ports in the firewall for `upsd`.
507 '';
508 };
509
510 maxStartDelay = lib.mkOption {
511 default = 45;
512 type = lib.types.int;
513 description = ''
514 This can be set as a global variable above your first UPS
515 definition and it can also be set in a UPS section. This value
516 controls how long upsdrvctl will wait for the driver to finish
517 starting. This keeps your system from getting stuck due to a
518 broken driver or UPS.
519 '';
520 };
521
522 upsmon = lib.mkOption {
523 default = { };
524 description = ''
525 Options for the `upsmon.conf` configuration file.
526 '';
527 type = lib.types.submodule upsmonOptions;
528 };
529
530 upsd = lib.mkOption {
531 default = { };
532 description = ''
533 Options for the `upsd.conf` configuration file.
534 '';
535 type = lib.types.submodule upsdOptions;
536 };
537
538 ups = lib.mkOption {
539 default = { };
540 # see nut/etc/ups.conf.sample
541 description = ''
542 This is where you configure all the UPSes that this system will be
543 monitoring directly. These are usually attached to serial ports,
544 but USB devices are also supported.
545 '';
546 type = with lib.types; attrsOf (submodule upsOptions);
547 };
548
549 users = lib.mkOption {
550 default = { };
551 description = ''
552 Users that can access upsd. See `man upsd.users`.
553 '';
554 type = with lib.types; attrsOf (submodule userOptions);
555 };
556
557 };
558 };
559
560 config = lib.mkIf cfg.enable {
561
562 assertions = [
563 (
564 let
565 totalPowerValue = lib.foldl' lib.add 0 (
566 map (monitor: monitor.powerValue) (lib.attrValues cfg.upsmon.monitor)
567 );
568 minSupplies = cfg.upsmon.settings.MINSUPPLIES;
569 in
570 lib.mkIf cfg.upsmon.enable {
571 assertion = totalPowerValue >= minSupplies;
572 message = ''
573 `power.ups.upsmon`: Total configured power value (${toString totalPowerValue}) must be at least MINSUPPLIES (${toString minSupplies}).
574 '';
575 }
576 )
577 ];
578
579 # For interactive use.
580 environment.systemPackages = [ pkgs.nut ];
581 environment.variables = envVars;
582
583 networking.firewall = lib.mkIf cfg.openFirewall {
584 allowedTCPPorts =
585 if cfg.upsd.listen == [ ] then
586 [ defaultPort ]
587 else
588 lib.unique (lib.forEach cfg.upsd.listen (listen: listen.port));
589 };
590
591 systemd.slices.system-ups = {
592 description = "Network UPS Tools (NUT) Slice";
593 documentation = [ "https://networkupstools.org/" ];
594 };
595
596 systemd.services.upsmon =
597 let
598 secrets = lib.mapAttrsToList (name: monitor: "upsmon_password_${name}") cfg.upsmon.monitor;
599 createUpsmonConf = installSecrets upsmonConf "/run/nut/upsmon.conf" cfg.upsmon.user secrets;
600 in
601 {
602 enable = cfg.upsmon.enable;
603 description = "Uninterruptible Power Supplies (Monitor)";
604 after = [ "network.target" ];
605 wantedBy = [ "multi-user.target" ];
606 serviceConfig = {
607 Type = "forking";
608 ExecStartPre = "${createUpsmonConf}";
609 ExecStart = "${pkgs.nut}/sbin/upsmon -u ${cfg.upsmon.user}";
610 ExecReload = "${pkgs.nut}/sbin/upsmon -c reload";
611 LoadCredential = lib.mapAttrsToList (
612 name: monitor: "upsmon_password_${name}:${monitor.passwordFile}"
613 ) cfg.upsmon.monitor;
614 Slice = "system-ups.slice";
615 };
616 environment = envVars;
617 };
618
619 systemd.services.upsd =
620 let
621 secrets = lib.mapAttrsToList (name: user: "upsdusers_password_${name}") cfg.users;
622 createUpsdUsers = installSecrets upsdUsers "/run/nut/upsd.users" "root" secrets;
623 in
624 {
625 enable = cfg.upsd.enable;
626 description = "Uninterruptible Power Supplies (Daemon)";
627 after = [
628 "network.target"
629 "upsmon.service"
630 ];
631 wantedBy = [ "multi-user.target" ];
632 serviceConfig = {
633 Type = "forking";
634 ExecStartPre = "${createUpsdUsers}";
635 # TODO: replace 'root' by another username.
636 ExecStart = "${pkgs.nut}/sbin/upsd -u root";
637 ExecReload = "${pkgs.nut}/sbin/upsd -c reload";
638 LoadCredential = lib.mapAttrsToList (
639 name: user: "upsdusers_password_${name}:${user.passwordFile}"
640 ) cfg.users;
641 Slice = "system-ups.slice";
642 };
643 environment = envVars;
644 restartTriggers = [
645 config.environment.etc."nut/upsd.conf".source
646 ];
647 };
648
649 systemd.services.upsdrv = {
650 enable = cfg.upsd.enable;
651 description = "Uninterruptible Power Supplies (Register all UPS)";
652 after = [ "upsd.service" ];
653 wantedBy = [ "multi-user.target" ];
654 serviceConfig = {
655 Type = "oneshot";
656 RemainAfterExit = true;
657 # TODO: replace 'root' by another username.
658 ExecStart = "${pkgs.nut}/bin/upsdrvctl -u root start";
659 Slice = "system-ups.slice";
660 };
661 environment = envVars;
662 restartTriggers = [
663 config.environment.etc."nut/ups.conf".source
664 ];
665 };
666
667 systemd.services.ups-killpower = lib.mkIf (cfg.upsmon.settings.POWERDOWNFLAG != null) {
668 enable = cfg.upsd.enable;
669 description = "UPS Kill Power";
670 wantedBy = [ "shutdown.target" ];
671 after = [ "shutdown.target" ];
672 before = [ "final.target" ];
673 unitConfig = {
674 ConditionPathExists = cfg.upsmon.settings.POWERDOWNFLAG;
675 DefaultDependencies = "no";
676 };
677 environment = envVars;
678 serviceConfig = {
679 Type = "oneshot";
680 ExecStart = "${pkgs.nut}/bin/upsdrvctl shutdown";
681 Slice = "system-ups.slice";
682 };
683 };
684
685 environment.etc = {
686 "nut/nut.conf".source = pkgs.writeText "nut.conf" ''
687 MODE = ${cfg.mode}
688 '';
689 "nut/ups.conf".source = pkgs.writeText "ups.conf" ''
690 maxstartdelay = ${toString cfg.maxStartDelay}
691
692 ${lib.concatStringsSep "\n\n" (lib.forEach (lib.attrValues cfg.ups) (ups: ups.summary))}
693 '';
694 "nut/upsd.conf".source = pkgs.writeText "upsd.conf" ''
695 ${lib.concatStringsSep "\n" (
696 lib.forEach cfg.upsd.listen (listen: "LISTEN ${listen.address} ${toString listen.port}")
697 )}
698 ${cfg.upsd.extraConfig}
699 '';
700 "nut/upssched.conf".source = cfg.schedulerRules;
701 "nut/upsd.users".source = "/run/nut/upsd.users";
702 "nut/upsmon.conf".source = "/run/nut/upsmon.conf";
703 };
704
705 power.ups.schedulerRules = lib.mkDefault "${pkgs.nut}/etc/upssched.conf.sample";
706
707 systemd.tmpfiles.rules = [
708 "d /var/state/ups -"
709 "d /var/lib/nut 700"
710 ];
711
712 services.udev.packages = [ pkgs.nut ];
713
714 users.users.nutmon = lib.mkIf (cfg.upsmon.user == "nutmon") {
715 isSystemUser = true;
716 group = cfg.upsmon.group;
717 };
718 users.groups.nutmon = lib.mkIf (cfg.upsmon.user == "nutmon" && cfg.upsmon.group == "nutmon") { };
719
720 };
721}