Home Blog CV Projects Patterns Notes Book Colophon Search

Start of Debian Bookworm Lets Encrypt script for Sanic

29 Jun, 2024

I've run this on a £3.20/month digital ocean droplet and it seems to work at about 2-6k requests per second for hello world depending on the concurrency. You'll need to answer some questions as you go for ufw and certbot.

WARNING: I haven't left what is deployed with this particular script running long enough to check renewal is actually successful or whether logs actually rotate. I'll update the post once I do.

#!/bin/bash

set -e

# Define environment variables
SANIC_USER="sanic"
GROUP_NAME="ssl-cert"
APP_DIR="/home/${SANIC_USER}/app"
VENV_DIR="/home/${SANIC_USER}/venv"
DOMAIN="hello.example.com"
WELL_KNOWN_DIR="${APP_DIR}/certbot"

# Update and upgrade existing packages
# apt update
apt -y upgrade


# Install necessary packages
apt install -y certbot acl fail2ban ntp unattended-upgrades logrotate htop python3-venv authbind ufw

# Configure Fail2Ban
cat <<EOL > /etc/fail2ban/jail.local
[DEFAULT]
bantime = 10m
findtime = 10m
maxretry = 5

[sshd]
enabled = true
port = ssh
backend = systemd
EOL

systemctl restart fail2ban

# Configure automatic updates
cat <<EOL > /etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOL

# Configure UFW to allow SSH, HTTP, and HTTPS
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow http
ufw allow https
ufw enable

# Check if the user exists
if id "$SANIC_USER" &>/dev/null; then
    echo "User $SANIC_USER exists."
else
    echo "User $SANIC_USER does not exist. Creating user..."
    sudo adduser --disabled-password --gecos "" ${SANIC_USER}
    sudo usermod -s /usr/sbin/nologin ${SANIC_USER}
    sudo mkdir -p "/home/${SANIC_USER}/app"
    sudo chown "${SANIC_USER}:${SANIC_USER}" -R "/home/${SANIC_USER}"
fi

# Ensure the application directory exists
mkdir -p ${APP_DIR}
chown -R ${SANIC_USER}:${SANIC_USER} ${APP_DIR}

sudo -u ${SANIC_USER} python3 -m venv ${VENV_DIR}
sudo -u ${SANIC_USER} ${VENV_DIR}/bin/pip install sanic

# Create hello.py
cat <<EOL > ${APP_DIR}/hello.py
from sanic import Sanic, response
from sanic.exceptions import SanicException

app = Sanic("hello")

@app.route("/")
async def hello_world(request):
    return response.json({"message": "Hello, world!"})

# Custom error handler for various HTTP status codes
async def custom_error_handler(request, exception):
    status_code = exception.status_code if isinstance(exception, SanicException) else 500
    return response.json(
        {"message": f"An error occurred: {status_code}"},
        status=status_code
    )

# Register the custom handler for common error statuses
app.error_handler.add(Exception, custom_error_handler)
app.error_handler.add(400, custom_error_handler)
app.error_handler.add(401, custom_error_handler)
app.error_handler.add(403, custom_error_handler)
app.error_handler.add(404, custom_error_handler)
app.error_handler.add(500, custom_error_handler)

if __name__ == "__main__":
    app.run()
EOL

# Change ownership of the hello.py file
chown ${SANIC_USER}:${SANIC_USER} ${APP_DIR}/hello.py

# Create httpredirect.py
cat <<EOL > ${APP_DIR}/httpredirect.py
from sanic import Sanic, exceptions, response

app = Sanic("http_redir")

# Serve ACME/certbot files without HTTPS, for certificate renewals
app.static("/.well-known", "${WELL_KNOWN_DIR}/.well-known", resource_type="dir")

@app.exception(exceptions.NotFound, exceptions.MethodNotSupported)
async def redirect_everything_else(request, exception):
    server, path = request.server_name, request.path
    if server and path.startswith("/"):
        return response.redirect(f"https://{server}{path}", status=308)
    return response.text("Bad Request. Please use HTTPS!", status=400)

# Custom error handler for various HTTP status codes
async def custom_error_handler(request, exception):
    status_code = exception.status_code if isinstance(exception, exceptions.SanicException) else 500
    return response.json(
        {"message": f"An error occurred: {status_code}"},
        status=status_code
    )

# Register the custom handler for common error statuses
app.error_handler.add(Exception, custom_error_handler)
app.error_handler.add(400, custom_error_handler)
app.error_handler.add(401, custom_error_handler)
app.error_handler.add(403, custom_error_handler)
app.error_handler.add(404, custom_error_handler)
app.error_handler.add(500, custom_error_handler)

if __name__ == "__main__":
    app.run()
EOL

# Change ownership of the httpredirect.py file
chown ${SANIC_USER}:${SANIC_USER} ${APP_DIR}/httpredirect.py

# Configure authbind for Sanic to bind to ports 80 and 443
touch /etc/authbind/byport/80
touch /etc/authbind/byport/443
chown ${SANIC_USER}:${SANIC_USER} /etc/authbind/byport/80
chown ${SANIC_USER}:${SANIC_USER} /etc/authbind/byport/443
chmod 500 /etc/authbind/byport/80
chmod 500 /etc/authbind/byport/443


# Create the systemd service file for httpredirect
cat <<EOL > /etc/systemd/system/httpredirect.service
[Unit]
Description=Sanic HTTP Redirect Service
After=network.target

[Service]
User=${SANIC_USER}
WorkingDirectory=${APP_DIR}
ExecStart=/usr/bin/authbind ${VENV_DIR}/bin/sanic httpredirect:app --host 0.0.0.0 --port 80
Restart=always

[Install]
WantedBy=multi-user.target
EOL

# Reload systemd and start the httpredirect service
systemctl daemon-reload
systemctl enable httpredirect
systemctl restart httpredirect

# Ensure the certbot directory exists and has the correct permissions
mkdir -p ${WELL_KNOWN_DIR}/.well-known/acme-challenge
chown -R ${SANIC_USER}:${SANIC_USER} ${WELL_KNOWN_DIR}

# Check if the fullchain.pem exists
if [ ! -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
  # Obtain the Let's Encrypt certificate with recommended settings
  certbot certonly --key-type ecdsa --preferred-chain "ISRG Root X1" --webroot -w ${WELL_KNOWN_DIR} -d ${DOMAIN}
  # If you do a test run with the sandbox, the certificates might end up in a directory with -0001 suffix. If you don't delete this, the real certbot command uses the same directory.
fi

# Update certbot renewal configuration
cat <<EOL > /etc/letsencrypt/renewal/${DOMAIN}.conf
# renew_before_expiry = 30 days
version = 2.1.0
archive_dir = /etc/letsencrypt/archive/${DOMAIN}
cert = /etc/letsencrypt/live/${DOMAIN}/cert.pem
privkey = /etc/letsencrypt/live/${DOMAIN}/privkey.pem
chain = /etc/letsencrypt/live/${DOMAIN}/chain.pem
fullchain = /etc/letsencrypt/live/${DOMAIN}/fullchain.pem

# Options used in the renewal process
[renewalparams]
account = 04fxxx
authenticator = webroot
webroot_path = ${WELL_KNOWN_DIR}
server = https://acme-v02.api.letsencrypt.org/directory
key_type = ecdsa
EOL

if getent group "$GROUP_NAME" >/dev/null 2>&1; then
    echo "Group $GROUP_NAME exists."
else
    echo "Group $GROUP_NAME does not exist."
    # Create a new group for SSL certificates
    groupadd $GROUP_NAME
    # Add the sanic user to the ssl-cert group
    usermod -aG $GROUP_NAME $SANIC_USER
fi

# Set permissions for Let's Encrypt directories and files
chgrp -R $GROUP_NAME /etc/letsencrypt
chmod -R 750 /etc/letsencrypt
chmod -R 750 /etc/letsencrypt/live
chmod -R 750 /etc/letsencrypt/archive
chmod 640 /etc/letsencrypt/archive/${DOMAIN}/privkey1.pem
chmod 640 /etc/letsencrypt/archive/${DOMAIN}/fullchain1.pem
chmod 640 /etc/letsencrypt/archive/${DOMAIN}/cert1.pem
chmod 640 /etc/letsencrypt/archive/${DOMAIN}/chain1.pem

# Set ACLs to ensure new files have the correct permissions
setfacl -R -m "g:${GROUP_NAME}:rx" /etc/letsencrypt/live /etc/letsencrypt/archive
setfacl -R -m "d:g:${GROUP_NAME}:rx" /etc/letsencrypt/live /etc/letsencrypt/archive


# Create the systemd service file for the Sanic app
cat <<EOL > /etc/systemd/system/sanic.service
[Unit]
Description=Sanic Application
After=network.target

[Service]
User=${SANIC_USER}
WorkingDirectory=${APP_DIR}
ExecStart=/usr/bin/authbind ${VENV_DIR}/bin/sanic hello:app --host 0.0.0.0 --port 443 --tls /etc/letsencrypt/live/${DOMAIN}
Restart=always

[Install]
WantedBy=multi-user.target
EOL

# Reload systemd and start the Sanic service
systemctl daemon-reload
systemctl enable sanic
systemctl restart sanic


# Perform a dry run of certbot renew to ensure everything is set up correctly
certbot renew --dry-run

echo "Sanic setup with HTTP redirect, Fail2Ban, and Certbot configuration completed successfully."

This also sets up fail2ban, ufw, logrotate and automatic updates, but doesn't disable the root user's SSH or move SSH to a non-standard port (both of which should be done but I felt might interfere with the web console agent Digital Ocean provides). There is htop for some easier monitoring too and authbind allows the non-privileged sanic user to bind to 80 and 433.

Let me know if there are any potential issues you can spot.

Benchmarking:

$ curl https://hello.example.com/
{"message":"Hello, world!"}
$ wrk -t 8 -d 10 -c 256 https://hello.exampe.com/
Running 10s test @ https://hello.example.com/
  8 threads and 256 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    38.93ms   36.54ms 896.85ms   98.30%
    Req/Sec   791.96    230.74     1.16k    77.40%
  60807 requests in 10.03s, 7.07MB read
Requests/sec:   6061.31
Transfer/sec:    722.15KB

By default, sanic starts quite a few workers which uses up a bit more RAM. It also seems to work fine if you add the --single-process flag to both services.

With a low memory machine you can get errors like this when installing packages:

update-initramfs: Generating /boot/initrd.img-6.1.0-21-amd64
Killed
E: mkinitramfs failure zstd -q -9 -T0 137
update-initramfs: failed for /boot/initrd.img-6.1.0-21-amd64 with 1.
dpkg: error processing package initramfs-tools (--configure):
 installed initramfs-tools package post-installation script subprocess returned error exit status 1
Errors were encountered while processing:
 initramfs-tools
E: Sub-process /usr/bin/dpkg returned an error code (1)

Add some swap space and you should be all good:

sudo fallocate -l 512M /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

If you want the swap space mounted each boot (probably a good idea) you can run this once to add the correct line to /etc/fstab:

echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

NOTE: setguid might be a better way of sharing access to the /etc/letsencrypt directory:

# Set group ownership to ssl-cert
sudo chgrp -R ssl-cert /etc/letsencrypt

# Apply setgid bit to ensure new files inherit the group
sudo find /etc/letsencrypt -type d -exec chmod g+s {} +

Also there's a bug with hard coding the certbot account ID.

Update Nov 2024:

root@debian-s-1vcpu-512mb-10gb-ams3-01:~# sudo systemctl status certbot.timer
● certbot.timer - Run certbot twice daily
     Loaded: loaded (/lib/systemd/system/certbot.timer; enabled; preset: enabled)
     Active: active (waiting) since Mon 2024-07-01 10:32:18 UTC; 4 months 8 days ago
    Trigger: Fri 2024-11-08 19:52:21 UTC; 6h left
   Triggers: ● certbot.service

Notice: journal has been rotated since unit was started, output may be incomplete.
root@debian-s-1vcpu-512mb-10gb-ams3-01:~# sudo systemctl status certbot.service
× certbot.service - Certbot
     Loaded: loaded (/lib/systemd/system/certbot.service; static)
     Active: failed (Result: exit-code) since Fri 2024-11-08 03:11:43 UTC; 10h ago
TriggeredBy: ● certbot.timer
       Docs: file:///usr/share/doc/python-certbot-doc/html/index.html
             https://certbot.eff.org/docs
    Process: 2057673 ExecStart=/usr/bin/certbot -q renew --no-random-sleep-on-renew (code=exited, status=1/FAILU>
   Main PID: 2057673 (code=exited, status=1/FAILURE)
        CPU: 938ms

Nov 08 03:11:42 debian-s-1vcpu-512mb-10gb-ams3-01 systemd[1]: Starting certbot.service - Certbot...
Nov 08 03:11:43 debian-s-1vcpu-512mb-10gb-ams3-01 certbot[2057673]: Failed to renew certificate hello.productspe>
Nov 08 03:11:43 debian-s-1vcpu-512mb-10gb-ams3-01 certbot[2057673]: All renewals failed. The following certifica>
Nov 08 03:11:43 debian-s-1vcpu-512mb-10gb-ams3-01 certbot[2057673]:   /etc/letsencrypt/live/hello.productspeople>
Nov 08 03:11:43 debian-s-1vcpu-512mb-10gb-ams3-01 certbot[2057673]: 1 renew failure(s), 0 parse failure(s)
Nov 08 03:11:43 debian-s-1vcpu-512mb-10gb-ams3-01 systemd[1]: certbot.service: Main process exited, code=exited,>
Nov 08 03:11:43 debian-s-1vcpu-512mb-10gb-ams3-01 systemd[1]: certbot.service: Failed with result 'exit-code'.
Nov 08 03:11:43 debian-s-1vcpu-512mb-10gb-ams3-01 systemd[1]: Failed to start certbot.service - Certbot.


root@debian-s-1vcpu-512mb-10gb-ams3-01:~# sudo tail -n 50 /var/log/letsencrypt/letsencrypt.log
Initialized: <certbot._internal.plugins.webroot.Authenticator object at 0x7fce62c6c9d0>
Prep: True
2024-11-08 03:11:43,378:DEBUG:certbot._internal.plugins.selection:Selected authenticator <certbot._internal.plugins.webroot.Authenticator object at 0x7fce62c6c9d0> and installer None
2024-11-08 03:11:43,379:INFO:certbot._internal.plugins.selection:Plugins selected: Authenticator webroot, Installer None
2024-11-08 03:11:43,381:ERROR:certbot._internal.renewal:Failed to renew certificate hello.productspeoplecareabout.com with error: Account at /etc/letsencrypt/accounts/acme-v01.api.letsencrypt.org/directory/04fxxx does not exist
2024-11-08 03:11:43,386:DEBUG:certbot._internal.renewal:Traceback was:
Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/certbot/_internal/renewal.py", line 532, in handle_renewal_request
    main.renew_cert(lineage_config, plugins, renewal_candidate)
  File "/usr/lib/python3/dist-packages/certbot/_internal/main.py", line 1538, in renew_cert
    le_client = _init_le_client(config, auth, installer)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/certbot/_internal/main.py", line 827, in _init_le_client
    acc, acme = _determine_account(config)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/certbot/_internal/main.py", line 721, in _determine_account
    acc = account_storage.load(config.account)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/certbot/_internal/account.py", line 244, in load
    return self._load_for_server_path(account_id, self.config.server_path)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/certbot/_internal/account.py", line 220, in _load_for_server_path
    prev_loaded_account = self._load_for_server_path(account_id, prev_server_path)
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/certbot/_internal/account.py", line 229, in _load_for_server_path
    raise errors.AccountNotFound(f"Account at {account_dir_path} does not exist")
certbot.errors.AccountNotFound: Account at /etc/letsencrypt/accounts/acme-v01.api.letsencrypt.org/directory/04fxxx does not exist

2024-11-08 03:11:43,386:DEBUG:certbot._internal.display.obj:Notifying user: 
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
2024-11-08 03:11:43,386:ERROR:certbot._internal.renewal:All renewals failed. The following certificates could not be renewed:
2024-11-08 03:11:43,387:ERROR:certbot._internal.renewal:  /etc/letsencrypt/live/hello.productspeoplecareabout.com/fullchain.pem (failure)
2024-11-08 03:11:43,387:DEBUG:certbot._internal.display.obj:Notifying user: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
2024-11-08 03:11:43,387:DEBUG:certbot._internal.log:Exiting abnormally:
Traceback (most recent call last):
  File "/usr/bin/certbot", line 33, in <module>
    sys.exit(load_entry_point('certbot==2.1.0', 'console_scripts', 'certbot')())
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/certbot/main.py", line 19, in main
    return internal_main.main(cli_args)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/certbot/_internal/main.py", line 1736, in main
    return config.func(config, plugins)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/certbot/_internal/main.py", line 1629, in renew
    renewal.handle_renewal_request(config)
  File "/usr/lib/python3/dist-packages/certbot/_internal/renewal.py", line 558, in handle_renewal_request
    raise errors.Error(
certbot.errors.Error: 1 renew failure(s), 0 parse failure(s)
2024-11-08 03:11:43,388:ERROR:certbot._internal.log:1 renew failure(s), 0 parse failure(s)
root@debian-s-1vcpu-512mb-10gb-ams3-01:~# ls -la /etc/letsencrypt/accounts/acme-v01.api.letsencrypt.org/directory/
ls: cannot access '/etc/letsencrypt/accounts/acme-v01.api.letsencrypt.org/directory/': No such file or directory
root@debian-s-1vcpu-512mb-10gb-ams3-01:~# ls -la /etc/letsencrypt/accounts/acme-v01.api.letsencrypt.orgls: cannot access '/etc/letsencrypt/accounts/acme-v01.api.letsencrypt.org': No such file or directory
root@debian-s-1vcpu-512mb-10gb-ams3-01:~# ls -la /etc/letsencrypt/accounts/
total 16
drwxr-x--- 4 root ssl-cert 4096 Jun 29 06:32 .
drwxr-x--- 9 root ssl-cert 4096 Nov  8 03:11 ..
drwxr-x--- 3 root ssl-cert 4096 Jun 29 06:32 acme-staging-v02.api.letsencrypt.org
drwxr-x--- 3 root ssl-cert 4096 Jun 29 06:32 acme-v02.api.letsencrypt.org
root@debian-s-1vcpu-512mb-10gb-ams3-01:~# ls -la /etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/
total 12
drwxr-x--- 3 root ssl-cert 4096 Jun 29 06:32 .
drwxr-x--- 3 root ssl-cert 4096 Jun 29 06:32 ..
drwxr-x--- 2 root ssl-cert 4096 Jun 29 06:32 daxxx
root@debian-s-1vcpu-512mb-10gb-ams3-01:~# sudo vim /etc/letsencrypt/renewal/hello.productspeoplecareabout.com.conf

Then it works again. So it seems like 01 became deprecated, and there was a 02 account registered, but although the auto update updated all packages, the certbot renew failed by trying to use the old account ID until the config was updated.

Let's see if renews next time.

No renewal hook ran so once the certificate renewed, the service didn't restart:

sudo certbot renew --deploy-hook "systemctl restart sanic"

Add it:

sudo vim /etc/letsencrypt/renewal/hello.productspeoplecareabout.com.conf

[renewparams]
renew_hook = systemctl restart sanic

sudo certbot renew --force-renewal

Comments

Be the first to comment.

Add Comment





Copyright James Gardner 1996-2020 All Rights Reserved. Admin.