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