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