diff --git a/README.md b/README.md index c6ae85b..750fb66 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ Module List Simple backups for PostgreSQL via `pg_dump`. + * `phoebe.backup.rsync`: + + Sync files from a remote machine creating a set of backups that + use hard links for files that don't change from day to day. This + is a simple and efficient way to backup a remote host. + * `phoebe.services.networking.wireguard`: Simple way to configure a whole network of WireGuard machines. diff --git a/modules/backup/default.nix b/modules/backup/default.nix index 99f7c02..a08439e 100644 --- a/modules/backup/default.nix +++ b/modules/backup/default.nix @@ -1,7 +1,53 @@ -{ config, lib, pkgs, ...}: +{ config, lib, pkgs, ...}: with lib; +let + cfg = config.phoebe.backup; + user = "backup"; +in { imports = [ ./postgresql.nix + ./rsync.nix ]; + + #### Interface + options.phoebe.backup = { + user = { + enable = mkEnableOption "Backup user and group."; + + name = mkOption { + type = types.str; + default = user; + description = "User to perform backups as."; + }; + + group = mkOption { + type = types.str; + default = user; + description = "Group for the backup user."; + }; + }; + + directory = mkOption { + type = types.path; + default = "/var/backup"; + description = '' + Base directory where backups will be stored. Each host to + back up will get a directory under this base directory. + ''; + }; + }; + + #### Implementation + config = mkIf cfg.user.enable { + users.users."${cfg.user.name}" = { + description = "Backup user."; + home = cfg.directory; + createHome = true; + group = cfg.user.group; + isSystemUser = true; + }; + + users.groups."${cfg.user.group}" = {}; + }; } diff --git a/modules/backup/rsync.nix b/modules/backup/rsync.nix new file mode 100644 index 0000000..16b9b9f --- /dev/null +++ b/modules/backup/rsync.nix @@ -0,0 +1,162 @@ +# Hard-linked backups via rsync. +{ config, lib, pkgs, ...}: with lib; + +let + cfg = config.phoebe.backup.rsync; + plib = config.phoebe.lib; + user = "backup"; + scripts = (import ../../pkgs/default.nix { inherit pkgs; }).backup-scripts; + + ############################################################################## + # Backup options. + backupOpts = { + options = { + host = mkOption { + type = types.str; + example = "example.com"; + description = "Host name for the machine to back up."; + }; + + port = mkOption { + type = types.ints.positive; + default = builtins.head config.services.openssh.ports; + example = 22; + description = "SSH port on the remote machine."; + }; + + user = mkOption { + type = types.str; + default = "backup"; + example = "root"; + description = "User name on the remote machine to use."; + }; + + directory = mkOption { + type = types.path; + default = "/var/backup"; + example = "/var/lib/backup"; + description = "Remote directory to sync to the local machine."; + }; + + key = mkOption { + type = types.nullOr types.path; + default = null; + example = "/home/backup/.ssh/id_ed25519"; + description = '' + Optional SSH key to use when connecting to the remote + machine. If the key is provided by NixOps then this backup + will wait until the key is available. + ''; + }; + + schedule = mkOption { + type = types.str; + default = "*-*-* 02:00:00"; + example = "*-*-* *:00/30:00"; + description = '' + A systemd calendar specification to designate the frequency + of the backup. You can use the "systemd-analyze calendar" + command to validate your calendar specification. + + When increasing the frequency of the backups you should + consider changing the number of backups that you keep. + ''; + }; + + keep = mkOption { + type = types.ints.positive; + default = 7; + example = 14; + description = "Number of backups to keep when deleting older backups."; + }; + }; + }; + + ############################################################################## + # Sanitize the name of a directory. + dir = path: replaceStrings ["/"] ["-"] (removePrefix "/" path); + + ############################################################################## + # Generate a service/timer name (without the suffix): + name = opts: "rsync-${opts.host}-${dir opts.directory}"; + + ############################################################################## + # Generate a systemd service for a backup. + service = opts: + let base = "${config.phoebe.backup.directory}/rsync"; + localdir = "${base}/${opts.host}/${dir opts.directory}"; + in rec { + description = "rsync backup for ${opts.host}:${opts.directory}"; + path = [ pkgs.coreutils scripts ]; + wants = plib.keyService opts.key; + after = wants; + + serviceConfig = { + Type = "simple"; + PermissionsStartOnly = "true"; + User = cfg.user; + }; + + preStart = '' + mkdir -p "${localdir}" + chown -R ${cfg.user}:${cfg.group} "${localdir}" + chmod -R 0700 "${localdir}" + ''; + + script = '' + export BACKUP_LIB_DIR=${scripts}/lib + export BACKUP_LOG_DIR=stdout + export BACKUP_SSH_KEY=${toString opts.key} + export BACKUP_SSH_PORT=${toString opts.port} + . "${scripts}/lib/backup.sh" + backup_via_rsync "${opts.user}@${opts.host}:${opts.directory}" "${localdir}" + backup-purge.sh -k "${toString opts.keep}" -d "${localdir}" + ''; + }; + + ############################################################################## + # Generate a systemd timer for a backup. + timer = opts: { + description = "Scheduled Backup of ${opts.host}:${opts.directory}"; + wantedBy = [ "timers.target" ]; + timerConfig.OnCalendar = opts.schedule; + timerConfig.RandomizedDelaySec = "5m"; + timerConfig.Unit = "${name opts}.service"; + }; + + ############################################################################## + # Generate systemd services and timers. + toSystemd = f: foldr (a: b: b // {"${name a}" = f a;}) {} cfg.schedules; + +in +{ + #### Interface + options.phoebe.backup.rsync = { + enable = mkEnableOption "rsync backups"; + + user = mkOption { + type = types.str; + default = user; + description = "User to perform backups as."; + }; + + group = mkOption { + type = types.str; + default = config.phoebe.backup.user.group; + description = "Group for the backup user."; + }; + + schedules = mkOption { + type = types.listOf (types.submodule backupOpts); + default = []; + description = "List of backups to perform."; + }; + }; + + #### Implementation + config = mkIf cfg.enable { + phoebe.backup.user.enable = cfg.user == user; + systemd.services = toSystemd service; + systemd.timers = toSystemd timer; + }; +} diff --git a/test/backup/rsync/default.nix b/test/backup/rsync/default.nix new file mode 100644 index 0000000..d0d2c4e --- /dev/null +++ b/test/backup/rsync/default.nix @@ -0,0 +1,47 @@ +{ pkgs ? import {} +}: + +let + service = "rsync-localhost-tmp-backup.service"; + +in +pkgs.nixosTest { + name = "rsync-backup-test"; + + nodes = { + simple = {config, pkgs, ...}: { + imports = [ ../../../modules ]; + phoebe.security.enable = false; + services.openssh.enable = true; + + users.users.root.openssh.authorizedKeys.keys = [ + (builtins.readFile ../../data/ssh.id_ed25519.pub) + ]; + + phoebe.backup.rsync = { + enable = true; + schedules = [ + { host = "localhost"; + directory = "/tmp/backup"; + user = "root"; + key = "/tmp/key"; + } + ]; + }; + }; + }; + + testScript = '' + $simple->start; + $simple->copyFileFromHost("${../../data/ssh.id_ed25519}", "/tmp/key"); + $simple->succeed("chmod 0600 /tmp/key"); + $simple->succeed("chown backup:backup /tmp/key"); + $simple->succeed("mkdir /tmp/backup"); + $simple->succeed("echo OKAY > /tmp/backup/file"); + $simple->waitForUnit("sshd.service"); + $simple->systemctl("start ${service}"); + $simple->waitForUnit("${service}"); + $simple->waitUntilFails("systemctl status ${service} | grep -q 'Active: active'"); + $simple->succeed("cat /var/backup/rsync/localhost/tmp-backup/*/file") =~ /OKAY/ or die; + ''; +} diff --git a/test/data/ssh.id_ed25519 b/test/data/ssh.id_ed25519 new file mode 100644 index 0000000..735fcde --- /dev/null +++ b/test/data/ssh.id_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACDvu8YUGTtH+Dr9JM6fk0lvTatPduUZleYMDxIw8ludwAAAAIg8mzDNPJsw +zQAAAAtzc2gtZWQyNTUxOQAAACDvu8YUGTtH+Dr9JM6fk0lvTatPduUZleYMDxIw8ludwA +AAAEDvidTlFEJvyV9Bn2rY2rMieHx/GMVdm2T8I/noFxdCb++7xhQZO0f4Ov0kzp+TSW9N +q0925RmV5gwPEjDyW53AAAAABHRlc3QB +-----END OPENSSH PRIVATE KEY----- diff --git a/test/data/ssh.id_ed25519.pub b/test/data/ssh.id_ed25519.pub new file mode 100644 index 0000000..a758763 --- /dev/null +++ b/test/data/ssh.id_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO+7xhQZO0f4Ov0kzp+TSW9Nq0925RmV5gwPEjDyW53A test