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 certificates are re-issued. Typically
62 the web server and other servers using certificates need to
63 be reloaded.
64 '';
65 };
66
67 plugins = mkOption {
68 type = types.listOf (types.enum [
69 "cert.der" "cert.pem" "chain.pem" "external.sh"
70 "fullchain.pem" "full.pem" "key.der" "key.pem" "account_key.json"
71 ]);
72 default = [ "fullchain.pem" "full.pem" "key.pem" "account_key.json" ];
73 description = ''
74 Plugins to enable. With default settings simp_le will
75 store public certificate bundle in <filename>fullchain.pem</filename>,
76 private key in <filename>key.pem</filename> and those two previous
77 files combined in <filename>full.pem</filename> in its state directory.
78 '';
79 };
80
81 extraDomains = mkOption {
82 type = types.attrsOf (types.nullOr types.str);
83 default = {};
84 example = literalExample ''
85 {
86 "example.org" = "/srv/http/nginx";
87 "mydomain.org" = null;
88 }
89 '';
90 description = ''
91 A list of extra domain names, which are included in the one certificate to be issued, with their
92 own server roots if needed.
93 '';
94 };
95 };
96 };
97
98in
99
100{
101
102 ###### interface
103
104 options = {
105 security.acme = {
106 directory = mkOption {
107 default = "/var/lib/acme";
108 type = types.str;
109 description = ''
110 Directory where certs and other state will be stored by default.
111 '';
112 };
113
114 validMin = mkOption {
115 type = types.int;
116 default = 30 * 24 * 3600;
117 description = "Minimum remaining validity before renewal in seconds.";
118 };
119
120 renewInterval = mkOption {
121 type = types.str;
122 default = "weekly";
123 description = ''
124 Systemd calendar expression when to check for renewal. See
125 <citerefentry><refentrytitle>systemd.time</refentrytitle>
126 <manvolnum>7</manvolnum></citerefentry>.
127 '';
128 };
129
130 preliminarySelfsigned = mkOption {
131 type = types.bool;
132 default = true;
133 description = ''
134 Whether a preliminary self-signed certificate should be generated before
135 doing ACME requests. This can be useful when certificates are required in
136 a webserver, but ACME needs the webserver to make its requests.
137
138 With preliminary self-signed certificate the webserver can be started and
139 can later reload the correct ACME certificates.
140 '';
141 };
142
143 tosHash = mkOption {
144 type = types.string;
145 default = "cc88d8d9517f490191401e7b54e9ffd12a2b9082ec7a1d4cec6101f9f1647e7b";
146 description = ''
147 SHA256 of the Terms of Services document. This changes once in a while.
148 '';
149 };
150
151 production = mkOption {
152 type = types.bool;
153 default = true;
154 description = ''
155 If set to true, use Let's Encrypt's production environment
156 instead of the staging environment. The main benefit of the
157 staging environment is to get much higher rate limits.
158
159 See
160 <literal>https://letsencrypt.org/docs/staging-environment</literal>
161 for more detail.
162 '';
163 };
164
165 certs = mkOption {
166 default = { };
167 type = with types; attrsOf (submodule certOpts);
168 description = ''
169 Attribute set of certificates to get signed and renewed.
170 '';
171 example = literalExample ''
172 {
173 "example.com" = {
174 webroot = "/var/www/challenges/";
175 email = "foo@example.com";
176 extraDomains = { "www.example.com" = null; "foo.example.com" = "/var/www/foo/"; };
177 };
178 "bar.example.com" = {
179 webroot = "/var/www/challenges/";
180 email = "bar@example.com";
181 };
182 }
183 '';
184 };
185 };
186 };
187
188 ###### implementation
189 config = mkMerge [
190 (mkIf (cfg.certs != { }) {
191
192 systemd.services = let
193 services = concatLists servicesLists;
194 servicesLists = mapAttrsToList certToServices cfg.certs;
195 certToServices = cert: data:
196 let
197 cpath = "${cfg.directory}/${cert}";
198 rights = if data.allowKeysForGroup then "750" else "700";
199 cmdline = [ "-v" "-d" data.domain "--default_root" data.webroot "--valid_min" cfg.validMin "--tos_sha256" cfg.tosHash ]
200 ++ optionals (data.email != null) [ "--email" data.email ]
201 ++ concatMap (p: [ "-f" p ]) data.plugins
202 ++ concatLists (mapAttrsToList (name: root: [ "-d" (if root == null then name else "${name}:${root}")]) data.extraDomains)
203 ++ (if cfg.production then []
204 else ["--server" "https://acme-staging.api.letsencrypt.org/directory"]);
205 acmeService = {
206 description = "Renew ACME Certificate for ${cert}";
207 after = [ "network.target" "network-online.target" ];
208 wants = [ "network-online.target" ];
209 serviceConfig = {
210 Type = "oneshot";
211 SuccessExitStatus = [ "0" "1" ];
212 PermissionsStartOnly = true;
213 User = data.user;
214 Group = data.group;
215 PrivateTmp = true;
216 };
217 path = [ pkgs.simp_le ];
218 preStart = ''
219 mkdir -p '${cfg.directory}'
220 chown 'root:root' '${cfg.directory}'
221 chmod 755 '${cfg.directory}'
222 if [ ! -d '${cpath}' ]; then
223 mkdir '${cpath}'
224 fi
225 chmod ${rights} '${cpath}'
226 chown -R '${data.user}:${data.group}' '${cpath}'
227 mkdir -p '${data.webroot}/.well-known/acme-challenge'
228 chown -R '${data.user}:${data.group}' '${data.webroot}/.well-known/acme-challenge'
229 '';
230 script = ''
231 cd '${cpath}'
232 set +e
233 simp_le ${escapeShellArgs cmdline}
234 EXITCODE=$?
235 set -e
236 echo "$EXITCODE" > /tmp/lastExitCode
237 exit "$EXITCODE"
238 '';
239 postStop = ''
240 if [ -e /tmp/lastExitCode ] && [ "$(cat /tmp/lastExitCode)" = "0" ]; then
241 echo "Executing postRun hook..."
242 ${data.postRun}
243 fi
244 '';
245
246 before = [ "acme-certificates.target" ];
247 wantedBy = [ "acme-certificates.target" ];
248 };
249 selfsignedService = {
250 description = "Create preliminary self-signed certificate for ${cert}";
251 preStart = ''
252 if [ ! -d '${cpath}' ]
253 then
254 mkdir -p '${cpath}'
255 chmod ${rights} '${cpath}'
256 chown '${data.user}:${data.group}' '${cpath}'
257 fi
258 '';
259 script =
260 ''
261 # Create self-signed key
262 workdir="/run/acme-selfsigned-${cert}"
263 ${pkgs.openssl.bin}/bin/openssl genrsa -des3 -passout pass:x -out $workdir/server.pass.key 2048
264 ${pkgs.openssl.bin}/bin/openssl rsa -passin pass:x -in $workdir/server.pass.key -out $workdir/server.key
265 ${pkgs.openssl.bin}/bin/openssl req -new -key $workdir/server.key -out $workdir/server.csr \
266 -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=IT Department/CN=example.com"
267 ${pkgs.openssl.bin}/bin/openssl x509 -req -days 1 -in $workdir/server.csr -signkey $workdir/server.key -out $workdir/server.crt
268
269 # Move key to destination
270 mv $workdir/server.key ${cpath}/key.pem
271 mv $workdir/server.crt ${cpath}/fullchain.pem
272
273 # Create full.pem for e.g. lighttpd (same format as "simp_le ... -f full.pem" creates)
274 cat "${cpath}/key.pem" "${cpath}/fullchain.pem" > "${cpath}/full.pem"
275
276 # Clean up working directory
277 rm $workdir/server.csr
278 rm $workdir/server.pass.key
279
280 # Give key acme permissions
281 chmod ${rights} '${cpath}/key.pem'
282 chown '${data.user}:${data.group}' '${cpath}/key.pem'
283 chmod ${rights} '${cpath}/fullchain.pem'
284 chown '${data.user}:${data.group}' '${cpath}/fullchain.pem'
285 chmod ${rights} '${cpath}/full.pem'
286 chown '${data.user}:${data.group}' '${cpath}/full.pem'
287 '';
288 serviceConfig = {
289 Type = "oneshot";
290 RuntimeDirectory = "acme-selfsigned-${cert}";
291 PermissionsStartOnly = true;
292 User = data.user;
293 Group = data.group;
294 };
295 unitConfig = {
296 # Do not create self-signed key when key already exists
297 ConditionPathExists = "!${cpath}/key.pem";
298 };
299 before = [
300 "acme-selfsigned-certificates.target"
301 ];
302 wantedBy = [
303 "acme-selfsigned-certificates.target"
304 ];
305 };
306 in (
307 [ { name = "acme-${cert}"; value = acmeService; } ]
308 ++
309 (if cfg.preliminarySelfsigned
310 then [ { name = "acme-selfsigned-${cert}"; value = selfsignedService; } ]
311 else []
312 )
313 );
314 servicesAttr = listToAttrs services;
315 injectServiceDep = {
316 after = [ "acme-selfsigned-certificates.target" ];
317 wants = [ "acme-selfsigned-certificates.target" "acme-certificates.target" ];
318 };
319 in
320 servicesAttr //
321 (if config.services.nginx.enable then { nginx = injectServiceDep; } else {}) //
322 (if config.services.lighttpd.enable then { lighttpd = injectServiceDep; } else {});
323
324 systemd.timers = flip mapAttrs' cfg.certs (cert: data: nameValuePair
325 ("acme-${cert}")
326 ({
327 description = "Renew ACME Certificate for ${cert}";
328 wantedBy = [ "timers.target" ];
329 timerConfig = {
330 OnCalendar = cfg.renewInterval;
331 Unit = "acme-${cert}.service";
332 Persistent = "yes";
333 AccuracySec = "5m";
334 RandomizedDelaySec = "1h";
335 };
336 })
337 );
338
339 systemd.targets."acme-selfsigned-certificates" = mkIf cfg.preliminarySelfsigned {};
340 systemd.targets."acme-certificates" = {};
341 })
342
343 ];
344
345 meta = {
346 maintainers = with lib.maintainers; [ abbradar fpletz globin ];
347 doc = ./acme.xml;
348 };
349}