Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

acme-dns: init at 0.8 #83474

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions nixos/modules/module-list.nix
Expand Up @@ -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
Expand Down
102 changes: 91 additions & 11 deletions nixos/modules/security/acme.nix
Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This dependency shouldn't be added if acme-dns isn't running on the same machine.

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
Expand Down Expand Up @@ -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
Comment on lines +410 to +443
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the registration logic moved to the lego binary or some helper binary installed if with acme-dns?

I'm a bit afraid of the more flaky parts here to break in funny ways, and assume more people would benefit from being able to check the DNS configuration w.r.t. acme-dns.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lego's goacmedns dependency isn't packaged separately, but if it was then we could use goacmedns-register(1). It would only affect 4 lines here, so I figured it wasn't worth it as the API is just an empty POST. If you think it'd be better then I can package and use it.

Unfortunately lego handles this really badly natively; it doesn't even bother registering until renew time (so you have to scramble to set up CNAME records at unpredictable intervals on initial migration to acme-dns certificates) and doesn't check the CNAME records at all (instead, it just prints the record unconditionally on initial registration and then fails mysteriously if you don't set it up). It would be nice if we could delegate some of this to lego, but given that we're already having to architect our own solution to forcing renewals on configuration changes, etc., I think some of this complexity is unavoidable to ensure a reasonable user experience unless lego changes its interface/operating model.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add comments to these issues into the comment at the beginning of the block.

'';
};

selfsignedService = {
description = "Create preliminary self-signed certificate for ${cert}";
path = [ pkgs.openssl ];
Expand Down Expand Up @@ -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;
Expand Down
98 changes: 58 additions & 40 deletions nixos/modules/security/acme.xml
Expand Up @@ -189,61 +189,79 @@ 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 <link xlink:href="https://go-acme.github.io/lego/dns/">lego docs</link>
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
<link xlink:href="https://github.com/joohoi/acme-dns">acme-dns</link>,
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.
</para>

<programlisting>
services.bind = {
<link linkend="opt-services.bind.enable">enable</link> = true;
<link linkend="opt-services.bind.extraConfig">extraConfig</link> = ''
include "/var/lib/secrets/dnskeys.conf";
'';
<link linkend="opt-services.bind.zones">zones</link> = [
rec {
name = "example.com";
file = "/var/db/bind/${name}";
master = true;
extraConfig = "allow-update { key rfc2136key.example.com.; };";
}
];
}
services.acme-dns = {
<link linkend="opt-services.acme-dns.enable">enable</link> = true;
<link linkend="opt-services.acme-dns.general">general</link> = {
<link linkend="opt-services.acme-dns.general.domain">domain</link> = "acme-dns.example.com";

# Email address in DNS SOA RNAME format; see the option
# documentation for details.
<link linkend="opt-services.acme-dns.general.nsadmin">nsadmin</link> = "admin+acme-dns.example.com";

<link linkend="opt-services.acme-dns.general.records">records</link> = [
"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
<xref linkend="opt-security.acme.acceptTerms" /> = true;
<xref linkend="opt-security.acme.email" /> = "admin+acme@example.com";
<xref linkend="opt-security.acme.certs" />."example.com" = {
<link linkend="opt-security.acme.certs._name_.domain">domain</link> = "*.example.com";
<link linkend="opt-security.acme.certs._name_.dnsProvider">dnsProvider</link> = "rfc2136";
<link linkend="opt-security.acme.certs._name_.credentialsFile">credentialsFile</link> = "/var/lib/secrets/certs.secret";
# We don't need to wait for propagation since this is a local DNS server
<link linkend="opt-security.acme.certs._name_.dnsPropagationCheck">dnsPropagationCheck</link> = false;
<link linkend="opt-security.acme.certs._name_.credentialsFile">credentialsFile</link> = 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
'';
};
</programlisting>

<para>
The <filename>dnskeys.conf</filename> and <filename>certs.secret</filename>
must be kept secure and thus you should not keep their contents in your
Nix config. Instead, generate them one time with these commands:
</para>
You'll need to mirror the <literal>A</literal>,
<literal>AAAA</literal> and <literal>NS</literal> records with the
upstream DNS provider for your domain (here
<literal>example.com</literal>) so that the ACME provider can resolve
the acme-dns domain. Note that if your DNS provider doesn't support
glue records (having both
<literal>A</literal>/<literal>AAAA</literal> and
<literal>NS</literal> records for the same zone), you'll need to set
<xref linkend="opt-services.acme-dns.general.nsname" /> to a
different domain name (hereafter
<literal>acme-dns-ns.example.com</literal>), add the upstream
<literal>A</literal>/<literal>AAAA</literal> records to that zone
instead, and adjust the <literal>NS</literal> record to
<literal>acme-dns.example.com. NS acme-dns-ns.example.com.</literal>
both upstream and in the acme-dns configuration. (You should
keep the records for <literal>acme-dns.example.com</literal> in
<xref linkend="opt-services.acme-dns.general.records" />;
<literal>acme-dns-ns.example.com</literal> will be the authoritative
nameserver for <literal>acme-dns.example.com</literal>, so acme-dns
must return records for that domain.)
</para>

<programlisting>
mkdir -p /var/lib/secrets
tsig-keygen rfc2136key.example.com &gt; /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 &gt; /var/lib/secrets/certs.secret &lt;&lt; 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
</programlisting>
<para>
Once that's set up, you'll need to add <literal>CNAME</literal>
records for the <literal>_acme-challenge</literal>
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 <literal>acme-dns-*.service</literal> units; after the first
issuance attempt, you can run <command>journalctl
--unit='acme-dns-*.service'</command> for a list of records to add to
your upstream DNS provider.
</para>

<para>
Now you're all set to generate certs! You should monitor the first invokation
Expand Down