The Fastmail/LetsEncrypt toolset for re-authorising SSL certificates
Prerequisites
- Fastmail provides your DNS
- LetsEncrypt provides your SSL certificates
- You have multiple domain names
- You want wildcard certificates
- You have Linux shell access to your Apache server
Problem
- The certbot renew command doesn’t deal with wildcards properly
- The Fastmail UI, while very flexible, falls down when it comes to the bulk fiddling required to authorise every domain
- To renew a domain, you have to take a long string of gibberish from a LetsEncrypt command and put it into your DNS settings. If you get it wrong, perhaps due to a typo, it can be fiddly to fix. Multiply by two (one for the bare domain example.com, one for the wildcard *.example.com) and multiply that by the number of domains, and you have a painful and error-prone process.
Solution
BRON is a script that works like certbot renew, give or take some fiddling. Basically, you run a LetsEncrypt command to request certificate renewals for all your domains. It’s set to pause at a specific point. When it does, you run the provided script to feed DNS identification info from the renewal command into Fastmail’s DNS system. You then tell the paused command to continue and it reads the DNS identification info and renews the domains.
Scripts
- In your
~/bin
folder (create it if necessary), you need two scripts,authfm
andallcerts
. Usechmod a+x
to make them executable. - In your
~/bron
folder, you needbron.pl
andlib/BronJMAP.pm
. - You will almost certainly need to run
cpan
and update or install some Perl modules. The usual trick is to install everything then try to run./bron.pl clear
and see what’s missing. If it dies with a “nothing to do” error then it’s working properly.
~/bin/authfm
This can be left unchanged unless for some reason you want the temporary file stored somewhere else.
#!/bin/bash
echo "$CERTBOT_DOMAIN $CERTBOT_VALIDATION">>/tmp/bron.txt
~/bin/allcerts
Edit this to include your domain names, one each for wildcard and bare versions.
#!/bin/bash
sudo rm /tmp/bron.txt
sudo certbot certonly \
--manual \
--manual-auth-hook ~/bin/authfm \
--preferred-challenges dns \
--server https://acme-v02.api.letsencrypt.org/directory \
--manual-public-ip-logging-ok \
--debug-challenges \
-d '*.example.com' -d 'example.com' \
-d '*.example.net' -d 'example.net' \
-d '*.example.org' -d 'example.org' \
-d '*.example.com.au' -d 'example.com.au' \
-d '*.example.fm' -d 'example.fm'
~/bron/password.txt
Put your email password (not an app password) in this. Sorry, in this version it’s in plaintext. Version 1.1 will fix that, I’m sure — or else FM will allow app passwords instead.
Fa$tMailP@ssw0rd!69
~/bron/bron.pl
Important: modify the use constant
line to your email addres, the one for which password.txt
contains your password. Sorry about that — version 1.1 will make this a little less clunky.
#!/usr/bin/perl -w
use strict;
use warnings;
use Data::Dumper;
use FindBin;
use lib "$FindBin::Bin/lib";
use BronJMAP;
use constant username => 'your-email-address@fastmail.com';
if ($#ARGV != 0) {
print "Usage:\n";
print " perl bron.pl clear Clear all _acme-challenge TXT entries from my DNS\n";
print " perl bron.pl add Add all _acme-challenge TXT entries from bron.txt\n";
die;
}
open PWD, "<password.txt";
my $email_password = <PWD>;
$email_password =~ s/\s+$//; # note chomp doesn't handle CRLF properly, dammit
close PWD;
$b = new BronJMAP;
$b->prepare($email_password, username);
$b->reloadDomains;
if ($ARGV[0] eq 'clear') {
$b->clearAllDomainChallenges;
} elsif ($ARGV[0] eq 'add') {
my @changes = ();
open IN, "</tmp/bron.txt";
foreach my $raw (<IN>) {
$raw =~ m/^(\S+) (\S+)\s*$/ or die "Implausible input in bron.txt: [$raw]\nDied";
push @changes, [$1,$2];
}
$b->addAllDomainChallenges(@changes);
} else {
die "Unknown command $ARGV[1].\n";
~/bron/lib/BronJMAP.pm
package BronJMAP;
use strict;
use warnings;
use Mail::JMAPTalk;
use HTTP::CookieJar;
use Data::Dumper;
use JSON;
use constant TRUE => (1==1);
use constant FALSE => (1==0);
sub new
{
my $class = shift;
return bless {}, $class;
}
sub prepare
{
my ($self, $password,$username) = @_;
my %options = ('X-JMAP-AuthCookies' => 'yes', Referer => 'https://www.fastmail.com/login/');
my $cookiejar = HTTP::CookieJar->new();
my $aua = HTTP::Tiny->new(agent => 'Fruitbat Auth Agent', cookie_jar => $cookiejar);
my $ajt = Mail::JMAPTalk->new(url => "https://www.fastmail.com/jmap/authenticate/", ua => $aua);
my $res = $ajt->Request({username => $username}, %options);
my $authres = $ajt->Request({
loginId => $res->{loginId},
remember => $JSON::false,
type => "password",
value => $password,
}, %options);
my %auth = (
Authorization => "Bearer $authres->{accessToken}",
);
my $using = [sort keys %{$authres->{capabilities}}];
my $accountId = $authres->{primaryAccounts}{'https://www.fastmail.com/dev/customer'};
my $jua = HTTP::Tiny->new(agent => 'Fruitbat Api Agent', cookie_jar => $cookiejar, default_headers => \%auth);
my $jt = Mail::JMAPTalk->new(url => $authres->{apiUrl}, ua => $jua);
$self->{jt} = $jt;
$self->{accountId} = $accountId;
$self->{using} = $using;
return $self;
}
sub reloadDomains
{
my ($self) = @_;
$self->{accountId} or die "Forgot to run prepare? Died";
my $raw = $self->{jt}->CallMethods([['Domain/get', {accountId => $self->{accountId}}, '0']], $self->{using});
my $domains = $raw->[0]->[1]{list};
$self->{domains} = $domains;
return $self;
}
sub _setDomains
{
my ($self, %sets) = @_;
my $update = {};
foreach my $id (keys %sets) {
my $set = $sets{$id};
$update->{$id} = {customDNS => $set};
}
my $result = $self->{jt}->CallMethods([['Domain/set', {accountId => $self->{accountId}, update => $update}, '0']], $self->{using});
return $result;
}
sub addOneDomainChallenge
{
my ($self,$domain,$gibberish) = @_;
my $domains = $self->{domains} or die "Forgot to run reloadDomains? Died";
my %update = (type => "TXT",
data => $gibberish,
"sub" => "_acme-challenge.");
my %sets = ();
foreach my $hash (@$domains) {
if ($hash->{domain} eq $domain) {
# Taking the arbitrarily-selected first item in the current DNS record set as a template, add in the update data (using the fact
# that (%h1,%h2) handles duplicate keys by favouring the latter hash) and create a new record set with that extra record
my $current = $hash->{customDNS};
my $first = $current->[0];
my %one = (%$first, %update);
$sets{$hash->{id}} = [@$current, \%one];
last;
}
}
die "Can't find $domain. Died" unless %sets;
$self->_setDomains(%sets);
return $self;
}
sub clearOneDomainChallenges
{
my ($self,$domain) = @_;
my $domains = $self->{domains} or die "Forgot to run reloadDomains? Died";
my %sets = ();
foreach my $hash (@$domains) {
if ($hash->{domain} eq $domain) {
# Read all the DNS records, but throw away any that look like ACME Challenge TXT records
my $current = $hash->{customDNS};
my @set = ();
foreach my $rec (@$current) {
next if $rec->{type} eq 'TXT' && $rec->{'sub'} eq "_acme-challenge.";
push @set, $rec;
}
$sets{$hash->{id}} = \@set;
last;
}
}
die "Can't find $domain. Died" unless %sets;
$self->_setDomains(%sets);
return $self;
}
sub addAllDomainChallenges
{
my ($self,@challenges) = @_;
my $domains = $self->{domains} or die "Forgot to run reloadDomains? Died";
my %sets = ();
foreach my $hash (@$domains) {
my $id = $hash->{id};
my $domain = $hash->{domain};
my @additions = ();
foreach my $challenge (@challenges) {
push @additions, {
type => 'TXT',
data => $challenge->[1],
"sub" => "_acme-challenge."
} if $challenge->[0] eq $domain;
}
next unless @additions;
my $current = $hash->{customDNS};
my $first = $current->[0];
my @set = (@$current);
foreach my $addition (@additions) {
my %merge = (%$first, %$addition);
push @set, \%merge;
}
$sets{$id} = \@set;
}
die "Can't find any matching domains. Died" unless %sets;
$self->_setDomains(%sets);
return $self;
}
sub clearAllDomainChallenges
{
my ($self) = @_;
my $domains = $self->{domains} or die "Forgot to run reloadDomains? Died";
my %sets = ();
foreach my $hash (@$domains) {
# Read all the DNS records, but throw away any that look like ACME Challenge TXT records
my $current = $hash->{customDNS};
my @set = ();
my $ignore = TRUE;
foreach my $rec (@$current) {
if ($rec->{type} eq 'TXT' && $rec->{'sub'} eq "_acme-challenge.") {
$ignore = FALSE;
next;
}
push @set, $rec;
}
$sets{$hash->{id}} = \@set unless $ignore;
}
die "Nothing to delete. Died" unless %sets;
$self->_setDomains(%sets);
return $self;
}
1;
Instructions
- Log in to the Linux shell containing your Apache server and the above scripts.
- Run
allcerts
until it pauses. - Log in to the same shell in a separate window.
cd ~/bron
./bron.pl add
- Swap back to the first window and hit Enter to continue
- In the second window, run
./bron.pl clear
to tidy up - Restart Apache with
sudo service apache2 reload
or equivalent. - That’s it. Come back in three months.