1{
2 config,
3 pkgs,
4 lib,
5 ...
6}:
7let
8 inherit (lib)
9 concatLists
10 concatMap
11 concatMapStringsSep
12 concatStringsSep
13 filterAttrs
14 flatten
15 getAttr
16 isAttrs
17 literalExpression
18 mapAttrs'
19 mapAttrsToList
20 mkIf
21 mkOption
22 optional
23 optionalString
24 sortOn
25 types
26 ;
27
28 # The priority of an option or section.
29 # The configurations format are order-sensitive. Pairs are added as children of
30 # the last sections if possible, otherwise, they start a new section.
31 # We sort them in topological order:
32 # 1. Leaf pairs.
33 # 2. Sections that may contain (1).
34 # 3. Sections that may contain (1) or (2).
35 # 4. Etc.
36 prioOf =
37 { name, value }:
38 if !isAttrs value then
39 0 # Leaf options.
40 else
41 {
42 target = 1; # Contains: options.
43 subvolume = 2; # Contains: options, target.
44 volume = 3; # Contains: options, target, subvolume.
45 }
46 .${name} or (throw "Unknow section '${name}'");
47
48 genConfig' = set: concatStringsSep "\n" (genConfig set);
49 genConfig =
50 set:
51 let
52 pairs = mapAttrsToList (name: value: { inherit name value; }) set;
53 sortedPairs = sortOn prioOf pairs;
54 in
55 concatMap genPair sortedPairs;
56 genSection =
57 sec: secName: value:
58 [ "${sec} ${secName}" ] ++ map (x: " " + x) (genConfig value);
59 genPair =
60 { name, value }:
61 if !isAttrs value then
62 [ "${name} ${value}" ]
63 else
64 concatLists (mapAttrsToList (genSection name) value);
65
66 sudoRule = {
67 users = [ "btrbk" ];
68 commands = [
69 {
70 command = "${pkgs.btrfs-progs}/bin/btrfs";
71 options = [ "NOPASSWD" ];
72 }
73 {
74 command = "${pkgs.coreutils}/bin/mkdir";
75 options = [ "NOPASSWD" ];
76 }
77 {
78 command = "${pkgs.coreutils}/bin/readlink";
79 options = [ "NOPASSWD" ];
80 }
81 # for ssh, they are not the same than the one hard coded in ${pkgs.btrbk}
82 {
83 command = "/run/current-system/sw/bin/btrfs";
84 options = [ "NOPASSWD" ];
85 }
86 {
87 command = "/run/current-system/sw/bin/mkdir";
88 options = [ "NOPASSWD" ];
89 }
90 {
91 command = "/run/current-system/sw/bin/readlink";
92 options = [ "NOPASSWD" ];
93 }
94 ];
95 };
96
97 sudo_doas =
98 if config.security.sudo.enable || config.security.sudo-rs.enable then
99 "sudo"
100 else if config.security.doas.enable then
101 "doas"
102 else
103 throw "The btrbk nixos module needs either sudo or doas enabled in the configuration";
104
105 addDefaults = settings: { backend = "btrfs-progs-${sudo_doas}"; } // settings;
106
107 mkConfigFile =
108 name: settings:
109 pkgs.writeTextFile {
110 name = "btrbk-${name}.conf";
111 text = genConfig' (addDefaults settings);
112 checkPhase = ''
113 set +e
114 ${pkgs.btrbk}/bin/btrbk -c $out dryrun
115 # According to btrbk(1), exit status 2 means parse error
116 # for CLI options or the config file.
117 if [[ $? == 2 ]]; then
118 echo "Btrbk configuration is invalid:"
119 cat $out
120 exit 1
121 fi
122 set -e
123 '';
124 };
125
126 streamCompressMap = {
127 gzip = pkgs.gzip;
128 pigz = pkgs.pigz;
129 bzip2 = pkgs.bzip2;
130 pbzip2 = pkgs.pbzip2;
131 bzip3 = pkgs.bzip3;
132 xz = pkgs.xz;
133 lzo = pkgs.lzo;
134 lz4 = pkgs.lz4;
135 zstd = pkgs.zstd;
136 };
137
138 cfg = config.services.btrbk;
139 sshEnabled = cfg.sshAccess != [ ];
140 serviceEnabled = cfg.instances != { };
141in
142{
143 meta.maintainers = with lib.maintainers; [ oxalica ];
144
145 options = {
146 services.btrbk = {
147 extraPackages = mkOption {
148 description = ''
149 Extra packages for btrbk, like compression utilities for `stream_compress`.
150
151 **Note**: This option will get deprecated in future releases.
152 Required compression programs will get automatically provided to btrbk
153 depending on configured compression method in
154 `services.btrbk.instances.<name>.settings` option.
155 '';
156 type = types.listOf types.package;
157 default = [ ];
158 example = literalExpression "[ pkgs.xz ]";
159 };
160 niceness = mkOption {
161 description = "Niceness for local instances of btrbk. Also applies to remote ones connecting via ssh when positive.";
162 type = types.ints.between (-20) 19;
163 default = 10;
164 };
165 ioSchedulingClass = mkOption {
166 description = "IO scheduling class for btrbk (see {manpage}`ionice(1)` for a quick description). Applies to local instances, and remote ones connecting by ssh if set to idle.";
167 type = types.enum [
168 "idle"
169 "best-effort"
170 "realtime"
171 ];
172 default = "best-effort";
173 };
174 instances = mkOption {
175 description = "Set of btrbk instances. The instance named `btrbk` is the default one.";
176 type =
177 with types;
178 attrsOf (submodule {
179 options = {
180 onCalendar = mkOption {
181 type = types.nullOr types.str;
182 default = "daily";
183 description = ''
184 How often this btrbk instance is started. See {manpage}`systemd.time(7)` for more information about the format.
185 Setting it to null disables the timer, thus this instance can only be started manually.
186 '';
187 };
188 snapshotOnly = mkOption {
189 type = types.bool;
190 default = false;
191 description = ''
192 Whether to run in snapshot only mode. This skips backup creation and deletion steps.
193 Useful when you want to manually backup to an external drive that might not always be connected.
194 Use `btrbk -c /path/to/conf resume` to trigger manual backups.
195 More examples [here](https://github.com/digint/btrbk#example-backups-to-usb-disk).
196 See also `snapshot` subcommand in {manpage}`btrbk(1)`.
197 '';
198 };
199 settings = mkOption {
200 type = types.submodule {
201 freeformType =
202 let
203 t = types.attrsOf (
204 types.either types.str (t // { description = "instances of this type recursively"; })
205 );
206 in
207 t;
208 options = {
209 stream_compress = mkOption {
210 description = ''
211 Compress the btrfs send stream before transferring it from/to remote locations using a
212 compression command.
213 '';
214 type = types.enum [
215 "gzip"
216 "pigz"
217 "bzip2"
218 "pbzip2"
219 "bzip3"
220 "xz"
221 "lzo"
222 "lz4"
223 "zstd"
224 "no"
225 ];
226 default = "no";
227 };
228 };
229 };
230 default = { };
231 example = {
232 snapshot_preserve_min = "2d";
233 snapshot_preserve = "14d";
234 volume = {
235 "/mnt/btr_pool" = {
236 target = "/mnt/btr_backup/mylaptop";
237 subvolume = {
238 "rootfs" = { };
239 "home" = {
240 snapshot_create = "always";
241 };
242 };
243 };
244 };
245 };
246 description = "configuration options for btrbk. Nested attrsets translate to subsections.";
247 };
248 };
249 });
250 default = { };
251 };
252 sshAccess = mkOption {
253 description = "SSH keys that should be able to make or push snapshots on this system remotely with btrbk";
254 type =
255 with types;
256 listOf (submodule {
257 options = {
258 key = mkOption {
259 type = str;
260 description = "SSH public key allowed to login as user `btrbk` to run remote backups.";
261 };
262 roles = mkOption {
263 type = listOf (enum [
264 "info"
265 "source"
266 "target"
267 "delete"
268 "snapshot"
269 "send"
270 "receive"
271 ]);
272 example = [
273 "source"
274 "info"
275 "send"
276 ];
277 description = "What actions can be performed with this SSH key. See ssh_filter_btrbk(1) for details";
278 };
279 };
280 });
281 default = [ ];
282 };
283 };
284
285 };
286 config = mkIf (sshEnabled || serviceEnabled) {
287
288 environment.systemPackages = [ pkgs.btrbk ] ++ cfg.extraPackages;
289
290 security.sudo.extraRules = mkIf (sudo_doas == "sudo") [ sudoRule ];
291 security.sudo-rs.extraRules = mkIf (sudo_doas == "sudo") [ sudoRule ];
292
293 security.doas = mkIf (sudo_doas == "doas") {
294 extraRules =
295 let
296 doasCmdNoPass = cmd: {
297 users = [ "btrbk" ];
298 cmd = cmd;
299 noPass = true;
300 };
301 in
302 [
303 (doasCmdNoPass "${pkgs.btrfs-progs}/bin/btrfs")
304 (doasCmdNoPass "${pkgs.coreutils}/bin/mkdir")
305 (doasCmdNoPass "${pkgs.coreutils}/bin/readlink")
306 # for ssh, they are not the same than the one hard coded in ${pkgs.btrbk}
307 (doasCmdNoPass "/run/current-system/sw/bin/btrfs")
308 (doasCmdNoPass "/run/current-system/sw/bin/mkdir")
309 (doasCmdNoPass "/run/current-system/sw/bin/readlink")
310
311 # doas matches command, not binary
312 (doasCmdNoPass "btrfs")
313 (doasCmdNoPass "mkdir")
314 (doasCmdNoPass "readlink")
315 ];
316 };
317 users.users.btrbk = {
318 isSystemUser = true;
319 # ssh needs a home directory
320 home = "/var/lib/btrbk";
321 createHome = true;
322 shell = "${pkgs.bash}/bin/bash";
323 group = "btrbk";
324 openssh.authorizedKeys.keys = map (
325 v:
326 let
327 options = concatMapStringsSep " " (x: "--" + x) v.roles;
328 ioniceClass =
329 {
330 "idle" = 3;
331 "best-effort" = 2;
332 "realtime" = 1;
333 }
334 .${cfg.ioSchedulingClass};
335 sudo_doas_flag = "--${sudo_doas}";
336 in
337 ''command="${pkgs.util-linux}/bin/ionice -t -c ${toString ioniceClass} ${
338 optionalString (cfg.niceness >= 1) "${pkgs.coreutils}/bin/nice -n ${toString cfg.niceness}"
339 } ${pkgs.btrbk}/share/btrbk/scripts/ssh_filter_btrbk.sh ${sudo_doas_flag} ${options}" ${v.key}''
340 ) cfg.sshAccess;
341 };
342 users.groups.btrbk = { };
343 systemd.tmpfiles.rules = [
344 "d /var/lib/btrbk 0750 btrbk btrbk"
345 "d /var/lib/btrbk/.ssh 0700 btrbk btrbk"
346 "f /var/lib/btrbk/.ssh/config 0700 btrbk btrbk - StrictHostKeyChecking=accept-new"
347 ];
348 environment.etc = mapAttrs' (name: instance: {
349 name = "btrbk/${name}.conf";
350 value.source = mkConfigFile name instance.settings;
351 }) cfg.instances;
352 systemd.services = mapAttrs' (name: instance: {
353 name = "btrbk-${name}";
354 value = {
355 description = "Takes BTRFS snapshots and maintains retention policies.";
356 unitConfig.Documentation = "man:btrbk(1)";
357 path =
358 [ "/run/wrappers" ]
359 ++ cfg.extraPackages
360 ++ optional (instance.settings.stream_compress != "no") (
361 getAttr instance.settings.stream_compress streamCompressMap
362 );
363 serviceConfig = {
364 User = "btrbk";
365 Group = "btrbk";
366 Type = "oneshot";
367 ExecStart = "${pkgs.btrbk}/bin/btrbk -c /etc/btrbk/${name}.conf ${
368 if instance.snapshotOnly then "snapshot" else "run"
369 }";
370 Nice = cfg.niceness;
371 IOSchedulingClass = cfg.ioSchedulingClass;
372 StateDirectory = "btrbk";
373 };
374 };
375 }) cfg.instances;
376
377 systemd.timers = mapAttrs' (name: instance: {
378 name = "btrbk-${name}";
379 value = {
380 description = "Timer to take BTRFS snapshots and maintain retention policies.";
381 wantedBy = [ "timers.target" ];
382 timerConfig = {
383 OnCalendar = instance.onCalendar;
384 AccuracySec = "10min";
385 Persistent = true;
386 };
387 };
388 }) (filterAttrs (name: instance: instance.onCalendar != null) cfg.instances);
389 };
390
391}