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