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 776ef07d716c6..f2bb206f4c75c 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 acmeDnsService 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 = "Ensure 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; 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 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 {}; 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 {