From e1f2a8dd272cdbe4f5e54853008a3e5f24f4af71 Mon Sep 17 00:00:00 2001 From: Lexi Winter Date: Tue, 3 Jun 2025 07:21:08 +0100 Subject: initial commit --- .gitignore | 1 + Makefile | 75 +++++++++++++++++ README | 55 ++++++++++++ acme.conf.5 | 39 +++++++++ acme.conf.sample | 33 ++++++++ domains.conf.5 | 105 +++++++++++++++++++++++ domains.conf.sample | 50 +++++++++++ example-hook.sh | 25 ++++++ init.sh | 56 ++++++++++++ kerberos-challenge.sh | 146 ++++++++++++++++++++++++++++++++ lfacme-renew.8 | 26 ++++++ lfacme-renew.sh | 229 ++++++++++++++++++++++++++++++++++++++++++++++++++ lfacme-setup.8 | 20 +++++ lfacme-setup.sh | 8 ++ 14 files changed, 868 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README create mode 100644 acme.conf.5 create mode 100644 acme.conf.sample create mode 100644 domains.conf.5 create mode 100644 domains.conf.sample create mode 100644 example-hook.sh create mode 100644 init.sh create mode 100644 kerberos-challenge.sh create mode 100644 lfacme-renew.8 create mode 100644 lfacme-renew.sh create mode 100644 lfacme-setup.8 create mode 100644 lfacme-setup.sh 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= Run the hook '' after (re)issuing this certificate. +# If 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 <"$csrconf" <>"$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 -- cgit v1.2.3