summary refs log tree commit diff
diff options
context:
space:
mode:
authoraszlig <aszlig@redmoonstudios.org>2017-09-13 23:30:07 +0200
committeraszlig <aszlig@redmoonstudios.org>2017-09-13 23:30:07 +0200
commit62711f426536c2a1e2b80947fa352a0f8a5a127f (patch)
tree71400306695840b3d09effc59a350f6176fc9508
parent50cf2a715665464fc85f403d6cea84ae2106da8d (diff)
parent01fffd94e5848e55fab88cd85a6ac958e1a309ff (diff)
downloadnixlib-62711f426536c2a1e2b80947fa352a0f8a5a127f.tar
nixlib-62711f426536c2a1e2b80947fa352a0f8a5a127f.tar.gz
nixlib-62711f426536c2a1e2b80947fa352a0f8a5a127f.tar.bz2
nixlib-62711f426536c2a1e2b80947fa352a0f8a5a127f.tar.lz
nixlib-62711f426536c2a1e2b80947fa352a0f8a5a127f.tar.xz
nixlib-62711f426536c2a1e2b80947fa352a0f8a5a127f.tar.zst
nixlib-62711f426536c2a1e2b80947fa352a0f8a5a127f.zip
Merge pull request #27683 (add test for ACME)
This is a rebased version of the pull request with small fixes due to
changes in recent master.

Original description from the pull request:

  Currently this is only a very basic test which gets certificates via
  the enableACME option of the nginx module.

  However the main reason why I'm not directly merging and putting this
  up for review is that the complexity here lies in the support-modules
  needed for the test. The support modules are for running a Boulder
  instance along with a DNS resolver (as a separate module).

  For details about the implementation, see the commit messages and the
  comments at the start of the respective support modules.

I'm merging this first of all because other than @abbradar, none of the
other requested reviewers did comment on the changes and second because
the change here is adding a test, so even if the implementation would be
so disgusting and crappy it's better than having no test at all.

The comment of @abbradar was:

  Can't we factor Boulder into a proper package and a NixOS service?
  Maybe not very general purpose for now but still -- putting everything
  into one test seems painful to me.

My objection to this is that the components are heavily patched and some
of them don't even have a release, so I'm not sure whether infesting
pkgs/ with them is really a good idea.

Nevertheless, we can still do that later.

Cc: @fpletz, @domenkozar, @bjornfor
-rw-r--r--nixos/release.nix1
-rw-r--r--nixos/tests/acme.nix62
-rw-r--r--nixos/tests/common/letsencrypt.nix446
-rw-r--r--nixos/tests/common/resolver.nix141
4 files changed, 650 insertions, 0 deletions
diff --git a/nixos/release.nix b/nixos/release.nix
index 34198a950647..38c446c1f8a4 100644
--- a/nixos/release.nix
+++ b/nixos/release.nix
@@ -214,6 +214,7 @@ in rec {
   # Run the tests for each platform.  You can run a test by doing
   # e.g. ‘nix-build -A tests.login.x86_64-linux’, or equivalently,
   # ‘nix-build tests/login.nix -A result’.
+  tests.acme = callTest tests/acme.nix {};
   tests.avahi = callTest tests/avahi.nix {};
   tests.bittorrent = callTest tests/bittorrent.nix {};
   tests.blivet = callTest tests/blivet.nix {};
diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix
new file mode 100644
index 000000000000..f44524a1d6a8
--- /dev/null
+++ b/nixos/tests/acme.nix
@@ -0,0 +1,62 @@
+let
+  commonConfig = { config, lib, pkgs, nodes, ... }: {
+    networking.nameservers = [
+      nodes.letsencrypt.config.networking.primaryIPAddress
+    ];
+
+    nixpkgs.overlays = lib.singleton (self: super: {
+      cacert = super.cacert.overrideDerivation (drv: {
+        installPhase = (drv.installPhase or "") + ''
+          cat "${nodes.letsencrypt.config.test-support.letsencrypt.caCert}" \
+            >> "$out/etc/ssl/certs/ca-bundle.crt"
+        '';
+      });
+
+      pythonPackages = (super.python.override {
+        packageOverrides = lib.const (pysuper: {
+          certifi = pysuper.certifi.overrideDerivation (drv: {
+            postPatch = (drv.postPatch or "") + ''
+              cat "${self.cacert}/etc/ssl/certs/ca-bundle.crt" \
+                > certifi/cacert.pem
+            '';
+          });
+        });
+      }).pkgs;
+    });
+  };
+
+in import ./make-test.nix {
+  name = "acme";
+
+  nodes = {
+    letsencrypt = ./common/letsencrypt.nix;
+
+    webserver = { config, pkgs, ... }: {
+      imports = [ commonConfig ];
+      networking.firewall.allowedTCPPorts = [ 80 443 ];
+
+      networking.extraHosts = ''
+        ${config.networking.primaryIPAddress} example.com
+      '';
+
+      services.nginx.enable = true;
+      services.nginx.virtualHosts."example.com" = {
+        enableACME = true;
+        forceSSL = true;
+        locations."/".root = pkgs.runCommand "docroot" {} ''
+          mkdir -p "$out"
+          echo hello world > "$out/index.html"
+        '';
+      };
+    };
+
+    client = commonConfig;
+  };
+
+  testScript = ''
+    $letsencrypt->waitForUnit("boulder.service");
+    startAll;
+    $webserver->waitForUnit("acme-certificates.target");
+    $client->succeed('curl https://example.com/ | grep -qF "hello world"');
+  '';
+}
diff --git a/nixos/tests/common/letsencrypt.nix b/nixos/tests/common/letsencrypt.nix
new file mode 100644
index 000000000000..5a2a266d4da5
--- /dev/null
+++ b/nixos/tests/common/letsencrypt.nix
@@ -0,0 +1,446 @@
+# 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} = {
+      onlySSL = true;
+      enableACME = false;
+      sslCertificate = wfeCertFile;
+      sslCertificateKey = wfeKeyFile;
+      locations."/".proxyPass = "http://127.0.0.1:80";
+    };
+    services.nginx.virtualHosts.${siteDomain} = {
+      onlySSL = true;
+      enableACME = false;
+      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}
+      '';
+    };
+  };
+}