diff options
author | Ismaël Bouya <ismael.bouya@normalesup.org> | 2020-01-15 20:41:19 +0100 |
---|---|---|
committer | Ismaël Bouya <ismael.bouya@normalesup.org> | 2020-04-25 00:04:41 +0200 |
commit | 0540384561541f94435ad0f6e268e6989fb1d37a (patch) | |
tree | 7e8c8b06f6039073dd2d2e648d11b3dba77e63df /modules | |
parent | 13cf2ab3521b5abeea0ee723d4657d667c666f32 (diff) | |
download | NUR-0540384561541f94435ad0f6e268e6989fb1d37a.tar.gz NUR-0540384561541f94435ad0f6e268e6989fb1d37a.tar.zst NUR-0540384561541f94435ad0f6e268e6989fb1d37a.zip |
Upgrade acme bot
Diffstat (limited to 'modules')
-rw-r--r-- | modules/acme2.nix | 340 | ||||
-rw-r--r-- | modules/default.nix | 1 | ||||
-rw-r--r-- | modules/websites/default.nix | 10 |
3 files changed, 346 insertions, 5 deletions
diff --git a/modules/acme2.nix b/modules/acme2.nix new file mode 100644 index 00000000..408c098e --- /dev/null +++ b/modules/acme2.nix | |||
@@ -0,0 +1,340 @@ | |||
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 | } | ||
diff --git a/modules/default.nix b/modules/default.nix index 9ff6ea62..98dc77d8 100644 --- a/modules/default.nix +++ b/modules/default.nix | |||
@@ -19,4 +19,5 @@ | |||
19 | 19 | ||
20 | php-application = ./websites/php-application.nix; | 20 | php-application = ./websites/php-application.nix; |
21 | websites = ./websites; | 21 | websites = ./websites; |
22 | acme2 = ./acme2.nix; | ||
22 | } // (if builtins.pathExists ./private then import ./private else {}) | 23 | } // (if builtins.pathExists ./private then import ./private else {}) |
diff --git a/modules/websites/default.nix b/modules/websites/default.nix index 6ba0d687..e69080e9 100644 --- a/modules/websites/default.nix +++ b/modules/websites/default.nix | |||
@@ -149,7 +149,7 @@ in | |||
149 | serverAliases = [ "*" ]; | 149 | serverAliases = [ "*" ]; |
150 | enableSSL = false; | 150 | enableSSL = false; |
151 | logFormat = "combinedVhost"; | 151 | logFormat = "combinedVhost"; |
152 | documentRoot = "${config.security.acme.directory}/acme-challenge"; | 152 | documentRoot = "/var/lib/acme/acme-challenge"; |
153 | extraConfig = '' | 153 | extraConfig = '' |
154 | RewriteEngine on | 154 | RewriteEngine on |
155 | RewriteCond "%{REQUEST_URI}" "!^/\.well-known" | 155 | RewriteCond "%{REQUEST_URI}" "!^/\.well-known" |
@@ -178,9 +178,9 @@ in | |||
178 | }; | 178 | }; |
179 | toVhost = ips: vhostConf: { | 179 | toVhost = ips: vhostConf: { |
180 | enableSSL = true; | 180 | enableSSL = true; |
181 | sslServerCert = "${config.security.acme.directory}/${vhostConf.certName}/cert.pem"; | 181 | sslServerCert = "${config.security.acme2.certs."${vhostConf.certName}".directory}/cert.pem"; |
182 | sslServerKey = "${config.security.acme.directory}/${vhostConf.certName}/key.pem"; | 182 | sslServerKey = "${config.security.acme2.certs."${vhostConf.certName}".directory}/key.pem"; |
183 | sslServerChain = "${config.security.acme.directory}/${vhostConf.certName}/chain.pem"; | 183 | sslServerChain = "${config.security.acme2.certs."${vhostConf.certName}".directory}/chain.pem"; |
184 | logFormat = "combinedVhost"; | 184 | logFormat = "combinedVhost"; |
185 | listen = map (ip: { inherit ip; port = 443; }) ips; | 185 | listen = map (ip: { inherit ip; port = 443; }) ips; |
186 | hostName = builtins.head vhostConf.hosts; | 186 | hostName = builtins.head vhostConf.hosts; |
@@ -223,7 +223,7 @@ in | |||
223 | } | 223 | } |
224 | ) cfg.env; | 224 | ) cfg.env; |
225 | 225 | ||
226 | config.security.acme.certs = let | 226 | config.security.acme2.certs = let |
227 | typesToManage = attrsets.filterAttrs (k: v: v.enable) cfg.env; | 227 | typesToManage = attrsets.filterAttrs (k: v: v.enable) cfg.env; |
228 | flatVhosts = lists.flatten (attrsets.mapAttrsToList (k: v: | 228 | flatVhosts = lists.flatten (attrsets.mapAttrsToList (k: v: |
229 | attrValues v.vhostConfs | 229 | attrValues v.vhostConfs |