1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6
7 cfg = config.security.acme;
8
9 certOpts = { name, ... }: {
10 options = {
11 webroot = mkOption {
12 type = types.str;
13 example = "/var/lib/acme/acme-challenges";
14 description = ''
15 Where the webroot of the HTTP vhost is located.
16 <filename>.well-known/acme-challenge/</filename> directory
17 will be created below the webroot if it doesn't exist.
18 <literal>http://example.org/.well-known/acme-challenge/</literal> must also
19 be available (notice unencrypted HTTP).
20 '';
21 };
22
23 domain = mkOption {
24 type = types.str;
25 default = name;
26 description = "Domain to fetch certificate for (defaults to the entry name)";
27 };
28
29 email = mkOption {
30 type = types.nullOr types.str;
31 default = null;
32 description = "Contact email address for the CA to be able to reach you.";
33 };
34
35 user = mkOption {
36 type = types.str;
37 default = "root";
38 description = "User running the ACME client.";
39 };
40
41 group = mkOption {
42 type = types.str;
43 default = "root";
44 description = "Group running the ACME client.";
45 };
46
47 allowKeysForGroup = mkOption {
48 type = types.bool;
49 default = false;
50 description = ''
51 Give read permissions to the specified group
52 (<option>security.acme.cert.<name>.group</option>) to read SSL private certificates.
53 '';
54 };
55
56 postRun = mkOption {
57 type = types.lines;
58 default = "";
59 example = "systemctl reload nginx.service";
60 description = ''
61 Commands to run after new certificates go live. Typically
62 the web server and other servers using certificates need to
63 be reloaded.
64
65 Executed in the same directory with the new certificate.
66 '';
67 };
68
69 plugins = mkOption {
70 type = types.listOf (types.enum [
71 "cert.der" "cert.pem" "chain.pem" "external.sh"
72 "fullchain.pem" "full.pem" "key.der" "key.pem" "account_key.json"
73 ]);
74 default = [ "fullchain.pem" "full.pem" "key.pem" "account_key.json" ];
75 description = ''
76 Plugins to enable. With default settings simp_le will
77 store public certificate bundle in <filename>fullchain.pem</filename>,
78 private key in <filename>key.pem</filename> and those two previous
79 files combined in <filename>full.pem</filename> in its state directory.
80 '';
81 };
82
83 activationDelay = mkOption {
84 type = types.nullOr types.str;
85 default = null;
86 description = ''
87 Systemd time span expression to delay copying new certificates to main
88 state directory. See <citerefentry><refentrytitle>systemd.time</refentrytitle>
89 <manvolnum>7</manvolnum></citerefentry>.
90 '';
91 };
92
93 preDelay = mkOption {
94 type = types.lines;
95 default = "";
96 description = ''
97 Commands to run after certificates are re-issued but before they are
98 activated. Typically the new certificate is published to DNS.
99
100 Executed in the same directory with the new certificate.
101 '';
102 };
103
104 extraDomains = mkOption {
105 type = types.attrsOf (types.nullOr types.str);
106 default = {};
107 example = literalExample ''
108 {
109 "example.org" = "/srv/http/nginx";
110 "mydomain.org" = null;
111 }
112 '';
113 description = ''
114 A list of extra domain names, which are included in the one certificate to be issued, with their
115 own server roots if needed.
116 '';
117 };
118 };
119 };
120
121in
122
123{
124
125 ###### interface
126
127 options = {
128 security.acme = {
129 directory = mkOption {
130 default = "/var/lib/acme";
131 type = types.str;
132 description = ''
133 Directory where certs and other state will be stored by default.
134 '';
135 };
136
137 validMin = mkOption {
138 type = types.int;
139 default = 30 * 24 * 3600;
140 description = "Minimum remaining validity before renewal in seconds.";
141 };
142
143 renewInterval = mkOption {
144 type = types.str;
145 default = "weekly";
146 description = ''
147 Systemd calendar expression when to check for renewal. See
148 <citerefentry><refentrytitle>systemd.time</refentrytitle>
149 <manvolnum>7</manvolnum></citerefentry>.
150 '';
151 };
152
153 preliminarySelfsigned = mkOption {
154 type = types.bool;
155 default = true;
156 description = ''
157 Whether a preliminary self-signed certificate should be generated before
158 doing ACME requests. This can be useful when certificates are required in
159 a webserver, but ACME needs the webserver to make its requests.
160
161 With preliminary self-signed certificate the webserver can be started and
162 can later reload the correct ACME certificates.
163 '';
164 };
165
166 production = mkOption {
167 type = types.bool;
168 default = true;
169 description = ''
170 If set to true, use Let's Encrypt's production environment
171 instead of the staging environment. The main benefit of the
172 staging environment is to get much higher rate limits.
173
174 See
175 <literal>https://letsencrypt.org/docs/staging-environment</literal>
176 for more detail.
177 '';
178 };
179
180 certs = mkOption {
181 default = { };
182 type = with types; attrsOf (submodule certOpts);
183 description = ''
184 Attribute set of certificates to get signed and renewed.
185 '';
186 example = literalExample ''
187 {
188 "example.com" = {
189 webroot = "/var/www/challenges/";
190 email = "foo@example.com";
191 extraDomains = { "www.example.com" = null; "foo.example.com" = "/var/www/foo/"; };
192 };
193 "bar.example.com" = {
194 webroot = "/var/www/challenges/";
195 email = "bar@example.com";
196 };
197 }
198 '';
199 };
200 };
201 };
202
203 ###### implementation
204 config = mkMerge [
205 (mkIf (cfg.certs != { }) {
206
207 systemd.services = let
208 services = concatLists servicesLists;
209 servicesLists = mapAttrsToList certToServices cfg.certs;
210 certToServices = cert: data:
211 let
212 cpath = lpath + optionalString (data.activationDelay != null) ".staging";
213 lpath = "${cfg.directory}/${cert}";
214 rights = if data.allowKeysForGroup then "750" else "700";
215 cmdline = [ "-v" "-d" data.domain "--default_root" data.webroot "--valid_min" cfg.validMin ]
216 ++ optionals (data.email != null) [ "--email" data.email ]
217 ++ concatMap (p: [ "-f" p ]) data.plugins
218 ++ concatLists (mapAttrsToList (name: root: [ "-d" (if root == null then name else "${name}:${root}")]) data.extraDomains)
219 ++ optionals (!cfg.production) ["--server" "https://acme-staging.api.letsencrypt.org/directory"];
220 acmeService = {
221 description = "Renew ACME Certificate for ${cert}";
222 after = [ "network.target" "network-online.target" ];
223 wants = [ "network-online.target" ];
224 serviceConfig = {
225 Type = "oneshot";
226 SuccessExitStatus = [ "0" "1" ];
227 PermissionsStartOnly = true;
228 User = data.user;
229 Group = data.group;
230 PrivateTmp = true;
231 };
232 path = with pkgs; [ simp_le systemd ];
233 preStart = ''
234 mkdir -p '${cfg.directory}'
235 chown 'root:root' '${cfg.directory}'
236 chmod 755 '${cfg.directory}'
237 if [ ! -d '${cpath}' ]; then
238 mkdir '${cpath}'
239 fi
240 chmod ${rights} '${cpath}'
241 chown -R '${data.user}:${data.group}' '${cpath}'
242 mkdir -p '${data.webroot}/.well-known/acme-challenge'
243 chown -R '${data.user}:${data.group}' '${data.webroot}/.well-known/acme-challenge'
244 '';
245 script = ''
246 cd '${cpath}'
247 set +e
248 simp_le ${escapeShellArgs cmdline}
249 EXITCODE=$?
250 set -e
251 echo "$EXITCODE" > /tmp/lastExitCode
252 exit "$EXITCODE"
253 '';
254 postStop = ''
255 cd '${cpath}'
256
257 if [ -e /tmp/lastExitCode ] && [ "$(cat /tmp/lastExitCode)" = "0" ]; then
258 ${if data.activationDelay != null then ''
259
260 ${data.preDelay}
261
262 if [ -d '${lpath}' ]; then
263 systemd-run --no-block --on-active='${data.activationDelay}' --unit acme-setlive-${cert}.service
264 else
265 systemctl --wait start acme-setlive-${cert}.service
266 fi
267 '' else data.postRun}
268
269 # noop ensuring that the "if" block is non-empty even if
270 # activationDelay == null and postRun == ""
271 true
272 fi
273 '';
274
275 before = [ "acme-certificates.target" ];
276 wantedBy = [ "acme-certificates.target" ];
277 };
278 delayService = {
279 description = "Set certificate for ${cert} live";
280 path = with pkgs; [ rsync ];
281 serviceConfig = {
282 Type = "oneshot";
283 };
284 script = ''
285 rsync -a --delete-after '${cpath}/' '${lpath}'
286 '';
287 postStop = data.postRun;
288 };
289 selfsignedService = {
290 description = "Create preliminary self-signed certificate for ${cert}";
291 path = [ pkgs.openssl ];
292 preStart = ''
293 if [ ! -d '${cpath}' ]
294 then
295 mkdir -p '${cpath}'
296 chmod ${rights} '${cpath}'
297 chown '${data.user}:${data.group}' '${cpath}'
298 fi
299 '';
300 script =
301 ''
302 workdir="$(mktemp -d)"
303
304 # Create CA
305 openssl genrsa -des3 -passout pass:x -out $workdir/ca.pass.key 2048
306 openssl rsa -passin pass:x -in $workdir/ca.pass.key -out $workdir/ca.key
307 openssl req -new -key $workdir/ca.key -out $workdir/ca.csr \
308 -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=Security Department/CN=example.com"
309 openssl x509 -req -days 1 -in $workdir/ca.csr -signkey $workdir/ca.key -out $workdir/ca.crt
310
311 # Create key
312 openssl genrsa -des3 -passout pass:x -out $workdir/server.pass.key 2048
313 openssl rsa -passin pass:x -in $workdir/server.pass.key -out $workdir/server.key
314 openssl req -new -key $workdir/server.key -out $workdir/server.csr \
315 -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=IT Department/CN=example.com"
316 openssl x509 -req -days 1 -in $workdir/server.csr -CA $workdir/ca.crt \
317 -CAkey $workdir/ca.key -CAserial $workdir/ca.srl -CAcreateserial \
318 -out $workdir/server.crt
319
320 # Copy key to destination
321 cp $workdir/server.key ${cpath}/key.pem
322
323 # Create fullchain.pem (same format as "simp_le ... -f fullchain.pem" creates)
324 cat $workdir/{server.crt,ca.crt} > "${cpath}/fullchain.pem"
325
326 # Create full.pem for e.g. lighttpd
327 cat $workdir/{server.key,server.crt,ca.crt} > "${cpath}/full.pem"
328
329 # Give key acme permissions
330 chown '${data.user}:${data.group}' "${cpath}/"{key,fullchain,full}.pem
331 chmod ${rights} "${cpath}/"{key,fullchain,full}.pem
332 '';
333 serviceConfig = {
334 Type = "oneshot";
335 PermissionsStartOnly = true;
336 PrivateTmp = true;
337 User = data.user;
338 Group = data.group;
339 };
340 unitConfig = {
341 # Do not create self-signed key when key already exists
342 ConditionPathExists = "!${cpath}/key.pem";
343 };
344 before = [
345 "acme-selfsigned-certificates.target"
346 ];
347 wantedBy = [
348 "acme-selfsigned-certificates.target"
349 ];
350 };
351 in (
352 [ { name = "acme-${cert}"; value = acmeService; } ]
353 ++ optional cfg.preliminarySelfsigned { name = "acme-selfsigned-${cert}"; value = selfsignedService; }
354 ++ optional (data.activationDelay != null) { name = "acme-setlive-${cert}"; value = delayService; }
355 );
356 servicesAttr = listToAttrs services;
357 injectServiceDep = {
358 after = [ "acme-selfsigned-certificates.target" ];
359 wants = [ "acme-selfsigned-certificates.target" "acme-certificates.target" ];
360 };
361 in
362 servicesAttr //
363 (if config.services.nginx.enable then { nginx = injectServiceDep; } else {}) //
364 (if config.services.lighttpd.enable then { lighttpd = injectServiceDep; } else {});
365
366 systemd.timers = flip mapAttrs' cfg.certs (cert: data: nameValuePair
367 ("acme-${cert}")
368 ({
369 description = "Renew ACME Certificate for ${cert}";
370 wantedBy = [ "timers.target" ];
371 timerConfig = {
372 OnCalendar = cfg.renewInterval;
373 Unit = "acme-${cert}.service";
374 Persistent = "yes";
375 AccuracySec = "5m";
376 RandomizedDelaySec = "1h";
377 };
378 })
379 );
380
381 systemd.targets."acme-selfsigned-certificates" = mkIf cfg.preliminarySelfsigned {};
382 systemd.targets."acme-certificates" = {};
383 })
384
385 ];
386
387 meta = {
388 maintainers = with lib.maintainers; [ abbradar fpletz globin ];
389 doc = ./acme.xml;
390 };
391}