diff options
Diffstat (limited to 'nixos/tests/common')
-rw-r--r-- | nixos/tests/common/letsencrypt.nix | 444 | ||||
-rw-r--r-- | nixos/tests/common/resolver.nix | 141 |
2 files changed, 585 insertions, 0 deletions
diff --git a/nixos/tests/common/letsencrypt.nix b/nixos/tests/common/letsencrypt.nix new file mode 100644 index 000000000000..162f50385d8f --- /dev/null +++ b/nixos/tests/common/letsencrypt.nix @@ -0,0 +1,444 @@ +# Fully pluggable module to have Letsencrypt's Boulder ACME service running in +# a test environment. +# +# The certificate for the ACME service is exported as: +# +# config.test-support.letsencrypt.caCert +# +# This value can be used inside the configuration of other test nodes to inject +# the snakeoil certificate into security.pki.certificateFiles or into package +# overlays. +# +# Another value that's needed if you don't use a custom resolver (see below for +# notes on that) is to add the letsencrypt node as a nameserver to every node +# that needs to acquire certificates using ACME, because otherwise the API host +# for letsencrypt.org can't be resolved. +# +# A configuration example of a full node setup using this would be this: +# +# { +# letsencrypt = import ./common/letsencrypt.nix; +# +# example = { nodes, ... }: { +# networking.nameservers = [ +# nodes.letsencrypt.config.networking.primaryIPAddress +# ]; +# security.pki.certificateFiles = [ +# nodes.letsencrypt.config.test-support.letsencrypt.caCert +# ]; +# }; +# } +# +# By default, this module runs a local resolver, generated using resolver.nix +# from the same directory to automatically discover all zones in the network. +# +# If you do not want this and want to use your own resolver, you can just +# override networking.nameservers like this: +# +# { +# letsencrypt = { nodes, ... }: { +# imports = [ ./common/letsencrypt.nix ]; +# networking.nameservers = [ +# nodes.myresolver.config.networking.primaryIPAddress +# ]; +# }; +# +# myresolver = ...; +# } +# +# Keep in mind, that currently only _one_ resolver is supported, if you have +# more than one resolver in networking.nameservers only the first one will be +# used. +# +# Also make sure that whenever you use a resolver from a different test node +# that it has to be started _before_ the ACME service. +{ config, pkgs, lib, ... }: + +let + softhsm = pkgs.stdenv.mkDerivation rec { + name = "softhsm-${version}"; + version = "1.3.8"; + + src = pkgs.fetchurl { + url = "https://dist.opendnssec.org/source/${name}.tar.gz"; + sha256 = "0flmnpkgp65ym7w3qyg78d3fbmvq3aznmi66rgd420n33shf7aif"; + }; + + configureFlags = [ "--with-botan=${pkgs.botan}" ]; + buildInputs = [ pkgs.sqlite ]; + }; + + pkcs11-proxy = pkgs.stdenv.mkDerivation { + name = "pkcs11-proxy"; + + src = pkgs.fetchFromGitHub { + owner = "SUNET"; + repo = "pkcs11-proxy"; + rev = "944684f78bca0c8da6cabe3fa273fed3db44a890"; + sha256 = "1nxgd29y9wmifm11pjcdpd2y293p0dgi0x5ycis55miy97n0f5zy"; + }; + + postPatch = "patchShebangs mksyscalls.sh"; + + nativeBuildInputs = [ pkgs.cmake ]; + buildInputs = [ pkgs.openssl pkgs.libseccomp ]; + }; + + mkGoDep = { goPackagePath, url ? "https://${goPackagePath}", rev, sha256 }: { + inherit goPackagePath; + src = pkgs.fetchgit { inherit url rev sha256; }; + }; + + goose = let + owner = "liamstask"; + repo = "goose"; + rev = "8488cc47d90c8a502b1c41a462a6d9cc8ee0a895"; + version = "20150116"; + + in pkgs.buildGoPackage rec { + name = "${repo}-${version}"; + + src = pkgs.fetchFromBitbucket { + name = "${name}-src"; + inherit rev owner repo; + sha256 = "1jy0pscxjnxjdg3hj111w21g8079rq9ah2ix5ycxxhbbi3f0wdhs"; + }; + + goPackagePath = "bitbucket.org/${owner}/${repo}"; + subPackages = [ "cmd/goose" ]; + extraSrcs = map mkGoDep [ + { goPackagePath = "github.com/go-sql-driver/mysql"; + rev = "2e00b5cd70399450106cec6431c2e2ce3cae5034"; + sha256 = "085g48jq9hzmlcxg122n0c4pi41sc1nn2qpx1vrl2jfa8crsppa5"; + } + { goPackagePath = "github.com/kylelemons/go-gypsy"; + rev = "08cad365cd28a7fba23bb1e57aa43c5e18ad8bb8"; + sha256 = "1djv7nii3hy451n5jlslk0dblqzb1hia1cbqpdwhnps1g8hqjy8q"; + } + { goPackagePath = "github.com/lib/pq"; + rev = "ba5d4f7a35561e22fbdf7a39aa0070f4d460cfc0"; + sha256 = "1mfbqw9g00bk24bfmf53wri5c2wqmgl0qh4sh1qv2da13a7cwwg3"; + } + { goPackagePath = "github.com/mattn/go-sqlite3"; + rev = "2acfafad5870400156f6fceb12852c281cbba4d5"; + sha256 = "1rpgil3w4hh1cibidskv1js898hwz83ps06gh0hm3mym7ki8d5h7"; + } + { goPackagePath = "github.com/ziutek/mymysql"; + rev = "0582bcf675f52c0c2045c027fd135bd726048f45"; + sha256 = "0bkc9x8sgqbzgdimsmsnhb0qrzlzfv33fgajmmjxl4hcb21qz3rf"; + } + { goPackagePath = "golang.org/x/net"; + url = "https://go.googlesource.com/net"; + rev = "10c134ea0df15f7e34d789338c7a2d76cc7a3ab9"; + sha256 = "14cbr2shl08gyg85n5gj7nbjhrhhgrd52h073qd14j97qcxsakcz"; + } + ]; + }; + + boulder = let + owner = "letsencrypt"; + repo = "boulder"; + rev = "9866abab8962a591f06db457a4b84c518cc88243"; + version = "20170510"; + + in pkgs.buildGoPackage rec { + name = "${repo}-${version}"; + + src = pkgs.fetchFromGitHub { + name = "${name}-src"; + inherit rev owner repo; + sha256 = "170m5cjngbrm36wi7wschqw8jzs7kxpcyzmshq3pcrmcpigrhna1"; + }; + + postPatch = '' + # compat for go < 1.8 + sed -i -e 's/time\.Until(\([^)]\+\))/\1.Sub(time.Now())/' \ + test/ocsp/helper/helper.go + + find test -type f -exec sed -i -e '/libpkcs11-proxy.so/ { + s,/usr/local,${pkcs11-proxy}, + }' {} + + + sed -i -r \ + -e '/^def +install/a \ return True' \ + -e 's,exec \./bin/,,' \ + test/startservers.py + + cat "${snakeOilCa}/ca.key" > test/test-ca.key + cat "${snakeOilCa}/ca.pem" > test/test-ca.pem + ''; + + goPackagePath = "github.com/${owner}/${repo}"; + buildInputs = [ pkgs.libtool ]; + }; + + boulderSource = "${boulder.out}/share/go/src/${boulder.goPackagePath}"; + + softHsmConf = pkgs.writeText "softhsm.conf" '' + 0:/var/lib/softhsm/slot0.db + 1:/var/lib/softhsm/slot1.db + ''; + + snakeOilCa = pkgs.runCommand "snakeoil-ca" { + buildInputs = [ pkgs.openssl ]; + } '' + mkdir "$out" + openssl req -newkey rsa:4096 -x509 -sha256 -days 36500 \ + -subj '/CN=Snakeoil CA' -nodes \ + -out "$out/ca.pem" -keyout "$out/ca.key" + ''; + + createAndSignCert = fqdn: let + snakeoilCertConf = pkgs.writeText "snakeoil.cnf" '' + [req] + default_bits = 4096 + prompt = no + default_md = sha256 + req_extensions = req_ext + distinguished_name = dn + [dn] + CN = ${fqdn} + [req_ext] + subjectAltName = DNS:${fqdn} + ''; + in pkgs.runCommand "snakeoil-certs-${fqdn}" { + buildInputs = [ pkgs.openssl ]; + } '' + mkdir "$out" + openssl genrsa -out "$out/snakeoil.key" 4096 + openssl req -new -key "$out/snakeoil.key" \ + -config ${lib.escapeShellArg snakeoilCertConf} \ + -out snakeoil.csr + openssl x509 -req -in snakeoil.csr -sha256 -set_serial 666 \ + -CA "${snakeOilCa}/ca.pem" -CAkey "${snakeOilCa}/ca.key" \ + -extfile ${lib.escapeShellArg snakeoilCertConf} \ + -out "$out/snakeoil.pem" -days 36500 + ''; + + wfeCerts = createAndSignCert wfeDomain; + wfeDomain = "acme-v01.api.letsencrypt.org"; + wfeCertFile = "${wfeCerts}/snakeoil.pem"; + wfeKeyFile = "${wfeCerts}/snakeoil.key"; + + siteCerts = createAndSignCert siteDomain; + siteDomain = "letsencrypt.org"; + siteCertFile = "${siteCerts}/snakeoil.pem"; + siteKeyFile = "${siteCerts}/snakeoil.key"; + + # Retrieved via: + # curl -s -I https://acme-v01.api.letsencrypt.org/terms \ + # | sed -ne 's/^[Ll]ocation: *//p' + tosUrl = "https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf"; + tosPath = builtins.head (builtins.match "https?://[^/]+(.*)" tosUrl); + + tosFile = pkgs.fetchurl { + url = tosUrl; + sha256 = "08b2gacdz23mzji2pjr1pwnk82a84rzvr36isif7mmi9kydl6wv3"; + }; + + resolver = let + message = "You need to define a resolver for the letsencrypt test module."; + firstNS = lib.head config.networking.nameservers; + in if config.networking.nameservers == [] then throw message else firstNS; + + cfgDir = pkgs.stdenv.mkDerivation { + name = "boulder-config"; + src = "${boulderSource}/test/config"; + nativeBuildInputs = [ pkgs.jq ]; + phases = [ "unpackPhase" "patchPhase" "installPhase" ]; + postPatch = '' + sed -i -e 's/5002/80/' -e 's/5002/443/' va.json + sed -i -e '/listenAddress/s/:4000/:80/' wfe.json + sed -i -r \ + -e ${lib.escapeShellArg "s,http://boulder:4000/terms/v1,${tosUrl},g"} \ + -e 's,http://(boulder|127\.0\.0\.1):4000,https://${wfeDomain},g' \ + -e '/dnsResolver/s/127\.0\.0\.1:8053/${resolver}:53/' \ + *.json + if grep 4000 *.json; then exit 1; fi + + # Change all ports from 1909X to 909X, because the 1909X range of ports is + # allocated by startservers.py in order to intercept gRPC communication. + sed -i -e 's/\<1\(909[0-9]\)\>/\1/' *.json + + # Patch out all additional issuer certs + jq '. + {ca: (.ca + {Issuers: + [.ca.Issuers[] | select(.CertFile == "test/test-ca.pem")] + })}' ca.json > tmp + mv tmp ca.json + ''; + installPhase = "cp -r . \"$out\""; + }; + + components = { + gsb-test-srv.args = "-apikey my-voice-is-my-passport"; + gsb-test-srv.waitForPort = 6000; + gsb-test-srv.first = true; + boulder-sa.args = "--config ${cfgDir}/sa.json"; + boulder-wfe.args = "--config ${cfgDir}/wfe.json"; + boulder-ra.args = "--config ${cfgDir}/ra.json"; + boulder-ca.args = "--config ${cfgDir}/ca.json"; + boulder-va.args = "--config ${cfgDir}/va.json"; + boulder-publisher.args = "--config ${cfgDir}/publisher.json"; + boulder-publisher.waitForPort = 9091; + ocsp-updater.args = "--config ${cfgDir}/ocsp-updater.json"; + ocsp-updater.after = [ "boulder-publisher" ]; + ocsp-responder.args = "--config ${cfgDir}/ocsp-responder.json"; + ct-test-srv = {}; + mail-test-srv.args = "--closeFirst 5"; + }; + + commonPath = [ softhsm pkgs.mariadb goose boulder ]; + + mkServices = a: b: with lib; listToAttrs (concatLists (mapAttrsToList a b)); + + componentServices = mkServices (name: attrs: let + mkSrvName = n: "boulder-${n}.service"; + firsts = lib.filterAttrs (lib.const (c: c.first or false)) components; + firstServices = map mkSrvName (lib.attrNames firsts); + firstServicesNoSelf = lib.remove "boulder-${name}.service" firstServices; + additionalAfter = firstServicesNoSelf ++ map mkSrvName (attrs.after or []); + needsPort = attrs ? waitForPort; + inits = map (n: "boulder-init-${n}.service") [ "mysql" "softhsm" ]; + portWaiter = { + name = "boulder-${name}"; + value = { + description = "Wait For Port ${toString attrs.waitForPort} (${name})"; + after = [ "boulder-real-${name}.service" "bind.service" ]; + requires = [ "boulder-real-${name}.service" ]; + requiredBy = [ "boulder.service" ]; + serviceConfig.Type = "oneshot"; + serviceConfig.RemainAfterExit = true; + script = let + netcat = "${pkgs.netcat-openbsd}/bin/nc"; + portCheck = "${netcat} -z 127.0.0.1 ${toString attrs.waitForPort}"; + in "while ! ${portCheck}; do :; done"; + }; + }; + in lib.optional needsPort portWaiter ++ lib.singleton { + name = if needsPort then "boulder-real-${name}" else "boulder-${name}"; + value = { + description = "Boulder ACME Component (${name})"; + after = inits ++ additionalAfter; + requires = inits; + requiredBy = [ "boulder.service" ]; + path = commonPath; + environment.GORACE = "halt_on_error=1"; + environment.SOFTHSM_CONF = softHsmConf; + environment.PKCS11_PROXY_SOCKET = "tcp://127.0.0.1:5657"; + serviceConfig.WorkingDirectory = boulderSource; + serviceConfig.ExecStart = "${boulder}/bin/${name} ${attrs.args or ""}"; + serviceConfig.Restart = "on-failure"; + }; + }) components; + +in { + imports = [ ./resolver.nix ]; + + options.test-support.letsencrypt.caCert = lib.mkOption { + type = lib.types.path; + description = '' + A certificate file to use with the <literal>nodes</literal> attribute to + inject the snakeoil CA certificate used in the ACME server into + <option>security.pki.certificateFiles</option>. + ''; + }; + + config = { + test-support = { + resolver.enable = let + isLocalResolver = config.networking.nameservers == [ "127.0.0.1" ]; + in lib.mkOverride 900 isLocalResolver; + letsencrypt.caCert = "${snakeOilCa}/ca.pem"; + }; + + # This has priority 140, because modules/testing/test-instrumentation.nix + # already overrides this with priority 150. + networking.nameservers = lib.mkOverride 140 [ "127.0.0.1" ]; + networking.firewall.enable = false; + + networking.extraHosts = '' + 127.0.0.1 ${toString [ + "sa.boulder" "ra.boulder" "wfe.boulder" "ca.boulder" "va.boulder" + "publisher.boulder" "ocsp-updater.boulder" "admin-revoker.boulder" + "boulder" "boulder-mysql" wfeDomain + ]} + ${config.networking.primaryIPAddress} ${wfeDomain} ${siteDomain} + ''; + + services.mysql.enable = true; + services.mysql.package = pkgs.mariadb; + + services.nginx.enable = true; + services.nginx.recommendedProxySettings = true; + services.nginx.virtualHosts.${wfeDomain} = { + enableSSL = true; + sslCertificate = wfeCertFile; + sslCertificateKey = wfeKeyFile; + locations."/".proxyPass = "http://127.0.0.1:80"; + }; + services.nginx.virtualHosts.${siteDomain} = { + enableSSL = true; + sslCertificate = siteCertFile; + sslCertificateKey = siteKeyFile; + locations.${tosPath}.extraConfig = "alias ${tosFile};"; + }; + + systemd.services = { + pkcs11-daemon = { + description = "PKCS11 Daemon"; + after = [ "boulder-init-softhsm.service" ]; + before = map (n: "${n}.service") (lib.attrNames componentServices); + wantedBy = [ "multi-user.target" ]; + environment.SOFTHSM_CONF = softHsmConf; + environment.PKCS11_DAEMON_SOCKET = "tcp://127.0.0.1:5657"; + serviceConfig.ExecStart = let + softhsmLib = "${softhsm}/lib/softhsm/libsofthsm.so"; + in "${pkcs11-proxy}/bin/pkcs11-daemon ${softhsmLib}"; + }; + + boulder-init-mysql = { + description = "Boulder ACME Init (MySQL)"; + after = [ "mysql.service" ]; + serviceConfig.Type = "oneshot"; + serviceConfig.RemainAfterExit = true; + serviceConfig.WorkingDirectory = boulderSource; + path = commonPath; + script = "${pkgs.bash}/bin/sh test/create_db.sh"; + }; + + boulder-init-softhsm = { + description = "Boulder ACME Init (SoftHSM)"; + environment.SOFTHSM_CONF = softHsmConf; + serviceConfig.Type = "oneshot"; + serviceConfig.RemainAfterExit = true; + serviceConfig.WorkingDirectory = boulderSource; + preStart = "mkdir -p /var/lib/softhsm"; + path = commonPath; + script = '' + softhsm --slot 0 --init-token \ + --label intermediate --pin 5678 --so-pin 1234 + softhsm --slot 0 --import test/test-ca.key \ + --label intermediate_key --pin 5678 --id FB + softhsm --slot 1 --init-token \ + --label root --pin 5678 --so-pin 1234 + softhsm --slot 1 --import test/test-root.key \ + --label root_key --pin 5678 --id FA + ''; + }; + + boulder = { + description = "Boulder ACME Server"; + after = map (n: "${n}.service") (lib.attrNames componentServices); + wantedBy = [ "multi-user.target" ]; + serviceConfig.Type = "oneshot"; + serviceConfig.RemainAfterExit = true; + script = let + ports = lib.range 8000 8005 ++ lib.singleton 80; + netcat = "${pkgs.netcat-openbsd}/bin/nc"; + mkPortCheck = port: "${netcat} -z 127.0.0.1 ${toString port}"; + checks = "(${lib.concatMapStringsSep " && " mkPortCheck ports})"; + in "while ! ${checks}; do :; done"; + }; + } // componentServices; + }; +} diff --git a/nixos/tests/common/resolver.nix b/nixos/tests/common/resolver.nix new file mode 100644 index 000000000000..a1901c5c8167 --- /dev/null +++ b/nixos/tests/common/resolver.nix @@ -0,0 +1,141 @@ +# This module automatically discovers zones in BIND and NSD NixOS +# configurations and creates zones for all definitions of networking.extraHosts +# (except those that point to 127.0.0.1 or ::1) within the current test network +# and delegates these zones using a fake root zone served by a BIND recursive +# name server. +{ config, nodes, pkgs, lib, ... }: + +{ + options.test-support.resolver.enable = lib.mkOption { + type = lib.types.bool; + default = true; + internal = true; + description = '' + Whether to enable the resolver that automatically discovers zone in the + test network. + + This option is <literal>true</literal> by default, because the module + defining this option needs to be explicitly imported. + + The reason this option exists is for the + <filename>nixos/tests/common/letsencrypt.nix</filename> module, which + needs that option to disable the resolver once the user has set its own + resolver. + ''; + }; + + config = lib.mkIf config.test-support.resolver.enable { + networking.firewall.enable = false; + services.bind.enable = true; + services.bind.cacheNetworks = lib.mkForce [ "any" ]; + services.bind.forwarders = lib.mkForce []; + services.bind.zones = lib.singleton { + name = "."; + file = let + addDot = zone: zone + lib.optionalString (!lib.hasSuffix "." zone) "."; + mkNsdZoneNames = zones: map addDot (lib.attrNames zones); + mkBindZoneNames = zones: map (zone: addDot zone.name) zones; + getZones = cfg: mkNsdZoneNames cfg.services.nsd.zones + ++ mkBindZoneNames cfg.services.bind.zones; + + getZonesForNode = attrs: { + ip = attrs.config.networking.primaryIPAddress; + zones = lib.filter (zone: zone != ".") (getZones attrs.config); + }; + + zoneInfo = lib.mapAttrsToList (lib.const getZonesForNode) nodes; + + # A and AAAA resource records for all the definitions of + # networking.extraHosts except those for 127.0.0.1 or ::1. + # + # The result is an attribute set with keys being the host name and the + # values are either { ipv4 = ADDR; } or { ipv6 = ADDR; } where ADDR is + # the IP address for the corresponding key. + recordsFromExtraHosts = let + getHostsForNode = lib.const (n: n.config.networking.extraHosts); + allHostsList = lib.mapAttrsToList getHostsForNode nodes; + allHosts = lib.concatStringsSep "\n" allHostsList; + + reIp = "[a-fA-F0-9.:]+"; + reHost = "[a-zA-Z0-9.-]+"; + + matchAliases = str: let + matched = builtins.match "[ \t]+(${reHost})(.*)" str; + continue = lib.singleton (lib.head matched) + ++ matchAliases (lib.last matched); + in if matched == null then [] else continue; + + matchLine = str: let + result = builtins.match "[ \t]*(${reIp})[ \t]+(${reHost})(.*)" str; + in if result == null then null else { + ipAddr = lib.head result; + hosts = lib.singleton (lib.elemAt result 1) + ++ matchAliases (lib.last result); + }; + + skipLine = str: let + rest = builtins.match "[^\n]*\n(.*)" str; + in if rest == null then "" else lib.head rest; + + getEntries = str: acc: let + result = matchLine str; + next = getEntries (skipLine str); + newEntry = acc ++ lib.singleton result; + continue = if result == null then next acc else next newEntry; + in if str == "" then acc else continue; + + isIPv6 = str: builtins.match ".*:.*" str != null; + loopbackIps = [ "127.0.0.1" "::1" ]; + filterLoopback = lib.filter (e: !lib.elem e.ipAddr loopbackIps); + + allEntries = lib.concatMap (entry: map (host: { + inherit host; + ${if isIPv6 entry.ipAddr then "ipv6" else "ipv4"} = entry.ipAddr; + }) entry.hosts) (filterLoopback (getEntries (allHosts + "\n") [])); + + mkRecords = entry: let + records = lib.optional (entry ? ipv6) "AAAA ${entry.ipv6}" + ++ lib.optional (entry ? ipv4) "A ${entry.ipv4}"; + mkRecord = typeAndData: "${entry.host}. IN ${typeAndData}"; + in lib.concatMapStringsSep "\n" mkRecord records; + + in lib.concatMapStringsSep "\n" mkRecords allEntries; + + # All of the zones that are subdomains of existing zones. + # For example if there is only "example.com" the following zones would + # be 'subZones': + # + # * foo.example.com. + # * bar.example.com. + # + # While the following would *not* be 'subZones': + # + # * example.com. + # * com. + # + subZones = let + allZones = lib.concatMap (zi: zi.zones) zoneInfo; + isSubZoneOf = z1: z2: lib.hasSuffix z2 z1 && z1 != z2; + in lib.filter (z: lib.any (isSubZoneOf z) allZones) allZones; + + # All the zones without 'subZones'. + filteredZoneInfo = map (zi: zi // { + zones = lib.filter (x: !lib.elem x subZones) zi.zones; + }) zoneInfo; + + in pkgs.writeText "fake-root.zone" '' + $TTL 3600 + . IN SOA ns.fakedns. admin.fakedns. ( 1 3h 1h 1w 1d ) + ns.fakedns. IN A ${config.networking.primaryIPAddress} + . IN NS ns.fakedns. + ${lib.concatImapStrings (num: { ip, zones }: '' + ns${toString num}.fakedns. IN A ${ip} + ${lib.concatMapStrings (zone: '' + ${zone} IN NS ns${toString num}.fakedns. + '') zones} + '') (lib.filter (zi: zi.zones != []) filteredZoneInfo)} + ${recordsFromExtraHosts} + ''; + }; + }; +} |