aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Makefile75
-rw-r--r--README55
-rw-r--r--acme.conf.539
-rw-r--r--acme.conf.sample33
-rw-r--r--domains.conf.5105
-rw-r--r--domains.conf.sample50
-rw-r--r--example-hook.sh25
-rw-r--r--init.sh56
-rw-r--r--kerberos-challenge.sh146
-rw-r--r--lfacme-renew.826
-rw-r--r--lfacme-renew.sh229
-rw-r--r--lfacme-setup.820
-rw-r--r--lfacme-setup.sh8
14 files changed, 868 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..45d62d8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+*.sw?
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6d83189
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,75 @@
+PREFIX?= /usr/local
+LIBDIR?= ${DESTDIR}/${PREFIX}/share/lfacme
+BINDIR?= ${DESTDIR}/${PREFIX}/sbin
+CONFDIR?= ${DESTDIR}/${PREFIX}/etc
+MANDIR?= ${DESTDIR}/${PREFIX}/share/man
+MAN5DIR?= ${MANDIR}/man5
+MAN8DIR?= ${MANDIR}/man8
+HOOKDIR?= ${CONFDIR}/hooks
+
+LIBMODE?= 0644
+LIB_FILES= init.sh \
+ kerberos-challenge.sh
+
+BINMODE?= 0755
+BIN_FILES= lfacme-renew.sh \
+ lfacme-setup.sh
+
+CONFMODE?= 0644
+CONF_FILES= acme.conf.sample \
+ domains.conf.sample
+
+HOOKMODE?= 0755
+HOOK_FILES= example-hook.sh
+
+MANMODE?= 0644
+MAN5FILES= acme.conf.5 \
+ domains.conf.5
+MAN8FILES= lfacme-renew.8 \
+ lfacme-setup.8
+
+default: all
+
+all:
+ @echo "Nothing to do."
+
+install:
+ @echo 'create ${LIBDIR}'; install -d ${LIBDIR}; \
+ for lib in ${LIB_FILES}; do \
+ echo "install ${LIBDIR}/$$lib"; \
+ install -C -m ${LIBMODE} "$$lib" "${LIBDIR}/$$lib"; \
+ done; \
+ \
+ echo 'create ${BINDIR}'; install -d ${BINDIR}; \
+ for bin in ${BIN_FILES}; do \
+ basename=$${bin%*.sh}; \
+ echo "install ${BINDIR}/$$basename"; \
+ install -C -m ${BINMODE} "$$bin" "${BINDIR}/$$basename"; \
+ done; \
+ \
+ echo 'create ${CONFDIR}'; install -d ${CONFDIR}; \
+ for conf in ${CONF_FILES}; do \
+ echo "install ${CONFDIR}/$$conf"; \
+ install -C -m ${CONFMODE} "$$conf" "${CONFDIR}/$$conf"; \
+ done; \
+ \
+ echo 'create ${HOOKDIR}'; install -d ${HOOKDIR}; \
+ for hook in ${HOOK_FILES}; do \
+ basename=$${hook%*.sh}; \
+ echo "install ${HOOKDIR}/$$basename"; \
+ install -C -m ${HOOKMODE} "$$hook" "${HOOKDIR}/$$basename"; \
+ done; \
+ \
+ echo 'create ${MANDIR}'; install -d ${MANDIR}; \
+ \
+ echo 'create ${MAN5DIR}'; install -d ${MAN5DIR}; \
+ for man in ${MAN5FILES}; do \
+ echo "install ${MAN5DIR}/$$man"; \
+ install -C -m ${MANMODE} "$$man" "${MAN5DIR}/$$man"; \
+ done; \
+ \
+ echo 'create ${MAN8DIR}'; install -d ${MAN8DIR}; \
+ for man in ${MAN8FILES}; do \
+ echo "install ${MAN8DIR}/$$man"; \
+ install -C -m ${MANMODE} "$$man" "${MAN8DIR}/$$man"; \
+ done; \
diff --git a/README b/README
new file mode 100644
index 0000000..098c41d
--- /dev/null
+++ b/README
@@ -0,0 +1,55 @@
+lfacme: a simple ACME client based on uacme
+-------------------------------------------
+
+lfacme is a wrapper around uacme to make it a bit more flexible. i wrote it
+primarily for my own use, but you're welcome to use it too.
+
+currently, there is one major limitation: the only supported domain validation
+method is dns-01 with Kerberized nsupdate. patches to improve this would be
+welcome.
+
+it's only tested on FreeBSD and may or may not work on other platforms.
+if it doesn't work, it shouldn't be difficult to port.
+
+requirements:
+
++ POSIX-compatible /bin/sh
++ uacme (in FreeBSD: security/uacme)
++ OpenSSL command-line tool
++ BIND's "dig" and "nsupdate" (in FreeBSD: dns/bind-tools)
++ Kerberos kinit (either MIT or Heimdal should work)
+
+install:
+
+# make install [DESTDIR=/some/where]
+
+usage:
+
++ make sure /etc/krb5.keytab exists since this will be used to issue the
+ Kerberos ticket for domain validation.
++ create the config files (see below):
+ /usr/local/etc/uacme/acme.conf and
+ /usr/local/etc/uacme/domains.conf
++ run "lfacme-setup" to create an ACME account
++ run "lfacme-renew" to issue certificates
++ put "lfacme-renew" in cron if you want to renew certificates automatically.
+ it's fine to run this once a day, since it won't renew certificates unless
+ they're going to expire soon.
+
+known issues:
+
++ lfacme assumes it's installed in /usr/local. if you want to change this,
+ you'll need to edit the scripts.
+
++ we disable ARI in uacme (uacme --no-ari) because it's broken on non-glibc
+ platforms. this is a uacme bug: https://github.com/ndilieto/uacme/issues/91
+
+config files:
+
+there are two configuration files:
+
++ acme.conf configures the global behaviour of lfacme
++ domains.conf lists the certificates lfacme should issue
+
+these both come with manual pages which explain how to configure them,
+and sample configs are provided.
diff --git a/acme.conf.5 b/acme.conf.5
new file mode 100644
index 0000000..9b632b5
--- /dev/null
+++ b/acme.conf.5
@@ -0,0 +1,39 @@
+.\" This source code is released into the public domain.
+.Dd June 3, 2025
+.Dt ACME.CONF 5
+.Os
+.Sh NAME
+.Nm acme.conf
+.Nd lfacme global configuration file
+.Sh SYNOPSIS
+.Pa /usr/local/etc/lfacme/acme.conf
+.Sh DESCRIPTION
+The
+.Nm
+file is a shell script used to configure the global behaviour of
+.Nm lfacme .
+The following variables may be set:
+.Bl -tag -width indent
+.It Va ACME_URL
+(Required.)
+The URL of the ACME server.
+.It Va ACME_DIR
+(Required.)
+The path to the base configuration directory, where certificates will be stored.
+.It Va ACME_HOOKDIR
+The path to a directory containing hooks to invoke when issuing certificates
+(see
+.Xr domains.conf 5 ) .
+The default value is
+.Pa ${ACME_DIR}/hooks .
+.It Va ACME_KERBEROS_PRINCIPAL
+The Kerberos principal to use when responding to a
+.Dq dns-01
+challenge.
+The default value is
+.Dq host/$(hostname) .
+.El
+.Sh SEE ALSO
+.Xr domains.conf 5 ,
+.Xr lfacme-setup 8 ,
+.Xr lfacme-renew 8
diff --git a/acme.conf.sample b/acme.conf.sample
new file mode 100644
index 0000000..f6f8432
--- /dev/null
+++ b/acme.conf.sample
@@ -0,0 +1,33 @@
+# 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.
+
+
+### ACME_URL: The URL of the ACME server.
+# No default, you must set this.
+
+# Let's Encrypt production::
+#ACME_URL="https://acme-v02.api.letsencrypt.org/directory"
+
+# Let's Encrypt staging:
+#ACME_URL="https://acme-staging-v02.api.letsencrypt.org/directory"
+
+
+### ACME_DIR: The path to lfacme's configuration directory.
+# This is also where generated certificates are stored.
+# No default, you must set this.
+
+#ACME_DIR="/usr/local/etc/acme"
+
+
+### ACME_HOOKDIR: The path to the directory containing certificate hooks.
+# The default is "${ACME_DIR}/hooks".
+# There is usually no need to change this.
+
+#ACME_HOOKDIR="/some/directory"
+
+
+### ACME_KERBEROS_PRINCIPAL: The Kerberos principal we use for nsupdate.
+# The default is "host/$(hostname)", which assumes a default realm is
+# configured in /etc/krb5.conf.
+
+#ACME_KERBEROS_PRINCIPAL="host/server.example.org@EXAMPLE.ORG"
diff --git a/domains.conf.5 b/domains.conf.5
new file mode 100644
index 0000000..c4e1966
--- /dev/null
+++ b/domains.conf.5
@@ -0,0 +1,105 @@
+.\" This source code is released into the public domain.
+.Dd June 3, 2025
+.Dt DOMAINS.CONF 5
+.Os
+.Sh NAME
+.Nm domains.conf
+.Nd lfacme domains configuration file
+.Sh SYNOPSIS
+.Pa /usr/local/etc/lfacme/domains.conf
+.Sh DESCRIPTION
+The
+.Nm
+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
+.Nm lfacme
+in the certificate filename, but is not part of the certificate itself.
+The remaining fields are certificate options, which may be either subject alt
+names or options for the certificate.
+.Pp
+If no subject alt names are provided, then the certificate name is used as
+the common name and subject alt name.
+.Pp
+The following options may be set:
+.Bl -tag -width indent
+.It Sy type Ns Li = Ns Ar keytype
+Configure the private key type.
+The
+.Ar keytype
+argument may be
+.Dq ec
+to generate a secp384r1 ECDSA key, or
+.Dq rsa
+to generate a 3072-bit RSA key.
+If not specified, the default value is
+.Dq ec .
+.It Sy hook Ns Li = Ns Ar filename
+Invoke
+.Ar filename
+when this certificate is issued or renewed.
+If
+.Ar filename
+begins with a
+.Sq /
+character, then it is assumed to be an absolute path,
+otherwise it is relative to the
+.Va ACME_HOOKDIR
+configured in
+.Xr acme.conf 5 .
+This option may be specified multiple times.
+.Pp
+The hook will be called with a single argument,
+which may be one of the following:
+.Bl -tag -width newcert
+.It Sy newcert
+A certificate has been issued or renewed.
+.El
+.Pp
+The following environment variables will be when running the hook script:
+.Bl -tag -width LFACME_CERTFILE
+.It Sy LFACME_CERT
+The identifier of the certificate, i.e. the first field in
+.Nm .
+This is not necessarily the certificate's common name.
+.It Sy LFACME_CERTFILE
+The path of a file which contains the public certificate and any issuer
+certificates, in PEM format.
+.It Sy lFACME_KEYFILE
+The path of a file which contains the private key file in PEM format.
+.El
+.El
+.Sh EXAMPLES
+Issue a certificate for
+.Dq example.org
+using the default options.
+We don't provide any SANs, so the certificate name is used as the domain.
+.Bd -literal -offset indent
+example.org
+.Ed
+.Pp
+Issue a certificate for
+.Dq example.org
+with some SANs.
+Notice that because we specify one SAN, we now have to specify all of them.
+.Bd -literal -offset indent
+example.org example.org www.example.org
+.Ed
+.Pp
+Issue two certificates for an SMTP server, one EC and one RSA.
+Some older SMTP clients still don't like EC certs.
+Run a hook after the certificate is (re)issued.
+.Bd -literal -offset indent
+smtp-ec smtp.example.org type=ec hook=install-smtp-cert
+smtp-rsa smtp.example.org type=rsa hook=install-smtp-cert
+.Ed
+.Pp
+Issue a certificate for a server and run multiple hooks.
+.Bd -literal -offset indent
+server.example.org hook=nginx hook=postfix hook=node-exporter
+.Ed
+.Sh SEE ALSO
+.Xr acme.conf 5 ,
+.Xr lfacme-renew 8
diff --git a/domains.conf.sample b/domains.conf.sample
new file mode 100644
index 0000000..dbcca4a
--- /dev/null
+++ b/domains.conf.sample
@@ -0,0 +1,50 @@
+##
+# Domains configuration file for lfacme.
+#
+# This is NOT a shell script (unlike acme.conf) so you cannot use shell
+# syntax here.
+#
+# Empty lines and lines beginning with a '#' character are ignored.
+
+##
+# Each line specifies one certificate using one or more whitespace-separated
+# fields.
+#
+# The first field is the certificate name, which is only used internally by
+# lfacme and is not part of the certificate.
+#
+# The remaining fields are certificate options, which may be either subject alt
+# names or options for the certificate.
+#
+# If no subject alt names are provided, then the certificate name is used as
+# the common name and subject alt name.
+
+##
+# 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.
+
+# Issue a cert for example.org using the default options.
+# We don't provide any SANs, so the certificate name is used as the domain.
+example.org
+
+# Issue a cert for example.org with some SANs.
+# Notice that because we specify one SAN, we now have to specify all of them.
+example.org example.org www.example.org
+
+# Issue two certs for an SMTP server, one EC and one RSA.
+# Some older SMTP clients still don't like EC certs.
+# Run a hook after the certificate is (re)issued.
+smtp-ec smtp.example.org type=ec hook=install-smtp-cert
+smtp-rsa smtp.example.org type=rsa hook=install-smtp-cert
+
+# Issue a certificate for a server and run multiple hooks.
+server.example.org hook=nginx hook=postfix hook=node-exporter
diff --git a/example-hook.sh b/example-hook.sh
new file mode 100644
index 0000000..01c5644
--- /dev/null
+++ b/example-hook.sh
@@ -0,0 +1,25 @@
+#! /bin/sh
+# An example hook.
+
+# Action is always 'newcert', at least for now.
+action="$1"
+
+# Environment variables:
+# $LFACME_CERT is the name of the certificate
+# $LFACME_CERTFILE is the filename of the certificate.
+# $LFACME_KEYFILE is the filename of the private key.
+
+set -e
+
+case "$action" in
+newcert)
+ # The certificate was issued or renewed.
+ cp "$LFACME_CERTFILE" /usr/local/etc/nginx/tls/cert.pem
+ cp "$LFACME_KEYFILE" /usr/local/etc/nginx/tls/key.pem
+ nginx -s reload
+ ;;
+
+*)
+ # Ignore unknown actions, because new ones might be added later.
+ ;;
+esac
diff --git a/init.sh b/init.sh
new file mode 100644
index 0000000..b1c9494
--- /dev/null
+++ b/init.sh
@@ -0,0 +1,56 @@
+# This source code is released into the public domain.
+
+_BASEDIR="/usr/local"
+_SHARE="${_BASEDIR}/share/lfacme"
+_CONFDIR="${_BASEDIR}/etc/lfacme"
+_CONFIG="${_CONFDIR}/acme.conf"
+_DOMAINS="${_CONFDIR}/domains.conf"
+_UACME=/usr/local/bin/uacme
+_UACME_DIR="${_CONFDIR}/certs"
+
+_PROGNAME="$0"
+
+_uacme() {
+ "$_UACME" -a "$ACME_URL" -c "$_UACME_DIR" "$@"
+}
+
+_fatal() {
+ local _fmt=$1; shift
+ local _msg="$(printf "$_fmt" "$@")"
+ printf >&2 '%s: FATAL: %s\n' "$_PROGNAME" "$_msg"
+ exit 1
+}
+
+_error() {
+ local _fmt=$1; shift
+ local _msg="$(printf "$_fmt" "$@")"
+ printf >&2 '%s: ERROR: %s\n' "$_PROGNAME" "$_msg"
+}
+
+_warn() {
+ local _fmt=$1; shift
+ local _msg="$(printf "$_fmt" "$@")"
+ printf >&2 '%s: WARNING: %s\n' "$_PROGNAME" "$_msg"
+}
+
+if ! [ -f "$_CONFIG" ]; then
+ _fatal "missing %s" "$_CONFIG"
+fi
+
+. "$_CONFIG"
+
+if [ -z "$ACME_URL" ]; then
+ _fatal "ACME_URL must be set in %s" "$_CONFIG"
+fi
+
+if [ -z "$ACME_DIR" ]; then
+ _fatal "ACME_DIR must be set in %s" "$_CONFIG"
+fi
+
+if [ -z "$ACME_KERBEROS_PRINCIPAL" ]; then
+ ACME_KERBEROS_PRINCIPAL="host/$(hostname)"
+fi
+
+if [ -z "$ACME_HOOKDIR" ]; then
+ ACME_HOOKDIR="${_CONFDIR}/hooks"
+fi
diff --git a/kerberos-challenge.sh b/kerberos-challenge.sh
new file mode 100644
index 0000000..95ca2af
--- /dev/null
+++ b/kerberos-challenge.sh
@@ -0,0 +1,146 @@
+#! /bin/sh
+# This source code is released into the public domain.
+
+. /usr/local/share/lfacme/init.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
+
+#set
+#exit 1
+
+if [ "$#" -ne 5 ]; then
+ _fatal "missing arguments"
+fi
+
+if [ "$METHOD" != "dns-01" ]; then
+ _warn "skip method %s" "$METHOD"
+ exit 1
+fi
+
+if ! kinit -k -t /etc/krb5.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"
+ local auth="$2"
+
+ nsupdate -g <<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 -g <<EOF
+update delete _acme-challenge.${DOMAIN}. 300 IN TXT "${AUTH}"
+send
+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"
+
+ echo "waiting for $domain on nameserver $ns..."
+
+ waited=0
+ waitlimit=60
+ while sleep 1; do
+ waited=$((waited + 1))
+ if [ $waited -ge $waitlimit ]; then
+ _error "timed out waiting for nameserver update for %s" \
+ "$domain"
+ return 1
+ fi
+
+ data="$(dig "_acme-challenge.$domain" txt @$nameserver +short)"
+ if [ -z "$data" ]; then
+ continue
+ fi
+
+ if [ "$data" = "\"$auth\"" ]; then
+ return 0
+ fi
+ done
+}
+
+# Wait for DNS servers to have the given record.
+_wait_for_record() {
+ local domain="$1"
+ local auth="$2"
+ local nameservers="$(_getnameservers "$domain")"
+
+ for ns in $nameservers; do
+ _wait_for_nameserver "$domain" "$auth" "$ns" || return 1
+ done
+
+ return 0
+}
+
+case "$ACTION" in
+ begin)
+ _add_record "$DOMAIN" "$AUTH" \
+ && _wait_for_record "$DOMAIN" "$AUTH"
+ exit $?
+ ;;
+
+ done|failed)
+ _remove_record "$DOMAIN" "$AUTH"
+ exit $?
+ ;;
+
+ *)
+ _fatal "unknown action: %s" "$ACTION"
+ ;;
+esac
+
+
diff --git a/lfacme-renew.8 b/lfacme-renew.8
new file mode 100644
index 0000000..e198dc2
--- /dev/null
+++ b/lfacme-renew.8
@@ -0,0 +1,26 @@
+.\" This source code is released into the public domain.
+.Dd June 3, 2025
+.Dt LFACME-RENEW 8
+.Os
+.Sh NAME
+.Nm lfacme-renew
+.Nd issue or renew ACME certificates
+.Sh SYNOPSIS
+.Nm
+.Sh DESCRIPTION
+The
+.Nm
+utility examines the ACME certificates configured in
+.Xr domains.conf 5 .
+If a certificate was previously issued and is still valid for longer than 30
+days, it will be ignored.
+Otherwise, the certificate will be issued or renewed and any configured hook
+scripts will be invoked.
+.Pp
+An ACME account must be created using
+.Xr lfacme-setup 8
+before running
+.Nm .
+.Sh SEE ALSO
+.Xr domains.conf 5 ,
+.Xr lfacme-setup 8
diff --git a/lfacme-renew.sh b/lfacme-renew.sh
new file mode 100644
index 0000000..e29788c
--- /dev/null
+++ b/lfacme-renew.sh
@@ -0,0 +1,229 @@
+#! /bin/sh
+# This source code is released into the public domain.
+
+. /usr/local/share/lfacme/init.sh
+
+if ! [ -d "$_UACME_DIR" ]; then
+ _fatal "run lfacme-setup first"
+fi
+
+if ! [ -f "$_DOMAINS" ]; then
+ _fatal "missing $_DOMAINS"
+fi
+
+args=$(getopt v $*)
+if [ $? -ne 0 ]; then
+ exit 1
+fi
+set -- $args
+
+# ARI is broken due to https://github.com/ndilieto/uacme/issues/91
+_uacme_flags="--no-ari"
+
+while :; do
+ case "$1" in
+ -v)
+ _uacme_flags="$_uacme_flags -v"
+ shift;;
+ --)
+ shift; break;;
+ esac
+done
+
+# Create a key if it doesn't already exist. It would be better to always
+# create a new key here, but currently uacme doesn't have a way to tell us
+# that we need to do that.
+_make_key() {
+ local keytype="$1"
+ local keyfile="$2"
+
+ if [ -s "$keyfile" ]; then
+ return 0
+ fi
+
+ local _umask=$(umask)
+ umask 077
+
+ case $keytype in
+ ec) openssl ecparam -name secp384r1 -genkey -noout -out "$keyfile";;
+ rsa) openssl genrsa -out "$keyfile" 3072;;
+ *) _error "%s: unknown key type %s?" "$keyfile" "$keytype"
+ return 1;;
+ esac
+
+ local _ret=$?
+ umask $_umask
+
+ return $_ret
+}
+
+# Create a new CSR for a domain.
+_make_csr() {
+ local csrfile="$1"
+ local keyfile="$2"
+ local domain="$3"
+ local altnames="$4"
+ local csrconf="${csrfile}.cnf"
+
+ cat >"$csrconf" <<EOF
+[req]
+distinguished_name = req_distinguished_name
+req_extensions = req_ext
+prompt = no
+
+[req_distinguished_name]
+commonName = $domain
+
+[req_ext]
+subjectAltName = @alt_names
+
+[alt_names]
+DNS.1 = $domain
+EOF
+
+ local _i=2
+ for altname in $altnames; do
+ printf >>"$csrconf" 'DNS.%d = %s\n' "$_i" "$altname"
+ _i=$((_i + 1))
+ done
+
+ # Generate the CSR
+ openssl req -new -key "$keyfile" -out "$csrfile" -config "$csrconf"
+ return $?
+}
+
+# Process a single cert.
+_docert() {
+ local identifier="$1"; shift
+
+ # uacme creates the cert name by stripping the extension from the
+ # CSR filename, so the basename has to match the identifier.
+ local dir="${_UACME_DIR}/${identifier}"
+ local keyfile="${dir}/${identifier}-key.pem"
+ local csrfile="${dir}/${identifier}.csr"
+ local certfile="${dir}/${identifier}-cert.pem"
+
+ # these can be overridden by args
+ local keytype="ec"
+ local altnames=""
+ local hooks=""
+ local domain=""
+
+ # parse arguments for this cert
+ while ! [ -z "$1" ]; do
+ case "$1" in
+ type=rsa) keytype=rsa;;
+ type=ec) keytype=ec;;
+ type=*) _error "%s: unknown key type: %s" \
+ "$identifier" "${1#type=*}"
+ return 1;;
+ hook=*) hooks="$hooks ${1#hook=*}";;
+ *=*) _error "%s: unknown option: %s" \
+ "$identifier" "$1"
+ return 1;;
+ *.*) altnames="$altnames $1"
+ # Take the domain from the first altname.
+ if [ -z "$domain" ]; then
+ domain="$1"
+ fi
+ ;;
+ *) _error "%s: unknown option: %s" \
+ "$identifier" "$1"
+ return 1;;
+ esac
+ shift
+ done
+
+ # If no altnames were given, the identifier is the domain.
+ if [ -z "$domain" ]; then
+ domain="$identifier"
+ fi
+
+ # make sure all the hook scripts are valid. if the hook name
+ # begins with a '/' it's a full path, otherwise it's related to
+ # ACME_HOOKDIR.
+ local _rhooks=""
+ for hook in $hooks; do
+ if [ "${hook#/*}" = "$hook" ]; then
+ hook="${ACME_HOOKDIR}/$hook"
+ fi
+
+ if ! [ -f "$hook" ]; then
+ _error "%s: hook does not exist: %s" \
+ "$identifier" "$hook"
+ return 1
+ fi
+
+ if ! [ -x "$hook" ]; then
+ _error "%s: hook is not executable: %s" \
+ "$identifier" "$hook"
+ return 1
+ fi
+
+ _rhooks="$_rhooks $hook"
+ done
+
+ mkdir -p -m0700 "$dir"
+
+ if ! _make_key "$keytype" "$keyfile"; then
+ _error "%s: could not create a new private key" "$identifier"
+ return 1
+ fi
+
+ if ! _make_csr "$csrfile" "$keyfile" "$domain" "$altnames"; then
+ _error "%s: could not create the certificate signing request" \
+ "$identifier"
+ return 1
+ fi
+
+ _uacme $_uacme_flags \
+ -h "${_SHARE}/kerberos-challenge.sh" \
+ issue "$csrfile"
+ _ret=$?
+
+ # exit 1 means the cert wasn't reissued
+ if [ "$_ret" -eq 1 ]; then
+ return 0
+ fi
+
+ # exit 2 means an actual error
+ if [ "$_ret" -eq 2 ]; then
+ _error "%s: failed to issue certificate" "$identifier"
+ return 1
+ fi
+
+ # any other non-zero exit code is unexpected
+ if [ "$_ret" -ne 0 ]; then
+ _error "%s: unexpected exit code from uacme: %d" \
+ "$identifier" "$_ret"
+ return 1
+ fi
+
+ # otherwise, exit code is 0 which means we (re)issued the cert,
+ # so run the hooks.
+ for hook in $_rhooks; do
+ env "LFACME_CERT=${identifier}" \
+ "LFACME_KEYFILE=${keyfile}" \
+ "LFACME_CERTFILE=${certfile}" \
+ $hook newcert
+ if [ "$?" -ne 0 ]; then
+ _warn "%s: hook script '%s' failed" \
+ "$identifier" "$hook"
+ fi
+ # should we do anything if the hook failed?
+ done
+
+ return $?
+}
+
+_exit=0
+
+cat "$_DOMAINS" \
+| egrep -v '^(#|[[:space:]]*$)' \
+| while read identifier args; do
+ if ! _docert "$identifier" $args; then
+ _exit=1
+ fi
+done
+
+exit $_exit
diff --git a/lfacme-setup.8 b/lfacme-setup.8
new file mode 100644
index 0000000..f6c51ca
--- /dev/null
+++ b/lfacme-setup.8
@@ -0,0 +1,20 @@
+.\" This source code is released into the public domain.
+.Dd June 3, 2025
+.Dt LFACME-SETUP 8
+.Os
+.Sh NAME
+.Nm lfacme-setup
+.Nd create a new ACME account
+.Sh SYNOPSIS
+.Nm
+.Sh DESCRIPTION
+The
+.Nm
+utility will register a new account with the ACME provider configured in
+.Xr acme.conf 5 .
+If the provider requires accepting terms of service to create an account,
+the ToS URL will be printed and
+.Nm
+will prompt the user to accept them.
+.Sh SEE ALSO
+.Xr acme.conf 5
diff --git a/lfacme-setup.sh b/lfacme-setup.sh
new file mode 100644
index 0000000..c2a0798
--- /dev/null
+++ b/lfacme-setup.sh
@@ -0,0 +1,8 @@
+#! /bin/sh
+# This source code is released into the public domain.
+
+. /usr/local/share/lfacme/init.sh
+
+mkdir -p "$_UACME_DIR"
+
+_uacme new