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