main
  1{
  2  config,
  3  lib,
  4  pkgs,
  5  ...
  6}:
  7
  8with lib;
  9
 10let
 11  cfg = config.services.rsync-replica;
 12
 13  # Convert schedule shortcuts to systemd OnCalendar format
 14  scheduleToCalendar =
 15    schedule:
 16    if schedule == "hourly" then
 17      "hourly"
 18    else if schedule == "daily" then
 19      "daily"
 20    else if schedule == "weekly" then
 21      "weekly"
 22    else
 23      schedule;
 24
 25  # Generate systemd service for a single job
 26  mkReplicaService = name: jobCfg: {
 27    description = "Rsync replica job: ${name}";
 28    serviceConfig = {
 29      Type = "oneshot";
 30      User = jobCfg.user;
 31      Group = jobCfg.group;
 32    };
 33    script =
 34      let
 35        # Build rsync command for each source path
 36        syncCommands = map (
 37          sourcePath:
 38          let
 39            # Extract the basename for destination (e.g., /neo/videos -> videos)
 40            basename = baseNameOf sourcePath;
 41            destPath = "${jobCfg.destination}/${basename}";
 42
 43            # Base rsync arguments
 44            baseArgs = [
 45              "-aAX" # archive mode with ACLs and xattrs
 46              "--verbose"
 47              "--human-readable"
 48              "--progress"
 49            ]
 50            ++ optional jobCfg.delete "--delete"
 51            ++ optional jobCfg.deleteExcluded "--delete-excluded"
 52            ++ jobCfg.rsyncArgs;
 53
 54            # SSH command with custom args
 55            sshCmd = "ssh ${concatStringsSep " " jobCfg.sshArgs}";
 56
 57            # Full rsync command
 58            rsyncArgs = concatStringsSep " " (
 59              baseArgs
 60              ++ [
 61                "-e '${sshCmd}'"
 62                "${jobCfg.source.user}@${jobCfg.source.host}:${sourcePath}/"
 63                "${destPath}/"
 64              ]
 65            );
 66          in
 67          ''
 68            echo "Syncing ${sourcePath} from ${jobCfg.source.host} to ${destPath}"
 69            mkdir -p "${destPath}"
 70            ${pkgs.rsync}/bin/rsync ${rsyncArgs}
 71          ''
 72        ) jobCfg.source.paths;
 73      in
 74      concatStringsSep "\n" syncCommands;
 75
 76    path = with pkgs; [
 77      openssh
 78      rsync
 79    ];
 80  };
 81
 82  # Generate systemd timer for a single job
 83  mkReplicaTimer = name: jobCfg: {
 84    description = "Timer for rsync replica job: ${name}";
 85    wantedBy = [ "timers.target" ];
 86    timerConfig = {
 87      OnCalendar = scheduleToCalendar jobCfg.schedule;
 88      Persistent = true;
 89      RandomizedDelaySec = jobCfg.randomizedDelay;
 90    };
 91  };
 92
 93in
 94{
 95  options.services.rsync-replica = {
 96    enable = mkEnableOption "rsync-based replication service";
 97
 98    jobs = mkOption {
 99      type = types.attrsOf (
100        types.submodule {
101          options = {
102            source = {
103              host = mkOption {
104                type = types.str;
105                description = "Remote hostname to sync from";
106                example = "rhea";
107              };
108
109              user = mkOption {
110                type = types.str;
111                default = "root";
112                description = "SSH user to connect as";
113              };
114
115              paths = mkOption {
116                type = types.listOf types.str;
117                description = "List of absolute paths on the source host to sync";
118                example = [
119                  "/neo/videos"
120                  "/neo/pictures"
121                ];
122              };
123            };
124
125            destination = mkOption {
126              type = types.str;
127              description = "Local destination directory (source basenames will be created here)";
128              example = "/neo";
129            };
130
131            schedule = mkOption {
132              type = types.str;
133              default = "daily";
134              description = ''
135                When to run the sync. Can be "hourly", "daily", "weekly", or a systemd OnCalendar format.
136                See systemd.time(7) for OnCalendar format details.
137              '';
138              example = "daily";
139            };
140
141            delete = mkOption {
142              type = types.bool;
143              default = true;
144              description = "Delete files in destination that don't exist in source (true replica)";
145            };
146
147            deleteExcluded = mkOption {
148              type = types.bool;
149              default = false;
150              description = "Also delete excluded files from destination";
151            };
152
153            rsyncArgs = mkOption {
154              type = types.listOf types.str;
155              default = [ ];
156              description = "Additional arguments to pass to rsync";
157              example = [
158                "--exclude=*.tmp"
159                "--bwlimit=10000"
160              ];
161            };
162
163            sshArgs = mkOption {
164              type = types.listOf types.str;
165              default = [ ];
166              description = "Additional arguments to pass to SSH";
167              example = [
168                "-p 2222"
169                "-i /root/.ssh/id_ed25519"
170              ];
171            };
172
173            user = mkOption {
174              type = types.str;
175              default = "root";
176              description = "User to run the rsync job as";
177            };
178
179            group = mkOption {
180              type = types.str;
181              default = "root";
182              description = "Group to run the rsync job as";
183            };
184
185            randomizedDelay = mkOption {
186              type = types.str;
187              default = "0";
188              description = "Randomized delay before starting the job (systemd RandomizedDelaySec)";
189              example = "1h";
190            };
191          };
192        }
193      );
194      default = { };
195      description = "Rsync replication jobs to run";
196    };
197  };
198
199  config = mkIf cfg.enable {
200    systemd.services = mapAttrs' (
201      name: jobCfg: nameValuePair "rsync-replica-${name}" (mkReplicaService name jobCfg)
202    ) cfg.jobs;
203
204    systemd.timers = mapAttrs' (
205      name: jobCfg: nameValuePair "rsync-replica-${name}" (mkReplicaTimer name jobCfg)
206    ) cfg.jobs;
207  };
208}