1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6 # Type for a valid systemd unit option. Needed for correctly passing "timerConfig" to "systemd.timers"
7 unitOption = (import ../../system/boot/systemd-unit-options.nix { inherit config lib; }).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 ({ config, 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 # added on 2021-08-28, s3CredentialsFile should
27 # be removed in the future (+ remember the warning)
28 default = config.s3CredentialsFile;
29 description = ''
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 = ''
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 = ''
49 Options to pass to rclone to control its behavior.
50 See <link xlink:href="https://rclone.org/docs/#options"/> for
51 available options. When specifying option names, strip the
52 leading <literal>--</literal>. To set a flag such as
53 <literal>--drive-use-trash</literal>, which does not take a value,
54 set the value to the Boolean <literal>true</literal>.
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 = ''
66 Configuration for the rclone remote being used for backup.
67 See the remote's specific options under rclone's docs at
68 <link xlink:href="https://rclone.org/docs/"/>. When specifying
69 option names, use the "config" name specified in the docs.
70 For example, to set <literal>--b2-hard-delete</literal> for a B2
71 remote, use <literal>hard_delete = true</literal> in the
72 attribute set.
73 Warning: Secrets set in here will be world-readable in the Nix
74 store! Consider using the <literal>rcloneConfigFile</literal>
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 = ''
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 <literal>rcloneConfig</literal> will override those set in this
94 file.
95 '';
96 };
97
98 repository = mkOption {
99 type = types.str;
100 description = ''
101 repository to backup to.
102 '';
103 example = "sftp:backup@192.168.1.100:/backups/${name}";
104 };
105
106 paths = mkOption {
107 type = types.nullOr (types.listOf types.str);
108 default = null;
109 description = ''
110 Which paths to backup. If null or an empty array, no
111 backup command will be run. This can be used to create a
112 prune-only job.
113 '';
114 example = [
115 "/var/lib/postgresql"
116 "/home/user/backup"
117 ];
118 };
119
120 timerConfig = mkOption {
121 type = types.attrsOf unitOption;
122 default = {
123 OnCalendar = "daily";
124 };
125 description = ''
126 When to run the backup. See man systemd.timer for details.
127 '';
128 example = {
129 OnCalendar = "00:05";
130 RandomizedDelaySec = "5h";
131 };
132 };
133
134 user = mkOption {
135 type = types.str;
136 default = "root";
137 description = ''
138 As which user the backup should run.
139 '';
140 example = "postgresql";
141 };
142
143 extraBackupArgs = mkOption {
144 type = types.listOf types.str;
145 default = [];
146 description = ''
147 Extra arguments passed to restic backup.
148 '';
149 example = [
150 "--exclude-file=/etc/nixos/restic-ignore"
151 ];
152 };
153
154 extraOptions = mkOption {
155 type = types.listOf types.str;
156 default = [];
157 description = ''
158 Extra extended options to be passed to the restic --option flag.
159 '';
160 example = [
161 "sftp.command='ssh backup@192.168.1.100 -i /home/user/.ssh/id_rsa -s sftp'"
162 ];
163 };
164
165 initialize = mkOption {
166 type = types.bool;
167 default = false;
168 description = ''
169 Create the repository if it doesn't exist.
170 '';
171 };
172
173 pruneOpts = mkOption {
174 type = types.listOf types.str;
175 default = [];
176 description = ''
177 A list of options (--keep-* et al.) for 'restic forget
178 --prune', to automatically prune old snapshots. The
179 'forget' command is run *after* the 'backup' command, so
180 keep that in mind when constructing the --keep-* options.
181 '';
182 example = [
183 "--keep-daily 7"
184 "--keep-weekly 5"
185 "--keep-monthly 12"
186 "--keep-yearly 75"
187 ];
188 };
189
190 dynamicFilesFrom = mkOption {
191 type = with types; nullOr str;
192 default = null;
193 description = ''
194 A script that produces a list of files to back up. The
195 results of this command are given to the '--files-from'
196 option.
197 '';
198 example = "find /home/matt/git -type d -name .git";
199 };
200 };
201 }));
202 default = {};
203 example = {
204 localbackup = {
205 paths = [ "/home" ];
206 repository = "/mnt/backup-hdd";
207 passwordFile = "/etc/nixos/secrets/restic-password";
208 initialize = true;
209 };
210 remotebackup = {
211 paths = [ "/home" ];
212 repository = "sftp:backup@host:/backups/home";
213 passwordFile = "/etc/nixos/secrets/restic-password";
214 extraOptions = [
215 "sftp.command='ssh backup@host -i /etc/nixos/secrets/backup-private-key -s sftp'"
216 ];
217 timerConfig = {
218 OnCalendar = "00:05";
219 RandomizedDelaySec = "5h";
220 };
221 };
222 };
223 };
224
225 config = {
226 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);
227 systemd.services =
228 mapAttrs' (name: backup:
229 let
230 extraOptions = concatMapStrings (arg: " -o ${arg}") backup.extraOptions;
231 resticCmd = "${pkgs.restic}/bin/restic${extraOptions}";
232 filesFromTmpFile = "/run/restic-backups-${name}/includes";
233 backupPaths = if (backup.dynamicFilesFrom == null)
234 then if (backup.paths != null) then concatStringsSep " " backup.paths else ""
235 else "--files-from ${filesFromTmpFile}";
236 pruneCmd = optionals (builtins.length backup.pruneOpts > 0) [
237 ( resticCmd + " forget --prune " + (concatStringsSep " " backup.pruneOpts) )
238 ( resticCmd + " check" )
239 ];
240 # Helper functions for rclone remotes
241 rcloneRemoteName = builtins.elemAt (splitString ":" backup.repository) 1;
242 rcloneAttrToOpt = v: "RCLONE_" + toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v);
243 rcloneAttrToConf = v: "RCLONE_CONFIG_" + toUpper (rcloneRemoteName + "_" + v);
244 toRcloneVal = v: if lib.isBool v then lib.boolToString v else v;
245 in nameValuePair "restic-backups-${name}" ({
246 environment = {
247 RESTIC_PASSWORD_FILE = backup.passwordFile;
248 RESTIC_REPOSITORY = backup.repository;
249 } // optionalAttrs (backup.rcloneOptions != null) (mapAttrs' (name: value:
250 nameValuePair (rcloneAttrToOpt name) (toRcloneVal value)
251 ) backup.rcloneOptions) // optionalAttrs (backup.rcloneConfigFile != null) {
252 RCLONE_CONFIG = backup.rcloneConfigFile;
253 } // optionalAttrs (backup.rcloneConfig != null) (mapAttrs' (name: value:
254 nameValuePair (rcloneAttrToConf name) (toRcloneVal value)
255 ) backup.rcloneConfig);
256 path = [ pkgs.openssh ];
257 restartIfChanged = false;
258 serviceConfig = {
259 Type = "oneshot";
260 ExecStart = (optionals (backupPaths != "") [ "${resticCmd} backup --cache-dir=%C/restic-backups-${name} ${concatStringsSep " " backup.extraBackupArgs} ${backupPaths}" ])
261 ++ pruneCmd;
262 User = backup.user;
263 RuntimeDirectory = "restic-backups-${name}";
264 CacheDirectory = "restic-backups-${name}";
265 CacheDirectoryMode = "0700";
266 } // optionalAttrs (backup.environmentFile != null) {
267 EnvironmentFile = backup.environmentFile;
268 };
269 } // optionalAttrs (backup.initialize || backup.dynamicFilesFrom != null) {
270 preStart = ''
271 ${optionalString (backup.initialize) ''
272 ${resticCmd} snapshots || ${resticCmd} init
273 ''}
274 ${optionalString (backup.dynamicFilesFrom != null) ''
275 ${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} > ${filesFromTmpFile}
276 ''}
277 '';
278 } // optionalAttrs (backup.dynamicFilesFrom != null) {
279 postStart = ''
280 rm ${filesFromTmpFile}
281 '';
282 })
283 ) config.services.restic.backups;
284 systemd.timers =
285 mapAttrs' (name: backup: nameValuePair "restic-backups-${name}" {
286 wantedBy = [ "timers.target" ];
287 timerConfig = backup.timerConfig;
288 }) config.services.restic.backups;
289 };
290}