From c554226227b71737d33d12370ada1eb2997c51a2 Mon Sep 17 00:00:00 2001 From: Sotiris Tsepelakis <sotirios.tsepelakis@tuwien.ac.at> Date: Thu, 23 Jan 2025 21:05:40 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20Services:=20add=20Certb?= =?UTF-8?q?ot=20with=20auto=20SSL=20renewal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + docker-compose.yml | 18 +++++ scripts/dump-vars.sh | 2 +- scripts/generate-ssl.sh | 170 ++++++++++++++++++++++++++++++++++++---- 4 files changed, 175 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index f4fa38d..0051758 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ # ssl keys and certificates ssl/* !ssl/.gitkeep + +# Certbot files +docker/certbot/* diff --git a/docker-compose.yml b/docker-compose.yml index b5fbcbc..3799671 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,24 @@ services: depends_on: - os-node-1 + certbot: + image: certbot/certbot + restart: "unless-stopped" + ports: + - 80:80 + volumes: + - ./docker/certbot/conf:/etc/letsencrypt + - ./docker/certbot/www:/var/www/certbot + - ./ssl:/ssl + entrypoint: > + /bin/sh -c ' + trap exit TERM; + while :; do + certbot renew --standalone --noninteractive --preferred-challenges http-01 --http-01-port 80; + sleep ${CERTBOT_SLEEP_INTERVAL:-12h} & wait $${!}; + done; + ' + volumes: os-node-1: os-node-2: diff --git a/scripts/dump-vars.sh b/scripts/dump-vars.sh index fb3aaba..d54d0f0 100755 --- a/scripts/dump-vars.sh +++ b/scripts/dump-vars.sh @@ -24,5 +24,5 @@ touch "${OUTPUT_FILE}" while read -r var; do echo $var >> "${OUTPUT_FILE}" done <<- EOF - $(env | grep -E "^DASHBOARDS_.*|^METRICBEAT_.*|^OPENSEARCH_.*") + $(env | grep -E "^CERTBOT_.*|^DASHBOARDS_.*|^METRICBEAT_.*|^OPENSEARCH_.*") EOF diff --git a/scripts/generate-ssl.sh b/scripts/generate-ssl.sh index 1e14f06..18edf44 100755 --- a/scripts/generate-ssl.sh +++ b/scripts/generate-ssl.sh @@ -1,17 +1,44 @@ #!/bin/bash # -# script for generating SSL keys and certificates +# Script for generating SSL keys and certificates. +# set -euo pipefail +# The guard below ensures, in most cases, that the script is executed from the correct directory (project directory). +# So it should be safe to assume that we can extract the path from the parent of the 'scripts' directory. +PROJECT_DIR="$(dirname "$(dirname "$(realpath "$0")")")" + +# ---------------- # +# Execution checks # +# ---------------- # if [[ -d "ssl" ]]; then cd "ssl" else - echo >&2 "error: this script needs to be executed from the project directory!" + echo >&2 "[ERROR] This script needs to be executed from the project directory!" exit 1 fi -# common certificate details +# --------------------- # +# Environment variables # +# --------------------- # +ENV_FILE="${PROJECT_DIR}/.env" + +# Check if the .env file exists and source it +if [[ -f "${ENV_FILE}" ]]; then + echo "[INFO] Detected .env file, sourcing it." + source "${ENV_FILE}" +fi + +# Set hostnames for Dashboards and Opensearch nodes. +# They are determining whether the setup is in a local or live environment. +DASHBOARDS_HOSTNAME="${DASHBOARDS_HOSTNAME:-localhost}" +OPENSEARCH_HOSTNAME="${OPENSEARCH_HOSTNAME:-localhost}" + +# --------------------- # +# Utilities definitions # +# --------------------- # +# Common certificate details organization="TU Wien" org_unit="Center for Research Data Management" country="AT" @@ -19,35 +46,35 @@ state="Vienna" locality="Vienna" function generate_certificate { - # generate keypair + # Generate keypair local cert_name="${1}" local common_name="${2}" - # generate the ssl files + # Generate the ssl files subject="/C=${country}/ST=${state}/L=${locality}/O=${organization}/OU=${org_unit}/CN=${common_name}" openssl genrsa -out "${cert_name}-key-temp.pem" 2048 openssl pkcs8 -inform PEM -outform PEM -in "${cert_name}-key-temp.pem" -topk8 -nocrypt -v1 PBE-SHA1-3DES -out "${cert_name}-key.pem" openssl req -new -key "${cert_name}-key.pem" -subj "${subject}" -out "${cert_name}.csr" openssl x509 -req -in "${cert_name}.csr" -CA root-ca-crt.pem -CAkey root-ca-key.pem -CAcreateserial -sha256 -out "${cert_name}-crt.pem" -days 365 - # clean up temporary files + # Clean up temporary files rm "${cert_name}-key-temp.pem" "${cert_name}.csr" } function generate_if_not_user_defined { - # generate ssl keypair if it wasn't explicitly set by an operator + # Generate ssl keypair if it wasn't explicitly set by an operator local cert_name="${1}" local common_name="${2}" local key_file="${cert_name}-key.pem" local cert_file="${cert_name}-crt.pem" if [[ -f "${key_file}" && ! -L "${key_file}" && -f "${cert_file}" && ! -L "${cert_file}" ]]; then - # if both files exist, we assume they were placed here by the operator - echo "info: keeping keypair for ${cert_name}." + # If both files exist, we assume they were placed here by the operator + echo "[INFO] Keeping keypair for ${cert_name}." elif [[ ! -e "${key_file}" || -L "${key_file}" || ! -e "${cert_file}" || -L "${cert_file}" ]]; then - # if the files are symlinks, we assume they were auto-generated by the script - # similar if they don't exist + # If the files are symlinks, we assume they were auto-generated by the script. + # Similarly if they don't exist. unlink "${key_file}" &>/dev/null || true unlink "${cert_file}" &>/dev/null || true generate_certificate "${cert_name}-gen" "${common_name}" @@ -55,13 +82,52 @@ function generate_if_not_user_defined { ln -s "${cert_name}-gen-crt.pem" "${cert_file}" else - # maybe the files got turned into directories by the bind mounts? - echo "info: something weird is going on with the files ${cert_name}-{key,crt}.pem" + # Maybe the files got turned into directories by the bind mounts? + echo "[INFO] Something weird is going on with the files ${cert_name}-{key,crt}.pem" echo "> please investigate manually." fi } +function is_local_setup { + local hostname="${1}" + [[ "${hostname}" == "localhost" ]] +} + +function link_certbot_certificates { + # Symlink Certbot certificates + local cert_name="${1}" + local hostname="${2}" + + # Spin up again certbot container and change directory permissions to make certificates readable. + # Symlinks won't work otherwise; 'live' directory has 700 permissions with root ownership. + docker compose up -d certbot + eval "docker compose exec -u root certbot sh -c \" + chmod 0755 /etc/letsencrypt/live /etc/letsencrypt/archive && + chmod -R +r /etc/letsencrypt/archive/* /etc/letsencrypt/live/* + \"" + + if [[ $? -ne 0 ]]; then + echo "[ERROR] Failed to adjust permissions for Let's Encrypt files." + exit 1 + fi + echo "[INFO] Permissions adjusted for Let's Encrypt directories." + + + echo "[INFO] Linking Certbot certificates for ${hostname} to ${cert_name}." + local ssl_path="${PROJECT_DIR}/docker/certbot/conf/live/${hostname}" + + if [[ -d "${ssl_path}" ]]; then + ln -sf "${ssl_path}/privkey.pem" "${cert_name}-key.pem" + ln -sf "${ssl_path}/fullchain.pem" "${cert_name}-crt.pem" + else + echo "[ERROR] Certbot certificates for ${hostname} not found. Ensure Certbot has generated them." + exit 1 + fi +} +# ------------ # +# Run sequence # +# ------------ # # Root CA subject="/C=${country}/ST=${state}/L=${locality}/O=${organization}/OU=${org_unit}/CN=ROOT" openssl genrsa -out root-ca-key.pem 2048 @@ -76,8 +142,78 @@ generate_certificate "node1" "os-node-1" # Node 2 cert (for internal communication) generate_certificate "node2" "os-node-2" -# Dashboards cert (for outside communication) -generate_if_not_user_defined "dashboards" "${OPENSEARCH_HOSTNAME:-localhost}" +# Determine external SSL certificates generation +if is_local_setup "${DASHBOARDS_HOSTNAME}" && is_local_setup "${OPENSEARCH_HOSTNAME}"; then + # REST HTTPS cert (for outside communication) + generate_if_not_user_defined "cluster" "${OPENSEARCH_HOSTNAME}" + + echo "[INFO] Detected local setup, generating local certificates for Dashboards and Cluster." + # Dashboards cert (for outside communication) + generate_if_not_user_defined "dashboards" "${DASHBOARDS_HOSTNAME}" + +else + echo "[INFO] Detected live setup, generating Let's Encrypt certificates for Dashboards and Cluster." + CERTBOT_EMAIL="${CERTBOT_EMAIL:-}" + CERTBOT_RSA_KEY_SIZE="${CERTBOT_RSA_KEY_SIZE:-4096}" + CERTBOT_STAGING="${CERTBOT_STAGING:-}" + + # Select appropriate email argument. + if [[ -z "${CERTBOT_EMAIL}" ]]; then + email_arg="--register-unsafely-without-email" + else + email_arg="--email ${CERTBOT_EMAIL}" + fi + + # Determine certbot staging argument. + if [[ -z "${CERTBOT_STAGING}" ]] || [[ ! "${CERTBOT_STAGING}" =~ ^[01]$ ]]; then + echo "[WARN] Invalid or unset value for 'CERTBOT_STAGING'. Setting it to 1 (testing mode)." + CERTBOT_STAGING="1" + fi + if [[ "${CERTBOT_STAGING}" != "0" ]]; then staging_arg="--staging"; fi + + # Define common certbot command + certbot_cmd="certbot certonly ${staging_arg:-} ${email_arg} --rsa-key-size ${CERTBOT_RSA_KEY_SIZE} --standalone --agree-tos --force-renewal" + + # Check if Dashboards and Cluster point to the same hostname. + if [[ "${DASHBOARDS_HOSTNAME}" == "${OPENSEARCH_HOSTNAME}" ]]; then + echo "[INFO] Hostnames for Dashboards and Cluster are the same. Generating a single certificate for both." + LIVE_CRT_PATH="${PROJECT_DIR}/docker/certbot/conf/live/${DASHBOARDS_HOSTNAME}/fullchain.pem" + + if [[ -L "${LIVE_CRT_PATH}" ]]; then + echo "[WARN] Certificates for ${DASHBOARDS_HOSTNAME} already exist. Skipping generation." + + else + eval "docker compose run --rm -p \"80:80\" --entrypoint \"${certbot_cmd} -d ${DASHBOARDS_HOSTNAME}\" certbot" + # Symlink Certbot certificates + link_certbot_certificates "dashboards" "${DASHBOARDS_HOSTNAME}" + link_certbot_certificates "cluster" "${OPENSEARCH_HOSTNAME}" + fi + + else + echo "[INFO] Hostnames for are different. Generating separate certificates for each." + LIVE_DASHBOARDS_CRT_PATH="${PROJECT_DIR}/docker/certbot/conf/live/${DASHBOARDS_HOSTNAME}/fullchain.pem" + LIVE_OPENSEARCH_CRT_PATH="${PROJECT_DIR}/docker/certbot/conf/live/${OPENSEARCH_HOSTNAME}/fullchain.pem" + + # Attempt to generate certificate for DASHBOARDS_HOSTNAME. + if [[ -L "${LIVE_DASHBOARDS_CRT_PATH}" ]]; then + echo "[WARN] Certificates for ${DASHBOARDS_HOSTNAME} already exist. Skipping generation." + + else + eval "docker compose run --rm -p \"80:80\" --entrypoint \"${certbot_cmd} -d ${DASHBOARDS_HOSTNAME}\" certbot" + link_certbot_certificates "dashboards" "${DASHBOARDS_HOSTNAME}" + fi + + # Attempt to generate certificate for OPENSEARCH_HOSTNAME. + if [[ -L "${LIVE_OPENSEARCH_CRT_PATH}" ]]; then + echo "[WARN] Certificates for ${OPENSEARCH_HOSTNAME} already exist. Skipping generation." + + else + eval "docker compose run --rm -p \"80:80\" --entrypoint \"${certbot_cmd} -d ${OPENSEARCH_HOSTNAME}\" certbot" + link_certbot_certificates "cluster" "${OPENSEARCH_HOSTNAME}" + fi + fi +fi -# REST HTTPS cert (for outside communication) -generate_if_not_user_defined "cluster" "${DASHBOARDS_HOSTNAME:-localhost}" +# Ensure read permissions to all files in the 'ssl' directory because 'opensearch' user must be able to read them in the container. +# This is especially important for the *-key.pem files (private keys). +chmod -R +r "${PROJECT_DIR}/ssl" -- GitLab