]> git.immae.eu Git - perso/Immae/Config/Nix/NUR.git/blob - modules/acme2.nix
Make acme-challenge writable
[perso/Immae/Config/Nix/NUR.git] / modules / acme2.nix
1 { config, lib, pkgs, ... }:
2
3 with lib;
4
5 let
6
7 cfg = config.security.acme2;
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 server = mkOption {
24 type = types.nullOr types.str;
25 default = null;
26 description = ''
27 ACME Directory Resource URI. Defaults to let's encrypt
28 production endpoint,
29 https://acme-v02.api.letsencrypt.org/directory, if unset.
30 '';
31 };
32
33 domain = mkOption {
34 type = types.str;
35 default = name;
36 description = "Domain to fetch certificate for (defaults to the entry name)";
37 };
38
39 email = mkOption {
40 type = types.nullOr types.str;
41 default = null;
42 description = "Contact email address for the CA to be able to reach you.";
43 };
44
45 user = mkOption {
46 type = types.str;
47 default = "root";
48 description = "User running the ACME client.";
49 };
50
51 group = mkOption {
52 type = types.str;
53 default = "root";
54 description = "Group running the ACME client.";
55 };
56
57 allowKeysForGroup = mkOption {
58 type = types.bool;
59 default = false;
60 description = ''
61 Give read permissions to the specified group
62 (<option>security.acme2.cert.&lt;name&gt;.group</option>) to read SSL private certificates.
63 '';
64 };
65
66 postRun = mkOption {
67 type = types.lines;
68 default = "";
69 example = "systemctl reload nginx.service";
70 description = ''
71 Commands to run after new certificates go live. Typically
72 the web server and other servers using certificates need to
73 be reloaded.
74
75 Executed in the same directory with the new certificate.
76 '';
77 };
78
79 plugins = mkOption {
80 type = types.listOf (types.enum [
81 "cert.der" "cert.pem" "chain.pem" "external.sh"
82 "fullchain.pem" "full.pem" "key.der" "key.pem" "account_key.json" "account_reg.json"
83 ]);
84 default = [ "fullchain.pem" "full.pem" "key.pem" "account_key.json" "account_reg.json" ];
85 description = ''
86 Plugins to enable. With default settings simp_le will
87 store public certificate bundle in <filename>fullchain.pem</filename>,
88 private key in <filename>key.pem</filename> and those two previous
89 files combined in <filename>full.pem</filename> in its state directory.
90 '';
91 };
92
93 directory = mkOption {
94 type = types.str;
95 readOnly = true;
96 default = "/var/lib/acme/${name}";
97 description = "Directory where certificate and other state is stored.";
98 };
99
100 extraDomains = mkOption {
101 type = types.attrsOf (types.nullOr types.str);
102 default = {};
103 example = literalExample ''
104 {
105 "example.org" = "/srv/http/nginx";
106 "mydomain.org" = null;
107 }
108 '';
109 description = ''
110 A list of extra domain names, which are included in the one certificate to be issued, with their
111 own server roots if needed.
112 '';
113 };
114 };
115 };
116
117 in
118
119 {
120
121 ###### interface
122 imports = [
123 (mkRemovedOptionModule [ "security" "acme2" "production" ] ''
124 Use security.acme2.server to define your staging ACME server URL instead.
125
126 To use the let's encrypt staging server, use security.acme2.server =
127 "https://acme-staging-v02.api.letsencrypt.org/directory".
128 ''
129 )
130 (mkRemovedOptionModule [ "security" "acme2" "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.")
131 (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")
132 (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")
133 ];
134 options = {
135 security.acme2 = {
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 server = mkOption {
154 type = types.nullOr types.str;
155 default = null;
156 description = ''
157 ACME Directory Resource URI. Defaults to let's encrypt
158 production endpoint,
159 <literal>https://acme-v02.api.letsencrypt.org/directory</literal>, if unset.
160 '';
161 };
162
163 preliminarySelfsigned = mkOption {
164 type = types.bool;
165 default = true;
166 description = ''
167 Whether a preliminary self-signed certificate should be generated before
168 doing ACME requests. This can be useful when certificates are required in
169 a webserver, but ACME needs the webserver to make its requests.
170
171 With preliminary self-signed certificate the webserver can be started and
172 can later reload the correct ACME certificates.
173 '';
174 };
175
176 certs = mkOption {
177 default = { };
178 type = with types; attrsOf (submodule certOpts);
179 description = ''
180 Attribute set of certificates to get signed and renewed. Creates
181 <literal>acme-''${cert}.{service,timer}</literal> systemd units for
182 each certificate defined here. Other services can add dependencies
183 to those units if they rely on the certificates being present,
184 or trigger restarts of the service if certificates get 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 lpath = "acme/${cert}";
213 rights = if data.allowKeysForGroup then "750" else "700";
214 cmdline = [ "-v" "-d" data.domain "--default_root" data.webroot "--valid_min" cfg.validMin ]
215 ++ optionals (data.email != null) [ "--email" data.email ]
216 ++ concatMap (p: [ "-f" p ]) data.plugins
217 ++ concatLists (mapAttrsToList (name: root: [ "-d" (if root == null then name else "${name}:${root}")]) data.extraDomains)
218 ++ optionals (cfg.server != null || data.server != null) ["--server" (if data.server == null then cfg.server else data.server)];
219 acmeService = {
220 description = "Renew ACME Certificate for ${cert}";
221 after = [ "network.target" "network-online.target" ];
222 wants = [ "network-online.target" ];
223 # simp_le uses requests, which uses certifi under the hood,
224 # which doesn't respect the system trust store.
225 # At least in the acme test, we provision a fake CA, impersonating the LE endpoint.
226 # REQUESTS_CA_BUNDLE is a way to teach python requests to use something else
227 environment.REQUESTS_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
228 serviceConfig = {
229 Type = "oneshot";
230 # With RemainAfterExit the service is considered active even
231 # after the main process having exited, which means when it
232 # gets changed, the activation phase restarts it, meaning
233 # the permissions of the StateDirectory get adjusted
234 # according to the specified group
235 RemainAfterExit = true;
236 SuccessExitStatus = [ "0" "1" ];
237 User = data.user;
238 Group = data.group;
239 PrivateTmp = true;
240 StateDirectory = lpath;
241 StateDirectoryMode = rights;
242 ExecStartPre =
243 let
244 script = pkgs.writeScript "acme-pre-start" ''
245 #!${pkgs.runtimeShell} -e
246 mkdir -p '${data.webroot}/.well-known/acme-challenge'
247 chmod a+w '${data.webroot}/.well-known/acme-challenge'
248 #doesn't work for multiple concurrent runs
249 #chown -R '${data.user}:${data.group}' '${data.webroot}/.well-known/acme-challenge'
250 '';
251 in
252 "+${script}";
253 WorkingDirectory = "/var/lib/${lpath}";
254 ExecStart = "${pkgs.simp_le_0_17}/bin/simp_le ${escapeShellArgs cmdline}";
255 ExecStartPost =
256 let
257 script = pkgs.writeScript "acme-post-start" ''
258 #!${pkgs.runtimeShell} -e
259 ${data.postRun}
260 '';
261 in
262 "+${script}";
263 };
264
265 };
266 selfsignedService = {
267 description = "Create preliminary self-signed certificate for ${cert}";
268 path = [ pkgs.openssl ];
269 script =
270 ''
271 workdir="$(mktemp -d)"
272
273 # Create CA
274 openssl genrsa -des3 -passout pass:xxxx -out $workdir/ca.pass.key 2048
275 openssl rsa -passin pass:xxxx -in $workdir/ca.pass.key -out $workdir/ca.key
276 openssl req -new -key $workdir/ca.key -out $workdir/ca.csr \
277 -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=Security Department/CN=example.com"
278 openssl x509 -req -days 1 -in $workdir/ca.csr -signkey $workdir/ca.key -out $workdir/ca.crt
279
280 # Create key
281 openssl genrsa -des3 -passout pass:xxxx -out $workdir/server.pass.key 2048
282 openssl rsa -passin pass:xxxx -in $workdir/server.pass.key -out $workdir/server.key
283 openssl req -new -key $workdir/server.key -out $workdir/server.csr \
284 -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=IT Department/CN=example.com"
285 openssl x509 -req -days 1 -in $workdir/server.csr -CA $workdir/ca.crt \
286 -CAkey $workdir/ca.key -CAserial $workdir/ca.srl -CAcreateserial \
287 -out $workdir/server.crt
288
289 # Copy key to destination
290 cp $workdir/server.key /var/lib/${lpath}/key.pem
291
292 # Create fullchain.pem (same format as "simp_le ... -f fullchain.pem" creates)
293 cat $workdir/{server.crt,ca.crt} > "/var/lib/${lpath}/fullchain.pem"
294
295 # Create full.pem for e.g. lighttpd
296 cat $workdir/{server.key,server.crt,ca.crt} > "/var/lib/${lpath}/full.pem"
297
298 # Give key acme permissions
299 chown '${data.user}:${data.group}' "/var/lib/${lpath}/"{key,fullchain,full}.pem
300 chmod ${rights} "/var/lib/${lpath}/"{key,fullchain,full}.pem
301 '';
302 serviceConfig = {
303 Type = "oneshot";
304 PrivateTmp = true;
305 StateDirectory = lpath;
306 User = data.user;
307 Group = data.group;
308 };
309 unitConfig = {
310 # Do not create self-signed key when key already exists
311 ConditionPathExists = "!/var/lib/${lpath}/key.pem";
312 };
313 };
314 in (
315 [ { name = "acme-${cert}"; value = acmeService; } ]
316 ++ optional cfg.preliminarySelfsigned { name = "acme-selfsigned-${cert}"; value = selfsignedService; }
317 );
318 servicesAttr = listToAttrs services;
319 in
320 servicesAttr;
321
322 # FIXME: this doesn't work for multiple users
323 systemd.tmpfiles.rules =
324 flip mapAttrsToList cfg.certs
325 (cert: data: "d ${data.webroot}/.well-known/acme-challenge - ${data.user} ${data.group}");
326
327 systemd.timers = flip mapAttrs' cfg.certs (cert: data: nameValuePair
328 ("acme-${cert}")
329 ({
330 description = "Renew ACME Certificate for ${cert}";
331 wantedBy = [ "timers.target" ];
332 timerConfig = {
333 OnCalendar = cfg.renewInterval;
334 Unit = "acme-${cert}.service";
335 Persistent = "yes";
336 AccuracySec = "5m";
337 RandomizedDelaySec = "1h";
338 };
339 })
340 );
341
342 systemd.targets.acme-selfsigned-certificates = mkIf cfg.preliminarySelfsigned {};
343 systemd.targets.acme-certificates = {};
344 })
345
346 ];
347
348 meta = {
349 maintainers = with lib.maintainers; [ abbradar fpletz globin ];
350 #doc = ./acme.xml;
351 };
352 }