1# Fully pluggable module to have Letsencrypt's Boulder ACME service running in
2# a test environment.
3#
4# The certificate for the ACME service is exported as:
5#
6# config.test-support.letsencrypt.caCert
7#
8# This value can be used inside the configuration of other test nodes to inject
9# the snakeoil certificate into security.pki.certificateFiles or into package
10# overlays.
11#
12# Another value that's needed if you don't use a custom resolver (see below for
13# notes on that) is to add the letsencrypt node as a nameserver to every node
14# that needs to acquire certificates using ACME, because otherwise the API host
15# for letsencrypt.org can't be resolved.
16#
17# A configuration example of a full node setup using this would be this:
18#
19# {
20# letsencrypt = import ./common/letsencrypt;
21#
22# example = { nodes, ... }: {
23# networking.nameservers = [
24# nodes.letsencrypt.config.networking.primaryIPAddress
25# ];
26# security.pki.certificateFiles = [
27# nodes.letsencrypt.config.test-support.letsencrypt.caCert
28# ];
29# };
30# }
31#
32# By default, this module runs a local resolver, generated using resolver.nix
33# from the parent directory to automatically discover all zones in the network.
34#
35# If you do not want this and want to use your own resolver, you can just
36# override networking.nameservers like this:
37#
38# {
39# letsencrypt = { nodes, ... }: {
40# imports = [ ./common/letsencrypt ];
41# networking.nameservers = [
42# nodes.myresolver.config.networking.primaryIPAddress
43# ];
44# };
45#
46# myresolver = ...;
47# }
48#
49# Keep in mind, that currently only _one_ resolver is supported, if you have
50# more than one resolver in networking.nameservers only the first one will be
51# used.
52#
53# Also make sure that whenever you use a resolver from a different test node
54# that it has to be started _before_ the ACME service.
55{ config, pkgs, lib, ... }:
56
57let
58 softhsm = pkgs.stdenv.mkDerivation rec {
59 name = "softhsm-${version}";
60 version = "1.3.8";
61
62 src = pkgs.fetchurl {
63 url = "https://dist.opendnssec.org/source/${name}.tar.gz";
64 sha256 = "0flmnpkgp65ym7w3qyg78d3fbmvq3aznmi66rgd420n33shf7aif";
65 };
66
67 configureFlags = [ "--with-botan=${pkgs.botan}" ];
68 buildInputs = [ pkgs.sqlite ];
69 };
70
71 pkcs11-proxy = pkgs.stdenv.mkDerivation {
72 name = "pkcs11-proxy";
73
74 src = pkgs.fetchFromGitHub {
75 owner = "SUNET";
76 repo = "pkcs11-proxy";
77 rev = "944684f78bca0c8da6cabe3fa273fed3db44a890";
78 sha256 = "1nxgd29y9wmifm11pjcdpd2y293p0dgi0x5ycis55miy97n0f5zy";
79 };
80
81 postPatch = "patchShebangs mksyscalls.sh";
82
83 nativeBuildInputs = [ pkgs.cmake ];
84 buildInputs = [ pkgs.openssl pkgs.libseccomp ];
85 };
86
87 mkGoDep = { goPackagePath, url ? "https://${goPackagePath}", rev, sha256 }: {
88 inherit goPackagePath;
89 src = pkgs.fetchgit { inherit url rev sha256; };
90 };
91
92 goose = let
93 owner = "liamstask";
94 repo = "goose";
95 rev = "8488cc47d90c8a502b1c41a462a6d9cc8ee0a895";
96 version = "20150116";
97
98 in pkgs.buildGoPackage rec {
99 name = "${repo}-${version}";
100
101 src = pkgs.fetchFromBitbucket {
102 name = "${name}-src";
103 inherit rev owner repo;
104 sha256 = "1jy0pscxjnxjdg3hj111w21g8079rq9ah2ix5ycxxhbbi3f0wdhs";
105 };
106
107 goPackagePath = "bitbucket.org/${owner}/${repo}";
108 subPackages = [ "cmd/goose" ];
109 extraSrcs = map mkGoDep [
110 { goPackagePath = "github.com/go-sql-driver/mysql";
111 rev = "2e00b5cd70399450106cec6431c2e2ce3cae5034";
112 sha256 = "085g48jq9hzmlcxg122n0c4pi41sc1nn2qpx1vrl2jfa8crsppa5";
113 }
114 { goPackagePath = "github.com/kylelemons/go-gypsy";
115 rev = "08cad365cd28a7fba23bb1e57aa43c5e18ad8bb8";
116 sha256 = "1djv7nii3hy451n5jlslk0dblqzb1hia1cbqpdwhnps1g8hqjy8q";
117 }
118 { goPackagePath = "github.com/lib/pq";
119 rev = "ba5d4f7a35561e22fbdf7a39aa0070f4d460cfc0";
120 sha256 = "1mfbqw9g00bk24bfmf53wri5c2wqmgl0qh4sh1qv2da13a7cwwg3";
121 }
122 { goPackagePath = "github.com/mattn/go-sqlite3";
123 rev = "2acfafad5870400156f6fceb12852c281cbba4d5";
124 sha256 = "1rpgil3w4hh1cibidskv1js898hwz83ps06gh0hm3mym7ki8d5h7";
125 }
126 { goPackagePath = "github.com/ziutek/mymysql";
127 rev = "0582bcf675f52c0c2045c027fd135bd726048f45";
128 sha256 = "0bkc9x8sgqbzgdimsmsnhb0qrzlzfv33fgajmmjxl4hcb21qz3rf";
129 }
130 { goPackagePath = "golang.org/x/net";
131 url = "https://go.googlesource.com/net";
132 rev = "10c134ea0df15f7e34d789338c7a2d76cc7a3ab9";
133 sha256 = "14cbr2shl08gyg85n5gj7nbjhrhhgrd52h073qd14j97qcxsakcz";
134 }
135 ];
136 };
137
138 boulder = let
139 owner = "letsencrypt";
140 repo = "boulder";
141 rev = "9c6a1f2adc4c26d925588f5ae366cfd4efb7813a";
142 version = "20180129";
143
144 in pkgs.buildGoPackage rec {
145 name = "${repo}-${version}";
146
147 src = pkgs.fetchFromGitHub {
148 name = "${name}-src";
149 inherit rev owner repo;
150 sha256 = "09kszswrifm9rc6idfaq0p1mz5w21as2qbc8gd5pphrq9cf9pn55";
151 };
152
153 postPatch = ''
154 # compat for go < 1.8
155 sed -i -e 's/time\.Until(\([^)]\+\))/\1.Sub(time.Now())/' \
156 test/ocsp/helper/helper.go
157
158 find test -type f -exec sed -i -e '/libpkcs11-proxy.so/ {
159 s,/usr/local,${pkcs11-proxy},
160 }' {} +
161
162 sed -i -r \
163 -e '/^def +install/a \ return True' \
164 -e 's,exec \./bin/,,' \
165 test/startservers.py
166
167 cat ${lib.escapeShellArg snakeOilCerts.ca.key} > test/test-ca.key
168 cat ${lib.escapeShellArg snakeOilCerts.ca.cert} > test/test-ca.pem
169 '';
170
171 # Until vendored pkcs11 is go 1.9 compatible
172 preBuild = ''
173 rm -r go/src/github.com/letsencrypt/boulder/vendor/github.com/miekg/pkcs11
174 '';
175
176 # XXX: Temporarily brought back putting the source code in the output,
177 # since e95f17e2720e67e2eabd59d7754c814d3e27a0b2 was removing that from
178 # buildGoPackage.
179 preInstall = ''
180 mkdir -p $out
181 pushd "$NIX_BUILD_TOP/go"
182 while read f; do
183 echo "$f" | grep -q '^./\(src\|pkg/[^/]*\)/${goPackagePath}' \
184 || continue
185 mkdir -p "$(dirname "$out/share/go/$f")"
186 cp "$NIX_BUILD_TOP/go/$f" "$out/share/go/$f"
187 done < <(find . -type f)
188 popd
189 '';
190
191 extraSrcs = map mkGoDep [
192 { goPackagePath = "github.com/miekg/pkcs11";
193 rev = "6dbd569b952ec150d1425722dbbe80f2c6193f83";
194 sha256 = "1m8g6fx7df6hf6q6zsbyw1icjmm52dmsx28rgb0h930wagvngfwb";
195 }
196 ];
197
198 goPackagePath = "github.com/${owner}/${repo}";
199 buildInputs = [ pkgs.libtool ];
200 };
201
202 boulderSource = "${boulder.out}/share/go/src/${boulder.goPackagePath}";
203
204 softHsmConf = pkgs.writeText "softhsm.conf" ''
205 0:/var/lib/softhsm/slot0.db
206 1:/var/lib/softhsm/slot1.db
207 '';
208
209 snakeOilCerts = import ./snakeoil-certs.nix;
210
211 wfeDomain = "acme-v01.api.letsencrypt.org";
212 wfeCertFile = snakeOilCerts.${wfeDomain}.cert;
213 wfeKeyFile = snakeOilCerts.${wfeDomain}.key;
214
215 siteDomain = "letsencrypt.org";
216 siteCertFile = snakeOilCerts.${siteDomain}.cert;
217 siteKeyFile = snakeOilCerts.${siteDomain}.key;
218
219 # Retrieved via:
220 # curl -s -I https://acme-v01.api.letsencrypt.org/terms \
221 # | sed -ne 's/^[Ll]ocation: *//p'
222 tosUrl = "https://letsencrypt.org/documents/2017.11.15-LE-SA-v1.2.pdf";
223 tosPath = builtins.head (builtins.match "https?://[^/]+(.*)" tosUrl);
224
225 tosFile = pkgs.fetchurl {
226 url = tosUrl;
227 sha256 = "0yvyckqzj0b1xi61sypcha82nanizzlm8yqy828h2jbza7cxi26c";
228 };
229
230 resolver = let
231 message = "You need to define a resolver for the letsencrypt test module.";
232 firstNS = lib.head config.networking.nameservers;
233 in if config.networking.nameservers == [] then throw message else firstNS;
234
235 cfgDir = pkgs.stdenv.mkDerivation {
236 name = "boulder-config";
237 src = "${boulderSource}/test/config";
238 nativeBuildInputs = [ pkgs.jq ];
239 phases = [ "unpackPhase" "patchPhase" "installPhase" ];
240 postPatch = ''
241 sed -i -e 's/5002/80/' -e 's/5002/443/' va.json
242 sed -i -e '/listenAddress/s/:4000/:80/' wfe.json
243 sed -i -r \
244 -e ${lib.escapeShellArg "s,http://boulder:4000/terms/v1,${tosUrl},g"} \
245 -e 's,http://(boulder|127\.0\.0\.1):4000,https://${wfeDomain},g' \
246 -e '/dnsResolver/s/127\.0\.0\.1:8053/${resolver}:53/' \
247 *.json
248 if grep 4000 *.json; then exit 1; fi
249
250 # Change all ports from 1909X to 909X, because the 1909X range of ports is
251 # allocated by startservers.py in order to intercept gRPC communication.
252 sed -i -e 's/\<1\(909[0-9]\)\>/\1/' *.json
253
254 # Patch out all additional issuer certs
255 jq '. + {ca: (.ca + {Issuers:
256 [.ca.Issuers[] | select(.CertFile == "test/test-ca.pem")]
257 })}' ca.json > tmp
258 mv tmp ca.json
259 '';
260 installPhase = "cp -r . \"$out\"";
261 };
262
263 components = {
264 gsb-test-srv.args = "-apikey my-voice-is-my-passport";
265 gsb-test-srv.waitForPort = 6000;
266 gsb-test-srv.first = true;
267 boulder-sa.args = "--config ${cfgDir}/sa.json";
268 boulder-wfe.args = "--config ${cfgDir}/wfe.json";
269 boulder-ra.args = "--config ${cfgDir}/ra.json";
270 boulder-ca.args = "--config ${cfgDir}/ca.json";
271 boulder-va.args = "--config ${cfgDir}/va.json";
272 boulder-publisher.args = "--config ${cfgDir}/publisher.json";
273 boulder-publisher.waitForPort = 9091;
274 ocsp-updater.args = "--config ${cfgDir}/ocsp-updater.json";
275 ocsp-updater.after = [ "boulder-publisher" ];
276 ocsp-responder.args = "--config ${cfgDir}/ocsp-responder.json";
277 ct-test-srv = {};
278 mail-test-srv.args = let
279 key = "${boulderSource}/test/mail-test-srv/minica-key.pem";
280 crt = "${boulderSource}/test/mail-test-srv/minica.pem";
281 in
282 "--closeFirst 5 --cert ${crt} --key ${key}";
283 };
284
285 commonPath = [ softhsm pkgs.mariadb goose boulder ];
286
287 mkServices = a: b: with lib; listToAttrs (concatLists (mapAttrsToList a b));
288
289 componentServices = mkServices (name: attrs: let
290 mkSrvName = n: "boulder-${n}.service";
291 firsts = lib.filterAttrs (lib.const (c: c.first or false)) components;
292 firstServices = map mkSrvName (lib.attrNames firsts);
293 firstServicesNoSelf = lib.remove "boulder-${name}.service" firstServices;
294 additionalAfter = firstServicesNoSelf ++ map mkSrvName (attrs.after or []);
295 needsPort = attrs ? waitForPort;
296 inits = map (n: "boulder-init-${n}.service") [ "mysql" "softhsm" ];
297 portWaiter = {
298 name = "boulder-${name}";
299 value = {
300 description = "Wait For Port ${toString attrs.waitForPort} (${name})";
301 after = [ "boulder-real-${name}.service" "bind.service" ];
302 requires = [ "boulder-real-${name}.service" ];
303 requiredBy = [ "boulder.service" ];
304 serviceConfig.Type = "oneshot";
305 serviceConfig.RemainAfterExit = true;
306 script = let
307 netcat = "${pkgs.libressl.nc}/bin/nc";
308 portCheck = "${netcat} -z 127.0.0.1 ${toString attrs.waitForPort}";
309 in "while ! ${portCheck}; do :; done";
310 };
311 };
312 in lib.optional needsPort portWaiter ++ lib.singleton {
313 name = if needsPort then "boulder-real-${name}" else "boulder-${name}";
314 value = {
315 description = "Boulder ACME Component (${name})";
316 after = inits ++ additionalAfter;
317 requires = inits;
318 requiredBy = [ "boulder.service" ];
319 path = commonPath;
320 environment.GORACE = "halt_on_error=1";
321 environment.SOFTHSM_CONF = softHsmConf;
322 environment.PKCS11_PROXY_SOCKET = "tcp://127.0.0.1:5657";
323 serviceConfig.WorkingDirectory = boulderSource;
324 serviceConfig.ExecStart = "${boulder}/bin/${name} ${attrs.args or ""}";
325 serviceConfig.Restart = "on-failure";
326 };
327 }) components;
328
329in {
330 imports = [ ../resolver.nix ];
331
332 options.test-support.letsencrypt.caCert = lib.mkOption {
333 type = lib.types.path;
334 description = ''
335 A certificate file to use with the <literal>nodes</literal> attribute to
336 inject the snakeoil CA certificate used in the ACME server into
337 <option>security.pki.certificateFiles</option>.
338 '';
339 };
340
341 config = {
342 test-support = {
343 resolver.enable = let
344 isLocalResolver = config.networking.nameservers == [ "127.0.0.1" ];
345 in lib.mkOverride 900 isLocalResolver;
346 letsencrypt.caCert = snakeOilCerts.ca.cert;
347 };
348
349 # This has priority 140, because modules/testing/test-instrumentation.nix
350 # already overrides this with priority 150.
351 networking.nameservers = lib.mkOverride 140 [ "127.0.0.1" ];
352 networking.firewall.enable = false;
353
354 networking.extraHosts = ''
355 127.0.0.1 ${toString [
356 "sa.boulder" "ra.boulder" "wfe.boulder" "ca.boulder" "va.boulder"
357 "publisher.boulder" "ocsp-updater.boulder" "admin-revoker.boulder"
358 "boulder" "boulder-mysql" wfeDomain
359 ]}
360 ${config.networking.primaryIPAddress} ${wfeDomain} ${siteDomain}
361 '';
362
363 services.mysql.enable = true;
364 services.mysql.package = pkgs.mariadb;
365
366 services.nginx.enable = true;
367 services.nginx.recommendedProxySettings = true;
368 # This fixes the test on i686
369 services.nginx.commonHttpConfig = ''
370 server_names_hash_bucket_size 64;
371 '';
372 services.nginx.virtualHosts.${wfeDomain} = {
373 onlySSL = true;
374 enableACME = false;
375 sslCertificate = wfeCertFile;
376 sslCertificateKey = wfeKeyFile;
377 locations."/".proxyPass = "http://127.0.0.1:80";
378 };
379 services.nginx.virtualHosts.${siteDomain} = {
380 onlySSL = true;
381 enableACME = false;
382 sslCertificate = siteCertFile;
383 sslCertificateKey = siteKeyFile;
384 locations.${tosPath}.extraConfig = "alias ${tosFile};";
385 };
386
387 systemd.services = {
388 pkcs11-daemon = {
389 description = "PKCS11 Daemon";
390 after = [ "boulder-init-softhsm.service" ];
391 before = map (n: "${n}.service") (lib.attrNames componentServices);
392 wantedBy = [ "multi-user.target" ];
393 environment.SOFTHSM_CONF = softHsmConf;
394 environment.PKCS11_DAEMON_SOCKET = "tcp://127.0.0.1:5657";
395 serviceConfig.ExecStart = let
396 softhsmLib = "${softhsm}/lib/softhsm/libsofthsm.so";
397 in "${pkcs11-proxy}/bin/pkcs11-daemon ${softhsmLib}";
398 };
399
400 boulder-init-mysql = {
401 description = "Boulder ACME Init (MySQL)";
402 after = [ "mysql.service" ];
403 serviceConfig.Type = "oneshot";
404 serviceConfig.RemainAfterExit = true;
405 serviceConfig.WorkingDirectory = boulderSource;
406 path = commonPath;
407 script = "${pkgs.bash}/bin/sh test/create_db.sh";
408 };
409
410 boulder-init-softhsm = {
411 description = "Boulder ACME Init (SoftHSM)";
412 environment.SOFTHSM_CONF = softHsmConf;
413 serviceConfig.Type = "oneshot";
414 serviceConfig.RemainAfterExit = true;
415 serviceConfig.WorkingDirectory = boulderSource;
416 preStart = "mkdir -p /var/lib/softhsm";
417 path = commonPath;
418 script = ''
419 softhsm --slot 0 --init-token \
420 --label intermediate --pin 5678 --so-pin 1234
421 softhsm --slot 0 --import test/test-ca.key \
422 --label intermediate_key --pin 5678 --id FB
423 softhsm --slot 1 --init-token \
424 --label root --pin 5678 --so-pin 1234
425 softhsm --slot 1 --import test/test-root.key \
426 --label root_key --pin 5678 --id FA
427 '';
428 };
429
430 boulder = {
431 description = "Boulder ACME Server";
432 after = map (n: "${n}.service") (lib.attrNames componentServices);
433 wantedBy = [ "multi-user.target" ];
434 serviceConfig.Type = "oneshot";
435 serviceConfig.RemainAfterExit = true;
436 script = let
437 ports = lib.range 8000 8005 ++ lib.singleton 80;
438 netcat = "${pkgs.libressl.nc}/bin/nc";
439 mkPortCheck = port: "${netcat} -z 127.0.0.1 ${toString port}";
440 checks = "(${lib.concatMapStringsSep " && " mkPortCheck ports})";
441 in "while ! ${checks}; do :; done";
442 };
443 } // componentServices;
444 };
445}