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 description = "backup files with duplicity";
167
168 environment.HOME = stateDirectory;
169
170 script =
171 let
172 target = lib.escapeShellArg cfg.targetUrl;
173 extra = lib.escapeShellArgs (
174 [
175 "--archive-dir"
176 stateDirectory
177 ]
178 ++ cfg.extraFlags
179 );
180 dup = "${pkgs.duplicity}/bin/duplicity";
181 in
182 ''
183 set -x
184 ${dup} cleanup ${target} --force ${extra}
185 ${lib.optionalString (
186 cfg.cleanup.maxAge != null
187 ) "${dup} remove-older-than ${lib.escapeShellArg cfg.cleanup.maxAge} ${target} --force ${extra}"}
188 ${lib.optionalString (
189 cfg.cleanup.maxFull != null
190 ) "${dup} remove-all-but-n-full ${toString cfg.cleanup.maxFull} ${target} --force ${extra}"}
191 ${lib.optionalString (
192 cfg.cleanup.maxIncr != null
193 ) "${dup} remove-all-inc-of-but-n-full ${toString cfg.cleanup.maxIncr} ${target} --force ${extra}"}
194 exec ${dup} ${if cfg.fullIfOlderThan == "always" then "full" else "incr"} ${
195 lib.escapeShellArgs (
196 [
197 cfg.root
198 cfg.targetUrl
199 ]
200 ++ lib.optionals (cfg.includeFileList != null) [
201 "--include-filelist"
202 cfg.includeFileList
203 ]
204 ++ lib.optionals (cfg.excludeFileList != null) [
205 "--exclude-filelist"
206 cfg.excludeFileList
207 ]
208 ++ lib.concatMap (p: [
209 "--include"
210 p
211 ]) cfg.include
212 ++ lib.concatMap (p: [
213 "--exclude"
214 p
215 ]) cfg.exclude
216 ++ (lib.optionals (cfg.fullIfOlderThan != "never" && cfg.fullIfOlderThan != "always") [
217 "--full-if-older-than"
218 cfg.fullIfOlderThan
219 ])
220 )
221 } ${extra}
222 '';
223 serviceConfig = {
224 PrivateTmp = true;
225 ProtectSystem = "strict";
226 ProtectHome = "read-only";
227 StateDirectory = baseNameOf stateDirectory;
228 }
229 // lib.optionalAttrs (localTarget != null) {
230 ReadWritePaths = localTarget;
231 }
232 // lib.optionalAttrs (cfg.secretFile != null) {
233 EnvironmentFile = cfg.secretFile;
234 };
235 }
236 // lib.optionalAttrs (cfg.frequency != null) {
237 startAt = cfg.frequency;
238 };
239
240 tmpfiles.rules = lib.optional (localTarget != null) "d ${localTarget} 0700 root root -";
241 };
242
243 assertions = lib.singleton {
244 # Duplicity will fail if the last file selection option is an include. It
245 # is not always possible to detect but this simple case can be caught.
246 assertion = cfg.include != [ ] -> cfg.exclude != [ ] || cfg.extraFlags != [ ];
247 message = ''
248 Duplicity will fail if you only specify included paths ("Because the
249 default is to include all files, the expression is redundant. Exiting
250 because this probably isn't what you meant.")
251 '';
252 };
253 };
254}