diff --git a/README.md b/README.md index 9d43a47..3fbb161 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ EFI/BIOS boot config | Module | [Link](./modules/boot.nix) | - | - | Allows you Nginx recommended options | Module | [Link](./modules/nginx.nix) | - | agenix | Sets many recommended settings for nginx with a single switch plus some opinionated defaults. Also adds a switch for setting recommended security headers on each location. Node options | Module | [Link](./modules/node.nix) | - | - | A module that stores meta information about your nodes (hosts). Required for some other modules that operate across nodes. Guests (MicroVMs & Containers) | Module | [Link](./modules/guests) | zfs, node options | - | This module implements a common interface to use guest systems with microvms or nixos-containers. +Restic hetzner storage box setup | Module | [Link](./modules/restic.nix) | - | - | This module exposes new options for restic backups that allow a simple setup of hetzner storage boxes. There's [an app](./apps/setup-hetzner-storage-boxes.nix) that you should expose on your flake to automate remote setup. #### Home Manager Modules diff --git a/apps/setup-hetzner-storage-boxes.nix b/apps/setup-hetzner-storage-boxes.nix new file mode 100644 index 0000000..5151b52 --- /dev/null +++ b/apps/setup-hetzner-storage-boxes.nix @@ -0,0 +1,100 @@ +{ + pkgs, + nixosConfigurations, + decryptIdentity, +}: let + inherit + (pkgs.lib) + attrValues + concatLines + concatStringsSep + escapeShellArg + filterAttrs + flatten + flip + getExe + groupBy + head + length + mapAttrs + mapAttrsToList + optional + throwIf + unique + ; + + allBoxDefinitions = flatten ( + flip map (attrValues nixosConfigurations) ( + hostCfg: + flip map (attrValues hostCfg.config.services.restic.backups) ( + backupCfg: + optional backupCfg.hetznerStorageBox.enable backupCfg.hetznerStorageBox + ) + ) + ); + + subUserFor = box: "${box.mainUser}-sub${toString box.subUid}"; + boxesBySubuser = groupBy subUserFor allBoxDefinitions; + + # We need to know the main storage box user to create subusers + boxSubuserToMainUser = + flip mapAttrs boxesBySubuser (_: boxes: + head (unique (flip map boxes (box: box.mainUser)))); + + boxSubuserToPrivateKeys = + flip mapAttrs boxesBySubuser (_: boxes: + unique (flip map boxes (box: box.sshPrivateKeyFile))); + + # Any subuid that has more than one path in use + boxSubuserToPaths = + flip mapAttrs boxesBySubuser (_: boxes: + unique (flip map boxes (box: box.path))); + + duplicates = filterAttrs (_: boxes: length boxes > 1) boxSubuserToPaths; + + # Only one path must remain per subuser. + boxSubuserToPath = throwIf (duplicates != {}) '' + At least one storage box subuser has multiple paths assigned to it: + ${concatStringsSep "\n" (mapAttrsToList (n: v: "${n}: ${toString v}") duplicates)} + '' (mapAttrs (_: head) boxSubuserToPaths); + + appendPubkey = privateKey: '' + PATH="$PATH:${pkgs.age-plugin-yubikey}/bin" ${pkgs.rage}/bin/rage -d -i ${decryptIdentity} ${escapeShellArg privateKey} \ + | (exec 3<&0; ssh-keygen -f /proc/self/fd/3 -y) \ + >> "$TMPFILE" + ''; + + setupSubuser = subuser: privateKeys: let + mainUser = boxSubuserToMainUser.${subuser}; + path = boxSubuserToPath.${subuser}; + in '' + echo "${mainUser} (for ${subuser}): Removing old ${path}/.ssh if it exists" + # Remove any .ssh folder if it exists + ${pkgs.openssh}/bin/ssh -p 23 "${mainUser}@${mainUser}.your-storagebox.de" -- rm -r ./${path}/.ssh &>/dev/null || true + echo "${mainUser} (for ${subuser}): Creating ${path}/.ssh" + # Create subuser directory and .ssh + ${pkgs.openssh}/bin/ssh -p 23 "${mainUser}@${mainUser}.your-storagebox.de" -- mkdir -p ./${path}/.ssh + + # Derive and upload all authorized keys + TMPFILE=$(mktemp) + ${concatLines (map appendPubkey privateKeys)} + echo "${mainUser} (for ${subuser}): Uploading $(wc -l < "$TMPFILE") authorized_keys" + ${pkgs.openssh}/bin/scp -P 23 "$TMPFILE" "${mainUser}@${mainUser}.your-storagebox.de":./${path}/.ssh/authorized_keys + rm "$TMPFILE" + ''; +in { + type = "app"; + program = getExe (pkgs.writeShellApplication { + name = "setup-hetzner-storage-boxes"; + text = '' + set -euo pipefail + + ${concatLines (mapAttrsToList setupSubuser boxSubuserToPrivateKeys)} + + echo + echo "Please visit https://robot.hetzner.com/storage and make sure" + echo "that the following subusers are setup correctly:" + ${concatLines (mapAttrsToList (u: p: "echo ' ${u}: ${p}'") boxSubuserToPath)} + ''; + }); +} diff --git a/modules/default.nix b/modules/default.nix index 00ae9c2..4b683c5 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -7,6 +7,7 @@ ./interface-naming.nix ./nginx.nix ./node.nix + ./restic.nix ]; nixpkgs.overlays = [ diff --git a/modules/restic.nix b/modules/restic.nix new file mode 100644 index 0000000..6af5d9b --- /dev/null +++ b/modules/restic.nix @@ -0,0 +1,56 @@ +{lib, ...}: let + inherit + (lib) + mkEnableOption + mkIf + mkOption + types + ; +in { + options.services.restic.backups = mkOption { + type = types.attrsOf (types.submodule ({config, ...}: { + options.hetznerStorageBox = { + enable = mkEnableOption "Automatically configure this backup to use the given hetzner storage box. Will use SFTP via SSH."; + + mainUser = mkOption { + type = types.str; + description = '' + The main user. While not technically required for restic, we still use it to + derive the subuser name and it is required for the automatic setup script + that creates the users. + ''; + }; + + subUid = mkOption { + type = types.int; + description = "The id of the subuser that was allocated on the hetzner server for this backup."; + }; + + path = mkOption { + type = types.str; + description = '' + The remote path to backup into. While not technically required for restic + (since the subuser is chrooted on the remote), it is required for the + automatic setup script that creates the users. + ''; + }; + + sshPrivateKeyFile = mkOption { + type = types.path; + description = "The path to the ssh private key to use for uploading backups. Don't use a path from the nix store!"; + }; + }; + + config = let + subuser = "${config.hetznerStorageBox.mainUser}-sub${toString config.hetznerStorageBox.subUid}"; + url = "${subuser}@${subuser}.your-storagebox.de"; + in + mkIf config.hetznerStorageBox.enable { + repository = "sftp://${url}:23/"; + extraOptions = [ + "sftp.command='ssh -s sftp -p 23 -i ${config.hetznerStorageBox.sshPrivateKeyFile} ${url}'" + ]; + }; + })); + }; +}