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 {