]>
Commit | Line | Data |
---|---|---|
981fa803 IB |
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.<name>.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 | WorkingDirectory = "/var/lib/${lpath}"; | |
243 | ExecStart = "${pkgs.simp_le_0_17}/bin/simp_le ${escapeShellArgs cmdline}"; | |
244 | ExecStartPost = | |
245 | let | |
246 | script = pkgs.writeScript "acme-post-start" '' | |
247 | #!${pkgs.runtimeShell} -e | |
248 | ${data.postRun} | |
249 | ''; | |
250 | in | |
251 | "+${script}"; | |
252 | }; | |
253 | ||
254 | }; | |
255 | selfsignedService = { | |
256 | description = "Create preliminary self-signed certificate for ${cert}"; | |
257 | path = [ pkgs.openssl ]; | |
258 | script = | |
259 | '' | |
260 | workdir="$(mktemp -d)" | |
261 | ||
262 | # Create CA | |
263 | openssl genrsa -des3 -passout pass:xxxx -out $workdir/ca.pass.key 2048 | |
264 | openssl rsa -passin pass:xxxx -in $workdir/ca.pass.key -out $workdir/ca.key | |
265 | openssl req -new -key $workdir/ca.key -out $workdir/ca.csr \ | |
266 | -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=Security Department/CN=example.com" | |
267 | openssl x509 -req -days 1 -in $workdir/ca.csr -signkey $workdir/ca.key -out $workdir/ca.crt | |
268 | ||
269 | # Create key | |
270 | openssl genrsa -des3 -passout pass:xxxx -out $workdir/server.pass.key 2048 | |
271 | openssl rsa -passin pass:xxxx -in $workdir/server.pass.key -out $workdir/server.key | |
272 | openssl req -new -key $workdir/server.key -out $workdir/server.csr \ | |
273 | -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=IT Department/CN=example.com" | |
274 | openssl x509 -req -days 1 -in $workdir/server.csr -CA $workdir/ca.crt \ | |
275 | -CAkey $workdir/ca.key -CAserial $workdir/ca.srl -CAcreateserial \ | |
276 | -out $workdir/server.crt | |
277 | ||
278 | # Copy key to destination | |
279 | cp $workdir/server.key /var/lib/${lpath}/key.pem | |
280 | ||
281 | # Create fullchain.pem (same format as "simp_le ... -f fullchain.pem" creates) | |
282 | cat $workdir/{server.crt,ca.crt} > "/var/lib/${lpath}/fullchain.pem" | |
283 | ||
284 | # Create full.pem for e.g. lighttpd | |
285 | cat $workdir/{server.key,server.crt,ca.crt} > "/var/lib/${lpath}/full.pem" | |
286 | ||
287 | # Give key acme permissions | |
288 | chown '${data.user}:${data.group}' "/var/lib/${lpath}/"{key,fullchain,full}.pem | |
289 | chmod ${rights} "/var/lib/${lpath}/"{key,fullchain,full}.pem | |
290 | ''; | |
291 | serviceConfig = { | |
292 | Type = "oneshot"; | |
293 | PrivateTmp = true; | |
294 | StateDirectory = lpath; | |
295 | User = data.user; | |
296 | Group = data.group; | |
297 | }; | |
298 | unitConfig = { | |
299 | # Do not create self-signed key when key already exists | |
300 | ConditionPathExists = "!/var/lib/${lpath}/key.pem"; | |
301 | }; | |
302 | }; | |
303 | in ( | |
304 | [ { name = "acme-${cert}"; value = acmeService; } ] | |
305 | ++ optional cfg.preliminarySelfsigned { name = "acme-selfsigned-${cert}"; value = selfsignedService; } | |
306 | ); | |
307 | servicesAttr = listToAttrs services; | |
308 | in | |
309 | servicesAttr; | |
310 | ||
311 | systemd.tmpfiles.rules = | |
312 | flip mapAttrsToList cfg.certs | |
313 | (cert: data: "d ${data.webroot}/.well-known/acme-challenge - ${data.user} ${data.group}"); | |
314 | ||
315 | systemd.timers = flip mapAttrs' cfg.certs (cert: data: nameValuePair | |
316 | ("acme-${cert}") | |
317 | ({ | |
318 | description = "Renew ACME Certificate for ${cert}"; | |
319 | wantedBy = [ "timers.target" ]; | |
320 | timerConfig = { | |
321 | OnCalendar = cfg.renewInterval; | |
322 | Unit = "acme-${cert}.service"; | |
323 | Persistent = "yes"; | |
324 | AccuracySec = "5m"; | |
325 | RandomizedDelaySec = "1h"; | |
326 | }; | |
327 | }) | |
328 | ); | |
329 | ||
330 | systemd.targets.acme-selfsigned-certificates = mkIf cfg.preliminarySelfsigned {}; | |
331 | systemd.targets.acme-certificates = {}; | |
332 | }) | |
333 | ||
334 | ]; | |
335 | ||
336 | meta = { | |
337 | maintainers = with lib.maintainers; [ abbradar fpletz globin ]; | |
338 | #doc = ./acme.xml; | |
339 | }; | |
340 | } |