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 timerConfig = mkOption {
130 type = types.attrsOf unitOption;
131 default = {
132 OnCalendar = "daily";
133 };
134 description = lib.mdDoc ''
135 When to run the backup. See man systemd.timer for details.
136 '';
137 example = {
138 OnCalendar = "00:05";
139 RandomizedDelaySec = "5h";
140 };
141 };
142
143 user = mkOption {
144 type = types.str;
145 default = "root";
146 description = lib.mdDoc ''
147 As which user the backup should run.
148 '';
149 example = "postgresql";
150 };
151
152 extraBackupArgs = mkOption {
153 type = types.listOf types.str;
154 default = [ ];
155 description = lib.mdDoc ''
156 Extra arguments passed to restic backup.
157 '';
158 example = [
159 "--exclude-file=/etc/nixos/restic-ignore"
160 ];
161 };
162
163 extraOptions = mkOption {
164 type = types.listOf types.str;
165 default = [ ];
166 description = lib.mdDoc ''
167 Extra extended options to be passed to the restic --option flag.
168 '';
169 example = [
170 "sftp.command='ssh backup@192.168.1.100 -i /home/user/.ssh/id_rsa -s sftp'"
171 ];
172 };
173
174 initialize = mkOption {
175 type = types.bool;
176 default = false;
177 description = lib.mdDoc ''
178 Create the repository if it doesn't exist.
179 '';
180 };
181
182 pruneOpts = mkOption {
183 type = types.listOf types.str;
184 default = [ ];
185 description = lib.mdDoc ''
186 A list of options (--keep-\* et al.) for 'restic forget
187 --prune', to automatically prune old snapshots. The
188 'forget' command is run *after* the 'backup' command, so
189 keep that in mind when constructing the --keep-\* options.
190 '';
191 example = [
192 "--keep-daily 7"
193 "--keep-weekly 5"
194 "--keep-monthly 12"
195 "--keep-yearly 75"
196 ];
197 };
198
199 checkOpts = mkOption {
200 type = types.listOf types.str;
201 default = [ ];
202 description = lib.mdDoc ''
203 A list of options for 'restic check', which is run after
204 pruning.
205 '';
206 example = [
207 "--with-cache"
208 ];
209 };
210
211 dynamicFilesFrom = mkOption {
212 type = with types; nullOr str;
213 default = null;
214 description = lib.mdDoc ''
215 A script that produces a list of files to back up. The
216 results of this command are given to the '--files-from'
217 option.
218 '';
219 example = "find /home/matt/git -type d -name .git";
220 };
221
222 backupPrepareCommand = mkOption {
223 type = with types; nullOr str;
224 default = null;
225 description = lib.mdDoc ''
226 A script that must run before starting the backup process.
227 '';
228 };
229
230 backupCleanupCommand = mkOption {
231 type = with types; nullOr str;
232 default = null;
233 description = lib.mdDoc ''
234 A script that must run after finishing the backup process.
235 '';
236 };
237
238 package = mkOption {
239 type = types.package;
240 default = pkgs.restic;
241 defaultText = literalExpression "pkgs.restic";
242 description = lib.mdDoc ''
243 Restic package to use.
244 '';
245 };
246 };
247 }));
248 default = { };
249 example = {
250 localbackup = {
251 paths = [ "/home" ];
252 repository = "/mnt/backup-hdd";
253 passwordFile = "/etc/nixos/secrets/restic-password";
254 initialize = true;
255 };
256 remotebackup = {
257 paths = [ "/home" ];
258 repository = "sftp:backup@host:/backups/home";
259 passwordFile = "/etc/nixos/secrets/restic-password";
260 extraOptions = [
261 "sftp.command='ssh backup@host -i /etc/nixos/secrets/backup-private-key -s sftp'"
262 ];
263 timerConfig = {
264 OnCalendar = "00:05";
265 RandomizedDelaySec = "5h";
266 };
267 };
268 };
269 };
270
271 config = {
272 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);
273 systemd.services =
274 mapAttrs'
275 (name: backup:
276 let
277 extraOptions = concatMapStrings (arg: " -o ${arg}") backup.extraOptions;
278 resticCmd = "${backup.package}/bin/restic${extraOptions}";
279 filesFromTmpFile = "/run/restic-backups-${name}/includes";
280 backupPaths =
281 if (backup.dynamicFilesFrom == null)
282 then if (backup.paths != null) then concatStringsSep " " backup.paths else ""
283 else "--files-from ${filesFromTmpFile}";
284 pruneCmd = optionals (builtins.length backup.pruneOpts > 0) [
285 (resticCmd + " forget --prune --cache-dir=%C/restic-backups-${name} " + (concatStringsSep " " backup.pruneOpts))
286 (resticCmd + " check --cache-dir=%C/restic-backups-${name} " + (concatStringsSep " " backup.checkOpts))
287 ];
288 # Helper functions for rclone remotes
289 rcloneRemoteName = builtins.elemAt (splitString ":" backup.repository) 1;
290 rcloneAttrToOpt = v: "RCLONE_" + toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v);
291 rcloneAttrToConf = v: "RCLONE_CONFIG_" + toUpper (rcloneRemoteName + "_" + v);
292 toRcloneVal = v: if lib.isBool v then lib.boolToString v else v;
293 in
294 nameValuePair "restic-backups-${name}" ({
295 environment = {
296 RESTIC_PASSWORD_FILE = backup.passwordFile;
297 RESTIC_REPOSITORY = backup.repository;
298 RESTIC_REPOSITORY_FILE = backup.repositoryFile;
299 } // optionalAttrs (backup.rcloneOptions != null) (mapAttrs'
300 (name: value:
301 nameValuePair (rcloneAttrToOpt name) (toRcloneVal value)
302 )
303 backup.rcloneOptions) // optionalAttrs (backup.rcloneConfigFile != null) {
304 RCLONE_CONFIG = backup.rcloneConfigFile;
305 } // optionalAttrs (backup.rcloneConfig != null) (mapAttrs'
306 (name: value:
307 nameValuePair (rcloneAttrToConf name) (toRcloneVal value)
308 )
309 backup.rcloneConfig);
310 path = [ pkgs.openssh ];
311 restartIfChanged = false;
312 serviceConfig = {
313 Type = "oneshot";
314 ExecStart = (optionals (backupPaths != "") [ "${resticCmd} backup --cache-dir=%C/restic-backups-${name} ${concatStringsSep " " backup.extraBackupArgs} ${backupPaths}" ])
315 ++ pruneCmd;
316 User = backup.user;
317 RuntimeDirectory = "restic-backups-${name}";
318 CacheDirectory = "restic-backups-${name}";
319 CacheDirectoryMode = "0700";
320 } // optionalAttrs (backup.environmentFile != null) {
321 EnvironmentFile = backup.environmentFile;
322 };
323 } // optionalAttrs (backup.initialize || backup.dynamicFilesFrom != null || backup.backupPrepareCommand != null) {
324 preStart = ''
325 ${optionalString (backup.backupPrepareCommand != null) ''
326 ${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand}
327 ''}
328 ${optionalString (backup.initialize) ''
329 ${resticCmd} snapshots || ${resticCmd} init
330 ''}
331 ${optionalString (backup.dynamicFilesFrom != null) ''
332 ${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} > ${filesFromTmpFile}
333 ''}
334 '';
335 } // optionalAttrs (backup.dynamicFilesFrom != null || backup.backupCleanupCommand != null) {
336 postStop = ''
337 ${optionalString (backup.backupCleanupCommand != null) ''
338 ${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand}
339 ''}
340 ${optionalString (backup.dynamicFilesFrom != null) ''
341 rm ${filesFromTmpFile}
342 ''}
343 '';
344 })
345 )
346 config.services.restic.backups;
347 systemd.timers =
348 mapAttrs'
349 (name: backup: nameValuePair "restic-backups-${name}" {
350 wantedBy = [ "timers.target" ];
351 timerConfig = backup.timerConfig;
352 })
353 config.services.restic.backups;
354 };
355}