Let's Encrypt with OpenBSD and Rex

Published at 2022-07-30T12:14:31+01:00

I was amazed at how easy it is to automatically generate and update Let's Encrypt certificates with OpenBSD.

                                               /    _    \
  The Hebern Machine                            \ ." ". /
                                  ___            /     \
                              ..""   ""..       |   O   |
                             /           \      |       |
                            /             \     |       |
                          ---------------------------------
                        _/  o     (O)     o   _            |
                      _/                    ." ".          |
                    I/    _________________/     \         |
                  _/I   ."                        |        |
          =====  /  I  /                         /         |
     =====  | | |   \ |       _________________."          |
=====  | |  | | |   /  \     /  _|_|__|_|_          __     |
  | |  | |  | | |   \   "._."  /  o    o  \       ."  ".   |
  |  --|  --|  -|   /          \         _/      /      \  |
   \____\____\__|   \  ______   |       /       |        | |
               --------      ---       /        |        | |
              ( )        (O)          /          \      /  |
               -----------------------            ".__."   |
               _|__________________________________________|_
              /                                              \
             /________________________________________________\
                                 ASCII Art by John Savard

Table of Contents

What's Let's Encrypt?

Let's Encrypt is a non-profit certificate authority run by Internet Security Research Group that provides X.509 certificates for Transport Layer Security (TLS) encryption at no charge. It is the world's largest certificate authority, used by more than 265 million websites, with the goal of all websites being secure and using HTTPS.

=> Source: Wikipedia

In short, it gives away TLS certificates for your website - for free! The catch is, that the certificates are only valid for three months. So it is better to automate certificate generation and renewals.

Meet acme-client

When invoked, the client first checks whether certificates actually require to be generated.

* It first checks whether a certificate already exists; if not, it will attempt to generate a new one.
* If the certificate already exists but expires within the next 30 days, it will renew it.
* Otherwise, `acme-client` won't do anything.

Oversimplified, the following steps are undertaken by `acme-client` for generating a new certificate:

* Reading its config file `/etc/acme-client.conf` for a list of hosts (and their alternative names) to generate certificates. So it means you can also have certificates for arbitrary subdomains!
* Automatic generation of the private certificate part (the certificate key) and the certificate signing request (CSR) to `/etc/ssl/...`.
* Requesting Let's Encrypt to sign the certificate. This also includes providing a set of temporary files requested by Let's Encrypt in the next step for verification.
* Let's Encrypt then will contact the hostname for the certificate through a particular URL (e.g. `http://foo.zone/.well-known/acme-challenge/...`) to verify that the requester is the valid owner of the host.
* Let's Encrypt generates a certificate, which then is downloaded to `/etc/ssl/...`.

## Configuration

There is some (but easy) configuration required to make that all work on OpenBSD.

### acme-client.conf

This is how my `/etc/acme-client.conf` looks like (I copied a template from `/etc/examples/acme-client.conf` to `/etc/acme-client.conf` and added my domains to the bottom:

$OpenBSD: acme-client.conf,v 1.4 2020/09/17 09:13:06 florian Exp $

authority letsencrypt {

api url "https://acme-v02.api.letsencrypt.org/directory"

account key "/etc/acme/letsencrypt-privkey.pem"

}

authority letsencrypt-staging {

api url "https://acme-staging-v02.api.letsencrypt.org/directory"

account key "/etc/acme/letsencrypt-staging-privkey.pem"

}

authority buypass {

api url "https://api.buypass.com/acme/directory"

account key "/etc/acme/buypass-privkey.pem"

contact "mailto:me@example.com"

}

authority buypass-test {

api url "https://api.test4.buypass.no/acme/directory"

account key "/etc/acme/buypass-test-privkey.pem"

contact "mailto:me@example.com"

}

domain buetow.org {

alternative names { www.buetow.org paul.buetow.org }

domain key "/etc/ssl/private/buetow.org.key"

domain full chain certificate "/etc/ssl/buetow.org.fullchain.pem"

sign with letsencrypt

}

domain dtail.dev {

alternative names { www.dtail.dev }

domain key "/etc/ssl/private/dtail.dev.key"

domain full chain certificate "/etc/ssl/dtail.dev.fullchain.pem"

sign with letsencrypt

}

domain foo.zone {

alternative names { www.foo.zone }

domain key "/etc/ssl/private/foo.zone.key"

domain full chain certificate "/etc/ssl/foo.zone.fullchain.pem"

sign with letsencrypt

}

domain irregular.ninja {

alternative names { www.irregular.ninja }

domain key "/etc/ssl/private/irregular.ninja.key"

domain full chain certificate "/etc/ssl/irregular.ninja.fullchain.pem"

sign with letsencrypt

}

domain snonux.land {

alternative names { www.snonux.land }

domain key "/etc/ssl/private/snonux.land.key"

domain full chain certificate "/etc/ssl/snonux.land.fullchain.pem"

sign with letsencrypt

}

### httpd.conf

For ACME to work, you will need to configure the HTTP daemon so that the "special" ACME requests from Let's Encrypt are served correctly. I am using the standard OpenBSD `httpd` here. These are the snippets I use for the `foo.zone` host in `/etc/httpd.conf` (of course, you need a similar setup for all other hosts as well):

server "foo.zone" {

listen on * port 80

location "/.well-known/acme-challenge/*" {

root "/acme"

request strip 2

}

location * {

block return 302 "https://$HTTP_HOST$REQUEST_URI"

}

}

server "foo.zone" {

listen on * tls port 443

tls {

certificate "/etc/ssl/foo.zone.fullchain.pem"

key "/etc/ssl/private/foo.zone.key"

}

location * {

root "/htdocs/gemtexter/foo.zone"

directory auto index

}

}

As you see, plain HTTP only serves the ACME challenge path. Otherwise, it redirects the requests to TLS. The TLS section then attempts to use the Let's Encrypt certificates.

It is worth noticing that `httpd` will start without the certificates being present. This will cause a certificate error when you try to reach the HTTPS endpoint, but it helps to bootstrap Let's Encrypt. As you saw in the config snippet above, Let's Encrypt only requests the plain HTTP endpoint for the verification process, so HTTPS doesn't need to be operational yet at this stage. But once the certificates are generated, you will have to reload or restart `httpd` to use any new certificate.

### CRON job

You could now run `doas acme-client foo.zone` to generate the certificate or to renew it. Or you could automate it with CRON.

I have created a script `/usr/local/bin/acme.sh` for that for all of my domains:

!/bin/sh

function handle_cert {

host=$1

# Create symlink, so that relayd also can read it.

crt_path=/etc/ssl/$host

if [ -e $crt_path.crt ]; then

    rm $crt_path.crt

fi

ln -s $crt_path.fullchain.pem $crt_path.crt

# Requesting and renewing certificate.

/usr/sbin/acme-client -v $host

}

has_update=no

handle_cert www.buetow.org

if [ $? -eq 0 ]; then

has_update=yes

fi

handle_cert www.paul.buetow.org

if [ $? -eq 0 ]; then

has_update=yes

fi

handle_cert www.tmp.buetow.org

if [ $? -eq 0 ]; then

has_update=yes

fi

handle_cert www.dtail.dev

if [ $? -eq 0 ]; then

has_update=yes

fi

handle_cert www.foo.zone

if [ $? -eq 0 ]; then

has_update=yes

fi

handle_cert www.irregular.ninja

if [ $? -eq 0 ]; then

has_update=yes

fi

handle_cert www.snonux.land

if [ $? -eq 0 ]; then

has_update=yes

fi

Pick up the new certs.

if [ $has_update = yes ]; then

/usr/sbin/rcctl reload httpd

/usr/sbin/rcctl reload relayd

/usr/sbin/rcctl restart smtpd

fi

And added the following line to `/etc/daily.local` to run the script once daily so that certificates will be renewed fully automatically:

/usr/local/bin/acme.sh

I am receiving a daily output via E-Mail like this now:

Running daily.local:

acme-client: /etc/ssl/buetow.org.fullchain.pem: certificate valid: 80 days left

acme-client: /etc/ssl/paul.buetow.org.fullchain.pem: certificate valid: 80 days left

acme-client: /etc/ssl/tmp.buetow.org.fullchain.pem: certificate valid: 80 days left

acme-client: /etc/ssl/dtail.dev.fullchain.pem: certificate valid: 80 days left

acme-client: /etc/ssl/foo.zone.fullchain.pem: certificate valid: 80 days left

acme-client: /etc/ssl/irregular.ninja.fullchain.pem: certificate valid: 80 days left

acme-client: /etc/ssl/snonux.land.fullchain.pem: certificate valid: 79 days left

## relayd.conf and smtpd.conf

Besides `httpd`, `relayd` (mainly for Gemini) and `smtpd` (for mail, of course) also use TLS certificates. And as you can see in `acme.sh`, the services are reloaded or restarted (`smtpd` doesn't support reload) whenever a certificate is generated or updated.

## Rexification

I didn't write all these configuration files by hand. As a matter of fact, everything is automated with the Rex configuration management system.

=> https://www.rexify.org

At the top of the `Rexfile` I define all my hosts:

our @acme_hosts = qw/buetow.org paul.buetow.org tmp.buetow.org dtail.dev foo.zone irregular.ninja snonux.land/;

### General ACME client configuration

ACME will be installed into the frontend group of hosts. Here, blowfish is the primary, and twofish is the secondary OpenBSD box.

group frontends => 'blowfish.buetow.org', 'twofish.buetow.org';

This is my Rex task for the general ACME configuration:

desc 'Configure ACME client';

task 'acme', group => 'frontends',

sub {

file '/etc/acme-client.conf',

  content => template('./etc/acme-client.conf.tpl',

    acme_hosts => \@acme_hosts,

    is_primary => $is_primary),

  owner => 'root',

  group => 'wheel',

  mode => '644';

file '/usr/local/bin/acme.sh',

  content => template('./scripts/acme.sh.tpl',

    acme_hosts => \@acme_hosts,

    is_primary => $is_primary),

  owner => 'root',

  group => 'wheel',

  mode => '744';

file '/etc/daily.local',

  ensure => 'present',

  owner => 'root',

  group => 'wheel',

  mode => '644';

append_if_no_such_line '/etc/daily.local', '/usr/local/bin/acme.sh';

};

And there is also a Rex task just to run the ACME script remotely:

desc 'Invoke ACME client';

task 'acme_invoke', group => 'frontends',

sub {

say run '/usr/local/bin/acme.sh';

};

Furthermore, this snippet (also at the top of the Rexfile) helps to determine whether the current server is the primary server (all hosts will be without the `www.` prefix) or the secondary server (all hosts will be with the `www.` prefix):

Bootstrapping the FQDN based on the server IP as the hostname and domain

facts aren't set yet due to the myname file in the first place.

our $fqdns = sub {

my $ipv4 = shift;

return 'blowfish.buetow.org' if $ipv4 eq '23.88.35.144';

return 'twofish.buetow.org' if $ipv4 eq '108.160.134.135';

Rex::Logger::info("Unable to determine hostname for $ipv4", 'error');

return 'HOSTNAME-UNKNOWN.buetow.org';

};

To determine whether the server is the primary or the secondary.

our $is_primary = sub {

my $ipv4 = shift;

$fqdns->($ipv4) eq 'blowfish.buetow.org';

};

The following is the `acme-client.conf.tpl` Rex template file used for the automation. You see that the `www.` prefix isn't sent for the primary server. E.g. `foo.zone` will be served by the primary server (in my case, a server located in Germany) and `www.foo.zone` by the secondary server (in my case, a server located in Japan):

$OpenBSD: acme-client.conf,v 1.4 2020/09/17 09:13:06 florian Exp $

authority letsencrypt {

api url "https://acme-v02.api.letsencrypt.org/directory"

account key "/etc/acme/letsencrypt-privkey.pem"

}

authority letsencrypt-staging {

api url "https://acme-staging-v02.api.letsencrypt.org/directory"

account key "/etc/acme/letsencrypt-staging-privkey.pem"

}

authority buypass {

api url "https://api.buypass.com/acme/directory"

account key "/etc/acme/buypass-privkey.pem"

contact "mailto:me@example.com"

}

authority buypass-test {

api url "https://api.test4.buypass.no/acme/directory"

account key "/etc/acme/buypass-test-privkey.pem"

contact "mailto:me@example.com"

}

<%

our $primary = $is_primary->($vio0_ip);

our $prefix = $primary ? '' : 'www.';

%>

<% for my $host (@$acme_hosts) { %>

domain <%= $prefix.$host %> {

domain key "/etc/ssl/private/<%= $prefix.$host %>.key"

domain full chain certificate "/etc/ssl/<%= $prefix.$host %>.fullchain.pem"

sign with letsencrypt

}

<% } %>

And this is the `acme.sh.tpl`:

!/bin/sh

<%

our $primary = $is_primary->($vio0_ip);

our $prefix = $primary ? '' : 'www.';

-%>

function handle_cert {

host=$1

# Create symlink, so that relayd also can read it.

crt_path=/etc/ssl/$host

if [ -e $crt_path.crt ]; then

    rm $crt_path.crt

fi

ln -s $crt_path.fullchain.pem $crt_path.crt

# Requesting and renewing certificate.

/usr/sbin/acme-client -v $host

}

has_update=no

<% for my $host (@$acme_hosts) { -%>

handle_cert <%= $prefix.$host %>

if [ $? -eq 0 ]; then

has_update=yes

fi

<% } -%>

Pick up the new certs.

if [ $has_update = yes ]; then

/usr/sbin/rcctl reload httpd

/usr/sbin/rcctl reload relayd

/usr/sbin/rcctl restart smtpd

fi

### Service rexification 

These are the Rex tasks setting up `httpd`, `relayd` and `smtpd` services:

desc 'Setup httpd';

task 'httpd', group => 'frontends',

sub {

append_if_no_such_line '/etc/rc.conf.local', 'httpd_flags=';

file '/etc/httpd.conf',

  content => template('./etc/httpd.conf.tpl',

    acme_hosts => \@acme_hosts,

    is_primary => $is_primary),

  owner => 'root',

  group => 'wheel',

  mode => '644',

  on_change => sub { service 'httpd' => 'restart' };

service 'httpd', ensure => 'started';

};

desc 'Setup relayd';

task 'relayd', group => 'frontends',

sub {

append_if_no_such_line '/etc/rc.conf.local', 'relayd_flags=';

file '/etc/relayd.conf',

  content => template('./etc/relayd.conf.tpl',

    ipv6address => $ipv6address,

    is_primary => $is_primary),

  owner => 'root',

  group => 'wheel',

  mode => '600',

  on_change => sub { service 'relayd' => 'restart' };

service 'relayd', ensure => 'started';

};

desc 'Setup OpenSMTPD';

task 'smtpd', group => 'frontends',

sub {

Rex::Logger::info('Dealing with mail aliases');

file '/etc/mail/aliases',

  source => './etc/mail/aliases',

  owner => 'root',

  group => 'wheel',

  mode => '644',

  on_change => sub { say run 'newaliases' };

Rex::Logger::info('Dealing with mail virtual domains');

file '/etc/mail/virtualdomains',

  source => './etc/mail/virtualdomains',

  owner => 'root',

  group => 'wheel',

  mode => '644',

  on_change => sub { service 'smtpd' => 'restart' };

Rex::Logger::info('Dealing with mail virtual users');

file '/etc/mail/virtualusers',

  source => './etc/mail/virtualusers',

  owner => 'root',

  group => 'wheel',

  mode => '644',

  on_change => sub { service 'smtpd' => 'restart' };

Rex::Logger::info('Dealing with smtpd.conf');

file '/etc/mail/smtpd.conf',

  content => template('./etc/mail/smtpd.conf.tpl',

    is_primary => $is_primary),

  owner => 'root',

  group => 'wheel',

  mode => '644',

  on_change => sub { service 'smtpd' => 'restart' };

service 'smtpd', ensure => 'started';

};

This is the `httpd.conf.tpl`:

<%

our $primary = $is_primary->($vio0_ip);

our $prefix = $primary ? '' : 'www.';

%>

Plain HTTP for ACME and HTTPS redirect

<% for my $host (@$acme_hosts) { %>

server "<%= $prefix.$host %>" {

listen on * port 80

location "/.well-known/acme-challenge/*" {

root "/acme"

request strip 2

}

location * {

block return 302 "https://$HTTP_HOST$REQUEST_URI"

}

}

<% } %>

Gemtexter hosts

<% for my $host (qw/foo.zone snonux.land/) { %>

server "<%= $prefix.$host %>" {

listen on * tls port 443

tls {

certificate "/etc/ssl/<%= $prefix.$host %>.fullchain.pem"

key "/etc/ssl/private/<%= $prefix.$host %>.key"

}

location * {

root "/htdocs/gemtexter/<%= $host %>"

directory auto index

}

}

<% } %>

DTail special host

server "<%= $prefix %>dtail.dev" {

listen on * tls port 443

tls {

certificate "/etc/ssl/<%= $prefix %>dtail.dev.fullchain.pem"

key "/etc/ssl/private/<%= $prefix %>dtail.dev.key"

}

location * {

block return 302 "https://github.dtail.dev$REQUEST_URI"

}

}

Irregular Ninja special host

server "<%= $prefix %>irregular.ninja" {

listen on * tls port 443

tls {

certificate "/etc/ssl/<%= $prefix %>irregular.ninja.fullchain.pem"

key "/etc/ssl/private/<%= $prefix %>irregular.ninja.key"

}

location * {

root "/htdocs/irregular.ninja"

directory auto index

}

}

buetow.org special host.

server "<%= $prefix %>buetow.org" {

listen on * tls port 443

tls {

certificate "/etc/ssl/<%= $prefix %>buetow.org.fullchain.pem"

key "/etc/ssl/private/<%= $prefix %>buetow.org.key"

}

block return 302 "https://paul.buetow.org"

}

server "<%= $prefix %>paul.buetow.org" {

listen on * tls port 443

tls {

certificate "/etc/ssl/<%= $prefix %>paul.buetow.org.fullchain.pem"

key "/etc/ssl/private/<%= $prefix %>paul.buetow.org.key"

}

block return 302 "https://foo.zone/contact-information.html"

}

server "<%= $prefix %>tmp.buetow.org" {

listen on * tls port 443

tls {

certificate "/etc/ssl/<%= $prefix %>tmp.buetow.org.fullchain.pem"

key "/etc/ssl/private/<%= $prefix %>tmp.buetow.org.key"

}

root "/htdocs/buetow.org/tmp"

directory auto index

}

and this the `relayd.conf.tpl`:

<%

our $primary = $is_primary->($vio0_ip);

our $prefix = $primary ? '' : 'www.';

%>

log connection

tcp protocol "gemini" {

tls keypair <%= $prefix %>foo.zone

tls keypair <%= $prefix %>buetow.org

}

relay "gemini4" {

listen on <%= $vio0_ip %> port 1965 tls

protocol "gemini"

forward to 127.0.0.1 port 11965

}

relay "gemini6" {

listen on <%= $ipv6address->($hostname) %> port 1965 tls

protocol "gemini"

forward to 127.0.0.1 port 11965

}

And last but not least, this is the `smtpd.conf.tpl`:

<%

our $primary = $is_primary->($vio0_ip);

our $prefix = $primary ? '' : 'www.';

%>

pki "buetow_org_tls" cert "/etc/ssl/<%= $prefix %>buetow.org.fullchain.pem"

pki "buetow_org_tls" key "/etc/ssl/private/<%= $prefix %>buetow.org.key"

table aliases file:/etc/mail/aliases

table virtualdomains file:/etc/mail/virtualdomains

table virtualusers file:/etc/mail/virtualusers

listen on socket

listen on all tls pki "buetow_org_tls" hostname "<%= $prefix %>buetow.org"

listen on all

action localmail mbox alias

action receive mbox virtual

action outbound relay

match from any for domain action receive

match from local for local action localmail

match from local for any action outbound

## All pieces together

For the complete `Rexfile` example and all the templates, please look at the Git repository:

=> https://codeberg.org/snonux/rexfiles

Besides ACME, other things, such as DNS servers, are also rexified. The following command will run all the Rex tasks and configure everything on my frontend machines automatically:

rex commons

Proxy Information
Original URL
gemini://foo.zone/gemfeed/2022-07-30-lets-encrypt-with-openbsd-and-rex.gmi
Status Code
Success (20)
Meta
text/gemini;
Capsule Response Time
397.610451 milliseconds
Gemini-to-HTML Time
5.97299 milliseconds

This content has been proxied by September (ba2dc).