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