1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.duplicity;
9
10 stateDirectory = "/var/lib/duplicity";
11
12 localTarget =
13 if lib.hasPrefix "file://" cfg.targetUrl then lib.removePrefix "file://" cfg.targetUrl else null;
14
15in
16{
17 options.services.duplicity = {
18 enable = lib.mkEnableOption "backups with duplicity";
19
20 root = lib.mkOption {
21 type = lib.types.path;
22 default = "/";
23 description = ''
24 Root directory to backup.
25 '';
26 };
27
28 include = lib.mkOption {
29 type = lib.types.listOf lib.types.str;
30 default = [ ];
31 example = [ "/home" ];
32 description = ''
33 List of paths to include into the backups. See the FILE SELECTION
34 section in {manpage}`duplicity(1)` for details on the syntax.
35 '';
36 };
37
38 exclude = lib.mkOption {
39 type = lib.types.listOf lib.types.str;
40 default = [ ];
41 description = ''
42 List of paths to exclude from backups. See the FILE SELECTION section in
43 {manpage}`duplicity(1)` for details on the syntax.
44 '';
45 };
46
47 includeFileList = lib.mkOption {
48 type = lib.types.nullOr lib.types.path;
49 default = null;
50 example = /path/to/fileList.txt;
51 description = ''
52 File containing newline-separated list of paths to include into the
53 backups. See the FILE SELECTION section in {manpage}`duplicity(1)` for
54 details on the syntax.
55 '';
56 };
57
58 excludeFileList = lib.mkOption {
59 type = lib.types.nullOr lib.types.path;
60 default = null;
61 example = /path/to/fileList.txt;
62 description = ''
63 File containing newline-separated list of paths to exclude into the
64 backups. See the FILE SELECTION section in {manpage}`duplicity(1)` for
65 details on the syntax.
66 '';
67 };
68
69 targetUrl = lib.mkOption {
70 type = lib.types.str;
71 example = "s3://host:port/prefix";
72 description = ''
73 Target url to backup to. See the URL FORMAT section in
74 {manpage}`duplicity(1)` for supported urls.
75 '';
76 };
77
78 secretFile = lib.mkOption {
79 type = lib.types.nullOr lib.types.path;
80 default = null;
81 description = ''
82 Path of a file containing secrets (gpg passphrase, access key...) in
83 the format of EnvironmentFile as described by
84 {manpage}`systemd.exec(5)`. For example:
85 ```
86 PASSPHRASE=«...»
87 AWS_ACCESS_KEY_ID=«...»
88 AWS_SECRET_ACCESS_KEY=«...»
89 ```
90 '';
91 };
92
93 frequency = lib.mkOption {
94 type = lib.types.nullOr lib.types.str;
95 default = "daily";
96 description = ''
97 Run duplicity with the given frequency (see
98 {manpage}`systemd.time(7)` for the format).
99 If null, do not run automatically.
100 '';
101 };
102
103 extraFlags = lib.mkOption {
104 type = lib.types.listOf lib.types.str;
105 default = [ ];
106 example = [
107 "--backend-retry-delay"
108 "100"
109 ];
110 description = ''
111 Extra command-line flags passed to duplicity. See
112 {manpage}`duplicity(1)`.
113 '';
114 };
115
116 fullIfOlderThan = lib.mkOption {
117 type = lib.types.str;
118 default = "never";
119 example = "1M";
120 description = ''
121 If `"never"` (the default) always do incremental
122 backups (the first backup will be a full backup, of course). If
123 `"always"` always do full backups. Otherwise, this
124 must be a string representing a duration. Full backups will be made
125 when the latest full backup is older than this duration. If this is not
126 the case, an incremental backup is performed.
127 '';
128 };
129
130 cleanup = {
131 maxAge = lib.mkOption {
132 type = lib.types.nullOr lib.types.str;
133 default = null;
134 example = "6M";
135 description = ''
136 If non-null, delete all backup sets older than the given time. Old backup sets
137 will not be deleted if backup sets newer than time depend on them.
138 '';
139 };
140 maxFull = lib.mkOption {
141 type = lib.types.nullOr lib.types.int;
142 default = null;
143 example = 2;
144 description = ''
145 If non-null, delete all backups sets that are older than the count:th last full
146 backup (in other words, keep the last count full backups and
147 associated incremental sets).
148 '';
149 };
150 maxIncr = lib.mkOption {
151 type = lib.types.nullOr lib.types.int;
152 default = null;
153 example = 1;
154 description = ''
155 If non-null, delete incremental sets of all backups sets that are
156 older than the count:th last full backup (in other words, keep only
157 old full backups and not their increments).
158 '';
159 };
160 };
161 };
162
163 config = lib.mkIf cfg.enable {
164 systemd = {
165 services.duplicity =
166 {
167 description = "backup files with duplicity";
168
169 environment.HOME = stateDirectory;
170
171 script =
172 let
173 target = lib.escapeShellArg cfg.targetUrl;
174 extra = lib.escapeShellArgs (
175 [
176 "--archive-dir"
177 stateDirectory
178 ]
179 ++ cfg.extraFlags
180 );
181 dup = "${pkgs.duplicity}/bin/duplicity";
182 in
183 ''
184 set -x
185 ${dup} cleanup ${target} --force ${extra}
186 ${lib.optionalString (
187 cfg.cleanup.maxAge != null
188 ) "${dup} remove-older-than ${lib.escapeShellArg cfg.cleanup.maxAge} ${target} --force ${extra}"}
189 ${lib.optionalString (
190 cfg.cleanup.maxFull != null
191 ) "${dup} remove-all-but-n-full ${toString cfg.cleanup.maxFull} ${target} --force ${extra}"}
192 ${lib.optionalString (
193 cfg.cleanup.maxIncr != null
194 ) "${dup} remove-all-inc-of-but-n-full ${toString cfg.cleanup.maxIncr} ${target} --force ${extra}"}
195 exec ${dup} ${if cfg.fullIfOlderThan == "always" then "full" else "incr"} ${
196 lib.escapeShellArgs (
197 [
198 cfg.root
199 cfg.targetUrl
200 ]
201 ++ lib.optionals (cfg.includeFileList != null) [
202 "--include-filelist"
203 cfg.includeFileList
204 ]
205 ++ lib.optionals (cfg.excludeFileList != null) [
206 "--exclude-filelist"
207 cfg.excludeFileList
208 ]
209 ++ lib.concatMap (p: [
210 "--include"
211 p
212 ]) cfg.include
213 ++ lib.concatMap (p: [
214 "--exclude"
215 p
216 ]) cfg.exclude
217 ++ (lib.optionals (cfg.fullIfOlderThan != "never" && cfg.fullIfOlderThan != "always") [
218 "--full-if-older-than"
219 cfg.fullIfOlderThan
220 ])
221 )
222 } ${extra}
223 '';
224 serviceConfig =
225 {
226 PrivateTmp = true;
227 ProtectSystem = "strict";
228 ProtectHome = "read-only";
229 StateDirectory = baseNameOf stateDirectory;
230 }
231 // lib.optionalAttrs (localTarget != null) {
232 ReadWritePaths = localTarget;
233 }
234 // lib.optionalAttrs (cfg.secretFile != null) {
235 EnvironmentFile = cfg.secretFile;
236 };
237 }
238 // lib.optionalAttrs (cfg.frequency != null) {
239 startAt = cfg.frequency;
240 };
241
242 tmpfiles.rules = lib.optional (localTarget != null) "d ${localTarget} 0700 root root -";
243 };
244
245 assertions = lib.singleton {
246 # Duplicity will fail if the last file selection option is an include. It
247 # is not always possible to detect but this simple case can be caught.
248 assertion = cfg.include != [ ] -> cfg.exclude != [ ] || cfg.extraFlags != [ ];
249 message = ''
250 Duplicity will fail if you only specify included paths ("Because the
251 default is to include all files, the expression is redundant. Exiting
252 because this probably isn't what you meant.")
253 '';
254 };
255 };
256}