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.
Be the first to comment.
Copyright James Gardner 1996-2020 All Rights Reserved. Admin.