From 9e1b49a75263a4c6956355aed51c00ed9036f857 Mon Sep 17 00:00:00 2001 From: Tim Wojtulewicz Date: Wed, 29 Jan 2025 11:37:44 -0700 Subject: [PATCH] Add certbot for managing a Lets Encrypt cert --- README.md | 10 +++ cert_setup/docker-compose.yml | 9 +++ cert_setup/init-certs.sh | 17 ++++ cert_setup/nginx-certtool.conf | 12 +++ cert_setup/ssl-update.sh | 137 +++++++++++++++++++++++++++++++++ docker-compose.yml | 3 + docker/Dockerfile.nginx | 6 ++ docker/nginx-default.conf | 42 +++++++++- 8 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 cert_setup/docker-compose.yml create mode 100755 cert_setup/init-certs.sh create mode 100644 cert_setup/nginx-certtool.conf create mode 100755 cert_setup/ssl-update.sh diff --git a/README.md b/README.md index 81a7041..c0ddcee 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,16 @@ cd zeek-pkg-web `secrets/.env` has a set of variables for passwords and such that PHP will need to connect to the database and update the packages list from GitHub. +## Initialize an SSL certificate + +- Edit `cert_setup/ssl-update.sh` and set the `DOMAINS` and `EMAIL` values to + be sane for your installation. +- Run the `cert_setup/init-certs.sh` script. This will generate a Let's Encrypt + certificate, store it in the location that nginx container will use, and add + a cron task to automatically update it. +- Edit `docker/nginx-default.conf` and set the hostname in the `ssl_certificate` + and `ssl_certificate_key` values to match the `DOMAINS` setting from earlier. + ## (For development only) Enable the database container - Edit `docker-compose.yml` and uncomment the section for the `db` service diff --git a/cert_setup/docker-compose.yml b/cert_setup/docker-compose.yml new file mode 100644 index 0000000..6bfa2ff --- /dev/null +++ b/cert_setup/docker-compose.yml @@ -0,0 +1,9 @@ +services: + nginx: + image: nginx:1.27.3 + volumes: + - ./nginx-certtool.conf:/etc/nginx/conf.d/default.conf + - ../data/certbot/letsencrypt:/etc/letsencrypt:ro + - ../data/certbot/www:/var/www/certbot:ro + ports: + - 80:80 diff --git a/cert_setup/init-certs.sh b/cert_setup/init-certs.sh new file mode 100755 index 0000000..3f11078 --- /dev/null +++ b/cert_setup/init-certs.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +cd $SCRIPT_DIR + +docker compose up -d nginx +bash ./ssl-update.sh +docker compose down nginx + +curl \ + -o ${SCRIPT_DIR}/../data/certbot/mozilla-dhparam.txt \ + https://ssl-config.mozilla.org/ffdhe2048.txt + +cat >/etc/cron.d/certbot.cron </dev/null && pwd)" +REPO_PATH="${SCRIPT_DIR}/.." +CERT_DIR_PATH="${REPO_PATH}/data/certbot/letsencrypt" +WEBROOT_PATH="${REPO_PATH}/data/certbot/www" +CERT_LOG_PATH="${REPO_PATH}/data/certbot/logs" +LE_RENEW_HOOK="docker restart zeek-pkg-web-nginx-1" +EXP_LIMIT="30" +CHECK_FREQ="30" +STAGING=0 +FIRST_RUN=0 + +if [[ -z $DOMAINS ]]; then + echo "No domains set, please fill -e 'DOMAINS=example.com www.example.com'" + exit 1 +fi + +if [[ -z $EMAIL ]]; then + echo "No email set, please fill -e 'EMAIL=your@email.tld'" + exit 1 +fi + +if [[ -z $CERT_DIR_PATH ]]; then + echo "No cert dir path set, please fill -e 'CERT_DIR_PATH=/etc/letsencrypt'" + exit 1 +fi + +if [[ -z $WEBROOT_PATH ]]; then + echo "No webroot path set, please fill -e 'WEBROOT_PATH=/tmp/letsencrypt'" + exit 1 +fi + +if [[ $STAGING -eq 1 ]]; then + echo "Using the staging environment" + ADDITIONAL="--staging" +fi + +DARRAYS=(${DOMAINS}) +EMAIL_ADDRESS=${EMAIL} +LE_DOMAINS=("${DARRAYS[*]/#/-d }") + +exp_limit="${EXP_LIMIT:-30}" +check_freq="${CHECK_FREQ:-30}" + +le_hook() { + if [[ $FIRST_RUN -eq 1 ]]; then + return + fi + + command=$(echo $LE_RENEW_HOOK) + echo "[INFO] Run: $command" + eval $command +} + +le_fixpermissions() { + echo "[INFO] Fixing permissions" + chown -R ${CHOWN:-root:root} ${CERT_DIR_PATH} + find ${CERT_DIR_PATH} -type d -exec chmod 755 {} \; + find ${CERT_DIR_PATH} -type f -exec chmod ${CHMOD:-644} {} \; +} + +le_renew() { + docker run --rm --name temp_certbot \ + -v "${CERT_DIR_PATH}:/etc/letsencrypt" \ + -v "${WEBROOT_PATH}:/tmp/letsencrypt" \ + -v "/data/servers-data/certbot/log:/var/log" \ + certbot/certbot:v3.1.0 certonly \ + --webroot --agree-tos --renew-by-default --non-interactive \ + --preferred-challenges http-01 \ + --server https://acme-v02.api.letsencrypt.org/directory --text ${ADDITIONAL} \ + --email ${EMAIL_ADDRESS} -w /tmp/letsencrypt ${LE_DOMAINS} + + le_fixpermissions + le_hook +} + +le_check() { + cert_file="$CERT_DIR_PATH/live/$DARRAYS/fullchain.pem" + + echo "START check" + echo "file: $cert_file" + + if [[ -e $cert_file ]]; then + + exp=$(date -d "$(openssl x509 -in $cert_file -text -noout | grep "Not After" | cut -c 25-)" +%s) + datenow=$(date -d "now" +%s) + days_exp=$((($exp - $datenow) / 86400)) + + echo "Checking expiration date for $DARRAYS..." + + if [ "$days_exp" -gt "$exp_limit" ]; then + echo "The certificate is up to date, no need for renewal ($days_exp days left)." + else + echo "The certificate for $DARRAYS is about to expire soon. Starting webroot renewal script..." + le_renew + echo "Renewal process finished for domain $DARRAYS" + fi + + echo "Checking domains for $DARRAYS..." + + domains=($(openssl x509 -in $cert_file -text -noout | grep -oP '(?<=DNS:)[^,]*')) + new_domains=($( + for domain in ${DARRAYS[@]}; do + [[ " ${domains[@]} " =~ " ${domain} " ]] || echo $domain + done + )) + + if [ -z "$new_domains" ]; then + echo "The certificate have no changes, no need for renewal" + else + echo "The list of domains for $DARRAYS certificate has been changed. Starting webroot renewal script..." + le_renew + echo "Renewal process finished for domain $DARRAYS" + fi + + else + FIRST_RUN=1 + + echo "[INFO] certificate file not found for domain $DARRAYS. Starting webroot initial certificate request script..." + le_renew + echo "Certificate request process finished for domain $DARRAYS" + fi + +} + +echo "--- start. $(date)" + +le_check $1 diff --git a/docker-compose.yml b/docker-compose.yml index ff0323b..b2ddb92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: - 443:443 volumes: - ./bropkg/webroot:/var/www/html:ro + - ./data/certbot/letsencrypt:/etc/letsencrypt:ro + - ./data/certbot/www:/var/www/certbot:ro + - ./data/certbot/mozilla-dhparam.txt:/etc/nginx/mozilla-dhparam.txt:ro php: build: diff --git a/docker/Dockerfile.nginx b/docker/Dockerfile.nginx index dd4020d..afb0255 100644 --- a/docker/Dockerfile.nginx +++ b/docker/Dockerfile.nginx @@ -1,3 +1,9 @@ FROM nginx:1.27.3 +RUN apt update -y +RUN DEBIAN_FRONTEND=noninteractive apt-get install -y \ + certbot \ + cron \ + python3-certbot-nginx + COPY docker/nginx-default.conf /etc/nginx/conf.d/default.conf diff --git a/docker/nginx-default.conf b/docker/nginx-default.conf index f140ab8..5d078ca 100644 --- a/docker/nginx-default.conf +++ b/docker/nginx-default.conf @@ -1,9 +1,23 @@ server { + listen 80; + listen [::]:80 ipv6only=on; - listen 80 default_server; - listen [::]:80 ipv6only=on default_server; -# listen 443 ssl default_server; -# listen [::]:443 ssl ipv6only=on default_server; + server_name _; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl ipv6only=on; + + server_name _; root /var/www/html; index index.html index.php; @@ -39,4 +53,24 @@ server { location ~ /.ht { deny all; } + + # HSTS (ngx_http_headers_module is required) (63072000 seconds) + add_header Strict-Transport-Security "max-age=63072000" always; + + ssl_certificate /etc/letsencrypt/live/domain.com/cert.pem; + ssl_certificate_key /etc/letsencrypt/live/domain.com/privkey.pem; } + +# These options come from https://ssl-config.mozilla.org, as of 2025/01/16. +# intermediate configuration +ssl_protocols TLSv1.2 TLSv1.3; +ssl_ecdh_curve X25519:prime256v1:secp384r1; +ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305; +ssl_prefer_server_ciphers off; + +# see also ssl_session_ticket_key alternative to stateful session cache +ssl_session_timeout 1d; +ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + +# curl https://ssl-config.mozilla.org/ffdhe2048.txt > /path/to/dhparam +ssl_dhparam "/etc/nginx/mozilla-dhparam.txt";