1{
2 config,
3 lib,
4 pkgs,
5 utils,
6 ...
7}:
8let
9 # Type for a valid systemd unit option. Needed for correctly passing "timerConfig" to "systemd.timers"
10 inherit (utils.systemdUtils.unitOptions) unitOption;
11in
12{
13 options.services.restic.backups = lib.mkOption {
14 description = ''
15 Periodic backups to create with Restic.
16 '';
17 type = lib.types.attrsOf (
18 lib.types.submodule (
19 { name, ... }:
20 {
21 options = {
22 passwordFile = lib.mkOption {
23 type = with lib.types; nullOr str;
24 default = null;
25 description = ''
26 Read the repository password from a file.
27 '';
28 example = "/etc/nixos/restic-password";
29 };
30
31 environmentFile = lib.mkOption {
32 type = with lib.types; nullOr str;
33 default = null;
34 description = ''
35 file containing the credentials to access the repository, in the
36 format of an EnvironmentFile as described by {manpage}`systemd.exec(5)`
37 '';
38 };
39
40 rcloneOptions = lib.mkOption {
41 type =
42 with lib.types;
43 nullOr (
44 attrsOf (oneOf [
45 str
46 bool
47 ])
48 );
49 default = null;
50 description = ''
51 Options to pass to rclone to control its behavior.
52 See <https://rclone.org/docs/#options> for
53 available options. When specifying option names, strip the
54 leading `--`. To set a flag such as
55 `--drive-use-trash`, which does not take a value,
56 set the value to the Boolean `true`.
57 '';
58 example = {
59 bwlimit = "10M";
60 drive-use-trash = "true";
61 };
62 };
63
64 rcloneConfig = lib.mkOption {
65 type =
66 with lib.types;
67 nullOr (
68 attrsOf (oneOf [
69 str
70 bool
71 ])
72 );
73 default = null;
74 description = ''
75 Configuration for the rclone remote being used for backup.
76 See the remote's specific options under rclone's docs at
77 <https://rclone.org/docs/>. When specifying
78 option names, use the "config" name specified in the docs.
79 For example, to set `--b2-hard-delete` for a B2
80 remote, use `hard_delete = true` in the
81 attribute set.
82 Warning: Secrets set in here will be world-readable in the Nix
83 store! Consider using the `rcloneConfigFile`
84 option instead to specify secret values separately. Note that
85 options set here will override those set in the config file.
86 '';
87 example = {
88 type = "b2";
89 account = "xxx";
90 key = "xxx";
91 hard_delete = true;
92 };
93 };
94
95 rcloneConfigFile = lib.mkOption {
96 type = with lib.types; nullOr path;
97 default = null;
98 description = ''
99 Path to the file containing rclone configuration. This file
100 must contain configuration for the remote specified in this backup
101 set and also must be readable by root. Options set in
102 `rcloneConfig` will override those set in this
103 file.
104 '';
105 };
106
107 inhibitsSleep = lib.mkOption {
108 default = false;
109 type = lib.types.bool;
110 example = true;
111 description = ''
112 Prevents the system from sleeping while backing up.
113 '';
114 };
115
116 repository = lib.mkOption {
117 type = with lib.types; nullOr str;
118 default = null;
119 description = ''
120 repository to backup to.
121 '';
122 example = "sftp:backup@192.168.1.100:/backups/${name}";
123 };
124
125 repositoryFile = lib.mkOption {
126 type = with lib.types; nullOr path;
127 default = null;
128 description = ''
129 Path to the file containing the repository location to backup to.
130 '';
131 };
132
133 paths = lib.mkOption {
134 # This is nullable for legacy reasons only. We should consider making it a pure listOf
135 # after some time has passed since this comment was added.
136 type = lib.types.nullOr (lib.types.listOf lib.types.str);
137 default = [ ];
138 description = ''
139 Which paths to backup, in addition to ones specified via
140 `dynamicFilesFrom`. If null or an empty array and
141 `dynamicFilesFrom` is also null, no backup command will be run.
142 This can be used to create a prune-only job.
143 '';
144 example = [
145 "/var/lib/postgresql"
146 "/home/user/backup"
147 ];
148 };
149
150 command = lib.mkOption {
151 type = lib.types.listOf lib.types.str;
152 default = [ ];
153 description = ''
154 Command to pass to --stdin-from-command. If null or an empty array, and `paths`/`dynamicFilesFrom`
155 are also null, no backup command will be run.
156 '';
157 example = [
158 "sudo"
159 "-u"
160 "postgres"
161 "pg_dumpall"
162 ];
163 };
164
165 exclude = lib.mkOption {
166 type = lib.types.listOf lib.types.str;
167 default = [ ];
168 description = ''
169 Patterns to exclude when backing up. See
170 https://restic.readthedocs.io/en/latest/040_backup.html#excluding-files for
171 details on syntax.
172 '';
173 example = [
174 "/var/cache"
175 "/home/*/.cache"
176 ".git"
177 ];
178 };
179
180 timerConfig = lib.mkOption {
181 type = lib.types.nullOr (lib.types.attrsOf unitOption);
182 default = {
183 OnCalendar = "daily";
184 Persistent = true;
185 };
186 description = ''
187 When to run the backup. See {manpage}`systemd.timer(5)` for
188 details. If null no timer is created and the backup will only
189 run when explicitly started.
190 '';
191 example = {
192 OnCalendar = "00:05";
193 RandomizedDelaySec = "5h";
194 Persistent = true;
195 };
196 };
197
198 user = lib.mkOption {
199 type = lib.types.str;
200 default = "root";
201 description = ''
202 As which user the backup should run.
203 '';
204 example = "postgresql";
205 };
206
207 extraBackupArgs = lib.mkOption {
208 type = lib.types.listOf lib.types.str;
209 default = [ ];
210 description = ''
211 Extra arguments passed to restic backup.
212 '';
213 example = [
214 "--cleanup-cache"
215 "--exclude-file=/etc/nixos/restic-ignore"
216 ];
217 };
218
219 extraOptions = lib.mkOption {
220 type = lib.types.listOf lib.types.str;
221 default = [ ];
222 description = ''
223 Extra extended options to be passed to the restic --option flag.
224 '';
225 example = [
226 "sftp.command='ssh backup@192.168.1.100 -i /home/user/.ssh/id_rsa -s sftp'"
227 ];
228 };
229
230 initialize = lib.mkOption {
231 type = lib.types.bool;
232 default = false;
233 description = ''
234 Create the repository if it doesn't exist.
235 '';
236 };
237
238 pruneOpts = lib.mkOption {
239 type = lib.types.listOf lib.types.str;
240 default = [ ];
241 description = ''
242 A list of options (--keep-\* et al.) for 'restic forget
243 --prune', to automatically prune old snapshots. The
244 'forget' command is run *after* the 'backup' command, so
245 keep that in mind when constructing the --keep-\* options.
246 '';
247 example = [
248 "--keep-daily 7"
249 "--keep-weekly 5"
250 "--keep-monthly 12"
251 "--keep-yearly 75"
252 ];
253 };
254
255 runCheck = lib.mkOption {
256 type = lib.types.bool;
257 default = builtins.length config.services.restic.backups.${name}.checkOpts > 0;
258 defaultText = lib.literalExpression ''builtins.length config.services.backups.${name}.checkOpts > 0'';
259 description = "Whether to run the `check` command with the provided `checkOpts` options.";
260 example = true;
261 };
262
263 checkOpts = lib.mkOption {
264 type = lib.types.listOf lib.types.str;
265 default = [ ];
266 description = ''
267 A list of options for 'restic check'.
268 '';
269 example = [
270 "--with-cache"
271 ];
272 };
273
274 dynamicFilesFrom = lib.mkOption {
275 type = with lib.types; nullOr str;
276 default = null;
277 description = ''
278 A script that produces a list of files to back up. The
279 results of this command are given to the '--files-from'
280 option. The result is merged with paths specified via `paths`.
281 '';
282 example = "find /home/matt/git -type d -name .git";
283 };
284
285 backupPrepareCommand = lib.mkOption {
286 type = with lib.types; nullOr str;
287 default = null;
288 description = ''
289 A script that must run before starting the backup process.
290 '';
291 };
292
293 backupCleanupCommand = lib.mkOption {
294 type = with lib.types; nullOr str;
295 default = null;
296 description = ''
297 A script that must run after finishing the backup process.
298 '';
299 };
300
301 package = lib.mkPackageOption pkgs "restic" { };
302
303 createWrapper = lib.mkOption {
304 type = lib.types.bool;
305 default = true;
306 description = ''
307 Whether to generate and add a script to the system path, that has the same environment variables set
308 as the systemd service. This can be used to e.g. mount snapshots or perform other opterations, without
309 having to manually specify most options.
310 '';
311 };
312
313 progressFps = lib.mkOption {
314 type = with lib.types; nullOr numbers.nonnegative;
315 default = null;
316 description = ''
317 Controls the frequency of progress reporting.
318 '';
319 example = 0.1;
320 };
321 };
322 }
323 )
324 );
325 default = { };
326 example = {
327 localbackup = {
328 paths = [ "/home" ];
329 exclude = [ "/home/*/.cache" ];
330 repository = "/mnt/backup-hdd";
331 passwordFile = "/etc/nixos/secrets/restic-password";
332 initialize = true;
333 };
334 remotebackup = {
335 paths = [ "/home" ];
336 repository = "sftp:backup@host:/backups/home";
337 passwordFile = "/etc/nixos/secrets/restic-password";
338 extraOptions = [
339 "sftp.command='ssh backup@host -i /etc/nixos/secrets/backup-private-key -s sftp'"
340 ];
341 timerConfig = {
342 OnCalendar = "00:05";
343 RandomizedDelaySec = "5h";
344 };
345 };
346 commandbackup = {
347 command = [
348 "\${lib.getExe pkgs.sudo}"
349 "-u postgres"
350 "\${pkgs.postgresql}/bin/pg_dumpall"
351 ];
352 extraBackupArgs = [ "--tag database" ];
353 repository = "s3:example.com/mybucket";
354 passwordFile = "/etc/nixos/secrets/restic-password";
355 environmentFile = "/etc/nixos/secrets/restic-environment";
356 pruneOpts = [
357 "--keep-daily 14"
358 "--keep-weekly 4"
359 "--keep-monthly 2"
360 "--group-by tags"
361 ];
362 };
363 };
364 };
365
366 config = {
367 assertions = lib.flatten (
368 lib.mapAttrsToList (name: backup: [
369 {
370 assertion =
371 ((backup.repository == null) != (backup.repositoryFile == null))
372 || (backup.environmentFile != null);
373 message = "services.restic.backups.${name}: exactly one of repository, repositoryFile or environmentFile should be set";
374 }
375 {
376 assertion =
377 let
378 fileBackup = (backup.paths != null && backup.paths != [ ]) || backup.dynamicFilesFrom != null;
379 commandBackup = backup.command != [ ];
380 in
381 !(fileBackup && commandBackup);
382 message = "services.restic.backups.${name}: cannot do both a command backup and a file backup at the same time.";
383 }
384 {
385 assertion = (backup.passwordFile != null) || (backup.environmentFile != null);
386 message = "services.restic.backups.${name}: passwordFile or environmentFile must be set";
387 }
388 ]) config.services.restic.backups
389 );
390 systemd.services = lib.mapAttrs' (
391 name: backup:
392 let
393 extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions;
394 inhibitCmd = lib.concatStringsSep " " [
395 "${pkgs.systemd}/bin/systemd-inhibit"
396 "--mode='block'"
397 "--who='restic'"
398 "--what='sleep'"
399 "--why=${lib.escapeShellArg "Scheduled backup ${name}"} "
400 ];
401 resticCmd = "${lib.optionalString backup.inhibitsSleep inhibitCmd}${lib.getExe backup.package}${extraOptions}";
402 excludeFlags = lib.optional (
403 backup.exclude != [ ]
404 ) "--exclude-file=${pkgs.writeText "exclude-patterns" (lib.concatStringsSep "\n" backup.exclude)}";
405 filesFromTmpFile = "/run/restic-backups-${name}/includes";
406 fileBackup = (backup.dynamicFilesFrom != null) || (backup.paths != null && backup.paths != [ ]);
407 commandBackup = backup.command != [ ];
408 doBackup = fileBackup || commandBackup;
409 pruneCmd = lib.optionals (builtins.length backup.pruneOpts > 0) [
410 (resticCmd + " unlock")
411 (resticCmd + " forget --prune " + (lib.concatStringsSep " " backup.pruneOpts))
412 ];
413 checkCmd = lib.optionals backup.runCheck [
414 (resticCmd + " check " + (lib.concatStringsSep " " backup.checkOpts))
415 ];
416 # Helper functions for rclone remotes
417 rcloneRemoteName = builtins.elemAt (lib.splitString ":" backup.repository) 1;
418 rcloneAttrToOpt = v: "RCLONE_" + lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v);
419 rcloneAttrToConf = v: "RCLONE_CONFIG_" + lib.toUpper (rcloneRemoteName + "_" + v);
420 toRcloneVal = v: if lib.isBool v then lib.boolToString v else v;
421 in
422 lib.nameValuePair "restic-backups-${name}" (
423 {
424 environment = {
425 # not %C, because that wouldn't work in the wrapper script
426 RESTIC_CACHE_DIR = "/var/cache/restic-backups-${name}";
427 RESTIC_PASSWORD_FILE = backup.passwordFile;
428 RESTIC_REPOSITORY = backup.repository;
429 RESTIC_REPOSITORY_FILE = backup.repositoryFile;
430 }
431 // lib.optionalAttrs (backup.rcloneOptions != null) (
432 lib.mapAttrs' (
433 name: value: lib.nameValuePair (rcloneAttrToOpt name) (toRcloneVal value)
434 ) backup.rcloneOptions
435 )
436 // lib.optionalAttrs (backup.rcloneConfigFile != null) {
437 RCLONE_CONFIG = backup.rcloneConfigFile;
438 }
439 // lib.optionalAttrs (backup.rcloneConfig != null) (
440 lib.mapAttrs' (
441 name: value: lib.nameValuePair (rcloneAttrToConf name) (toRcloneVal value)
442 ) backup.rcloneConfig
443 )
444 // lib.optionalAttrs (backup.progressFps != null) {
445 RESTIC_PROGRESS_FPS = toString backup.progressFps;
446 };
447 path = [ config.programs.ssh.package ];
448 restartIfChanged = false;
449 wants = [ "network-online.target" ];
450 after = [ "network-online.target" ];
451 serviceConfig = {
452 Type = "oneshot";
453 ExecStart =
454 lib.optionals doBackup [
455 "${resticCmd} backup ${
456 lib.concatStringsSep " " (
457 backup.extraBackupArgs
458 ++ lib.optionals fileBackup (excludeFlags ++ [ "--files-from=${filesFromTmpFile}" ])
459 ++ lib.optionals commandBackup ([ "--stdin-from-command=true --" ] ++ backup.command)
460 )
461 }"
462 ]
463 ++ pruneCmd
464 ++ checkCmd;
465 User = backup.user;
466 RuntimeDirectory = "restic-backups-${name}";
467 CacheDirectory = "restic-backups-${name}";
468 CacheDirectoryMode = "0700";
469 PrivateTmp = true;
470 }
471 // lib.optionalAttrs (backup.environmentFile != null) {
472 EnvironmentFile = backup.environmentFile;
473 };
474 }
475 // lib.optionalAttrs (backup.initialize || doBackup || backup.backupPrepareCommand != null) {
476 preStart = ''
477 ${lib.optionalString (backup.backupPrepareCommand != null) ''
478 ${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand}
479 ''}
480 ${lib.optionalString backup.initialize ''
481 ${resticCmd} cat config > /dev/null || ${resticCmd} init
482 ''}
483 ${lib.optionalString (backup.paths != null && backup.paths != [ ]) ''
484 cat ${pkgs.writeText "staticPaths" (lib.concatLines backup.paths)} >> ${filesFromTmpFile}
485 ''}
486 ${lib.optionalString (backup.dynamicFilesFrom != null) ''
487 ${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} >> ${filesFromTmpFile}
488 ''}
489 '';
490 }
491 // lib.optionalAttrs (doBackup || backup.backupCleanupCommand != null) {
492 postStop = ''
493 ${lib.optionalString (backup.backupCleanupCommand != null) ''
494 ${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand}
495 ''}
496 ${lib.optionalString fileBackup ''
497 rm ${filesFromTmpFile}
498 ''}
499 '';
500 }
501 )
502 ) config.services.restic.backups;
503 systemd.timers = lib.mapAttrs' (
504 name: backup:
505 lib.nameValuePair "restic-backups-${name}" {
506 wantedBy = [ "timers.target" ];
507 inherit (backup) timerConfig;
508 }
509 ) (lib.filterAttrs (_: backup: backup.timerConfig != null) config.services.restic.backups);
510
511 # generate wrapper scripts, as described in the createWrapper option
512 environment.systemPackages = lib.mapAttrsToList (
513 name: backup:
514 let
515 extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions;
516 resticCmd = "${lib.getExe backup.package}${extraOptions}";
517 in
518 pkgs.writeShellScriptBin "restic-${name}" ''
519 set -a # automatically export variables
520 ${lib.optionalString (backup.environmentFile != null) "source ${backup.environmentFile}"}
521 # set same environment variables as the systemd service
522 ${lib.pipe config.systemd.services."restic-backups-${name}".environment [
523 (lib.filterAttrs (n: v: v != null && n != "PATH"))
524 (lib.mapAttrs (_: v: "${v}"))
525 lib.toShellVars
526 ]}
527 PATH=${config.systemd.services."restic-backups-${name}".environment.PATH}:$PATH
528
529 exec ${resticCmd} "$@"
530 ''
531 ) (lib.filterAttrs (_: v: v.createWrapper) config.services.restic.backups);
532 };
533}