at 25.11-pre 13 kB view raw
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}