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 = lib.mdDoc ''
12 Periodic backups to create with Restic.
13 '';
14 type = types.attrsOf (types.submodule ({ config, name, ... }: {
15 options = {
16 passwordFile = mkOption {
17 type = types.str;
18 description = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 checkOpts = mkOption {
210 type = types.listOf types.str;
211 default = [ ];
212 description = lib.mdDoc ''
213 A list of options for 'restic check', which is run after
214 pruning.
215 '';
216 example = [
217 "--with-cache"
218 ];
219 };
220
221 dynamicFilesFrom = mkOption {
222 type = with types; nullOr str;
223 default = null;
224 description = lib.mdDoc ''
225 A script that produces a list of files to back up. The
226 results of this command are given to the '--files-from'
227 option. The result is merged with paths specified via `paths`.
228 '';
229 example = "find /home/matt/git -type d -name .git";
230 };
231
232 backupPrepareCommand = mkOption {
233 type = with types; nullOr str;
234 default = null;
235 description = lib.mdDoc ''
236 A script that must run before starting the backup process.
237 '';
238 };
239
240 backupCleanupCommand = mkOption {
241 type = with types; nullOr str;
242 default = null;
243 description = lib.mdDoc ''
244 A script that must run after finishing the backup process.
245 '';
246 };
247
248 package = mkOption {
249 type = types.package;
250 default = pkgs.restic;
251 defaultText = literalExpression "pkgs.restic";
252 description = lib.mdDoc ''
253 Restic package to use.
254 '';
255 };
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 (resticCmd + " check " + (concatStringsSep " " backup.checkOpts))
309 ];
310 # Helper functions for rclone remotes
311 rcloneRemoteName = builtins.elemAt (splitString ":" backup.repository) 1;
312 rcloneAttrToOpt = v: "RCLONE_" + toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v);
313 rcloneAttrToConf = v: "RCLONE_CONFIG_" + toUpper (rcloneRemoteName + "_" + v);
314 toRcloneVal = v: if lib.isBool v then lib.boolToString v else v;
315 in
316 nameValuePair "restic-backups-${name}" ({
317 environment = {
318 # not %C, because that wouldn't work in the wrapper script
319 RESTIC_CACHE_DIR = "/var/cache/restic-backups-${name}";
320 RESTIC_PASSWORD_FILE = backup.passwordFile;
321 RESTIC_REPOSITORY = backup.repository;
322 RESTIC_REPOSITORY_FILE = backup.repositoryFile;
323 } // optionalAttrs (backup.rcloneOptions != null) (mapAttrs'
324 (name: value:
325 nameValuePair (rcloneAttrToOpt name) (toRcloneVal value)
326 )
327 backup.rcloneOptions) // optionalAttrs (backup.rcloneConfigFile != null) {
328 RCLONE_CONFIG = backup.rcloneConfigFile;
329 } // optionalAttrs (backup.rcloneConfig != null) (mapAttrs'
330 (name: value:
331 nameValuePair (rcloneAttrToConf name) (toRcloneVal value)
332 )
333 backup.rcloneConfig);
334 path = [ config.programs.ssh.package ];
335 restartIfChanged = false;
336 wants = [ "network-online.target" ];
337 after = [ "network-online.target" ];
338 serviceConfig = {
339 Type = "oneshot";
340 ExecStart = (optionals doBackup [ "${resticCmd} backup ${concatStringsSep " " (backup.extraBackupArgs ++ excludeFlags)} --files-from=${filesFromTmpFile}" ])
341 ++ pruneCmd;
342 User = backup.user;
343 RuntimeDirectory = "restic-backups-${name}";
344 CacheDirectory = "restic-backups-${name}";
345 CacheDirectoryMode = "0700";
346 PrivateTmp = true;
347 } // optionalAttrs (backup.environmentFile != null) {
348 EnvironmentFile = backup.environmentFile;
349 };
350 } // optionalAttrs (backup.initialize || doBackup || backup.backupPrepareCommand != null) {
351 preStart = ''
352 ${optionalString (backup.backupPrepareCommand != null) ''
353 ${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand}
354 ''}
355 ${optionalString (backup.initialize) ''
356 ${resticCmd} snapshots || ${resticCmd} init
357 ''}
358 ${optionalString (backup.paths != null && backup.paths != []) ''
359 cat ${pkgs.writeText "staticPaths" (concatStringsSep "\n" backup.paths)} >> ${filesFromTmpFile}
360 ''}
361 ${optionalString (backup.dynamicFilesFrom != null) ''
362 ${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} >> ${filesFromTmpFile}
363 ''}
364 '';
365 } // optionalAttrs (doBackup || backup.backupCleanupCommand != null) {
366 postStop = ''
367 ${optionalString (backup.backupCleanupCommand != null) ''
368 ${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand}
369 ''}
370 ${optionalString doBackup ''
371 rm ${filesFromTmpFile}
372 ''}
373 '';
374 })
375 )
376 config.services.restic.backups;
377 systemd.timers =
378 mapAttrs'
379 (name: backup: nameValuePair "restic-backups-${name}" {
380 wantedBy = [ "timers.target" ];
381 timerConfig = backup.timerConfig;
382 })
383 (filterAttrs (_: backup: backup.timerConfig != null) config.services.restic.backups);
384
385 # generate wrapper scripts, as described in the createWrapper option
386 environment.systemPackages = lib.mapAttrsToList (name: backup: let
387 extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions;
388 resticCmd = "${backup.package}/bin/restic${extraOptions}";
389 in pkgs.writeShellScriptBin "restic-${name}" ''
390 set -a # automatically export variables
391 ${lib.optionalString (backup.environmentFile != null) "source ${backup.environmentFile}"}
392 # set same environment variables as the systemd service
393 ${lib.pipe config.systemd.services."restic-backups-${name}".environment [
394 (lib.filterAttrs (_: v: v != null))
395 (lib.mapAttrsToList (n: v: "${n}=${v}"))
396 (lib.concatStringsSep "\n")
397 ]}
398
399 exec ${resticCmd} $@
400 '') (lib.filterAttrs (_: v: v.createWrapper) config.services.restic.backups);
401 };
402}