From d179394cb8615eb9a1fb47f3cf4f2ad35e18a7df Mon Sep 17 00:00:00 2001 From: Emily Date: Wed, 25 Mar 2020 02:52:25 +0000 Subject: [PATCH 1/4] nixos/acme: add acme-dns client integration --- nixos/modules/security/acme.nix | 102 ++++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 11 deletions(-) diff --git a/nixos/modules/security/acme.nix b/nixos/modules/security/acme.nix index 776ef07d716c6..d2a519d3c65a1 100644 --- a/nixos/modules/security/acme.nix +++ b/nixos/modules/security/acme.nix @@ -314,21 +314,44 @@ in renewOpts = escapeShellArgs (globalOpts ++ [ "renew" "--days" (toString cfg.validMinDays) ] ++ certOpts ++ data.extraLegoRenewFlags); + + acmeDnsDeps = optional (data.dnsProvider == "acme-dns") + "acme-dns-${cert}.service"; + + commonServiceConfig = { + Type = "oneshot"; + User = data.user; + Group = data.group; + PrivateTmp = true; + StateDirectory = "acme/.lego/${cert} acme/.lego/accounts ${lpath}"; + StateDirectoryMode = if data.allowKeysForGroup then "750" else "700"; + WorkingDirectory = spath; + # Only try loading the credentialsFile if the dns challenge is enabled + EnvironmentFile = if data.dnsProvider != null then data.credentialsFile else null; + }; + acmeService = { description = "Renew ACME Certificate for ${cert}"; - after = [ "network.target" "network-online.target" ]; + + after = [ "network.target" "network-online.target" ] + ++ acmeDnsDeps; wants = [ "network-online.target" ]; + # We use `requires` to avoid lego running and falling + # back to its own acme-dns registration logic if ours + # fails; see acmeDnsRegisterService for rationale. + requires = acmeDnsDeps; wantedBy = mkIf (!config.boot.isContainer) [ "multi-user.target" ]; - serviceConfig = { - Type = "oneshot"; - User = data.user; - Group = data.group; - PrivateTmp = true; - StateDirectory = "acme/.lego/${cert} acme/.lego/accounts ${lpath}"; - StateDirectoryMode = if data.allowKeysForGroup then "750" else "700"; - WorkingDirectory = spath; - # Only try loading the credentialsFile if the dns challenge is enabled - EnvironmentFile = if data.dnsProvider != null then data.credentialsFile else null; + + # acme-dns requires CNAME support for _acme-challenge + # records. This setting only affects the behaviour of + # DNS-01 challenge propagation checks when a CNAME + # record is present; see: + # + # * https://go-acme.github.io/lego/dns/#experimental-features + # * https://github.com/go-acme/lego/blob/v3.5.0/challenge/dns01/dns_challenge.go#L179-L185 + environment.LEGO_EXPERIMENTAL_CNAME_SUPPORT = "true"; + + serviceConfig = commonServiceConfig // { ExecStart = pkgs.writeScript "acme-start" '' #!${pkgs.runtimeShell} -e test -L ${spath}/accounts -o -d ${spath}/accounts || ln -s ../accounts ${spath}/accounts @@ -364,8 +387,63 @@ in in "+${script}"; }; + }; + # For certificates using the acme-dns dnsProvider, we + # handle registration and CNAME checking ourselves + # rather than letting lego do it, as it only attempts + # registration upon renewal, leading to unpredictable + # timing of the manual interventions required to add + # the CNAME records. + acmeDnsService = { + description = "Register acme-dns Credentials for ${cert}"; + + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + serviceConfig = commonServiceConfig; + + # TODO: is openssl needed here? (needs testing with HTTPS + # acme-dns API) + path = [ pkgs.curl pkgs.openssl pkgs.dnsutils pkgs.jq ]; + script = '' + set -uo pipefail + + if ! [ -e "$ACME_DNS_STORAGE_PATH" ]; then + # We use --retry because the acme-dns server might + # not be up when the service starts (especially if + # it's local). + response=$(curl --fail --silent --show-error \ + --request POST "$ACME_DNS_API_BASE/register" \ + --max-time 30 --retry 5 --retry-connrefused \ + | jq ${escapeShellArg "{${builtins.toJSON cert}: .}"}) + # Write the response. We do this separately to the + # request to ensure that $ACME_DNS_STORAGE_PATH + # doesn't get written to if curl or jq fail. + echo "$response" > "$ACME_DNS_STORAGE_PATH" + fi + + src='_acme-challenge.${cert}.' + if ! target=$(jq --exit-status --raw-output \ + '.${builtins.toJSON cert}.fulldomain' \ + "$ACME_DNS_STORAGE_PATH"); then + echo "$ACME_DNS_STORAGE_PATH has invalid format." + echo "Try removing it and then running:" + echo ' systemctl restart acme-${cert}.service' + exit 1 + fi + + if ! dig +short CNAME "$src" | grep -qF "$target"; then + echo "Required CNAME record for $src not found." + echo "Please add the following DNS record:" + echo " $src CNAME $target." + echo "and then run:" + echo ' systemctl restart acme-${cert}.service' + exit 1 + fi + ''; }; + selfsignedService = { description = "Create preliminary self-signed certificate for ${cert}"; path = [ pkgs.openssl ]; @@ -416,6 +494,8 @@ in }; in ( [ { name = "acme-${cert}"; value = acmeService; } ] + ++ optional (data.dnsProvider == "acme-dns") + { name = "acme-dns-${cert}"; value = acmeDnsService; } ++ optional cfg.preliminarySelfsigned { name = "acme-selfsigned-${cert}"; value = selfsignedService; } ); servicesAttr = listToAttrs services; From b2f866b5078dacb84fed0a31e055ac84de1fda71 Mon Sep 17 00:00:00 2001 From: Emily Date: Thu, 19 Mar 2020 02:49:57 +0000 Subject: [PATCH 2/4] acme-dns: init at 0.8 --- pkgs/servers/dns/acme-dns/default.nix | 27 +++++++++++++++++++++++++++ pkgs/top-level/all-packages.nix | 2 ++ 2 files changed, 29 insertions(+) create mode 100644 pkgs/servers/dns/acme-dns/default.nix diff --git a/pkgs/servers/dns/acme-dns/default.nix b/pkgs/servers/dns/acme-dns/default.nix new file mode 100644 index 0000000000000..cc84a02f6970f --- /dev/null +++ b/pkgs/servers/dns/acme-dns/default.nix @@ -0,0 +1,27 @@ +{ lib +, fetchFromGitHub +, buildGoModule +}: + +buildGoModule rec { + pname = "acme-dns"; + version = "0.8"; + + src = fetchFromGitHub { + owner = "joohoi"; + repo = pname; + rev = "v${version}"; + hash = "sha256-jt4sKwC0Ws6HMFG6+EdeMLZsmmy1UhVihUARzd1EU+w="; + }; + + vendorSha256 = "sha256-jWkW7cuP0kd2ukdEJt92jMHWKwbCKL54tgEaVuo+SHs="; + + meta = { + description = "Limited DNS server to handle ACME DNS challenges easily and securely"; + inherit (src.meta) homepage; + changelog = "${meta.homepage}/blob/v${version}/README.md#changelog"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ emily ]; + platforms = lib.platforms.all; + }; +} diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix index 5ed66b3726782..8f3eaf2b83c51 100644 --- a/pkgs/top-level/all-packages.nix +++ b/pkgs/top-level/all-packages.nix @@ -536,6 +536,8 @@ in accuraterip-checksum = callPackage ../tools/audio/accuraterip-checksum { }; + acme-dns = callPackage ../servers/dns/acme-dns { }; + acme-sh = callPackage ../tools/admin/acme.sh { }; acoustidFingerprinter = callPackage ../tools/audio/acoustid-fingerprinter { From 97fe33e996077fc738e24b757066133916a5f3c8 Mon Sep 17 00:00:00 2001 From: Emily Date: Wed, 25 Mar 2020 02:52:39 +0000 Subject: [PATCH 3/4] nixos/acme-dns: init There are a few things worth noting about this module compared to the average NixOS module: * Uses the same option names and structure as the upstream project; this reduces the amount of boilerplate and templating required in the module and allows for easier porting of configurations to NixOS, at the expense of option names that are less harmonized with the rest of NixOS. * Hardened with an extensive set of sandboxing options, under the general principle that a publicly-exposed network service that essentially has complete power over your certificate renewal ought to run in as restricted an environment as possible. Unfortunately, at present this mostly means it's better-hardened than the web servers it helps issue keys for... Co-authored-by: Yegor Timoshenko --- nixos/modules/module-list.nix | 1 + nixos/modules/security/acme.nix | 4 +- nixos/modules/services/security/acme-dns.nix | 471 +++++++++++++++++++ nixos/tests/acme-dns.nix | 173 +++++++ nixos/tests/all-tests.nix | 1 + 5 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 nixos/modules/services/security/acme-dns.nix create mode 100644 nixos/tests/acme-dns.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 9f9bf3bc53294..4b8c8cfba949a 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -778,6 +778,7 @@ ./services/search/hound.nix ./services/search/kibana.nix ./services/search/solr.nix + ./services/security/acme-dns.nix ./services/security/bitwarden_rs/default.nix ./services/security/certmgr.nix ./services/security/cfssl.nix diff --git a/nixos/modules/security/acme.nix b/nixos/modules/security/acme.nix index d2a519d3c65a1..f2bb206f4c75c 100644 --- a/nixos/modules/security/acme.nix +++ b/nixos/modules/security/acme.nix @@ -338,7 +338,7 @@ in wants = [ "network-online.target" ]; # We use `requires` to avoid lego running and falling # back to its own acme-dns registration logic if ours - # fails; see acmeDnsRegisterService for rationale. + # fails; see acmeDnsService for rationale. requires = acmeDnsDeps; wantedBy = mkIf (!config.boot.isContainer) [ "multi-user.target" ]; @@ -396,7 +396,7 @@ in # timing of the manual interventions required to add # the CNAME records. acmeDnsService = { - description = "Register acme-dns Credentials for ${cert}"; + description = "Ensure acme-dns Credentials for ${cert}"; wants = [ "network-online.target" ]; after = [ "network-online.target" ]; diff --git a/nixos/modules/services/security/acme-dns.nix b/nixos/modules/services/security/acme-dns.nix new file mode 100644 index 0000000000000..6470606ba8a8b --- /dev/null +++ b/nixos/modules/services/security/acme-dns.nix @@ -0,0 +1,471 @@ +{ config, lib, pkgs, ... }: + +let + inherit (lib) types; + cfg = config.services.acme-dns; +in + +{ + options.services.acme-dns = { + enable = lib.mkOption { + type = types.bool; + default = false; + description = let + readme = + "${cfg.package.meta.homepage}/blob/v${cfg.package.version}/README.md"; + in '' + Enable the acme-dns server. + + acme-dns allows for easy and secure configuration of ACME DNS-01 + validation, which allows for the issuance of wildcard + certificates, using ACME TLS certificates even in the absence of + a public HTTP server, and separating out certificate renewal + responsibilities from the web server. + + Unlike most implementations of DNS-01 challenges, acme-dns + doesn't require dealing with DNS-provider-specific hooks or API + keys that give total control over your DNS zones; instead, it + implements a single-purpose DNS server to respond to + _acme-challenge requests, and an HTTP API + to register new domains and update the challenge records. + + To set the server up, you'll need to ensure that the nameservers + for delegate + to the machine running acme-dns; see the + acme-dns + documentation for more details. + + To use the server for ACME certificates, set + to + "acme-dns" and + to, e.g.: + + + pkgs.writeText "lego-example.com.env" ''' + ACME_DNS_API_BASE=http://localhost:8053 + ACME_DNS_STORAGE_PATH=/var/lib/acme/example.com/acme-dns.json + ''' + + + Note that you will have to manually add a CNAME record for the + _acme-challenge subdomain to your main + authoritative DNS server to complete registration; check + journalctl --unit='acme-dns-*.service' + after switching to the the new system configuration to get the + required DNS record to copy. + ''; + }; + + package = lib.mkOption { + type = types.package; + default = pkgs.acme-dns; + description = '' + acme-dns package to use. + ''; + }; + + general = lib.mkOption { + description = "General configuration."; + default = {}; + type = types.submodule { + options = { + listen = lib.mkOption { + type = types.str; + description = "Interface to listen on for DNS."; + default = ":53"; + }; + + protocol = lib.mkOption { + type = types.enum [ + "udp" "udp4" "udp6" + "tcp" "tcp4" "tcp6" + "both" "both4" "both6" + ]; + description = '' + DNS protocols to service (UDP/TCP/both, IPv4/IPv6/both). + ''; + default = "both"; + }; + + domain = lib.mkOption { + type = types.str; + description = '' + Domain name to serve DNS records for, without + trailing ".". + ''; + example = "acme-dns.example.com"; + }; + + nsname = lib.mkOption { + type = types.nullOr types.str; + description = '' + The primary name server for , + without trailing "."; used for the + MNAME field of SOA responses. + + Defaults to , which is probably + what you want (unless your authoritative DNS provider + doesn't support glue records). + ''; + default = null; + defaultText = "config.services.acme-dns.general.domain"; + example = "acme-dns.example.com"; + }; + + nsadmin = lib.mkOption { + type = types.str; + description = '' + Admin email address for SOA responses in RNAME format, + with "@" replaced by + "." and no trailing ".". + + If your email address's local-part has a + "." in it, escape it like so: + firstname\.lastname.example.com + ''; + example = "hostmaster.example.com"; + }; + + records = lib.mkOption { + type = types.listOf types.str; + description = '' + Static DNS records to serve. + + Make sure to add the A/AAAA/CNAME/NS records for + to the authoritative DNS server + for your root domain too. + ''; + example = [ + "acme-dns.example.com. A your.ip.v4.address" + "acme-dns.example.com. AAAA your:ip:v6::address" + "acme-dns.example.com. NS acme-dns.example.com." + "acme-dns.example.com. CAA 0 issue \"letsencrypt.org\"" + ]; + }; + + debug = lib.mkOption { + type = types.bool; + description = "Enable debug messages (CORS, ...?)."; + default = false; + }; + }; + }; + }; + + database = lib.mkOption { + description = "Database backend."; + default = {}; + type = types.submodule { + options = { + engine = lib.mkOption { + type = types.enum [ "sqlite3" "postgres" ]; + description = "Database engine."; + default = "sqlite3"; + }; + + connection = lib.mkOption { + type = types.str; + description = "Database connection string."; + default = "/var/lib/acme-dns/acme-dns.db"; + # TODO: allow specification via file for passwords? + example = "postgres://acme-dns@localhost/acme-dns"; + }; + }; + }; + }; + + api = lib.mkOption { + description = "HTTP API configuration."; + default = {}; + type = types.submodule { + options = { + ip = lib.mkOption { + type = types.str; + description = "Host to listen on."; + default = "localhost"; + }; + + port = lib.mkOption { + type = types.int; + description = "Port to listen on."; + default = 8053; + }; + + disable_registration = lib.mkOption { + type = types.bool; + description = '' + Disables the registration endpoint. Note that this will + prevent new domains in the client configurations from + being automatically registered, so ensure that + acme-dns-*.service succeed before + you enable this. + ''; + default = false; + }; + + tls = lib.mkOption { + # `cert` is deliberately not supported, as it's a hazard for + # bootstrapping when the certificate expires; see + # https://github.com/joohoi/acme-dns/blob/v0.8/README.md#https-api. + # + # If you really want to use it, this can be overridden + # with `extraConfig`. + type = types.enum [ "none" "letsencrypt" "letsencryptstaging" ]; + description = '' + TLS backend to use. You should set this to + if exposing the API over + the internet. + ''; + default = "none"; + }; + + acme_cache_dir = lib.mkOption { + type = types.path; + description = '' + Directory to store ACME data for the HTTP API TLS + certificate in when . + ''; + default = "/var/lib/acme-dns/api-certs"; + internal = true; + }; + + corsorigins = lib.mkOption { + type = types.listOf types.str; + description = "CORS allowed origins."; + default = [ "*" ]; + }; + + use_header = lib.mkOption { + type = types.bool; + description = '' + Get client IP from HTTP header + (see ). + ''; + default = false; + }; + + header_name = lib.mkOption { + type = types.str; + description = "HTTP header name for ."; + default = "X-Forwarded-For"; + }; + }; + }; + }; + + logconfig = lib.mkOption { + description = "Logging configuration."; + default = {}; + type = types.submodule { + options = { + loglevel = lib.mkOption { + type = types.enum [ "debug" "info" "warning" "error" ]; + description = "Minimum logging level."; + default = "debug"; + }; + + logtype = lib.mkOption { + type = types.enum [ "stdout" ]; + default = "stdout"; + # not currently customizable upstream + internal = true; + }; + + logformat = lib.mkOption { + type = types.enum [ "text" "json" ]; + description = "Logging format."; + default = "text"; + }; + }; + }; + }; + + extraConfig = lib.mkOption { + # TODO: use YAML type instead + type = types.attrs; + description = "Unchecked additional configuration."; + default = {}; + }; + + configText = lib.mkOption { + type = types.nullOr types.lines; + description = '' + Literal TOML configuration text. Overrides other configuration + options if set. + ''; + default = null; + }; + }; + + config = let + configFile = if cfg.configText != null + then pkgs.writeText "acme-dns.toml" cfg.configText + else let + baseConfig = { + general = cfg.general // + lib.optionalAttrs (cfg.general.nsname == null) + { nsname = cfg.general.domain; }; + inherit (cfg) database; + # TODO: https://github.com/joohoi/acme-dns/issues/218 + api = cfg.api // { port = toString cfg.api.port; }; + inherit (cfg) logconfig; + }; + + fullConfig = lib.recursiveUpdate baseConfig cfg.extraConfig; + in pkgs.runCommand "acme-dns.toml" {} '' + ${pkgs.remarshal}/bin/json2toml -o $out \ + <<<${lib.escapeShellArg (builtins.toJSON fullConfig)} + ''; + in lib.mkIf cfg.enable { + assertions = [ + { + assertion = !lib.hasInfix "@" cfg.general.nsadmin; + message = '' + Option services.acme-dns.general.nsadmin should contain a + valid DNS SOA RNAME-format email address with the "@" replaced + with ".". + ''; + } + ]; + + users.users.acme-dns.group = "acme-dns"; + users.groups.acme-dns = {}; + + systemd.services.acme-dns = { + description = "acme-dns server"; + + # We use network-online.target to ensure that acme-dns can reach + # Let's Encrypt to renew its own HTTPS API certificate on + # startup. This might be unnecessary if acme-dns is robust + # enough to properly retry, in which case this could be removed. + # + # Note that this should probably *not* be replaced with + # network.target unless necessary; see + # https://www.freedesktop.org/wiki/Software/systemd/NetworkTarget/. + after = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + # Run in an isolated filesystem namespace. + confinement.enable = true; + confinement.binSh = null; + + serviceConfig = { + ExecStart = "${cfg.package}/bin/acme-dns -c ${configFile}"; + + Restart = "always"; + RestartSec = "10s"; + StartLimitInterval = "1min"; + + # Set up /var/lib/acme-dns with appropriate permissions. + StateDirectory = "acme-dns"; + StateDirectoryMode = "0700"; + + # Mount / as a read-only tmpfs, overriding the default mutable + # mount used by systemd-confinement. + # + # TODO: Remove if/when #64405 is merged. + TemporaryFileSystem = lib.mkOverride 10 "/:ro"; + + # Allow some ubiquitous /etc configuration files. + BindReadOnlyPaths = [ + "-/etc/ld-nix.so.preload" + "-/etc/localtime" + "-/etc/nsswitch.conf" + "-/etc/resolv.conf" + "-/etc/hosts" + ]; + + User = "acme-dns"; + Group = "acme-dns"; + + # Needs CAP_NET_BIND_SERVICE for binding to privileged ports. + CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; + AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + + # NoNewPrivileges is implied by confinement.enable and multiple + # other things we set here, but `systemd-analyze security` + # wants to see it anyway. + NoNewPrivileges = true; + + # UMask = "0022" would make new files accessible only to the + # service user (and get us -0.1 delicious exposure points from + # systemd-analyze(1)), but results in a status=203/EXEC error on + # start, even when stubbing the ExecStart out with echo (maybe + # because of things the systemd sandboxing setup does?). + # + # TODO: Figure out what's up here and consider enabling it. + + # We don't enable ProtectSystem, as it's redundant to + # confinement.enable and exposes a lot of the filesystem (albeit + # as read-only). 0.2 exposure points are unfairly given to us by + # systemd-analyze(1) as a result. :( + + # See ProtectSystem, but this one is harmless, so we turn it on. + ProtectHome = true; + + # PrivateTmp is implied by confinement.enable. + + # PrivateDevices is implied by confinement.enable. + + # We can't use PrivateUsers, although we'd like to, because it + # unconditionally runs processes with no privileges on the host, + # and we need CAP_NET_BIND_SERVICE. This could be solved with + # socket activation support in acme-dns, or proxying. + # + # TODO: Add configuration to systemd-confinement for this? + PrivateUsers = lib.mkOverride 10 false; + + # Don't allow changing hostname. + ProtectHostname = true; + + # ProtectClock is redundant with CapabilityBoundingSet, + # SystemCallFilter, and PrivateDevices. We don't set it because + # it unnecessarily grants read permission for the RTC device, at + # the expense of 0.2 exposure points from systemd-analyze(1). + + # No need to access kernel logs. + ProtectKernelLogs = true; + + # Restrict the process to IP sockets. + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + + # Don't allow the process to use unprivileged user namespaces + # even if enabled in the kernel; they're unneeded and have been + # the cause of security bugs in the past. + RestrictNamespaces = true; + + # Unusual personalities/architectures can have obscure bugs, and + # we have no need for them. + LockPersonality = true; + + # No JIT, so no need for W+X memory. + MemoryDenyWriteExecute = true; + + # No need for realtime scheduling. + RestrictRealtime = true; + + # Disallow creation of setuid/setgid files. + RestrictSUIDSGID = true; + + # Don't leave IPC objects lying around. + RemoveIPC = true; + + # PrivateMounts is redundant with confinement.enable. + + # Restrict the set of available system calls. + # See also `systemd-analyze syscall-filter`. + SystemCallFilter = [ + "@system-service" + + # Disallow admin-only syscalls, adjusting resource limits, + # changing groups, and kernel keyring access. + "~@privileged @resources @setuid @keyring" + ]; + SystemCallErrorNumber = "EPERM"; + + # See LockPersonality. + SystemCallArchitectures = "native"; + }; + }; + }; +} diff --git a/nixos/tests/acme-dns.nix b/nixos/tests/acme-dns.nix new file mode 100644 index 0000000000000..eb479f7336328 --- /dev/null +++ b/nixos/tests/acme-dns.nix @@ -0,0 +1,173 @@ +# TODO: Test services.acme-dns.api.tls once +# https://github.com/joohoi/acme-dns/issues/214 is fixed. + +let + ipOf = node: node.config.networking.primaryIPAddress; + + common = { lib, nodes, ... }: { + networking.nameservers = lib.mkForce [ (ipOf nodes.coredns) ]; + }; +in + +import ./make-test-python.nix ({ lib, ... }: { + name = "acme-dns"; + meta.maintainers = with lib.maintainers; [ emily yegortimoshenko ]; + + nodes = { + acme.imports = [ common ./common/acme/server ]; + + acmedns = { nodes, pkgs, ... }: { + imports = [ common ]; + + networking.firewall = { + allowedTCPPorts = [ 53 8053 ]; + allowedUDPPorts = [ 53 ]; + }; + + services.acme-dns = { + enable = true; + api.ip = "0.0.0.0"; + general = { + domain = "acme-dns.test"; + nsadmin = "hostmaster.acme-dns.test"; + records = [ + "acme-dns.test. A ${ipOf nodes.acmedns}" + "acme-dns.test. NS acme-dns.test." + ]; + }; + }; + }; + + coredns = { nodes, pkgs, ... }: { + imports = [ common ]; + + networking.firewall = { + allowedTCPPorts = [ 53 ]; + allowedUDPPorts = [ 53 ]; + }; + + services.coredns = { + enable = true; + config = '' + . { + auto { + directory /etc/coredns/zones + reload 1s + } + } + + acme-dns.test { + forward . ${ipOf nodes.acmedns} + } + ''; + }; + + environment.etc = let + zone = records: { + text = '' + $TTL 1h + @ SOA coredns.test. hostmaster.example.test. ( + 1 ; serial + 1d 2h 1w 1 + ) + @ NS coredns.test. + ${records} + ''; + }; + zoneFile = domain: lib.nameValuePair "coredns/zones/db.${domain}"; + in lib.mapAttrs' zoneFile { + "coredns.test" = zone "@ A ${ipOf nodes.coredns}"; + "acme.test" = zone "@ A ${ipOf nodes.acme}"; + "example.test" = zone "webserver A ${ipOf nodes.webserver}" // + { mode = "0644"; }; + }; + }; + + webserver = { config, pkgs, ... }: { + imports = [ common ./common/acme/client ]; + + services.nginx.enable = true; + + security.acme = { + server = "https://acme.test/dir"; + certs."example.test" = { + domain = "*.example.test"; + user = "nginx"; + group = "nginx"; + dnsProvider = "acme-dns"; + credentialsFile = pkgs.writeText "lego-example.test.env" '' + ACME_DNS_API_BASE=http://acme-dns.test:8053 + ACME_DNS_STORAGE_PATH=/var/lib/acme/example.test/acme-dns.json + ''; + }; + }; + + systemd.targets."acme-finished-example.test" = {}; + systemd.services."acme-example.test" = { + wants = [ "acme-finished-example.test.target" ]; + before = [ "acme-finished-example.test.target" ]; + }; + + specialisation.serving.configuration = { + networking.firewall.allowedTCPPorts = [ 443 ]; + + services.nginx.virtualHosts."webserver.example.test" = { + onlySSL = true; + useACMEHost = "example.test"; + locations."/".root = pkgs.runCommand "root" {} '' + mkdir $out + echo "hello world" > $out/index.html + ''; + }; + }; + }; + + webclient.imports = [ common ./common/acme/client ]; + }; + + testScript = '' + start_all() + + acme.wait_for_unit("pebble.service") + acmedns.wait_for_unit("acme-dns.service") + coredns.wait_for_unit("coredns.service") + + + def acme_dns_check_failed(_) -> bool: + info = webserver.get_unit_info("acme-dns-example.test.service") + if info["ActiveState"] == "active": + raise Exception( + "acme-dns-example.test.service succeeded before the CNAME record was added" + ) + return info["ActiveState"] == "failed" + + + # Get the required CNAME record from the error message. + retry(acme_dns_check_failed) + acme_dns_record = webserver.succeed( + "journalctl --no-pager --output=cat --reverse --lines=1 " + "--unit=acme-dns-example.test.service " + "--grep='^ _acme-challenge\\.example\\.test\\. CNAME '" + ).strip() + + zone_file = "/etc/coredns/zones/db.example.test" + coredns.succeed( + f"printf '%s\\n' {acme_dns_record!r} >> {zone_file}", + f"sed -i 's/1 ; serial/2 ; serial/' {zone_file}", + "sleep 1", + ) + + webserver.start_job("acme-example.test.service") + webserver.wait_for_unit("acme-finished-example.test.target") + webserver.succeed( + "/run/current-system/specialisation/serving/bin/switch-to-configuration test" + ) + + webclient.wait_for_unit("default.target") + webclient.succeed("curl https://acme.test:15000/roots/0 > /tmp/ca.crt") + webclient.succeed("curl https://acme.test:15000/intermediate-keys/0 >> /tmp/ca.crt") + webclient.succeed( + "curl --cacert /tmp/ca.crt https://webserver.example.test | grep -qF 'hello world'" + ) + ''; +}) diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 5812098736439..33b1bc668ec19 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -23,6 +23,7 @@ in { _3proxy = handleTest ./3proxy.nix {}; acme = handleTest ./acme.nix {}; + acme-dns = handleTest ./acme-dns.nix {}; agda = handleTest ./agda.nix {}; atd = handleTest ./atd.nix {}; avahi = handleTest ./avahi.nix {}; From 7785f9ad8a053b340a4d8b6abb0ad00e4c0737bc Mon Sep 17 00:00:00 2001 From: Emily Date: Sun, 24 May 2020 23:48:29 +0100 Subject: [PATCH 4/4] nixos/acme: document acme-dns --- nixos/modules/security/acme.xml | 98 +++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 40 deletions(-) diff --git a/nixos/modules/security/acme.xml b/nixos/modules/security/acme.xml index f802faee97490..4c4c3b4349474 100644 --- a/nixos/modules/security/acme.xml +++ b/nixos/modules/security/acme.xml @@ -189,25 +189,32 @@ services.httpd = { ACME servers will only hand out wildcard certs over DNS validation. There a number of supported DNS providers and servers you can utilise, see the lego docs - for provider/server specific configuration values. For the sake of these - docs, we will provide a fully self-hosted example using bind. + for provider/server specific configuration values. For the sake of + this documentation, we will provide an example using + acme-dns, + which lets you host ACME DNS challenges on a separate DNS server for + simplicity and security. For single-machine setups, like shown here, + you can run acme-dns on the same machine that requests + the certificates. -services.bind = { - enable = true; - extraConfig = '' - include "/var/lib/secrets/dnskeys.conf"; - ''; - zones = [ - rec { - name = "example.com"; - file = "/var/db/bind/${name}"; - master = true; - extraConfig = "allow-update { key rfc2136key.example.com.; };"; - } - ]; -} +services.acme-dns = { + enable = true; + general = { + domain = "acme-dns.example.com"; + + # Email address in DNS SOA RNAME format; see the option + # documentation for details. + nsadmin = "admin+acme-dns.example.com"; + + records = [ + "acme-dns.example.com. A your.ip.v4.address" + "acme-dns.example.com. AAAA your:ip:v6::address" + "acme-dns.example.com. NS acme-dns.example.com." + ]; + }; +}; # Now we can configure ACME = true; @@ -215,35 +222,46 @@ services.bind = { ."example.com" = { domain = "*.example.com"; dnsProvider = "rfc2136"; - credentialsFile = "/var/lib/secrets/certs.secret"; - # We don't need to wait for propagation since this is a local DNS server - dnsPropagationCheck = false; + credentialsFile = pkgs.writeText "lego-example.com.env" '' + ACME_DNS_API_BASE=http://localhost:8053 + ACME_DNS_STORAGE_PATH=/var/lib/acme/example.com/acme-dns.json + ''; }; - The dnskeys.conf and certs.secret - must be kept secure and thus you should not keep their contents in your - Nix config. Instead, generate them one time with these commands: - + You'll need to mirror the A, + AAAA and NS records with the + upstream DNS provider for your domain (here + example.com) so that the ACME provider can resolve + the acme-dns domain. Note that if your DNS provider doesn't support + glue records (having both + A/AAAA and + NS records for the same zone), you'll need to set + to a + different domain name (hereafter + acme-dns-ns.example.com), add the upstream + A/AAAA records to that zone + instead, and adjust the NS record to + acme-dns.example.com. NS acme-dns-ns.example.com. + both upstream and in the acme-dns configuration. (You should + keep the records for acme-dns.example.com in + ; + acme-dns-ns.example.com will be the authoritative + nameserver for acme-dns.example.com, so acme-dns + must return records for that domain.) + - -mkdir -p /var/lib/secrets -tsig-keygen rfc2136key.example.com > /var/lib/secrets/dnskeys.conf -chown named:root /var/lib/secrets/dnskeys.conf -chmod 400 /var/lib/secrets/dnskeys.conf - -# Copy the secret value from the dnskeys.conf, and put it in -# RFC2136_TSIG_SECRET below - -cat > /var/lib/secrets/certs.secret << EOF -RFC2136_NAMESERVER='127.0.0.1:53' -RFC2136_TSIG_ALGORITHM='hmac-sha256.' -RFC2136_TSIG_KEY='rfc2136key.example.com' -RFC2136_TSIG_SECRET='your secret key' -EOF -chmod 400 /var/lib/secrets/certs.secret - + + Once that's set up, you'll need to add CNAME + records for the _acme-challenge + subdomains of each domain you're issuing certificates for to delegate + challenges to acme-dns. The required records are printed in the logs + of the acme-dns-*.service units; after the first + issuance attempt, you can run journalctl + --unit='acme-dns-*.service' for a list of records to add to + your upstream DNS provider. + Now you're all set to generate certs! You should monitor the first invokation