on v0.9.0.1 help sign in
obelisk/acme.nix
430 lines 18 kB history raw
1# Copied from https://github.com/NixOS/nixpkgs-channels/blob/70717a337f7ae4e486ba71a500367cad697e5f09/nixos/modules/security/acme.nix # MODIFIED
2# Lines marked '# MODIFIED' have been added or modified from the original. # MODIFIED
3{ config, lib, pkgs, ... }:
4with lib;
5let
7 cfg = config.security.acme;
9 certOpts = { name, ... }: {
10 options = {
11 webroot = mkOption {
12 type = types.nullOr types.str;
13 default = null;
14 example = "/var/lib/acme/acme-challenges";
15 description = ''
16 Where the webroot of the HTTP vhost is located.
17 <filename>.well-known/acme-challenge/</filename> directory
18 will be created below the webroot if it doesn't exist.
19 <literal>http://example.org/.well-known/acme-challenge/</literal> must also
20 be available (notice unencrypted HTTP).
21 '';
22 };
24 server = mkOption {
25 type = types.nullOr types.str;
26 default = null;
27 description = ''
28 ACME Directory Resource URI. Defaults to let's encrypt
29 production endpoint,
30 https://acme-v02.api.letsencrypt.org/directory, if unset.
31 '';
32 };
34 domain = mkOption {
35 type = types.str;
36 default = name;
37 description = "Domain to fetch certificate for (defaults to the entry name)";
38 };
40 email = mkOption {
41 type = types.nullOr types.str;
42 default = cfg.email;
43 description = "Contact email address for the CA to be able to reach you.";
44 };
46 user = mkOption {
47 type = types.str;
48 default = "root";
49 description = "User running the ACME client.";
50 };
52 group = mkOption {
53 type = types.str;
54 default = "root";
55 description = "Group running the ACME client.";
56 };
58 allowKeysForGroup = mkOption {
59 type = types.bool;
60 default = false;
61 description = ''
62 Give read permissions to the specified group
63 (<option>security.acme.cert.&lt;name&gt;.group</option>) to read SSL private certificates.
64 '';
65 };
67 postRun = mkOption {
68 type = types.lines;
69 default = "";
70 example = "systemctl reload nginx.service";
71 description = ''
72 Commands to run after new certificates go live. Typically
73 the web server and other servers using certificates need to
74 be reloaded.
75 Executed in the same directory with the new certificate.
76 '';
77 };
79 directory = mkOption {
80 type = types.str;
81 readOnly = true;
82 default = "/var/lib/acme/${name}";
83 description = "Directory where certificate and other state is stored.";
84 };
86 extraDomains = mkOption {
87 type = types.attrsOf (types.nullOr types.str);
88 default = {};
89 example = literalExample ''
90 {
91 "example.org" = null;
92 "mydomain.org" = null;
93 }
94 '';
95 description = ''
96 A list of extra domain names, which are included in the one certificate to be issued.
97 Setting a distinct server root is deprecated and not functional in 20.03+
98 '';
99 };
101 keyType = mkOption {
102 type = types.str;
103 default = "ec256";
104 description = ''
105 Key type to use for private keys.
106 For an up to date list of supported values check the --key-type option
107 at https://go-acme.github.io/lego/usage/cli/#usage.
108 '';
109 };
111 dnsProvider = mkOption {
112 type = types.nullOr types.str;
113 default = null;
114 example = "route53";
115 description = ''
116 DNS Challenge provider. For a list of supported providers, see the "code"
117 field of the DNS providers listed at https://go-acme.github.io/lego/dns/.
118 '';
119 };
121 credentialsFile = mkOption {
122 type = types.path;
123 description = ''
124 Path to an EnvironmentFile for the cert's service containing any required and
125 optional environment variables for your selected dnsProvider.
126 To find out what values you need to set, consult the documentation at
127 https://go-acme.github.io/lego/dns/ for the corresponding dnsProvider.
128 '';
129 example = "/var/src/secrets/example.org-route53-api-token";
130 };
132 dnsPropagationCheck = mkOption {
133 type = types.bool;
134 default = true;
135 description = ''
136 Toggles lego DNS propagation check, which is used alongside DNS-01
137 challenge to ensure the DNS entries required are available.
138 '';
139 };
140 };
141 };
143in
147 ###### interface
148 imports = [
149 (mkRemovedOptionModule [ "security" "acme" "production" ] ''
150 Use security.acme.server to define your staging ACME server URL instead.
151 To use the let's encrypt staging server, use security.acme.server =
152 "https://acme-staging-v02.api.letsencrypt.org/directory".
153 ''
154 )
155 # (mkRemovedOptionModule [ "security" "acme" "directory"] "ACME Directory is now hardcoded to /var/lib/acme and its permisisons are managed by systemd. See https://github.com/NixOS/nixpkgs/issues/53852 for more info.") # MODIFIED
156 # (mkRemovedOptionModule [ "security" "acme" "preDelay"] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal") # MODIFIED
157 # (mkRemovedOptionModule [ "security" "acme" "activationDelay"] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal") # MODIFIED
158 (mkChangedOptionModule [ "security" "acme" "validMin"] [ "security" "acme" "validMinDays"] (config: config.security.acme.validMin / (24 * 3600)))
159 ];
160 options = {
161 security.acme = {
163 validMinDays = mkOption {
164 type = types.int;
165 default = 30;
166 description = "Minimum remaining validity before renewal in days.";
167 };
169 email = mkOption {
170 type = types.nullOr types.str;
171 default = null;
172 description = "Contact email address for the CA to be able to reach you.";
173 };
175 renewInterval = mkOption {
176 type = types.str;
177 default = "daily";
178 description = ''
179 Systemd calendar expression when to check for renewal. See
180 <citerefentry><refentrytitle>systemd.time</refentrytitle>
181 <manvolnum>7</manvolnum></citerefentry>.
182 '';
183 };
185 server = mkOption {
186 type = types.nullOr types.str;
187 default = null;
188 description = ''
189 ACME Directory Resource URI. Defaults to let's encrypt
190 production endpoint,
191 <literal>https://acme-v02.api.letsencrypt.org/directory</literal>, if unset.
192 '';
193 };
195 preliminarySelfsigned = mkOption {
196 type = types.bool;
197 default = true;
198 description = ''
199 Whether a preliminary self-signed certificate should be generated before
200 doing ACME requests. This can be useful when certificates are required in
201 a webserver, but ACME needs the webserver to make its requests.
202 With preliminary self-signed certificate the webserver can be started and
203 can later reload the correct ACME certificates.
204 '';
205 };
207 acceptTerms = mkOption {
208 type = types.bool;
209 default = false;
210 description = ''
211 Accept the CA's terms of service. The default provier is Let's Encrypt,
212 you can find their ToS at https://letsencrypt.org/repository/
213 '';
214 };
216 certs = mkOption {
217 default = { };
218 type = with types; attrsOf (submodule certOpts);
219 description = ''
220 Attribute set of certificates to get signed and renewed. Creates
221 <literal>acme-''${cert}.{service,timer}</literal> systemd units for
222 each certificate defined here. Other services can add dependencies
223 to those units if they rely on the certificates being present,
224 or trigger restarts of the service if certificates get renewed.
225 '';
226 example = literalExample ''
227 {
228 "example.com" = {
229 webroot = "/var/www/challenges/";
230 email = "foo@example.com";
231 extraDomains = { "www.example.com" = null; "foo.example.com" = null; };
232 };
233 "bar.example.com" = {
234 webroot = "/var/www/challenges/";
235 email = "bar@example.com";
236 };
237 }
238 '';
239 };
240 };
241 };
243 ###### implementation
244 config = mkMerge [
245 (mkIf (cfg.certs != { }) {
247 assertions = let
248 certs = (mapAttrsToList (k: v: v) cfg.certs);
249 in [
250 {
251 assertion = all (certOpts: certOpts.dnsProvider == null || certOpts.webroot == null) certs;
252 message = ''
253 Options `security.acme.certs.<name>.dnsProvider` and
254 `security.acme.certs.<name>.webroot` are mutually exclusive.
255 '';
256 }
257 {
258 assertion = cfg.email != null || all (certOpts: certOpts.email != null) certs;
259 message = ''
260 You must define `security.acme.certs.<name>.email` or
261 `security.acme.email` to register with the CA.
262 '';
263 }
264 {
265 assertion = cfg.acceptTerms;
266 message = ''
267 You must accept the CA's terms of service before using
268 the ACME module by setting `security.acme.acceptTerms`
269 to `true`. For Let's Encrypt's ToS see https://letsencrypt.org/repository/
270 '';
271 }
272 ];
274 systemd.services = let
275 services = concatLists servicesLists;
276 servicesLists = mapAttrsToList certToServices cfg.certs;
277 certToServices = cert: data:
278 let
279 # StateDirectory must be relative, and will be created under /var/lib by systemd
280 lpath = "acme/${cert}";
281 apath = "/var/lib/${lpath}";
282 spath = "/var/lib/acme/.lego/${cert}";
283 fileMode = if data.allowKeysForGroup then "640" else "600";
284 globalOpts = [ "-d" data.domain "--email" data.email "--path" "." "--key-type" data.keyType ]
285 ++ optionals (cfg.acceptTerms) [ "--accept-tos" ]
286 ++ optionals (data.dnsProvider != null && !data.dnsPropagationCheck) [ "--dns.disable-cp" ]
287 ++ concatLists (mapAttrsToList (name: root: [ "-d" name ]) data.extraDomains)
288 ++ (if data.dnsProvider != null then [ "--dns" data.dnsProvider ] else [ "--http" "--http.webroot" data.webroot ])
289 ++ optionals (cfg.server != null || data.server != null) ["--server" (if data.server == null then cfg.server else data.server)];
290 runOpts = escapeShellArgs (globalOpts ++ [ "run" ]);
291 renewOpts = escapeShellArgs (globalOpts ++ [ "renew" "--days" (toString cfg.validMinDays) ]);
292 acmeService = {
293 description = "Renew ACME Certificate for ${cert}";
294 after = [ "network.target" "network-online.target" ];
295 wants = [ "network-online.target" ];
296 wantedBy = mkIf (!config.boot.isContainer) [ "multi-user.target" ];
297 serviceConfig = {
298 Type = "oneshot";
299 User = data.user;
300 Group = data.group;
301 PrivateTmp = true;
302 StateDirectory = "acme/.lego/${cert} acme/.lego/accounts ${lpath}";
303 StateDirectoryMode = if data.allowKeysForGroup then "750" else "700";
304 WorkingDirectory = spath;
305 # Only try loading the credentialsFile if the dns challenge is enabled
306 EnvironmentFile = if data.dnsProvider != null then data.credentialsFile else null;
307 ExecStart = pkgs.writeScript "acme-start" ''
308 #!${pkgs.runtimeShell} -e
309 test -L ${spath}/accounts -o -d ${spath}/accounts || ln -s ../accounts ${spath}/accounts
310 ${pkgs.lego}/bin/lego ${renewOpts} || ${pkgs.lego}/bin/lego ${runOpts}
311 '';
312 ExecStartPost =
313 let
314 keyName = builtins.replaceStrings ["*"] ["_"] data.domain;
315 script = pkgs.writeScript "acme-post-start" ''
316 #!${pkgs.runtimeShell} -e
317 cd ${apath}
318 # Test that existing cert is older than new cert
319 KEY=${spath}/certificates/${keyName}.key
320 KEY_CHANGED=no
321 if [ -e $KEY -a $KEY -nt key.pem ]; then
322 KEY_CHANGED=yes
323 cp -p ${spath}/certificates/${keyName}.key key.pem
324 cp -p ${spath}/certificates/${keyName}.crt fullchain.pem
325 cp -p ${spath}/certificates/${keyName}.issuer.crt chain.pem
326 ln -sf fullchain.pem cert.pem
327 cat key.pem fullchain.pem > full.pem
328 fi
329 chmod ${fileMode} *.pem
330 chown '${data.user}:${data.group}' *.pem
331 if [ "$KEY_CHANGED" = "yes" ]; then
332 : # noop in case postRun is empty
333 ${data.postRun}
334 fi
335 '';
336 in
337 "+${script}";
338 };
340 };
341 selfsignedService = {
342 description = "Create preliminary self-signed certificate for ${cert}";
343 path = [ pkgs.openssl ];
344 script =
345 ''
346 workdir="$(mktemp -d)"
347 # Create CA
348 openssl genrsa -des3 -passout pass:xxxx -out $workdir/ca.pass.key 2048
349 openssl rsa -passin pass:xxxx -in $workdir/ca.pass.key -out $workdir/ca.key
350 openssl req -new -key $workdir/ca.key -out $workdir/ca.csr \
351 -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=Security Department/CN=example.com"
352 openssl x509 -req -days 1 -in $workdir/ca.csr -signkey $workdir/ca.key -out $workdir/ca.crt
353 # Create key
354 openssl genrsa -des3 -passout pass:xxxx -out $workdir/server.pass.key 2048
355 openssl rsa -passin pass:xxxx -in $workdir/server.pass.key -out $workdir/server.key
356 openssl req -new -key $workdir/server.key -out $workdir/server.csr \
357 -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=IT Department/CN=example.com"
358 openssl x509 -req -days 1 -in $workdir/server.csr -CA $workdir/ca.crt \
359 -CAkey $workdir/ca.key -CAserial $workdir/ca.srl -CAcreateserial \
360 -out $workdir/server.crt
361 # Copy key to destination
362 cp $workdir/server.key ${apath}/key.pem
363 # Create fullchain.pem (same format as "simp_le ... -f fullchain.pem" creates)
364 cat $workdir/{server.crt,ca.crt} > "${apath}/fullchain.pem"
365 # Create full.pem for e.g. lighttpd
366 cat $workdir/{server.key,server.crt,ca.crt} > "${apath}/full.pem"
367 # Give key acme permissions
368 chown '${data.user}:${data.group}' "${apath}/"{key,fullchain,full}.pem
369 chmod ${fileMode} "${apath}/"{key,fullchain,full}.pem
370 '';
371 serviceConfig = {
372 Type = "oneshot";
373 PrivateTmp = true;
374 StateDirectory = lpath;
375 User = data.user;
376 Group = data.group;
377 };
378 unitConfig = {
379 # Do not create self-signed key when key already exists
380 ConditionPathExists = "!${apath}/key.pem";
381 };
382 };
383 in (
384 [ { name = "acme-${cert}"; value = acmeService; } ]
385 ++ optional cfg.preliminarySelfsigned { name = "acme-selfsigned-${cert}"; value = selfsignedService; }
386 );
387 servicesAttr = listToAttrs services;
388 in
389 servicesAttr;
391 systemd.tmpfiles.rules =
392 map (data: "d ${data.webroot}/.well-known/acme-challenge - ${data.user} ${data.group}") (filter (data: data.webroot != null) (attrValues cfg.certs));
394 systemd.timers = let
395 # Allow systemd to pick a convenient time within the day
396 # to run the check.
397 # This allows the coalescing of multiple timer jobs.
398 # We divide by the number of certificates so that if you
399 # have many certificates, the renewals are distributed over
400 # the course of the day to avoid rate limits.
401 numCerts = length (attrNames cfg.certs);
402 _24hSecs = 60 * 60 * 24;
403 AccuracySec = "${toString (_24hSecs / numCerts)}s";
404 in flip mapAttrs' cfg.certs (cert: data: nameValuePair
405 ("acme-${cert}")
406 ({
407 description = "Renew ACME Certificate for ${cert}";
408 wantedBy = [ "timers.target" ];
409 timerConfig = {
410 OnCalendar = cfg.renewInterval;
411 Unit = "acme-${cert}.service";
412 Persistent = "yes";
413 inherit AccuracySec;
414 # Skew randomly within the day, per https://letsencrypt.org/docs/integration-guide/.
415 RandomizedDelaySec = "24h";
416 };
417 })
418 );
420 systemd.targets.acme-selfsigned-certificates = mkIf cfg.preliminarySelfsigned {};
421 systemd.targets.acme-certificates = {};
422 })
424 ];
426 meta = {
427 maintainers = with lib.maintainers; [ abbradar fpletz globin m1cr0man ];
428 # doc = ./acme.xml; # MODIFIED
429 };