#!/bin/sh
# danectl - https://raf.org/danectl
# DNSSEC DANE implementation manager
#
# Copyright (C) 2021 raf <raf@raf.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <https://www.gnu.org/licenses/>.
#
# 20210906 raf <raf@raf.org>

name=danectl
version=0.6
date=20210906
author="raf <raf@raf.org>"
url=https://raf.org/danectl
git=https://github.com/raforg/danectl

# Some defaults

test=0
verbose=0
quiet=
le=/etc/letsencrypt
danectlrc="$HOME/.danectlrc"
umask 022

# The --help usage message

usage()
{
	local rc="$1"; shift
	[ -z "$@" ] || echo "$name: $@"
	cat << USAGE
usage: $name [options] command [arg...]

options:
 -h, --help                              - Show the usage message
 -V, --version                           - Show the name and version
 -v, --verbose                           - Verbose mode (announce actions)
 -q, --quiet                             - Quiet mode (pass -q to certbot)
 -n, --test                              - Test mode (don't change things)

commands:
 help                                    - Show the manual page
 aliases                                 - Show the command aliases

 certbot <options...>                    - Supply certbot command line options
 adopt <certname...>                     - Adopt an existing cert as current
 new <domain...>                         - Create a new original/current cert
 dup <domain...>                         - Create a new duplicate/next cert
 add-tlsa <certname> <_#._tcp[.host]...> - Add port/pro/host for TLSA output
 del-tlsa <certname> <_#._tcp[.host]...> - Delete port/pro/host for TLSA output
 show-tlsa <certname>                    - Show port/pro/host for TLSA output
 tlsa-current <certname...>              - Output TLSA RRs for the current key
 tlsa-next <certname...>                 - Output TLSA RRs for the next key
 tlsa-check [<certname...>]              - Check that TLSA RRs are published
 add-reload <certname> <service...>      - Add services to reload on rollover
 del-reload <certname> <service...>      - Delete services to reload on rollover
 show-reload <certname>                  - Show services to reload on rollover
 reload <certname...>                    - Reload affected services
 rollover <certname...>                  - Perform a key rollover
 status                                  - Show current/next status

 sshfp <domain>                          - Output SSHFP RRs for localhost
 sshfp-check <domain>                    - Check that SSHFP RRs are published
 openpgpkey <emailaddress>               - Output OPENPGPKEY RR for an email
 openpgpkey-check <emailaddress>         - Check OPENPGPKEY RR is published
 smimea <smimecert.pem>                  - Output SMIMEA RR for a certificate
 smimea-check <smimecert.pem>            - Check SMIMEA RR is published

Danectl is a DNSSEC DANE implementation manager. It uses certbot to create
and manage pairs of keys for use with a TLSA 3 1 1 current + next workflow.
It generates TLSA records for your TLS services for you to publish to the
DNS, checks that they are correctly published, and performs key rollovers.

Danectl can also generate and check SSHFP records for the local SSH server.
Danectl can also generate and check an OPENPGPKEY record for a GnuPG key.
Danectl can also generate and check an SMIMEA record for an S/MIME certificate.

Read "danectl help" for more information.

Name: $name
Version: $version
Date: $date
Author: $author
URL: $url
URL: $git

Copyright (C) 2021 $author

This is free software released under the terms of the GPLv2+:

  https://www.gnu.org/licenses

There is no warranty; not even for merchantability or fitness
for a particular purpose.

Report bugs to $author
USAGE
	exit "$rc"
}

# The long help message

help()
{
	cat << HELP
NAME

danectl - DNSSEC DANE implementation manager

SYNOPSIS

 usage: $name [options] command [arg...]

 options:
  -h, --help                             - Show the usage message
  -V, --version                          - Show the name and version
  -v, --verbose                          - Verbose mode (announce actions)
  -q, --quiet                            - Quiet mode (pass -q to certbot)
  -n, --test                             - Test mode (don't change things)

 commands:
  help                                   - Show the manual page
  aliases                                - Show the command aliases

  certbot <options...>                   - Supply certbot command line options
  adopt <certname...>                    - Adopt an existing cert as current
  new <domain...>                        - Create a new original/current cert
  dup <domain...>                        - Create a new duplicate/next cert
  add-tlsa <certname> <_#._tcp[.host]..> - Add port/pro/host for TLSA output
  del-tlsa <certname> <_#._tcp[.host]..> - Delete port/pro/host for TLSA output
  show-tlsa <certname>                   - Show port/pro/host for TLSA output
  tlsa-current <certname...>             - Output TLSA RRs for the current key
  tlsa-next <certname...>                - Output TLSA RRs for the next key
  tlsa-check [<certname...>]             - Check that TLSA RRs are published
  add-reload <certname> <service...>     - Add services to reload on rollover
  del-reload <certname> <service...>     - Delete services to reload on rollover
  show-reload <certname>                 - Show services to reload on rollover
  reload <certname...>                   - Reload affected services
  rollover <certname...>                 - Perform a key rollover
  status                                 - Show current/next status

  sshfp <domain>                         - Output SSHFP RRs for localhost
  sshfp-check <domain>                   - Check that SSHFP RRs are published
  openpgpkey <emailaddress>              - Output OPENPGPKEY RR for an email
  openpgpkey-check <emailaddress>        - Check OPENPGPKEY RR is published
  smimea <smimecert.pem>                 - Output SMIMEA RR for a certificate
  smimea-check <smimecert.pem>           - Check SMIMEA RR is published

INTRODUCTION

Danectl is a DNSSEC DANE implementation manager. It uses certbot to create
and manage pairs of keys for use with a TLSA 3 1 1 current + next workflow.
It generates TLSA records for your TLS services for you to publish to the
DNS, checks that they are correctly published, and performs key rollovers.

Danectl can also generate and check SSHFP records for the local SSH server.
Danectl can also generate and check an OPENPGPKEY record for a GnuPG key.
Danectl can also generate and check an SMIMEA record for an S/MIME certificate.

DESCRIPTION

Danectl lets you create a pair of certbot certificate lineages to be used
with DANE-aware TLS clients. They are referred to as the "original" and the
"duplicate", or as the "current" and the "next". The current and next will
repeatedly swap places between the original and the duplicate as the key
rolls over from one to the other (with a new "next" key being created after
each rollover).

If you already have a certbot certificate lineage that you want to use with
DANE, then instead of creating both certificate lineages, you can adopt the
existing one for DANE use, and then just create the duplicate.

After that, certbot automatically renews both certificates every few months,
but the underlying keypairs won't change, and the TLSA records (see below)
can remain stable.

You then configure danectl with the set of port/protocol/host combinations
that you need TLSA records for. Danectl can then output the TLSA records, in
zonefile format, and you need to publish them to the DNS (somehow). Danectl
can then check that the TLSA records have been published to the DNS.

You also need to configure danectl with the list of TLS services that need
to be reloaded when the key rolls over. This is needed even when certbot is
configured to do it with deploy hooks, because those hooks are only run when
a certificate is renewed. Service reloads also need to happen when there's a
DANE key rollover, and that doesn't necessarily happen at the same time as
automatic certbot certificate renewals.

You then need to configure your TLS services to use the "current"
certificate in /etc/letsencrypt/current/<cert-name>, and then reload them.
This is like following instructions for using a certbot certificate, but
replacing "live" with "current".

Periodically, you can perform key rollovers on a schedule that suits you
(e.g. annually). An emergency key rollover is exactly the same.

At any time, you can show the status (which certificate lineages are
current, which are next, which new TLSA records are not yet published, and
which old TLSA records have not yet been removed).

In addition to TLSA records, you can also generate SSHFP, OPENPGPKEY, and
SMIMEA records, and check that they are published.

OPTIONS

-h, --help

This outputs danectl's usage message, then exits.

-V, --version

This outputs danectl's name and version, then exits.

-v, --verbose

This enables verbose mode, causing danectl to print actions before
performing them.

-q, --quiet

This enables quiet mode, causing danectl to pass -q to certbot. This only
affects the "new", "dup", and "rollover" commands, and is probably a good
idea, as it makes the output tidier.

-n, --test

This enables test mode, preventing danectl from performing any changes. It
implies verbose mode, causing danectl to print the actions that would have
been performed.

COMMANDS

Danectl can show you this manual page:

  danectl help

You can also show the aliases for all of the commands:

  danectl aliases

Before you can use danectl to do anything interesting, you might need to
supply any command line options that certbot will need when it creates new
certificate lineages. This is for authentication and installation. The
default is "--apache" to use the Apache plugin. Don't use quotes and don't
put spaces inside arguments. It won't end well.

  danectl certbot --apache

The "certbot" command modifies your ~/.danectlrc file.

If you already have a certbot certificate lineage, you can adopt it for DANE
use:

  danectl adopt example.org

Note that you must use the cert-name, not the list of certified domains.
To see all of your cert-names, run "certbot certificates", or look in
/etc/letsencrypt/live.

This will create a symlink in /etc/letsencrypt/current to the adopted
certificate lineage.

If you want to create a new certbot certificate lineage for DANE use
instead:

  danectl new example.org www.example.org mail.example.org

Note that you must provide the complete list of domain names to certify. The
cert-name of the new certificate lineage will be the first domain in the list.

This will create a symlink in /etc/letsencrypt/current to the new
certificate lineage.

Either way, you then need to create an additional duplicate certificate
lineage for the same set of domains:

  danectl dup example.org www.example.org mail.example.org

Note that you must provide the complete list of domain names to certify.
It must be identical to the list of domains in the adopted or new original
certificate lineage. The cert-name will be the first domain in the list
followed by "-duplicate". You won't need to use that suffix with danectl,
but you will need to use it when using certbot directly.

This will create a symlink in /etc/letsencrypt/next to the duplicate
certificate lineage.

Once a pair of certificate lineages is set up, certbot will automatically
renew them both, but the underlying keypairs won't change. That means that
the TLSA records (see below) can remain stable for longer than just a few
months.

Most of the remaining commands below require the base cert-name as their
first argument. Not the list of domain names, just the first domain, or
cert-name, without any "-duplicate" suffix.

To specify the port/protocol/host combinations that you will want TLSA
records for:

  danectl add-tlsa example.org _443._tcp _443._tcp.www
  danectl add-tlsa example.org _25._tcp.mail _465._tcp.mail _587._tcp.mail
  danectl add-tlsa example.org _110._tcp.mail _143._tcp.mail
  danectl add-tlsa example.org _993._tcp.mail _995._tcp.mail

Do not include the base cert-name in the port/protocol/host combinations.
That will be included automatically.

They can also be removed:

  danectl del-tlsa example.org _110._tcp.mail _143._tcp.mail

The "add-tlsa" and "del-tlsa" commands modify your ~/.danectlrc file.

You can also show which port/protocol/host combinations will be included
for TLSA records:

  danectl show-tlsa example.org

To output all TLSA records for the current key:

  danectl tlsa-current example.org

To output all TLSA records for the next key:

  danectl tlsa-next example.org

Initially, you need to publish the TLSA records for both the current and
next keys in the DNS (somehow).

To check that all TLSA records for the current and next keys are correctly
published in the DNS:

  danectl tlsa-check example.org

If no cert-name is supplied, then all cert-names are checked.

All TLSA records for the current and next keys must be published in the DNS
before you configure your services to use the current key.

To specify the services that need to be reloaded when a key rolls over:

  danectl add-reload example.org apache2 postfix dovecot

They can also be removed:

  danectl del-reload example.org postfix # Postfix can look after itself :-)

The "add-reload" and "del-reload" commands modify your ~/.danectlrc file.

You can also show which services will be reloaded:

  danectl show-reload example.org

At any point, you can reload the services that need to be reloaded when
the key rolls over:

  danectl reload example.org

But this shouldn't be necessary. It's automatic when a key rolls over.
Although it might be useful in certbot renewal hooks.

Occasionally (e.g. annually), perform a key rollover:

  danectl rollover example.org

This will redesignate the next key as the current key (and vice versa),
reload affected services, and create a new keypair/certificate as the new
next key. It also outputs the old TLSA records for the old current key that
you need to remove from the DNS (somehow). And it outputs the new TLSA
records for the new next key that you need to publish in the DNS (somehow).

At any time, you can show the status of all certificate pairs:

  danectl status

This will show, for each base cert-name, which certificate lineage is
current (original or duplicate), and which is next. It will also show any
new TLSA records that should be, but are not, published in the DNS. It will
also show any old TLSA records that are published in the DNS, but should no
longer be.

If any of the symlinks in /etc/letsencrypt/{current,next} target certificate
lineages that no longer exist in /etc/letsencrypt/live, this is also
mentioned, and they are deleted. This indicates that the certificate lineage
was previously deleted with certbot.

You can also output SSHFP records for the local SSH server:

  danectl sshfp example.org

If you want to authorize your local SSH server's host keys to clients via
DNSSEC, you will need to publish these SSHFP records in the DNS (somehow).
You will also need to set VerifyHostKeyDNS to "yes" or "ask" in the ssh
client configuration.

To check that SSHFP records for the local SSH server are published in the
DNS:

  danectl sshfp-check example.org

You can also output an OPENPGPKEY record for the key that is associated with
an email address in your GnuPG public keyring:

  danectl openpgpkey user@example.org

The email address must be a valid argument for gpg's --export operation. If
you want your correspondents to find the key, you will need to publish the
OPENPGPKEY record in the DNS (somehow), and they will need to add "dane" to
their auto-key-locate list.

To check that the OPENPGPKEY record for the email address is published in
the DNS:

  danectl openpgpkey-check user@example.org

You can also output an SMIMEA record for an S/MIME certificate:

  danectl smimea smimecert.pem

The S/MIME certificate file must be in PEM format. If you want your
correspondents to find the certificate, you will need to publish the SMIMEA
record in the DNS (somehow), and they will probably need to consult their
mail client documentation.

To check that the SMIMEA record for an S/MIME certificate is published in
the DNS:

  danectl smimea-check smimecert.pem

FILES

  /etc/letsencrypt/live    - Certbot certificate lineages
  /etc/letsencrypt/current - Current keypairs/certificates
  /etc/letsencrypt/next    - Next keypairs/certificates
  ~/.danectlrc             - Configuration file

The ~/.danectlrc configuration file is technically a shell script.
But danectl's additions and removals are purely a single name="value"
line at a time. Bear that in mind if you modify it manually.

CAVEAT

If you need to add or remove any domains from a pair of certificate
lineages, you will need to do it with certbot, and you will need to do it
for both the original and the duplicate certificate lineages (e.g.
example.org and example.org-duplicate), and you will need to use certbot's
--cert-name command line option. Since there will be two certificate
lineages with the same set of domains, it would be ambiguous otherwise.
Perhaps danectl should be able to do this, but it doesn't. There may be
other things that you will need to use certbot directly for. Keep things in
sync.

BUGS

Danectl relies entirely on certbot. This isn't a bug. It's a choice. This
choice was made so as to make danectl easy to implement, and because it's
pragmatic. The same key can be used with DANE-aware or PKIX-aware clients.
You don't need to know which. And you don't need to exclude any clients.

But it means that it only works with keys that are created by certbot, and
with certificates that are issued by a Certificate Authority (CA). If you
want to use DANE so as to avoid the CA ecosystem entirely, danectl won't
help you do that.

On the other hand, only a single CA is involved, LetsEncrypt, not the entire
ecosystem, and danectl only generates TLSA 3 1 1 records, which only refer
to the local public key itself. There is no reliance on any keys belonging
to the CA. That's good because those keys can and will change on a schedule
that is beyond your control. But your own keys are under your control.

So danectl is opinionated and inflexible. Other things can be done with TLSA
records, but they can't be done with danectl. But what can be done with
danectl can be done easily. If you need greater flexibility, you'll need to
find it elsewhere.

For OPENPGPKEY usage, if your public keyring contains multiple keys with the
same email address keyid, danectl will only output the first one that gpg
exports. Let me know if this is a problem.

For SMIMEA usage, only SMIMEA 3 0 0 records are supported. This isn't really
a bug, as they seem to be the most/only useful ones.

Danectl doesn't actually publish or remove any DNS records for you. This
isn't really a bug. It's a limitation. There are too many ways to publish
records to the DNS. But danectl does try to make it easy for you to handle
that yourself by outputting records in the well-known Bind9 zonefile format.
When performing a key rollover, and when performing any of the checks, any
superfluous records that are to be removed are output in the form of
comments where the leading semicolon (";") is not followed by a space (" ").
That makes them easy to identify with grep (i.e. grep "^;[^ ]" for
superfluous records to be removed, and grep -v "^;[^ ]" for missing records
to be published). Perhaps a plugin system could be added to automate updates
for different DNS server software and different DNS service providers, but
it should be easy enough to write a separate script to read the output of
danectl and do what you need with it. And anyway, DANE-related DNS updates
probably won't be frequent enough for their automation to be important. But
of course, your mileage may vary.

REQUIREMENTS

For TLSA usage, danectl requires /bin/sh, ls, sed, grep, host, readlink,
certbot, openssl, sha256sum, and root privileges.

For SSHFP usage, danectl requires /bin/sh, sed, host, perl, and ssh-keygen.

For OPENPGPKEY usage, danectl requires /bin/sh, perl, and gpg.

For SMIMEA usage, danectl requires /bin/sh, perl, and openssl.

LICENSE

Danectl is released under the terms of the GPLv2+
https://www.gnu.org/licenses.

SEE ALSO

certbot(1), danebot(1), ssh(1), ssh_config(5), gpg(1), openssl(1),
RFC6698, RFC7671, RFC7672, RFC4255, RFC7929, RFC8162.

AUTHOR

$date $author

URL

  $url
  $git

HELP
}

die() { echo "$name: $@" >&2; exit 1; }
warn() { echo "$name: $@" >&2; }

# Supply certbot command line options

certbot_options()
{
	local options="$@"
	set_rc certbot_options "$options"
}

# Convert existing cert to reuse_key

adopt()
{
	local certname="$@"
	fname="$le/renewal/$certname.conf"
	line="reuse_key = True"
	[ -f "$fname" ] || die "adopt $certname: Failed to find renewal config file $fname"
	[ -e "$le/current/$certname" -o -e "$le/next/$certname" ] && die "adopt $certname: Already adopted"
	grep -q "$line" "$fname" && die "adopt $certname: Already in reuse_key mode"
	add_line "$fname" "$line"
	run ln -s "$le/live/$certname" "$le/current/$certname"
}

# Create a new original/current cert

new()
{
	local certname="$1"
	local domains="$@"
	domains="`echo $domains | sed 's/ /,/g'`"
	[ -d "$le/live/$certname" ] && die "new $certname: Already exists"
	run certbot run$quiet ${certbot_options:---apache} --reuse-key -d "$domains"
	[ $test = 0 -a $? != 0 ] && die "new $certname: certbot failed"
	[ $test = 0 -a ! -d "$le/live/$certname" ] && die "new $certname: certbot failed"
	run ln -s "$le/live/$certname" "$le/current/$certname"
}

# Create a new duplicate/next cert

dup()
{
	local certname="$1"
	local domains="$@"
	domains="`echo $domains | sed 's/ /,/g'`"
	[ ! -d "$le/live/$certname" ] && die "dup $certname: Failed to find live original $certname"
	[ ! -d "$le/current/$certname" ] && die "dup $certname: Failed to find current original $certname"
	[ -d "$le/live/$certname-duplicate" ] && die "dup $certname: Duplicate $certname-duplicate already exists"
	run certbot certonly$quiet ${certbot_options:---apache} --duplicate --reuse-key --cert-name "$certname-duplicate" -d "$domains"
	[ $test = 0 -a $? != 0 ] && die "dup $certname: certbot failed"
	[ $test = 0 -a ! -d "$le/live/$certname-duplicate" ] && die "dup $certname: certbot failed"
	run ln -s "$le/live/$certname-duplicate" "$le/next/$certname"
}

# Add port/pro/host for TLSA output

add_tlsa()
{
	local certname="$1"; shift
	shcertname="`shcertname $certname`"
	eval tlsa="\$tlsa_$shcertname"
	set_rc tlsa_"$shcertname" "$tlsa $@"
}

# Delete port/pro/host for TLSA output

del_tlsa()
{
	local certname="$1"; shift
	shcertname="`shcertname $certname`"
	eval tlsa="\$tlsa_$shcertname"
	for t in "$@"
	do
		tlsa="`echo $tlsa | remove_item \"$t\"`"
	done
	set_rc tlsa_"$shcertname" "$tlsa"
}

# Show port/pro/host for TLSA output

show_tlsa()
{
	local certname="$1"; shift
	shcertname="`shcertname $certname`"
	eval tlsa="\$tlsa_$shcertname"
	echo "$tlsa"
}

# Output TLSA RRs for the current/next key

tlsa_role()
{
	local role="$1"
	local certname="$2"
	local label="$3"
	local prefix="$4"
	shcertname="`shcertname $certname`"
	eval tlsa="\$tlsa_$shcertname"
	[ -z "$tlsa" ] && die "No TLSA records have been configured yet for $certname"
	hash="`tlsa_hash $role $certname`"
	echo "$prefix; $certname $label"
	for t in $tlsa
	do
		echo "$prefix$t.$certname.\tIN\tTLSA\t3 1 1 $hash"
	done
}

# Output TLSA RRs for the current key

tlsa_current()
{
	local certname="$1"
	local label="${2:-current}"
	tlsa_role current "$certname" "$label"
}

# Output TLSA RRs for the next key

tlsa_next()
{
	local certname="$1"
	local label="${2:-next}"
	local prefix="$3"
	tlsa_role next "$certname" "$label" "$prefix"
}

# Check that TLSA RRs are published

tlsa_check()
{
	local certname="$1"
	shcertname="`shcertname $certname`"
	eval tlsa="\$tlsa_$shcertname"
	[ -z "$tlsa" ] && die "No TLSA records have been configured yet for $certname"
	hash_current="`tlsa_hash current $certname`"
	hash_next="`tlsa_hash next $certname`"
	missing=0
	for t in $tlsa
	do
		if host -t tlsa "$t.$certname" | sed 's/ \([a-fA-F0-9][a-fA-F0-9]*\)$/\1/' | grep -iq "$hash_current"
		then
			:
		else
			[ "$missing" = 0 ] && echo "; Missing $certname current (must be published)"
			missing=1
			echo "$t.$certname.\tIN\tTLSA\t3 1 1 $hash_current"
		fi
	done
	missing=0
	for t in $tlsa
	do
		if host -t tlsa "$t.$certname" | sed 's/ \([a-fA-F0-9][a-fA-F0-9]*\)$/\1/' | grep -iq "$hash_next"
		then
			:
		else
			[ "$missing" = 0 ] && echo "; Missing $certname next (must be published)"
			missing=1
			echo "$t.$certname.\tIN\tTLSA\t3 1 1 $hash_next"
		fi
	done
	superfluous=0
	for t in $tlsa
	do
		extra="`host -t tlsa \"$t.$certname\" | grep TLSA | sed -e 's/^/;/' -e 's/ has /.	/' -e 's/ record /	/' -e 'y/ABCDEF/abcdef/' -e 's/TLSa/TLSA/' -e 's/ \([a-f0-9][a-f0-9]*\)$/\1/' | grep -v \"$hash_current\" | grep -v \"$hash_next\"`"
		if [ -n "$extra" ]
		then
			[ "$superfluous" = 0 ] && echo ";; Superfluous $certname (should be removed)"
			superfluous=1
			echo "$extra"
		fi
	done
}

# Add services to reload on rollover

add_reload()
{
	local certname="$1"; shift
	shcertname="`shcertname $certname`"
	eval reload="\$reload_$shcertname"
	set_rc reload_"$shcertname" "$reload $@"
}

# Delete services to reload on rollover

del_reload()
{
	local certname="$1"; shift
	shcertname="`shcertname $certname`"
	eval reload="\$reload_$shcertname"
	for t in "$@"
	do
		reload="`echo $reload | remove_item \"$t\"`"
	done
	set_rc reload_"$shcertname" "$reload"
}

# Show services to reload on rollover

show_reload()
{
	local certname="$1"; shift
	shcertname="`shcertname $certname`"
	eval reload="\$reload_$shcertname"
	echo "$reload"
}

# Reload affected services

reload()
{
	local certname="$1"
	shcertname="`shcertname $certname`"
	eval reload="\$reload_$shcertname"
	for service in $reload
	do
		case "`which systemctl`" in /*) run systemctl reload "$service"; continue;; esac
		case "`which service`" in /*) run service "$service" reload; continue;; esac
		case "`which rcctl`" in /*) run rcctl reload "$service"; continue;; esac
		if [ -x /etc/init.d/"$service" ]; then run /etc/init.d/"$service" reload; continue; fi
		if [ -x /etc/rc.d/"$service" ]; then run /etc/rc.d/"$service" reload; continue; fi
		if [ -x /usr/local/etc/rc.d/"$service" ]; then run /usr/local/etc/rc.d/"$service" reload; continue; fi
		warn "Unable to reload $service"
	done
}

# Perform a key rollover

rollover()
{
	local certname="$1"
	[ ! -d "$le/current/$certname" ] && die "rollover $certname: Failed to find current $certname"
	[ ! -d "$le/next/$certname" ] && die "rollover $certname: Failed to find next $certname"
	# Identify the transition: original to duplicate, or duplicate to original
	if echo "`readlink \"$le/current/$certname\"`" | grep -q ".-duplicate"
	then
		curr="-duplicate"
		next=""
	else
		curr=""
		next="-duplicate"
	fi
	# Perform the key rollover
	run rm "$le/next/$certname"
	run rm "$le/current/$certname"
    run ln -s "$le/live/$certname$next" "$le/current/$certname"
    run ln -s "$le/live/$certname$curr" "$le/next/$certname"
	reload "$certname"
	# Swap $curr and $next
	if [ "x$curr" = "x" ]
	then
		curr="-duplicate"
		next=""
	else
		curr=""
		next="-duplicate"
	fi
	# Show the old TLSA records that need to be removed from the DNS
	echo ";; Remove the following old records from the DNS"
	tlsa_next "$certname" previous ";"
	# Create the new next keypair/certificate
	del_line "$le/renewal/$certname$next.conf" "reuse_key ="
	run certbot renew$quiet --force-renewal --cert-name "$certname$next"
	rc=$?
	add_line "$le/renewal/$certname$next.conf" "reuse_key = True"
	[ $test = 0 -a $rc != 0 ] && die "rollover $certname: certbot failed after the rollover! You must successfully run: certbot renew --force-renewal --cert-name \"$certname$next\" (but first remove \"reuse_key = True\" from $le/renewal/$certname$next.conf and then put it back afterwards)"
	# Show the new TLSA records that need to be published to the DNS
	echo "; Publish the following new records in the DNS"
	tlsa_next "$certname"
}

# Show current/next status

status()
{
	for role in current next
	do
		for symlink in "$le/$role"/*
		do
			[ -h "$symlink" ] || continue
			certname="${symlink##*/}"
			target="`readlink \"$symlink\"`"
			note=; [ ! -d "$symlink" ] && note=" (deleted)" && run rm "$symlink"
			echo "$certname: $role ${target##*/}$note"
		done
	done | sort
	for symlink in "$le/current"/*
	do
		[ -h "$symlink" ] || continue
		tlsa_check "${symlink##*/}"
	done
}

# Output the public key hash for the given role and cert-name

tlsa_hash()
{
	local role="$1"
	local certname="$2"
	cert="$le/$role/$certname/cert.pem"
	echo "`openssl x509 -noout -pubkey -in \"$cert\" | openssl rsa -pubin -outform DER 2>/dev/null | sha256sum | sed 's/\s.*$//'`"
}

# Run a command unless testing, print it first if verbose

run()
{
	[ $verbose = 1 ] && echo "$@"
	[ $test = 0 ] && "$@"
}

# Add a line to a file unless testing, print it first if verbose

add_line()
{
	local fname="$1"; shift
	local line="$@";
	line="`echo $line | sed -e 's/\s+/ /g' -e 's/^ //' -e 's/ $//'`"
	[ $verbose = 1 ] && echo "Add \"$line\" to $fname"
	[ $test = 0 ] && echo "$line" >> "$fname"
}

# Delete a line from a file unless testing, print it first if verbose

del_line()
{
	local fname="$1"; shift
	local line="$@";
	[ $verbose = 1 ] && echo "Remove \"$line\" from $fname"
	[ $test = 1 ] && return
	grep -v "^$line" "$fname" > "$fname.tmp.$$"
	mv "$fname.tmp.$$" "$fname"
}

# Add a line to ~/.danectlrc

add_rc()
{
	[ -f "$danectlrc" ] || touch "$danectlrc"
	add_line "$danectlrc" "$@"
}

# Delete a line from ~/.danectlrc

del_rc()
{
	[ -f "$danectlrc" ] || touch "$danectlrc"
	del_line "$danectlrc" "$@"
}

# Set or replace a variable in ~/.danectlrc

set_rc()
{
	local name="$1"; shift
	local value="$@"
	value="`echo \"$value\" | sed -e 's/^ //'`"
	del_rc "$name="
	add_rc "$name=\"$value\""
}

# Remove something from a space-separated list (stdin/stdout)

remove_item()
{
	local item="$1"
	echo `sed 's/ /\n/g' | grep -v "$item"`
}

# Output a form of certname that is suitable for use in a shell variable identifier

shcertname()
{
	local certname="$1"
	echo "$certname" | sed 's/\./_/g'
}

# Check the prerequisites

check_prerequisites()
{
	[ -d "$le" ] || die "Failed to find $le"
	[ -w "$le" ] || die "Unable to write to $le (use sudo)"
	[ ! -d "$le/current" ] && mkdir "$le/current"
	[ ! -d "$le/next" ] && mkdir "$le/next"

	for cmd in ls sed grep host readlink certbot openssl sha256sum
	do
		case "`which $cmd`" in /*) ;; *) die "Failed to find $cmd";; esac
	done

	if [ -e "$danectlrc" ]
	then
		[ ! -O "$danectlrc" ] && die "$danectlrc is not owned by root"
		case "`ls -lL \"$danectlrc\"`" in
			-rw-r--r--*|-rw-r-----*|-rw-------*)
				;;
			*)
				die "$danectlrc is group- or world-writable"
				;;
		esac
		. "$danectlrc"
	fi
}

# Check the cert-names

check_certnames()
{
	for certname in "$@"
	do
		[ -d "$le/live/$certname" ] || die "Failed to find certname $certname"
	done
}

# Check the base cert-name and the duplicate version

check_both_certnames()
{
	for certname in "$@"
	do
		[ -d "$le/live/$certname" ] || die "Failed to find certname $certname"
		[ -d "$le/live/$certname-duplicate" ] || die "Failed to find certname $certname-duplicate"
	done
}

# Output all DANE certnames

all_certnames()
{
	for symlink in "$le"/current/*
	do
		[ -h "$symlink" ] || continue
		certname="${symlink##*/}"
		target="`readlink \"$symlink\"`"
		[ ! -d "$symlink" ] && continue
		[ ! -d "$le/next/$certname" ] && continue
		echo "$certname"
	done
}

# Check the prerequisites for SSHFP

check_sshfp_prerequisites()
{
	for cmd in sed host perl ssh-keygen
	do
		case "`which $cmd`" in /*) ;; *) die "Failed to find $cmd";; esac
	done
}

# Output SSHFP RRs for the current host

sshfp()
{
	local hostname="$1"
	ssh-keygen -r "$hostname" | sed -e 's/ /.	/' -e 's/ /	/' -e 's/ /	/'
}

# Check that SSHFP RRs are published

sshfp_check()
{
	local hostname="$1"
	perl -e '
		use strict;
		use warnings;
		my %current = map { $_ =~ /SSHFP (\d+ \d+ \S+)$/; ($1, 1) } split /\n/, `ssh-keygen -r "'"$hostname"'"`;
		my %published = map { $_ =~ /SSHFP record (\d+ \d+ \S+)(?:\s+(\S+))?$/; (lc($1 . ($2 // "")), 1) } grep { /has SSHFP record/ } split /\n/, `host -t sshfp "'"$hostname"'"`;
		my $missing = 0;
		for my $sshfp (sort keys %current)
		{
			print(($missing ? "" : "; Missing '"$hostname"' sshfp (must be published)\n"), "'"$hostname"'.\tIN\tSSHFP\t$sshfp\n"), $missing = 1 if !exists $published{$sshfp};
		}
		my $superfluous = 0;
		for my $sshfp (sort keys %published)
		{
			print(($superfluous ? "" : ";; Superfluous '"$hostname"' sshfp (should be removed)\n"), ";", "'"$hostname"'.\tIN\tSSHFP\t$sshfp\n"), $superfluous = 1 if !exists $current{$sshfp};
		}
	'
}

# Check the prerequisites for OPENPGPKEY

check_openpgpkey_prerequisites()
{
	for cmd in gpg perl
	do
		case "`which $cmd`" in /*) ;; *) die "Failed to find $cmd";; esac
	done
}

# Output OPENPGPKEY RR for an email

openpgpkey()
{
	local email="$1"
	# Convert gpg's hex output to base64
	gpg --export-options export-dane --export "$email" | perl -e '
		use strict;
		use warnings;
		sub base64
		{
			pos $_[0] = 0;
			my $padlen = (3 - length($_[0]) % 3) % 3;
			my $encoded = join "", map { pack("u", $_) =~ /^.(\S*)/ } $_[0] =~ /(.{1,45})/gs;
			$encoded =~ tr{` -_}{AA-Za-z0-9+/};
			$encoded =~ s/.{$padlen}$/"=" x $padlen/e if $padlen;
			$encoded =~ s/(.{1,56})/\t$1\n/g;
			return $encoded;
		}
		my $origin;
		my $prefix;
		my $comment = "";
		my $key_hex = "";
		while (<>)
		{
			$origin = $1, next if /^\$ORIGIN (\S+)/;
			$comment .= $_, next if /^;/;
			$prefix = $1, next if /^([0-9a-f]+) TYPE61 \\# \d+ \($/;
			$key_hex .= $1, next if /^\s+([0-9a-f]+)$/;
			last if /^\s+\)$/;
		}
		while (<>) {}
		exit unless $origin && $prefix && $key_hex;
		print($comment);
		print("$prefix.$origin\tIN\tOPENPGPKEY\t(\n");
		print(base64(pack("H*", $key_hex)));
		print("\t)\n");
	'
}

# Check OPENPGPKEY RR is published

openpgpkey_check()
{
	local email="$1"
	gpg --export-options export-dane --export "$email" | perl -e '
		use strict;
		use warnings;
		use Digest;
		# Get the key to check
		sub base64
		{
			pos $_[0] = 0;
			my $padlen = (3 - length($_[0]) % 3) % 3;
			my $encoded = join "", map { pack("u", $_) =~ /^.(\S*)/ } $_[0] =~ /(.{1,45})/gs;
			$encoded =~ tr{` -_}{AA-Za-z0-9+/};
			$encoded =~ s/.{$padlen}$/"=" x $padlen/e if $padlen;
			$encoded =~ s/(.{1,56})/\t$1\n/g;
			return $encoded;
		}
		my $origin;
		my $prefix;
		my $comment = "";
		my $key_hex = "";
		while (<>)
		{
			$origin = $1, next if /^\$ORIGIN (\S+)/;
			$comment .= $_, next if /^;/;
			$prefix = $1, next if /^([0-9a-f]+) TYPE61 \\# \d+ \($/;
			$key_hex .= $1, next if /^\s+([0-9a-f]+)$/;
			last if /^\s+\)$/;
		}
		while (<>) {}
		my $key_exists = $origin && $prefix && $key_hex;
		my $key_base64 = base64(pack("H*", $key_hex)) if $key_exists;
		my $email = q('"$email"');
		my ($localpart, $domain) = $email =~ /^([^@]+)@([^@]+)$/;
		die("'$name': Invalid email address: $email\n") unless defined $localpart && defined $domain;
		if (!$key_exists)
		{
			my $hash = Digest->new("SHA-256");
			$hash->add($localpart);
			$prefix = substr($hash->hexdigest(), 0, 28 * 2);
			$origin = "_openpgpkey.$domain";
		}
		# Is it published? Are any other keys published?
		my $missing = $key_exists;
		my $superfluous = "";
		for (grep { /has OPENPGPKEY record/ } split /\n/, `host -t openpgpkey "$prefix.$origin"`)
		{
			my ($name, $rrdata) = $_ =~ /^(\S+) has OPENPGPKEY record (.+)$/;
			$name = substr($name, 0, -(length($domain) + 1));
			$rrdata =~ s/ /\n\t/g;
			$rrdata = "\t$rrdata\n";
			$missing = 0, next if $key_base64 && $rrdata eq $key_base64;
			$superfluous .= "$comment$prefix.$origin\tIN\tOPENPGPKEY\t(\n$rrdata\t)\n";
		}
		print("; Missing $email openpgpkey (should be published)\n$comment$prefix.$origin\tIN\tOPENPGPKEY\t(\n$key_base64\t)\n") if $missing;
		$superfluous =~ s/^/;/mg if $superfluous;
		print(";; Superfluous $email openpgpkey (should be removed)\n$superfluous") if $superfluous;
	'
}

# Check the prerequisites for SMIMEA

check_smimea_prerequisites()
{
	for cmd in openssl perl
	do
		case "`which $cmd`" in /*) ;; *) die "Failed to find $cmd";; esac
	done
}

# Output SMIMEA RR for a certificate

smimea()
{
	local cert="$1"
	[ ! -f "$cert" ] && die "$cert is not a file"
	local email="`openssl x509 -inform pem -in \"$cert\" -noout -text | perl -e '
		use strict;
		use warnings;
		while (<>)
		{
			print("$1\n") if /emailAddress = (\S+)/;
		}
	'`"
	[ -z "$email" ] && die "Failed to find an email address in the certificate"
	openssl x509 -inform pem -in "$cert" -outform der | perl -e '
		use strict;
		use warnings;
		use Digest;
		binmode(STDIN);
		$/ = undef;
		my $cert = unpack("H*", <STDIN>);
		$cert =~ s/(.{1,56})/\t$1\n/g;
		my $email = q('"$email"');
		my ($localpart, $domain) = $email =~ /^([^@]+)@([^@]+)$/;
		die("'$name': Invalid email address: $email\n") unless defined $localpart && defined $domain;
		my $hash = Digest->new("SHA-256");
		$hash->add($localpart);
		my $prefix = substr($hash->hexdigest(), 0, 28 * 2);
		my $origin = "_smimecert.$domain.";
		print("; $email\n");
		print("$prefix.$origin\tIN\tSMIMEA\t(\n\t3 0 0\n$cert\t)\n");
	'
}

# Check SMIMEA RR is published

smimea_check()
{
	local cert="$1"
	[ ! -f "$cert" ] && die "$cert is not a file"
	local email="`openssl x509 -inform pem -in \"$cert\" -noout -text | perl -e '
		use strict;
		use warnings;
		while (<>)
		{
			print("$1\n") if /emailAddress = (\S+)/;
		}
	'`"
	[ -z "$email" ] && die "Failed to find an email address in the certificate"
	openssl x509 -inform pem -in "$cert" -outform der | perl -e '
		use strict;
		use warnings;
		use Digest;
		binmode(STDIN);
		$/ = undef;
		my $cert = unpack("H*", <STDIN>);
		$cert =~ s/(.{1,56})/\t$1\n/g;
		my $email = q('"$email"');
		my ($localpart, $domain) = $email =~ /^([^@]+)@([^@]+)$/;
		die("'$name': Invalid email address: $email\n") unless defined $localpart && defined $domain;
		my $hash = Digest->new("SHA-256");
		$hash->add($localpart);
		my $comment = "; $email\n";
		my $prefix = substr($hash->hexdigest(), 0, 28 * 2);
		my $origin = "_smimecert.$domain.";
		my $missing = 1;
		my $superfluous = "";
		for (grep { /has SMIMEA record/ } split /\n/, `host -t smimea "$prefix.$origin"`)
		{
			my ($name, $rrdata) = $_ =~ /^(\S+) has SMIMEA record (.+)$/;
			$name = substr($name, 0, -(length($domain) + 1));
			$rrdata = lc($rrdata);
			$rrdata =~ s/ /\n\t/g;
			$rrdata =~ s/^3\n\t0\n\t0/3 0 0/;
			$rrdata = "\t$rrdata\n";
			$missing = 0, next if $rrdata eq "\t3 0 0\n$cert";
			$superfluous .= "$comment$prefix.$origin\tIN\tSMIME\t(\n$rrdata\t)\n";
		}
		print("; Missing $email smimea (should be published)\n$comment$prefix.$origin\tIN\tSMIMEA\t(\n\t3 0 0\n$cert\t)\n") if $missing;
		$superfluous =~ s/^/;/mg if $superfluous;
		print(";; Superfluous $email smimea (should be removed)\n$superfluous") if $superfluous;
	'
}

# Handle any command line options

if [ $# = 0 ]
then
	usage 1 'Missing command' | ${PAGER:-more}
	exit 1
fi

while :
do
	case "$1" in
		-h|--help)
			usage 0
			;;
		-V|--version)
			echo "$name-$version"
			exit 0
			;;
		-v|--verbose)
			verbose=1
			shift
			;;
		-q|--quiet)
			quiet=' -q'
			shift
			;;
		-n|--test)
			verbose=1
			test=1
			shift
			;;
		--)
			shift
			;;
		--*)
			die "Unknown option $1"
			;;
		-*)
			die "Unknown option $1 (or unsupported option bundling)"
			;;
		*)
			break
			;;
	esac
done

# Handle the commands

command="$1"
[ $# != 0 ] && shift

case "$command" in
	help|h)
		help | ${PAGER:-more}
		;;

	certbot|c)
		[ $# = 0 ] && usage 1 "Missing certbot argument(s)"
		check_prerequisites
		certbot_options "$@"
		;;

	adopt|ad)
		[ $# = 0 ] && usage 1 "Missing adopt argument(s)"
		check_prerequisites
		check_certnames "$@"
		for certname in "$@"; do adopt "$certname"; done
		;;

	new|original|n|o)
		[ $# = 0 ] && usage 1 "Missing new argument(s)"
		check_prerequisites
		new "$@"
		;;

	dup|duplicate|d)
		[ $# = 0 ] && usage 1 "Missing dup argument(s)"
		check_prerequisites
		dup "$@"
		;;

	add-tlsa|tlsa-add|at|ta)
		[ $# -lt 2 ] && usage 1 "Missing add-tlsa argument(s)"
		check_prerequisites
		check_certnames "$1"
		add_tlsa "$@"
		;;

	del-tlsa|delete-tlsa|tlsa-del|tlsa-delete|dt|td)
		[ $# -lt 2 ] && usage 1 "Missing del-tlsa argument(s)"
		check_prerequisites
		check_certnames "$1"
		del_tlsa "$@"
		;;

	show-tlsa|tlsa-show|st|ts)
		[ $# != 1 ] && usage 1 "Missing show-tlsa argument"
		check_prerequisites
		check_certnames "$1"
		show_tlsa "$@"
		;;

	tlsa-current|current-tlsa|current|tc|ct|curr)
		[ $# = 0 ] && usage 1 "Missing tlsa-current argument(s)"
		check_prerequisites
		check_certnames "$@"
		for certname in "$@"; do tlsa_current "$certname"; done
		;;

	tlsa-next|next-tlsa|next|tn|nt)
		[ $# = 0 ] && usage 1 "Missing tlsa-next argument(s)"
		check_prerequisites
		check_certnames "$@"
		for certname in "$@"; do tlsa_next "$certname"; done
		;;

	tlsa-check|check-tlsa|check|tch|cht|ch|chk)
		check_prerequisites
		[ $# != 0 ] && check_both_certnames "$@"
		for certname in ${*:-`all_certnames`}; do tlsa_check "$certname"; done
		;;

	add-reload|reload-add|ar|ra)
		[ $# -lt 2 ] && usage 1 "Missing add-reload argument(s)"
		check_prerequisites
		check_certnames "$1"
		add_reload "$@"
		;;

	del-reload|delete-reload|reload-del|reload-delete|dr|rd)
		[ $# -lt 2 ] && usage 1 "Missing del-reload argument(s)"
		check_prerequisites
		check_certnames "$1"
		del_reload "$@"
		;;

	show-reload|reload-show|sr|rs)
		[ $# != 1 ] && usage 1 "Missing show-reload argument"
		check_prerequisites
		check_certnames "$1"
		show_reload "$@"
		;;

	reload|r)
		[ $# = 0 ] && usage 1 "Missing reload argument(s)"
		check_prerequisites
		check_certnames "$@"
		for certname in "$@"; do reload "$certname"; done
		;;

	rollover|ro)
		[ $# = 0 ] && usage 1 "Missing rollover argument(s)"
		check_prerequisites
		check_both_certnames "$@"
		for certname in "$@"; do rollover "$certname"; done
		;;

	status|s)
		check_prerequisites
		status "$@"
		;;

	sshfp|ssh)
		[ $# != 1 ] && usage 1 "Missing sshfp argument"
		check_sshfp_prerequisites
		sshfp "$@"
		;;

	sshfp-check|check-sshfp|ssh-check|check-ssh|sch|schk)
		[ $# != 1 ] && usage 1 "Missing sshfp-check argument"
		check_sshfp_prerequisites
		sshfp_check "$@"
		;;

	openpgpkey|pgpkey|gpgkey|pgp|gpg|p|g)
		[ $# != 1 ] && usage 1 "Missing openpgpkey argument"
		check_openpgpkey_prerequisites
		openpgpkey "$@"
		;;

	openpgpkey-check|check-openpgpkey|pgpkey-check|check-pgpkey|gpgkey-check|check-gpgkey|pgp-check|check-pgp|gpg-check|check-gpg|pc|cp|gc|cg)
		[ $# != 1 ] && usage 1 "Missing openpgpkey-check argument"
		check_openpgpkey_prerequisites
		openpgpkey_check "$@"
		;;

	smimea|smime|sm)
		[ $# != 1 ] && usage 1 "Missing smimea argument"
		check_smimea_prerequisites
		smimea "$@"
		;;

	smimea-check|check-smimea|smime-check|check-smime|smc|csm)
		[ $# != 1 ] && usage 1 "Missing smimea-check argument"
		check_smimea_prerequisites
		smimea_check "$@"
		;;

	aliases|a):
		echo "help: h"
		echo "certbot: c"
		echo "adopt: ad"
		echo "new: original n o"
		echo "dup: duplicate d"
		echo "add-tlsa: tlsa-add at ta"
		echo "del-tlsa: delete-tlsa tlsa-del tlsa-delete dt td"
		echo "show-tlsa: tlsa-show st ts"
		echo "tlsa-current: current-tlsa current tc ct curr"
		echo "tlsa-next: next-tlsa next tn nt"
		echo "tlsa-check: check-tlsa check tch cht ch chk"
		echo "add-reload: reload-add ar ra"
		echo "del-reload: delete-reload reload-del reload-delete dr rd"
		echo "show-reload: reload-show sr rs"
		echo "reload: r"
		echo "rollover: ro"
		echo "status: s"
		echo "sshfp: ssh"
		echo "sshfp-check: check-sshfp ssh-check check-ssh sch schk"
		echo "openpgpkey: pgpkey gpgkey pgp gpg p g"
		echo "openpgpkey-check: check-openpgpkey pgpkey-check check-pgpkey gpgkey-check check-gpgkey pgp-check check-pgp gpg-check check-gpg pc cp gc cg"
		echo "smimea: smime sm"
		echo "smimea-check: check-smimea smime-check check-smime smc csm"
		echo "aliases: a"
		;;

	'')
		usage 1 'Missing command' | ${PAGER:-more}
		exit 1
		;;

	*)
		usage 1 "Unknown command: $command" | ${PAGER:-more}
		exit 1
		;;
esac

exit $?

# vi:set ts=4 sw=4:
