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 # added on 2021-08-28, s3CredentialsFile should
27 # be removed in the future (+ remember the warning)
28 default = config.s3CredentialsFile;
29 description = lib.mdDoc ''
30 file containing the credentials to access the repository, in the
31 format of an EnvironmentFile as described by systemd.exec(5)
32 '';
33 };
34
35 s3CredentialsFile = mkOption {
36 type = with types; nullOr str;
37 default = null;
38 description = lib.mdDoc ''
39 file containing the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
40 for an S3-hosted repository, in the format of an EnvironmentFile
41 as described by systemd.exec(5)
42 '';
43 };
44
45 rcloneOptions = mkOption {
46 type = with types; nullOr (attrsOf (oneOf [ str bool ]));
47 default = null;
48 description = lib.mdDoc ''
49 Options to pass to rclone to control its behavior.
50 See <https://rclone.org/docs/#options> for
51 available options. When specifying option names, strip the
52 leading `--`. To set a flag such as
53 `--drive-use-trash`, which does not take a value,
54 set the value to the Boolean `true`.
55 '';
56 example = {
57 bwlimit = "10M";
58 drive-use-trash = "true";
59 };
60 };
61
62 rcloneConfig = mkOption {
63 type = with types; nullOr (attrsOf (oneOf [ str bool ]));
64 default = null;
65 description = lib.mdDoc ''
66 Configuration for the rclone remote being used for backup.
67 See the remote's specific options under rclone's docs at
68 <https://rclone.org/docs/>. When specifying
69 option names, use the "config" name specified in the docs.
70 For example, to set `--b2-hard-delete` for a B2
71 remote, use `hard_delete = true` in the
72 attribute set.
73 Warning: Secrets set in here will be world-readable in the Nix
74 store! Consider using the `rcloneConfigFile`
75 option instead to specify secret values separately. Note that
76 options set here will override those set in the config file.
77 '';
78 example = {
79 type = "b2";
80 account = "xxx";
81 key = "xxx";
82 hard_delete = true;
83 };
84 };
85
86 rcloneConfigFile = mkOption {
87 type = with types; nullOr path;
88 default = null;
89 description = lib.mdDoc ''
90 Path to the file containing rclone configuration. This file
91 must contain configuration for the remote specified in this backup
92 set and also must be readable by root. Options set in
93 `rcloneConfig` will override those set in this
94 file.
95 '';
96 };
97
98 repository = mkOption {
99 type = with types; nullOr str;
100 default = null;
101 description = lib.mdDoc ''
102 repository to backup to.
103 '';
104 example = "sftp:backup@192.168.1.100:/backups/${name}";
105 };
106
107 repositoryFile = mkOption {
108 type = with types; nullOr path;
109 default = null;
110 description = lib.mdDoc ''
111 Path to the file containing the repository location to backup to.
112 '';
113 };
114
115 paths = mkOption {
116 type = types.nullOr (types.listOf types.str);
117 default = null;
118 description = lib.mdDoc ''
119 Which paths to backup. If null or an empty array, no
120 backup command will be run. This can be used to create a
121 prune-only job.
122 '';
123 example = [
124 "/var/lib/postgresql"
125 "/home/user/backup"
126 ];
127 };
128
129 exclude = mkOption {
130 type = types.listOf types.str;
131 default = [ ];
132 description = lib.mdDoc ''
133 Patterns to exclude when backing up. See
134 https://restic.readthedocs.io/en/latest/040_backup.html#excluding-files for
135 details on syntax.
136 '';
137 example = [
138 "/var/cache"
139 "/home/*/.cache"
140 ".git"
141 ];
142 };
143
144 timerConfig = mkOption {
145 type = types.attrsOf unitOption;
146 default = {
147 OnCalendar = "daily";
148 Persistent = true;
149 };
150 description = lib.mdDoc ''
151 When to run the backup. See {manpage}`systemd.timer(5)` for details.
152 '';
153 example = {
154 OnCalendar = "00:05";
155 RandomizedDelaySec = "5h";
156 Persistent = true;
157 };
158 };
159
160 user = mkOption {
161 type = types.str;
162 default = "root";
163 description = lib.mdDoc ''
164 As which user the backup should run.
165 '';
166 example = "postgresql";
167 };
168
169 extraBackupArgs = mkOption {
170 type = types.listOf types.str;
171 default = [ ];
172 description = lib.mdDoc ''
173 Extra arguments passed to restic backup.
174 '';
175 example = [
176 "--exclude-file=/etc/nixos/restic-ignore"
177 ];
178 };
179
180 extraOptions = mkOption {
181 type = types.listOf types.str;
182 default = [ ];
183 description = lib.mdDoc ''
184 Extra extended options to be passed to the restic --option flag.
185 '';
186 example = [
187 "sftp.command='ssh backup@192.168.1.100 -i /home/user/.ssh/id_rsa -s sftp'"
188 ];
189 };
190
191 initialize = mkOption {
192 type = types.bool;
193 default = false;
194 description = lib.mdDoc ''
195 Create the repository if it doesn't exist.
196 '';
197 };
198
199 pruneOpts = mkOption {
200 type = types.listOf types.str;
201 default = [ ];
202 description = lib.mdDoc ''
203 A list of options (--keep-\* et al.) for 'restic forget
204 --prune', to automatically prune old snapshots. The
205 'forget' command is run *after* the 'backup' command, so
206 keep that in mind when constructing the --keep-\* options.
207 '';
208 example = [
209 "--keep-daily 7"
210 "--keep-weekly 5"
211 "--keep-monthly 12"
212 "--keep-yearly 75"
213 ];
214 };
215
216 checkOpts = mkOption {
217 type = types.listOf types.str;
218 default = [ ];
219 description = lib.mdDoc ''
220 A list of options for 'restic check', which is run after
221 pruning.
222 '';
223 example = [
224 "--with-cache"
225 ];
226 };
227
228 dynamicFilesFrom = mkOption {
229 type = with types; nullOr str;
230 default = null;
231 description = lib.mdDoc ''
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.
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 = lib.mdDoc ''
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 = lib.mdDoc ''
251 A script that must run after finishing the backup process.
252 '';
253 };
254
255 package = mkOption {
256 type = types.package;
257 default = pkgs.restic;
258 defaultText = literalExpression "pkgs.restic";
259 description = lib.mdDoc ''
260 Restic package to use.
261 '';
262 };
263 };
264 }));
265 default = { };
266 example = {
267 localbackup = {
268 paths = [ "/home" ];
269 exclude = [ "/home/*/.cache" ];
270 repository = "/mnt/backup-hdd";
271 passwordFile = "/etc/nixos/secrets/restic-password";
272 initialize = true;
273 };
274 remotebackup = {
275 paths = [ "/home" ];
276 repository = "sftp:backup@host:/backups/home";
277 passwordFile = "/etc/nixos/secrets/restic-password";
278 extraOptions = [
279 "sftp.command='ssh backup@host -i /etc/nixos/secrets/backup-private-key -s sftp'"
280 ];
281 timerConfig = {
282 OnCalendar = "00:05";
283 RandomizedDelaySec = "5h";
284 };
285 };
286 };
287 };
288
289 config = {
290 warnings = mapAttrsToList (n: v: "services.restic.backups.${n}.s3CredentialsFile is deprecated, please use services.restic.backups.${n}.environmentFile instead.") (filterAttrs (n: v: v.s3CredentialsFile != null) config.services.restic.backups);
291 assertions = mapAttrsToList (n: v: {
292 assertion = (v.repository == null) != (v.repositoryFile == null);
293 message = "services.restic.backups.${n}: exactly one of repository or repositoryFile should be set";
294 }) config.services.restic.backups;
295 systemd.services =
296 mapAttrs'
297 (name: backup:
298 let
299 extraOptions = concatMapStrings (arg: " -o ${arg}") backup.extraOptions;
300 resticCmd = "${backup.package}/bin/restic${extraOptions}";
301 excludeFlags = if (backup.exclude != []) then ["--exclude-file=${pkgs.writeText "exclude-patterns" (concatStringsSep "\n" backup.exclude)}"] else [];
302 filesFromTmpFile = "/run/restic-backups-${name}/includes";
303 backupPaths =
304 if (backup.dynamicFilesFrom == null)
305 then optionalString (backup.paths != null) (concatStringsSep " " backup.paths)
306 else "--files-from ${filesFromTmpFile}";
307 pruneCmd = optionals (builtins.length backup.pruneOpts > 0) [
308 (resticCmd + " forget --prune " + (concatStringsSep " " backup.pruneOpts))
309 (resticCmd + " check " + (concatStringsSep " " backup.checkOpts))
310 ];
311 # Helper functions for rclone remotes
312 rcloneRemoteName = builtins.elemAt (splitString ":" backup.repository) 1;
313 rcloneAttrToOpt = v: "RCLONE_" + toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v);
314 rcloneAttrToConf = v: "RCLONE_CONFIG_" + toUpper (rcloneRemoteName + "_" + v);
315 toRcloneVal = v: if lib.isBool v then lib.boolToString v else v;
316 in
317 nameValuePair "restic-backups-${name}" ({
318 environment = {
319 RESTIC_CACHE_DIR = "%C/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 = [ pkgs.openssh ];
335 restartIfChanged = false;
336 serviceConfig = {
337 Type = "oneshot";
338 ExecStart = (optionals (backupPaths != "") [ "${resticCmd} backup ${concatStringsSep " " (backup.extraBackupArgs ++ excludeFlags)} ${backupPaths}" ])
339 ++ pruneCmd;
340 User = backup.user;
341 RuntimeDirectory = "restic-backups-${name}";
342 CacheDirectory = "restic-backups-${name}";
343 CacheDirectoryMode = "0700";
344 PrivateTmp = true;
345 } // optionalAttrs (backup.environmentFile != null) {
346 EnvironmentFile = backup.environmentFile;
347 };
348 } // optionalAttrs (backup.initialize || backup.dynamicFilesFrom != null || backup.backupPrepareCommand != null) {
349 preStart = ''
350 ${optionalString (backup.backupPrepareCommand != null) ''
351 ${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand}
352 ''}
353 ${optionalString (backup.initialize) ''
354 ${resticCmd} snapshots || ${resticCmd} init
355 ''}
356 ${optionalString (backup.dynamicFilesFrom != null) ''
357 ${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} > ${filesFromTmpFile}
358 ''}
359 '';
360 } // optionalAttrs (backup.dynamicFilesFrom != null || backup.backupCleanupCommand != null) {
361 postStop = ''
362 ${optionalString (backup.backupCleanupCommand != null) ''
363 ${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand}
364 ''}
365 ${optionalString (backup.dynamicFilesFrom != null) ''
366 rm ${filesFromTmpFile}
367 ''}
368 '';
369 })
370 )
371 config.services.restic.backups;
372 systemd.timers =
373 mapAttrs'
374 (name: backup: nameValuePair "restic-backups-${name}" {
375 wantedBy = [ "timers.target" ];
376 timerConfig = backup.timerConfig;
377 })
378 config.services.restic.backups;
379 };
380}