diff options
| -rw-r--r-- | Makefile | 6 | ||||
| -rw-r--r-- | acme.conf.5 | 44 | ||||
| -rw-r--r-- | acme.conf.sample | 31 | ||||
| -rw-r--r-- | dns.sh | 78 | ||||
| -rw-r--r-- | dnsutils.sh | 84 | ||||
| -rw-r--r-- | domains.conf.5 | 23 | ||||
| -rw-r--r-- | domains.conf.sample | 45 | ||||
| -rw-r--r-- | kerberos.sh | 89 | ||||
| -rw-r--r-- | lfacme-dns.5 | 60 | ||||
| -rw-r--r-- | lfacme-http.5 | 13 | ||||
| -rw-r--r-- | lfacme-kerberos.5 | 30 |
11 files changed, 330 insertions, 173 deletions
@@ -10,10 +10,11 @@ MAN8DIR?= ${MANDIR}/man8 HOOKDIR?= ${CONFDIR}/hooks LIBMODE?= 0644 -LIB= init.sh +LIB= init.sh dnsutils.sh CHALLENGEMODE?= 0755 -CHALLENGE= http.sh \ +CHALLENGE= dns.sh \ + http.sh \ kerberos.sh BINMODE?= 0755 @@ -30,6 +31,7 @@ HOOK= example-hook.sh MANMODE?= 0644 MAN5= acme.conf.5 \ domains.conf.5 \ + lfacme-dns.5 \ lfacme-http.5 \ lfacme-kerberos.5 MAN8= lfacme-renew.8 \ diff --git a/acme.conf.5 b/acme.conf.5 index 269b99b..0f17377 100644 --- a/acme.conf.5 +++ b/acme.conf.5 @@ -10,9 +10,14 @@ .Sh DESCRIPTION The .Nm -file is a shell script used to configure the global behaviour of +file is used to configure the global behaviour of .Nm lfacme . -The following variables may be set: +Each option should be configured as a +.Xr sh 1 +variable assignment, i.e. +.Dq Ar option Ns = Ns Ar value . +.Pp +The following configuration variables are supported: .Bl -tag -width indent .It Va ACME_URL (Required.) @@ -28,39 +33,10 @@ The path to a directory containing hooks to invoke when issuing certificates .Xr domains.conf 5 ) . The default value is .Pa /usr/local/etc/lfacme/hooks . -.It Va ACME_HTTP_CHALLENGE_DIR -The directory to store ACME challenges when responding to an -.Dq http-01 -challenge with the -.Dq http -challenge handler. -This directory must be served at -.Dq /.well-known/acme-challenge -on any domain which will be validated with the -.Dq http -handler. -There is no default value; you must set this if you use the -.Dq http -handler. -.It Va ACME_KERBEROS_PRINCIPAL -The Kerberos principal to use when responding to a -.Dq dns-01 -challenge with the -.Dq kerberos -challenge handler. -The default value is -.Dq host/$(hostname) . -.It Va ACME_KERBEROS_KEYTAB -The Kerberos keytab to use when responding to a -.Dq dns-01 -challenge with the -.Dq kerberos -challenge handler. -The keytab must contain a Kerberos key for the principal configured in -.Va ACME_KERBEROS_PRINCIPAL . -The default value is -.Pa /etc/krb5.keytab . .El +.Pp +Additional configuration variables may be used by the ACME validation hooks; +refer to the manual page for each hook for more details. .Sh SEE ALSO .Xr domains.conf 5 , .Xr lfacme-renew 8 , diff --git a/acme.conf.sample b/acme.conf.sample index 86d8693..151e4c9 100644 --- a/acme.conf.sample +++ b/acme.conf.sample @@ -1,6 +1,11 @@ # This is a sample configuration file for lfacme. It is a shell script, # so you can include other files or call programs here if you like. +####################################################################### +# Base options. +# +# These options are used by lfacme itself. + ### ACME_URL # The URL of the ACME server. @@ -29,6 +34,12 @@ #ACME_HOOKDIR="/some/directory" +####################################################################### +# lfacme-http(5) options. +# +# These options are used for the "http" challenge. + + ### ACME_HTTP_CHALLENGE_DIR # When using the "http" challenge handler, this is the directory which contains # ACME challenges. This must be served at /.well-known/acme-challenge on any @@ -38,6 +49,25 @@ #ACME_HTTP_CHALLENGE_DIR="/var/www/acme-challenge" +####################################################################### +# lfacme-dns(5) options. +# +# These options are used for the "dns" challenge. + + +### ACME_DNS_KEYFILE +# Path to the TSIG key nsupdate will use to authenticate the update. +# No default; you must configure this when using the dns challenge. + +#ACME_DNS_KEYFILE="/path/to/key" + + +####################################################################### +# lfacme-kerberos(5) options. +# +# These options are used for the "kerberos" challenge. + + ### ACME_KERBEROS_PRINCIPAL # When using the "kerberos" challenge handler, this is the Kerberos principal # we use for nsupdate. The default is "host/$(hostname)", which assumes a @@ -45,6 +75,7 @@ #ACME_KERBEROS_PRINCIPAL="host/server.example.org@EXAMPLE.ORG" + ### ACME_KERBEROS_KEYTAB # When using the "kerberos" challenge handler, this is the keytab used to # issue the ticket. It must contain a key for $ACME_KERBEROS_PRINCIPAL. @@ -0,0 +1,78 @@ +#! /bin/sh +# This source code is released into the public domain. + +. /usr/local/share/lfacme/init.sh +. /usr/local/share/lfacme/dnsutils.sh + +# begin, done or failed +ACTION=$1 +# ACME method, must be dns-01. +METHOD=$2 +# This is the full domain name we're authorising. +DOMAIN=$3 +# Token name, not used for dns-01. +TOKEN=$4 +# The token value we need to create. +AUTH=$5 + +if [ "$#" -ne 5 ]; then + _fatal "missing arguments" +fi + +if [ "$METHOD" != "dns-01" ]; then + exit 1 +fi + +if [ -z "$ACME_DNS_KEYFILE" ]; then + _fatal "ACME_DNS_KEYFILE not configured" +fi + +# Add a new record using nsupdate. +_add_record() { + local domain="$1" + local auth="$2" + + nsupdate -k "$ACME_DNS_KEYFILE" <<EOF +update add _acme-challenge.${DOMAIN}. 300 IN TXT "${AUTH}" +send +EOF + return $? +} + +# Remove an existing record using nsupdate. +_remove_record() { + local domain="$1" + local auth="$2" + + nsupdate -k "$ACME_DNS_KEYFILE" <<EOF +update delete _acme-challenge.${DOMAIN}. 300 IN TXT "${AUTH}" +send +EOF + return $? +} + +case "$ACTION" in + begin) + if ! _add_record "$DOMAIN" "$AUTH"; then + _fatal "failed to add the DNS record for %s" "$DOMAIN" + exit 1 + fi + + if ! lfacme_dns_wait_for_record "$DOMAIN" "$AUTH"; then + _fatal "timed out waiting for the DNS record for '%s' to be published" \ + "$DOMAIN" + exit 1 + fi + + exit 0 + ;; + + done|failed) + _remove_record "$DOMAIN" "$AUTH" + exit $? + ;; + + *) + _fatal "unknown action: %s" "$ACTION" + ;; +esac diff --git a/dnsutils.sh b/dnsutils.sh new file mode 100644 index 0000000..a1523ff --- /dev/null +++ b/dnsutils.sh @@ -0,0 +1,84 @@ +# This source code is released into the public domain. +# +# Utility functions for DNS-based authorizations. + +# Retrieve the nameservers for a given domain. On failure, prints an error +# message and exits. +lfacme_dns_getnameservers() { + local domain="$1" + + # Keep removing labels from the name until we find one with nameservers. + local _trydomain="$domain" + while ! [ -z "$_trydomain" ]; do + if [ "$_trydomain" = "${_trydomain#*.}" ]; then + # If there are no dots in the domain, we couldn't + # find the nameservers. + break + fi + + # For CNAME records, a query for NS will return the CNAME. + # Therefore we have to check we actually got NS records. + local nameservers="$( + dig "$_trydomain" ns +noall +answer | \ + awk '$4 == "NS" { print $5 }' + )" + + if ! [ -z "$nameservers" ]; then + echo "$nameservers" + return + fi + + _trydomain="${_trydomain#*.}" + done + + _fatal "unable to find nameservers for %s" "$_trydomain" +} + +# Wait for the DNS record to appear on a specific nameserver. +lfacme_dns_wait_for_nameserver() { + local domain="$1" + local auth="$2" + local nameserver="$3" + + _verbose "waiting for nameserver %s" "$nameserver" + + local waited=0 + local waitlimit=60 + while sleep 1; do + waited=$((waited + 1)) + if [ "$waited" -ge "$waitlimit" ]; then + _error "timed out waiting for '%s' on '%s'" \ + "$domain" "$nameserver" + return 1 + fi + + local _rdatas="$( + dig "_acme-challenge.$domain" txt @$nameserver \ + +noall +answer \ + | awk '$4 == "TXT" { print $5 }' + )" + for rdata in $_rdatas; do + if [ "$rdata" = "\"$auth\"" ]; then + return 0 + fi + done + done +} + +# Wait for DNS servers to have the given record. +lfacme_dns_wait_for_record() { + local domain="$1" + local auth="$2" + local nameservers="$(lfacme_dns_getnameservers "$domain")" + + _verbose "waiting for the DNS record '%s' to be published" "$domain" + for ns in $nameservers; do + if ! lfacme_dns_wait_for_nameserver "$domain" "$auth" "$ns"; then + return 1 + fi + done + + return 0 +} + + diff --git a/domains.conf.5 b/domains.conf.5 index ba65610..fd071e4 100644 --- a/domains.conf.5 +++ b/domains.conf.5 @@ -14,9 +14,9 @@ file is used to configure the certificates that .Nm lfacme will issue or renew. Each line specifies one certificate as a series of whitespace-separated fields. -The first field is the certificate name, which is used internally by +The first field is the certificate name, which is used by .Nm lfacme -in the certificate filename but is not part of the certificate itself. +to create the certificate filename but is not part of the certificate itself. The remaining fields are either certificate options or subject alt names for the certificate. .Pp @@ -63,24 +63,25 @@ The challenge script is passed to .Xr uacme 1 ; see the uacme documentation for details on the calling convention. .Pp -Two challenge scripts are provided with +The following challenge scripts are provided with .Nm lfacme : .Bl -tag -width kerberos .It Sy http Use HTTP-based validation. -This requires -.Va ACME_HTTP_CHALLENGE_DIR -to be set in -.Xr acme.conf 5 . +See +.Xr lfacme-http 5 . This is the default challenge handler. +.It Sy dns +Use DNS-based validation with +.Xr nsupdate 1 . +See +.Xr lfacme-dns 5 . .It Sy kerberos Use DNS-based validation with .Xr nsupdate 1 using Kerberos authentication. -This requires -.Va ACME_KERBEROS_PRINCIPAL -to be set in -.Xr acme.conf 5 . +See +.Xr lfacme-kerberos 5 . .El .It Sy hook Ns Li = Ns Ar filename Invoke diff --git a/domains.conf.sample b/domains.conf.sample index 5eb66d4..59ce539 100644 --- a/domains.conf.sample +++ b/domains.conf.sample @@ -5,52 +5,13 @@ # syntax here. # # Empty lines and lines beginning with a '#' character are ignored. - -## -# Each line specifies one certificate as a series of whitespace-separated -# fields. The first field is the certificate name, which is used internally -# by lfacme in the certificate filename, but is not part of the certificate -# itself. # -# The remaining fields are either certificate options or subject alt names for -# the certificate. -# -# If no subject alt names are provided, then the certificate name is used as -# the common name and subject alt name. Otherwise, the first subject alt name -# is used as the common name. +# A few examples are provided here; see acme.conf(5) for complete documentation. -## -# Supported options: -# -# type=ec Generate a secp384r1 ECDSA private key. -# (This is the default.) -# -# type=rsa Generate a 3072-bit RSA private key. -# -# hook=<name> Run the hook '<name>' after (re)issuing this certificate. -# If <name> begins with a '/' then it is an absolute path, -# otherwise it is relative to $ACME_HOOKDIR. -# This option may be given multiple times. -# -# challenge=<name> -# Use <name> as the challenge handler. If <name> begins -# with '/' then it is an absolute path, otherwise it will -# be searched for in /usr/local/share/lfacme/challenge/, -# then /usr/local/etc/lfacme/challenge/. -# -# The challenge handler is passed to uacme(1), so you can use -# any uacme-compatible handler here. -# -# Two handlers are supplied with lfacme: "http" and "kerberos". -# The default handler is "http". -## -# A certificate name of "*" can be used to set the default options for any -# following certificates. Each "*" line replaces the previous default -# options. You can specify subject alt names here as well. -# -# For example, to use RSA (instead of the default ECDSA) for all certificates: +# To use RSA (instead of the default ECDSA) for all certificates: * type=rsa + # To use HTTP for all challenges: * challenge=http diff --git a/kerberos.sh b/kerberos.sh index 2bbfd0f..08663d8 100644 --- a/kerberos.sh +++ b/kerberos.sh @@ -2,6 +2,7 @@ # This source code is released into the public domain. . /usr/local/share/lfacme/init.sh +. /usr/local/share/lfacme/dnsutils.sh # begin, done or failed ACTION=$1 @@ -30,36 +31,6 @@ if ! kinit -k -t "$ACME_KERBEROS_KEYTAB" "$ACME_KERBEROS_PRINCIPAL"; then _fatal "failed to obtain a Kerberos ticket" fi -# Keep removing labels from the name until we find one with nameservers. -_getnameservers() { - local domain="$1" - - local _trydomain="$domain" - while ! [ -z "$_trydomain" ]; do - if [ "$_trydomain" = "${_trydomain#*.}" ]; then - # If there are no dots in the domain, we couldn't - # find the nameservers. - break - fi - - # For CNAME records, a query for NS will return the CNAME. - # Therefore we have to check we actually got NS records. - local nameservers="$( - dig "$_trydomain" ns +noall +answer | \ - awk '$4 == "NS" { print $5 }' - )" - - if ! [ -z "$nameservers" ]; then - echo "$nameservers" - return - fi - - _trydomain="${_trydomain#*.}" - done - - _fatal "unable to find nameservers for %s" "$_trydomain" -} - # Add a new record using nsupdate. _add_record() { local domain="$1" @@ -84,56 +55,20 @@ EOF return $? } -# Wait for the DNS record to appear on a specific nameserver. -_wait_for_nameserver() { - local domain="$1" - local auth="$2" - local nameserver="$3" - - _verbose "waiting for nameserver %s" "$nameserver" - - local waited=0 - local waitlimit=60 - while sleep 1; do - waited=$((waited + 1)) - if [ "$waited" -ge "$waitlimit" ]; then - _error "timed out waiting for '%s' on '%s'" \ - "$domain" "$nameserver" - return 1 +case "$ACTION" in + begin) + if ! _add_record "$DOMAIN" "$AUTH"; then + _fatal "failed to add the DNS record for %s" "$DOMAIN" + exit 1 fi - local _rdatas="$( - dig "_acme-challenge.$domain" txt @$nameserver \ - +noall +answer \ - | awk '$4 == "TXT" { print $5 }' - )" - for rdata in $_rdatas; do - if [ "$rdata" = "\"$auth\"" ]; then - return 0 - fi - done - done -} - -# Wait for DNS servers to have the given record. -_wait_for_record() { - local domain="$1" - local auth="$2" - local nameservers="$(_getnameservers "$domain")" - - _verbose "waiting for the DNS record '%s' to be published" "$domain" - for ns in $nameservers; do - _wait_for_nameserver "$domain" "$auth" "$ns" || return 1 - done - - return 0 -} + if ! lfacme_dns_wait_for_record "$DOMAIN" "$AUTH"; then + _fatal "timed out waiting for the DNS record for '%s' to be published" \ + "$DOMAIN" + exit 1 + fi -case "$ACTION" in - begin) - _add_record "$DOMAIN" "$AUTH" \ - && _wait_for_record "$DOMAIN" "$AUTH" - exit $? + exit 0 ;; done|failed) diff --git a/lfacme-dns.5 b/lfacme-dns.5 new file mode 100644 index 0000000..dedf250 --- /dev/null +++ b/lfacme-dns.5 @@ -0,0 +1,60 @@ +.\" This source code is released into the public domain. +.Dd June 4, 2025 +.Dt LFACME-DNS 5 +.Os +.Sh NAME +.Nm lfacme-dns +.Nd validate an ACME challenge via TSIG DNS updates +.Sh SYNOPSIS +In +.Xr domains.conf 5 : +.Bd -ragged -offset indent +.Ar domain +challenge=dns +.Ed +.Sh DESCRIPTION +The +.Nm +challenge hook will respond to an ACME domain validation using a DNS-based +.Dq dns-01 +authorization with TSIG-authenticated Dynamic DNS updates. +To use this challenge hook, configure one or more domains with +.Dq challenge=dns +in +.Xr domains.conf 5 . +.Pp +The +.Dq dns-01 +challenge expects the authorization token to be created as a TXT record at the +DNS name +.Dq _acme-challenge. Ns Ar domain . +When +.Nm +responds to the challenge, it will use +.Xr nsupdate 1 +to create this record. +The DNS update will be sent to the zone's master server (determined by the +MNAME field in the SOA record), and will be authenticated using the TSIG +key file configured by +.Ar ACME_DNS_KEYFILE +in +.Xr acme.conf 5 . +.Pp +Once validation is complete, the previously created DNS record will be removed. +.Sh CONFIGURATION +The +.Nm +challenge hook supports the following configuration options in +.Xr acme.conf 5 : +.Bl -tag -width indent +.It Va ACME_DNS_KEYFILE +(Required.) +The key file that will be passed to +.Xr nsupdate 1 +to authenticate the DNS update. +.El +.Sh SEE ALSO +.Xr acme.conf 5 , +.Xr domains.conf 5 , +.Xr lfacme-renew 8 , +.Xr nsupdate 1 diff --git a/lfacme-http.5 b/lfacme-http.5 index c66f9f8..ed5ca8e 100644 --- a/lfacme-http.5 +++ b/lfacme-http.5 @@ -37,6 +37,19 @@ in .Xr acme.conf 5 . This directory must be mapped to the appropriate path on the web server for the challenge to succeed. +.Sh CONFIGURATION +The +.Nm +challenge hook supports the following configuration options in +.Xr acme.conf 5 : +.Bl -tag -width indent +.It Va ACME_HTTP_CHALLENGE_DIR +(Required.) +The directory to place the challenge tokens in. +The contents of this directory should be served at the path +.Dq /.well-known/acme-challenge +on the web server for the domain to be validated. +.El .Sh SEE ALSO .Xr acme.conf 5 , .Xr domains.conf 5 , diff --git a/lfacme-kerberos.5 b/lfacme-kerberos.5 index 06b5b00..27973c7 100644 --- a/lfacme-kerberos.5 +++ b/lfacme-kerberos.5 @@ -34,9 +34,9 @@ responds to the challenge, it will use .Xr nsupdate 1 with the .Fl g -flag (enable GSS-TSIG) to create this token. -The DNS update will be sent to the zone's master server (determined by the -MNAME field in the SOA record). +flag to create this token. +The DNS update will be sent to the zone's master server, as determined by the +MNAME field in the SOA record. .Pp Before sending the update, .Nm @@ -46,10 +46,26 @@ for the principal configured by .Ar ACME_KERBEROS_PRINCIPAL in .Xr acme.conf 5 . -The principal's key must exist in the Kerberos keytab configured by -.Ar ACME_KERBEROS_KEYTAB -(by default, -.Pa /etc/krb5.keytab ) . +.Sh CONFIGURATION +The +.Nm +challenge hook supports the following configuration options in +.Xr acme.conf 5 : +.Bl -tag -width indent +.It Va ACME_KERBEROS_PRINCIPAL +The Kerberos principal to authenticate as when sending the DNS update. +The default value is +.Dq host/$(hostname) , +which assumes a default realm has been configured in +.Pa /etc/krb5.conf . +Explicitly configuring the principal is recommended, but not required. +.It Va ACME_KERBEROS_KEYTAB +The keytab used to issue the Kerberos ticket. +This must contain a key for the principal configured by +.Va ACME_KERBEROS_PRINCIPAL . +The default value is +.Pa /etc/krb5.keytab . +.El .Sh SEE ALSO .Xr acme.conf 5 , .Xr domains.conf 5 , |
