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}