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 = 04f9edc9a058793d0437f2c7b61b5e2a
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.

Comments

Be the first to comment.

Add Comment





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