Commit a1aafb6a2fcb

Vincent Demeester <vincent@sbr.pm>
2025-12-08 15:34:28
feat: Add rsync-replica NixOS module for automated backups
- Enable declarative rsync-based replication between hosts - Provide systemd-managed daily sync from rhea to aion - Support multiple jobs with flexible scheduling and SSH options Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent f1acc27
Changed files (6)
home
common
desktop
lib
modules
systems
home/common/desktop/mails.nix
@@ -101,4 +101,5 @@
     # across reboots and prevent re-indexing
     home = "${config.xdg.dataHome}/mu";
   };
+  home.packages = with pkgs; [ mblaze ];
 }
lib/default.nix
@@ -73,6 +73,7 @@
         self.nixosModules.wireguard-server
         self.nixosModules.govanityurl
         self.nixosModules.gosmee
+        self.nixosModules.rsync-replica
         inputs.agenix.nixosModules.default
         ../systems/new.nix
       ];
@@ -116,6 +117,7 @@
         self.nixosModules.wireguard-server
         self.nixosModules.govanityurl
         self.nixosModules.gosmee
+        self.nixosModules.rsync-replica
         inputs.agenix.nixosModules.default
         inputs.lanzaboote.nixosModules.lanzaboote
         homeInput.nixosModules.home-manager
@@ -179,6 +181,7 @@
         self.nixosModules.wireguard-server
         self.nixosModules.govanityurl
         self.nixosModules.gosmee
+        self.nixosModules.rsync-replica
         inputs.agenix.nixosModules.default
         inputs.lanzaboote.nixosModules.lanzaboote
         homeInput.nixosModules.home-manager
modules/rsync-replica/default.nix
@@ -0,0 +1,208 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+
+with lib;
+
+let
+  cfg = config.services.rsync-replica;
+
+  # Convert schedule shortcuts to systemd OnCalendar format
+  scheduleToCalendar =
+    schedule:
+    if schedule == "hourly" then
+      "hourly"
+    else if schedule == "daily" then
+      "daily"
+    else if schedule == "weekly" then
+      "weekly"
+    else
+      schedule;
+
+  # Generate systemd service for a single job
+  mkReplicaService = name: jobCfg: {
+    description = "Rsync replica job: ${name}";
+    serviceConfig = {
+      Type = "oneshot";
+      User = jobCfg.user;
+      Group = jobCfg.group;
+    };
+    script =
+      let
+        # Build rsync command for each source path
+        syncCommands = map (
+          sourcePath:
+          let
+            # Extract the basename for destination (e.g., /neo/videos -> videos)
+            basename = baseNameOf sourcePath;
+            destPath = "${jobCfg.destination}/${basename}";
+
+            # Base rsync arguments
+            baseArgs = [
+              "-aAX" # archive mode with ACLs and xattrs
+              "--verbose"
+              "--human-readable"
+              "--progress"
+            ]
+            ++ optional jobCfg.delete "--delete"
+            ++ optional jobCfg.deleteExcluded "--delete-excluded"
+            ++ jobCfg.rsyncArgs;
+
+            # SSH command with custom args
+            sshCmd = "ssh ${concatStringsSep " " jobCfg.sshArgs}";
+
+            # Full rsync command
+            rsyncArgs = concatStringsSep " " (
+              baseArgs
+              ++ [
+                "-e '${sshCmd}'"
+                "${jobCfg.source.user}@${jobCfg.source.host}:${sourcePath}/"
+                "${destPath}/"
+              ]
+            );
+          in
+          ''
+            echo "Syncing ${sourcePath} from ${jobCfg.source.host} to ${destPath}"
+            mkdir -p "${destPath}"
+            ${pkgs.rsync}/bin/rsync ${rsyncArgs}
+          ''
+        ) jobCfg.source.paths;
+      in
+      concatStringsSep "\n" syncCommands;
+
+    path = with pkgs; [
+      openssh
+      rsync
+    ];
+  };
+
+  # Generate systemd timer for a single job
+  mkReplicaTimer = name: jobCfg: {
+    description = "Timer for rsync replica job: ${name}";
+    wantedBy = [ "timers.target" ];
+    timerConfig = {
+      OnCalendar = scheduleToCalendar jobCfg.schedule;
+      Persistent = true;
+      RandomizedDelaySec = jobCfg.randomizedDelay;
+    };
+  };
+
+in
+{
+  options.services.rsync-replica = {
+    enable = mkEnableOption "rsync-based replication service";
+
+    jobs = mkOption {
+      type = types.attrsOf (
+        types.submodule {
+          options = {
+            source = {
+              host = mkOption {
+                type = types.str;
+                description = "Remote hostname to sync from";
+                example = "rhea";
+              };
+
+              user = mkOption {
+                type = types.str;
+                default = "root";
+                description = "SSH user to connect as";
+              };
+
+              paths = mkOption {
+                type = types.listOf types.str;
+                description = "List of absolute paths on the source host to sync";
+                example = [
+                  "/neo/videos"
+                  "/neo/pictures"
+                ];
+              };
+            };
+
+            destination = mkOption {
+              type = types.str;
+              description = "Local destination directory (source basenames will be created here)";
+              example = "/neo";
+            };
+
+            schedule = mkOption {
+              type = types.str;
+              default = "daily";
+              description = ''
+                When to run the sync. Can be "hourly", "daily", "weekly", or a systemd OnCalendar format.
+                See systemd.time(7) for OnCalendar format details.
+              '';
+              example = "daily";
+            };
+
+            delete = mkOption {
+              type = types.bool;
+              default = true;
+              description = "Delete files in destination that don't exist in source (true replica)";
+            };
+
+            deleteExcluded = mkOption {
+              type = types.bool;
+              default = false;
+              description = "Also delete excluded files from destination";
+            };
+
+            rsyncArgs = mkOption {
+              type = types.listOf types.str;
+              default = [ ];
+              description = "Additional arguments to pass to rsync";
+              example = [
+                "--exclude=*.tmp"
+                "--bwlimit=10000"
+              ];
+            };
+
+            sshArgs = mkOption {
+              type = types.listOf types.str;
+              default = [ ];
+              description = "Additional arguments to pass to SSH";
+              example = [
+                "-p 2222"
+                "-i /root/.ssh/id_ed25519"
+              ];
+            };
+
+            user = mkOption {
+              type = types.str;
+              default = "root";
+              description = "User to run the rsync job as";
+            };
+
+            group = mkOption {
+              type = types.str;
+              default = "root";
+              description = "Group to run the rsync job as";
+            };
+
+            randomizedDelay = mkOption {
+              type = types.str;
+              default = "0";
+              description = "Randomized delay before starting the job (systemd RandomizedDelaySec)";
+              example = "1h";
+            };
+          };
+        }
+      );
+      default = { };
+      description = "Rsync replication jobs to run";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services = mapAttrs' (
+      name: jobCfg: nameValuePair "rsync-replica-${name}" (mkReplicaService name jobCfg)
+    ) cfg.jobs;
+
+    systemd.timers = mapAttrs' (
+      name: jobCfg: nameValuePair "rsync-replica-${name}" (mkReplicaTimer name jobCfg)
+    ) cfg.jobs;
+  };
+}
modules/rsync-replica/README.md
@@ -0,0 +1,231 @@
+# rsync-replica Module
+
+A NixOS module for declarative rsync-based replication between hosts.
+
+## Features
+
+- Declarative configuration of rsync replication jobs
+- Systemd service and timer integration
+- Support for multiple sync jobs
+- Full archive mode with ACLs and extended attributes (-aAX)
+- Optional deletion of files (true replica/mirror mode)
+- Customizable rsync and SSH arguments
+- Flexible scheduling (hourly, daily, weekly, or custom systemd OnCalendar)
+
+## Usage
+
+### Basic Configuration
+
+```nix
+services.rsync-replica = {
+  enable = true;
+  jobs = {
+    backup-from-server = {
+      source = {
+        host = "source-hostname";
+        user = "root";
+        paths = [
+          "/path/to/source1"
+          "/path/to/source2"
+        ];
+      };
+      destination = "/local/backup";
+      schedule = "daily";
+      delete = true;
+    };
+  };
+};
+```
+
+### Multiple Jobs
+
+```nix
+services.rsync-replica = {
+  enable = true;
+  jobs = {
+    media-sync = {
+      source = {
+        host = "media-server";
+        paths = [ "/media/videos" "/media/music" ];
+      };
+      destination = "/backup/media";
+      schedule = "daily";
+    };
+
+    documents-sync = {
+      source = {
+        host = "file-server";
+        paths = [ "/documents" ];
+      };
+      destination = "/backup/documents";
+      schedule = "hourly";
+      rsyncArgs = [ "--exclude=*.tmp" "--bwlimit=5000" ];
+    };
+  };
+};
+```
+
+## Options
+
+### `services.rsync-replica.enable`
+- Type: `boolean`
+- Default: `false`
+- Description: Enable the rsync-replica service
+
+### `services.rsync-replica.jobs.<name>`
+- Type: `attribute set`
+- Description: Rsync replication jobs to run
+
+#### Job Options
+
+##### `source.host`
+- Type: `string`
+- Description: Remote hostname to sync from
+- Example: `"rhea"`
+
+##### `source.user`
+- Type: `string`
+- Default: `"root"`
+- Description: SSH user to connect as
+
+##### `source.paths`
+- Type: `list of strings`
+- Description: List of absolute paths on the source host to sync
+- Example: `[ "/neo/videos" "/neo/pictures" ]`
+
+##### `destination`
+- Type: `string`
+- Description: Local destination directory. Source basenames will be created here.
+- Example: `"/neo"` (will create `/neo/videos` and `/neo/pictures`)
+
+##### `schedule`
+- Type: `string`
+- Default: `"daily"`
+- Description: When to run the sync. Can be "hourly", "daily", "weekly", or a systemd OnCalendar format.
+- Example: `"daily"` or `"*-*-* 02:00:00"` (daily at 2 AM)
+
+##### `delete`
+- Type: `boolean`
+- Default: `true`
+- Description: Delete files in destination that don't exist in source (true replica/mirror mode)
+
+##### `deleteExcluded`
+- Type: `boolean`
+- Default: `false`
+- Description: Also delete excluded files from destination
+
+##### `rsyncArgs`
+- Type: `list of strings`
+- Default: `[ ]`
+- Description: Additional arguments to pass to rsync
+- Example: `[ "--exclude=*.tmp" "--bwlimit=10000" ]`
+
+##### `sshArgs`
+- Type: `list of strings`
+- Default: `[ ]`
+- Description: Additional arguments to pass to SSH
+- Example: `[ "-p 2222" "-i /root/.ssh/id_ed25519" ]`
+
+##### `user`
+- Type: `string`
+- Default: `"root"`
+- Description: User to run the rsync job as
+
+##### `group`
+- Type: `string`
+- Default: `"root"`
+- Description: Group to run the rsync job as
+
+##### `randomizedDelay`
+- Type: `string`
+- Default: `"0"`
+- Description: Randomized delay before starting the job (systemd RandomizedDelaySec)
+- Example: `"1h"` (randomize start within 1 hour)
+
+## How It Works
+
+For each job, the module:
+1. Creates a systemd service `rsync-replica-<job-name>`
+2. Creates a systemd timer `rsync-replica-<job-name>` to trigger the service
+3. For each path in `source.paths`, syncs `user@host:/path/` to `destination/basename/`
+
+For example, if you have:
+```nix
+source = {
+  host = "rhea";
+  paths = [ "/neo/videos" "/neo/pictures" ];
+};
+destination = "/neo";
+```
+
+This will sync:
+- `root@rhea:/neo/videos/` → `/neo/videos/`
+- `root@rhea:/neo/pictures/` → `/neo/pictures/`
+
+## Management Commands
+
+```bash
+# Check status of a sync job
+systemctl status rsync-replica-<job-name>
+
+# View timer schedule
+systemctl list-timers rsync-replica-*
+
+# Manually trigger a sync
+systemctl start rsync-replica-<job-name>
+
+# View logs
+journalctl -u rsync-replica-<job-name>
+```
+
+## Prerequisites
+
+- SSH access from the destination host to the source host
+- SSH keys set up for passwordless authentication
+- Sufficient disk space on destination
+- Network connectivity between hosts
+
+## Common Use Cases
+
+### Daily Backup with Exclusions
+```nix
+services.rsync-replica.jobs.daily-backup = {
+  source = {
+    host = "production-server";
+    paths = [ "/var/lib/important-data" ];
+  };
+  destination = "/backup";
+  schedule = "daily";
+  delete = true;
+  rsyncArgs = [
+    "--exclude=cache/"
+    "--exclude=*.log"
+    "--exclude=tmp/"
+  ];
+};
+```
+
+### Bandwidth-Limited Sync
+```nix
+services.rsync-replica.jobs.slow-sync = {
+  source = {
+    host = "remote-server";
+    paths = [ "/large/dataset" ];
+  };
+  destination = "/local/copy";
+  schedule = "weekly";
+  rsyncArgs = [ "--bwlimit=1000" ]; # Limit to 1 MB/s
+};
+```
+
+### Custom SSH Port
+```nix
+services.rsync-replica.jobs.custom-port = {
+  source = {
+    host = "server-with-custom-port";
+    paths = [ "/data" ];
+  };
+  destination = "/backup";
+  sshArgs = [ "-p 2222" ];
+};
+```
systems/aion/extra.nix
@@ -15,6 +15,36 @@
       endpoint = "${globals.net.vpn.endpoint}";
       endpointPublicKey = "${globals.machines.kerkouane.net.vpn.pubkey}";
     };
+
+    rsync-replica = {
+      enable = true;
+      jobs = {
+        rhea-backup = {
+          source = {
+            host = "rhea.sbr.pm";
+            user = "vincent";
+            paths = [
+              "/neo/documents"
+              "/neo/music"
+              "/neo/pictures"
+              "/neo/videos"
+            ];
+          };
+          destination = "/neo";
+          schedule = "daily";
+          delete = true; # Mirror mode: delete files in destination that don't exist in source
+          user = "vincent";
+          group = "users";
+          rsyncArgs = [
+            "--exclude=.Trash-*"
+            "--exclude=lost+found"
+          ];
+          sshArgs = [
+            "-o StrictHostKeyChecking=accept-new"
+          ];
+        };
+      };
+    };
   };
 
   networking.useDHCP = lib.mkDefault true;
flake.nix
@@ -138,6 +138,7 @@
         wireguard-server = ./modules/wireguard-server.nix;
         govanityurl = ./modules/govanityurl.nix;
         gosmee = ./modules/gosmee.nix;
+        rsync-replica = ./modules/rsync-replica;
       };
 
       # system-manager configurations