Bulk Registrar Of Names (BRON)

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 and allcerts. Use chmod a+x to make them executable.
  • In your ~/bron folder, you need bron.pl and lib/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

  1. Log in to the Linux shell containing your Apache server and the above scripts.
  2. Run allcerts until it pauses.
  3. Log in to the same shell in a separate window.
  4. cd ~/bron
  5. ./bron.pl add
  6. Swap back to the first window and hit Enter to continue
  7. In the second window, run ./bron.pl clear to tidy up
  8. Restart Apache with sudo service apache2 reload or equivalent.
  9. That’s it. Come back in three months.