diff --git a/flake.nix b/flake.nix index 3e834e2..0b9b3f1 100644 --- a/flake.nix +++ b/flake.nix @@ -22,6 +22,7 @@ let sharedModule = import ./modules/shared; btrbkModule = import ./modules/btrbk; + btrbkPatchedModule = import ./modules/btrbk-patched; dockerModule = import ./modules/docker; subvolumesModule = import ./modules/subvolumes; k3sModule = import ./modules/k3s; @@ -36,6 +37,7 @@ modules = [ sops-nix.nixosModules.sops sharedModule + btrbkPatchedModule { networking.hostName = hostname; } ] ++ extraModules; }; @@ -557,7 +559,7 @@ makeQuartz64ABackupConfig = makeBoardBackupConfig sd-images.packages.${system}.Quartz64A.modules; makePostgresConfig = system: - { hostname, selfIp, macAddress, keyFile, primaryIp ? null }: + { hostname, selfIp, macAddress, keyFile, primaryIp ? null, unlockMounts ? true, mountVolumes ? true, luksDevice ? "/dev/sda1" }: let device = "/dev/mapper/cryptdrive1"; mountDir = "/btrfs/ssd"; @@ -569,35 +571,72 @@ extraModules = sd-images.packages.${system}.Rock64.modules ++ [ dockerModule (networkModule { inherit macAddress selfIp; }) - (btrbkModule { - instances = [{ inherit mountDir primaryIp subvolumes; }]; - }) - (if primaryIp == null then + (if unlockMounts && mountVolumes then + (btrbkModule { + instances = [{ inherit mountDir primaryIp subvolumes; }]; + }) else { }) + (if primaryIp == null && unlockMounts && mountVolumes then (subvolumesModule { inherit device subvolumes; }) else { }) - ({ config, lib, ... }: - let keyFilePath = config.sops.secrets."${keyFile}".path; - in { + ({ config, lib, pkgs, ... }: + let + keyFilePath = config.sops.secrets.${keyFile}.path; + prepareNvme = '' + #!/usr/bin/env bash + + set -e + + DEVICE=$1 + + if [ "$DEVICE" == "" ]; then + echo "Must provide device" + exit 1 + fi + + echo "YES" | cryptsetup luksFormat "$DEVICE" -d ${keyFilePath} --label=LUKS_SSD + cryptsetup luksOpen "$DEVICE" cryptdrive1 -d ${keyFilePath} + + mkfs.btrfs /dev/mapper/cryptdrive1 + + mkdir -p /btrfs/nvme + mount /dev/mapper/cryptdrive1 ${mountDir} + + btrfs subvolume create ${mountDir}/@postgres + btrfs subvolume create ${mountDir}/@postgres-cfg + btrfs subvolume create ${mountDir}/@snapshots + + umount ${mountDir} + + cryptsetup luksClose cryptdrive1 + ''; + in + { sops.secrets.${keyFile} = { format = "binary"; sopsFile = ./secrets/${keyFile}.bin; }; + environment.systemPackages = with pkgs; + [ (writeShellScriptBin "prepare-nvme" prepareNvme) ]; + environment.etc.crypttab = { - enable = true; + enable = unlockMounts; text = '' - cryptdrive1 /dev/sda1 ${keyFilePath} luks + cryptdrive1 ${luksDevice} ${keyFilePath} luks ''; }; - fileSystems = { - "${mountDir}" = { - inherit device; - fsType = "btrfs"; - options = [ "defaults" "compress=zstd" "rw" ]; - }; - }; + fileSystems = + if unlockMounts && mountVolumes then + { + "${mountDir}" = { + inherit device; + fsType = "btrfs"; + options = [ "defaults" "compress=zstd" "rw" ]; + }; + } + else { }; }) ]; }; @@ -611,7 +650,8 @@ selfIp = "192.168.20.23"; macAddress = "02:fe:30:d8:cf:64"; keyFile = "redtailKeyFile"; - # primaryIp = "192.168.20.24"; + primaryIp = "192.168.20.24"; + luksDevice = "/dev/disk/by-label/LUKS_SSD"; }; redtail2 = makePostgresConfig system { @@ -619,7 +659,7 @@ selfIp = "192.168.20.24"; macAddress = "02:8a:70:2a:a8:5e"; keyFile = "redtailKeyFile"; - primaryIp = "192.168.20.23"; + # primaryIp = "192.168.20.23"; }; whitestorm1 = makePostgresConfig system { @@ -929,8 +969,8 @@ name = "whitestorm1"; } { - ip = "192.168.20.23"; - name = "redtail1"; + ip = "192.168.20.24"; + name = "redtail2"; } ]); }; @@ -1064,8 +1104,8 @@ name = "whitestorm1"; } { - ip = "192.168.20.23"; - name = "redtail1"; + ip = "192.168.20.24"; + name = "redtail2"; } ]); }; diff --git a/modules/btrbk-patched/default.nix b/modules/btrbk-patched/default.nix new file mode 100644 index 0000000..13528db --- /dev/null +++ b/modules/btrbk-patched/default.nix @@ -0,0 +1,273 @@ +{ config, pkgs, lib, ... }: +let + inherit (lib) + concatLists + concatMap + concatMapStringsSep + concatStringsSep + filterAttrs + isAttrs + literalExpression + mapAttrs' + mapAttrsToList + mkIf + mkOption + optionalString + sort + types + ; + + # The priority of an option or section. + # The configurations format are order-sensitive. Pairs are added as children of + # the last sections if possible, otherwise, they start a new section. + # We sort them in topological order: + # 1. Leaf pairs. + # 2. Sections that may contain (1). + # 3. Sections that may contain (1) or (2). + # 4. Etc. + prioOf = { name, value }: + if !isAttrs value then 0 # Leaf options. + else { + target = 1; # Contains: options. + subvolume = 2; # Contains: options, target. + volume = 3; # Contains: options, target, subvolume. + }.${name} or (throw "Unknow section '${name}'"); + + genConfig' = set: concatStringsSep "\n" (genConfig set); + genConfig = set: + let + pairs = mapAttrsToList (name: value: { inherit name value; }) set; + sortedPairs = sort (a: b: prioOf a < prioOf b) pairs; + in + concatMap genPair sortedPairs; + genSection = sec: secName: value: + [ "${sec} ${secName}" ] ++ map (x: " " + x) (genConfig value); + genPair = { name, value }: + if !isAttrs value + then [ "${name} ${value}" ] + else concatLists (mapAttrsToList (genSection name) value); + + sudo_doas = + if config.security.sudo.enable then "sudo" + else if config.security.doas.enable then "doas" + else throw "The btrbk nixos module needs either sudo or doas enabled in the configuration"; + + addDefaults = settings: { backend = "btrfs-progs-${sudo_doas}"; } // settings; + + mkConfigFile = name: settings: pkgs.writeTextFile { + name = "btrbk-${name}.conf"; + text = genConfig' (addDefaults settings); + checkPhase = '' + set +e + ${pkgs.btrbk}/bin/btrbk -c $out dryrun + # According to btrbk(1), exit status 2 means parse error + # for CLI options or the config file. + if [[ $? == 2 ]]; then + echo "Btrbk configuration is invalid:" + cat $out + exit 1 + fi + set -e + ''; + }; + + cfg = config.services.btrbk-patched; + sshEnabled = cfg.sshAccess != [ ]; + serviceEnabled = cfg.instances != { }; +in +{ + meta.maintainers = with lib.maintainers; [ oxalica ]; + + options = { + services.btrbk-patched = { + extraPackages = mkOption { + description = lib.mdDoc "Extra packages for btrbk, like compression utilities for `stream_compress`"; + type = types.listOf types.package; + default = [ ]; + example = literalExpression "[ pkgs.xz ]"; + }; + niceness = mkOption { + description = lib.mdDoc "Niceness for local instances of btrbk. Also applies to remote ones connecting via ssh when positive."; + type = types.ints.between (-20) 19; + default = 10; + }; + ioSchedulingClass = mkOption { + description = lib.mdDoc "IO scheduling class for btrbk (see ionice(1) for a quick description). Applies to local instances, and remote ones connecting by ssh if set to idle."; + type = types.enum [ "idle" "best-effort" "realtime" ]; + default = "best-effort"; + }; + instances = mkOption { + description = lib.mdDoc "Set of btrbk instances. The instance named `btrbk` is the default one."; + type = with types; + attrsOf ( + submodule { + options = { + onCalendar = mkOption { + type = types.nullOr types.str; + default = "daily"; + description = lib.mdDoc '' + How often this btrbk instance is started. See systemd.time(7) for more information about the format. + Setting it to null disables the timer, thus this instance can only be started manually. + ''; + }; + settings = mkOption { + type = let t = types.attrsOf (types.either types.str (t // { description = "instances of this type recursively"; })); in t; + default = { }; + example = { + snapshot_preserve_min = "2d"; + snapshot_preserve = "14d"; + volume = { + "/mnt/btr_pool" = { + target = "/mnt/btr_backup/mylaptop"; + subvolume = { + "rootfs" = { }; + "home" = { snapshot_create = "always"; }; + }; + }; + }; + }; + description = lib.mdDoc "configuration options for btrbk. Nested attrsets translate to subsections."; + }; + }; + } + ); + default = { }; + }; + sshAccess = mkOption { + description = lib.mdDoc "SSH keys that should be able to make or push snapshots on this system remotely with btrbk"; + type = with types; listOf ( + submodule { + options = { + key = mkOption { + type = str; + description = lib.mdDoc "SSH public key allowed to login as user `btrbk` to run remote backups."; + }; + roles = mkOption { + type = listOf (enum [ "info" "source" "target" "delete" "snapshot" "send" "receive" ]); + example = [ "source" "info" "send" ]; + description = lib.mdDoc "What actions can be performed with this SSH key. See ssh_filter_btrbk(1) for details"; + }; + }; + } + ); + default = [ ]; + }; + }; + + }; + config = mkIf (sshEnabled || serviceEnabled) { + environment.systemPackages = [ pkgs.btrbk ] ++ cfg.extraPackages; + security.sudo = mkIf (sudo_doas == "sudo") { + extraRules = [ + { + users = [ "btrbk" ]; + commands = [ + { command = "${pkgs.btrfs-progs}/bin/btrfs"; options = [ "NOPASSWD" ]; } + { command = "${pkgs.coreutils}/bin/mkdir"; options = [ "NOPASSWD" ]; } + { command = "${pkgs.coreutils}/bin/readlink"; options = [ "NOPASSWD" ]; } + # for ssh, they are not the same than the one hard coded in ${pkgs.btrbk} + { command = "/run/current-system/sw/bin/btrfs"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/mkdir"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/readlink"; options = [ "NOPASSWD" ]; } + ]; + } + ]; + }; + security.doas = mkIf (sudo_doas == "doas") { + extraRules = let + doasCmdNoPass = cmd: { users = [ "btrbk" ]; cmd = cmd; noPass = true; }; + in + [ + (doasCmdNoPass "${pkgs.btrfs-progs}/bin/btrfs") + (doasCmdNoPass "${pkgs.coreutils}/bin/mkdir") + (doasCmdNoPass "${pkgs.coreutils}/bin/readlink") + # for ssh, they are not the same than the one hard coded in ${pkgs.btrbk} + (doasCmdNoPass "/run/current-system/sw/bin/btrfs") + (doasCmdNoPass "/run/current-system/sw/bin/mkdir") + (doasCmdNoPass "/run/current-system/sw/bin/readlink") + + # doas matches command, not binary + (doasCmdNoPass "btrfs") + (doasCmdNoPass "mkdir") + (doasCmdNoPass "readlink") + ]; + }; + users.users.btrbk = { + isSystemUser = true; + # ssh needs a home directory + home = "/var/lib/btrbk"; + createHome = true; + shell = "${pkgs.bash}/bin/bash"; + group = "btrbk"; + openssh.authorizedKeys.keys = map + ( + v: + let + options = concatMapStringsSep " " (x: "--" + x) v.roles; + ioniceClass = { + "idle" = 3; + "best-effort" = 2; + "realtime" = 1; + }.${cfg.ioSchedulingClass}; + sudo_doas_flag = "--${sudo_doas}"; + in + ''command="${pkgs.util-linux}/bin/ionice -t -c ${toString ioniceClass} ${optionalString (cfg.niceness >= 1) "${pkgs.coreutils}/bin/nice -n ${toString cfg.niceness}"} ${pkgs.btrbk}/share/btrbk/scripts/ssh_filter_btrbk.sh ${sudo_doas_flag} ${options}" ${v.key}'' + ) + cfg.sshAccess; + }; + users.groups.btrbk = { }; + systemd.tmpfiles.rules = [ + "d /var/lib/btrbk 0750 btrbk btrbk" + "d /var/lib/btrbk/.ssh 0700 btrbk btrbk" + "f /var/lib/btrbk/.ssh/config 0700 btrbk btrbk - StrictHostKeyChecking=accept-new" + ]; + environment.etc = mapAttrs' + ( + name: instance: { + name = "btrbk/${name}.conf"; + value.source = mkConfigFile name instance.settings; + } + ) + cfg.instances; + systemd.services = mapAttrs' + ( + name: _: { + name = "btrbk-${name}"; + value = { + description = "Takes BTRFS snapshots and maintains retention policies."; + unitConfig.Documentation = "man:btrbk(1)"; + path = [ "/run/wrappers" ] ++ cfg.extraPackages; + serviceConfig = { + User = "btrbk"; + Group = "btrbk"; + Type = "oneshot"; + ExecStart = "${pkgs.btrbk}/bin/btrbk -c /etc/btrbk/${name}.conf run"; + Nice = cfg.niceness; + IOSchedulingClass = cfg.ioSchedulingClass; + StateDirectory = "btrbk"; + }; + }; + } + ) + cfg.instances; + + systemd.timers = mapAttrs' + ( + name: instance: { + name = "btrbk-${name}"; + value = { + description = "Timer to take BTRFS snapshots and maintain retention policies."; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = instance.onCalendar; + AccuracySec = "10min"; + Persistent = true; + }; + }; + } + ) + (filterAttrs (name: instance: instance.onCalendar != null) + cfg.instances); + }; + +} diff --git a/modules/btrbk/default.nix b/modules/btrbk/default.nix index f273762..dc410eb 100644 --- a/modules/btrbk/default.nix +++ b/modules/btrbk/default.nix @@ -96,7 +96,7 @@ in else [ ]); - services.btrbk = { + services.btrbk-patched = { sshAccess = [{ key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHTqU3EvTgY5/e9m6YyQWypQPK58t9iPmPnPYAvnODGB asonix@lionheart";