diff --git a/.gitignore b/.gitignore index f4fa38d84ba06a4eb223d066539f63b106bd76b6..005175804099c4406b0b91f60c7ceeeac42cd241 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ # ssl keys and certificates ssl/* !ssl/.gitkeep + +# Certbot files +docker/certbot/* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..b6563861ec4098c021d0e14afb7cd6768b4b51a5 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,26 @@ +# GitLab CI/CD configuration + +stages: + - staging + - production + +deploy-staging: + stage: staging + environment: + name: staging + url: ${DEPLOYMENT_HOST} + script: + - ./.gitlab-ci/deploy.sh + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + +deploy-production: + stage: production + environment: + name: production + url: ${DEPLOYMENT_HOST} + script: + - ./.gitlab-ci/deploy.sh + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + when: manual diff --git a/.gitlab-ci/deploy.sh b/.gitlab-ci/deploy.sh new file mode 100755 index 0000000000000000000000000000000000000000..e18ef3c14a2187d0b5c4825ba7cf509fa8658842 --- /dev/null +++ b/.gitlab-ci/deploy.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# +# Deployment script for CRDM Logs. +# Intended for execution by the GitLab Runner. + +set -euo pipefail + +# Ensure required environment variables are set. +# DEPLOYMENT_HOST denotes the SSH TARGET. Can be only ONE IP or DNS name. +if [ -z "$DEPLOYMENT_HOST" ]; then + echo "[ERROR] Make sure DEPLOYMENT_HOST is set." >&2 + exit 1 +fi + +# ----------------------- # +# Repository checks # +# ----------------------- # +DEPLOYMENT_PATH="${DEPLOYMENT_PATH:-crdm-logging-setup}" +SSH_TARGET="${DEPLOYMENT_USER:-logs}@${DEPLOYMENT_HOST}" + +# SSH to target, initialize repo if it doesn't exist. +# Otherwise, tear down old deployment and pull changes. +ssh "${SSH_TARGET}" "bash" <<- EOF +set -eu pipefail +mkdir -p "${DEPLOYMENT_PATH}" && cd "${DEPLOYMENT_PATH}" + +if git status > /dev/null 2>&1; then + echo "[INFO] Pulling origin." + git pull "${CI_REPOSITORY_URL}" +else + echo "[INFO] Git repository doesn't exist, cloning and setting up." + git clone "${CI_REPOSITORY_URL}" . > /dev/null 2>&1 +fi + +echo -n "[INFO] " && git checkout "${CI_COMMIT_BRANCH}" +EOF + +# ----------------------- # +# .env file checks # +# ----------------------- # +# Dump variables and transfer the file. +echo "[INFO] Dumping variables to .env file." +./scripts/dump-vars.sh ".env" +scp ".env" "${SSH_TARGET}:${DEPLOYMENT_PATH}/.env" + +# SSH again, check if ssl directory is empty: setup or rerun. +ssh "${SSH_TARGET}" "bash" <<- EOF +set -eu pipefail +cd "${DEPLOYMENT_PATH}" +if [ $(find "./ssl" -type f ! -name '.gitkeep' | wc -l) -eq 0 ]; then + echo "[INFO] SSL directory only contains .gitkeep. Running setup.sh to create certificates." + ./scripts/setup.sh +else + echo "[INFO] SSL directory is not empty. Skipping certificate setup." + docker compose up -d +fi +EOF diff --git a/LICENSE b/LICENSE index bfc4758f81a8a2f943a803b64bde13ba2cf2c07d..6b4a86fe8f01748c13fd255d3b069428c3970522 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 TU Wien +Copyright (c) 2022-2025 TU Wien Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docker-compose.base.yml b/docker-compose.base.yml index 7c5f901f07aa744836366c319adda2dc0bb2ae40..9fc740743bec59bb062e2e9ecdf9b7ff94ce868e 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -1,8 +1,7 @@ -version: '3' - services: os-node: image: opensearchproject/opensearch:2 + pull_policy: "always" restart: "unless-stopped" environment: - DISABLE_INSTALL_DEMO_CONFIG=true @@ -22,13 +21,14 @@ services: soft: 65536 # maximum number of open files for the OpenSearch user, set to at least 65536 on modern systems hard: 65536 volumes: - - os-node-1:/usr/share/opensearch/data - # NOTE: We map every file separately so that we don't overwrite the files created - # in the same directory from opensearch. - - ./opensearch/security/internal_users.template.yml:/usr/share/opensearch/config/internal_users.template.yml:ro - - ./opensearch/security/tenants.yml:/usr/share/opensearch/plugins/opensearch-security/securityconfig/tenants.yml:ro - - ./opensearch/opensearch.yml:/usr/share/opensearch/config/opensearch.yml:ro - - ./opensearch/init-security.sh:/usr/share/opensearch/init-security.sh:ro + - os-node-1-config:/usr/share/opensearch/config + - os-node-1-data:/usr/share/opensearch/data + - ./docker/opensearch/opensearch.yml:/usr/share/opensearch/config/opensearch.yml:ro + - ./docker/opensearch/init-security.sh:/usr/share/opensearch/init-security.sh:ro + # NOTE: In this section we map every configuration file separately so that + # we don't overwrite the files created in the same directory from OpenSearch. + - ./docker/opensearch/security/internal_users.template.yml:/usr/share/opensearch/config/opensearch-security/internal_users.template.yml + - ./docker/opensearch/security/tenants.yml:/usr/share/opensearch/config/opensearch-security/tenants.yml # internal ssl files - ./ssl/node1-crt.pem:/usr/share/opensearch/config/node-crt.pem:ro - ./ssl/node1-key.pem:/usr/share/opensearch/config/node-key.pem:ro @@ -38,4 +38,3 @@ services: # ssl for external communication - ./ssl/cluster-crt.pem:/usr/share/opensearch/config/cluster-crt.pem:ro - ./ssl/cluster-key.pem:/usr/share/opensearch/config/cluster-key.pem:ro - diff --git a/docker-compose.yml b/docker-compose.yml index 94c8cab30dd752694a45cb75749421acc6c34b13..e3132a80eff44bb0ef925a92b962288cc84722b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3' - services: os-node-1: extends: @@ -23,6 +21,7 @@ services: os-dashboards: image: opensearchproject/opensearch-dashboards:2 + pull_policy: "always" restart: "unless-stopped" environment: # note: the dashboards entrypoint performs translations of naming for the env vars @@ -33,7 +32,7 @@ services: ports: - ${DASHBOARDS_PORT:-443}:5601 volumes: - - ./dashboards/config/opensearch_dashboards.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml:ro + - ./docker/dashboards/config/opensearch_dashboards.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml:ro - ./ssl/dashboards-crt.pem:/usr/share/opensearch-dashboards/config/crt.pem:ro - ./ssl/dashboards-key.pem:/usr/share/opensearch-dashboards/config/key.pem:ro - ./ssl/root-ca-crt.pem:/usr/share/opensearch-dashboards/config/root-ca.pem:ro @@ -45,7 +44,10 @@ services: # Beats versions newer than 7.12.x are not supported by OpenSearch image: docker.elastic.co/beats/metricbeat-oss:7.12.1 restart: "unless-stopped" - user: root + user: ${METRICBEAT_CONTAINER_UID:-1000} + # add docker gid from the host so that docker.sock can be accessed + group_add: + - ${METRICBEAT_DOCKER_GID:-999} environment: - METRICBEAT_INDEX_INFIX=${METRICBEAT_INDEX_INFIX:-dev} - METRICBEAT_INTERVAL=${METRICBEAT_INTERVAL:-10s} @@ -58,14 +60,34 @@ services: - /sys/fs/cgroup:/hostfs/sys/fs/cgroup:ro - /proc:/hostfs/proc:ro - /:/hostfs:ro - - ./metricbeat/metricbeat.yml:/usr/share/metricbeat/metricbeat.yml:ro + - ./docker/metricbeat/metricbeat.yml:/usr/share/metricbeat/metricbeat.yml:ro 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-1-config: + os-node-1-data: os-node-2: metricbeat-data: + networks: default: diff --git a/dashboards/config/opensearch_dashboards.yml b/docker/dashboards/config/opensearch_dashboards.yml similarity index 100% rename from dashboards/config/opensearch_dashboards.yml rename to docker/dashboards/config/opensearch_dashboards.yml diff --git a/metricbeat/metricbeat.yml b/docker/metricbeat/metricbeat.yml similarity index 100% rename from metricbeat/metricbeat.yml rename to docker/metricbeat/metricbeat.yml diff --git a/docker/opensearch/init-security.sh b/docker/opensearch/init-security.sh new file mode 100755 index 0000000000000000000000000000000000000000..4fb551ff3c0850bd5b3db1f14898313d20c1ccc0 --- /dev/null +++ b/docker/opensearch/init-security.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# +# Update the internal_users via 'securityadmin.sh' to set their passwords. +# This should only be called on a new cluster. +# +# Usage: docker compose run os-node-1 ./init-security.sh +# + +set -euo pipefail + +# Define common paths in the opensearch docker container. +security_config_path="./config/opensearch-security" +security_tools_path="./plugins/opensearch-security/tools" + +# Get the passwords from the env vars. +admin_pw="${OPENSEARCH_ADMIN_PASSWORD:?admin password required}" +kibanaserver_pw="${OPENSEARCH_KIBANASERVER_PASSWORD:?kibana server password required}" +logginguser_pw="${OPENSEARCH_LOGGINGUSER_PASSWORD:?logging user password required}" + +# Hash the passwords using the security plugin's hash script. +admin_hash=$(${security_tools_path}/hash.sh -p "${admin_pw}" | awk '{print $NF}') +dashboards_hash=$(${security_tools_path}/hash.sh -p "${kibanaserver_pw}" | awk '{print $NF}') +logging_hash=$(${security_tools_path}/hash.sh -p "${logginguser_pw}" | awk '{print $NF}') + +internal_users_file="${security_config_path}/internal_users.yml" +internal_users_template="${security_config_path}/internal_users.template.yml" + +# Create internal_users.yml from the template. +# This is important to prevent docker mount issues. +cp "${internal_users_template}" "${internal_users_file}" +echo "[INFO] Updating password hashes." + +# Create a temporary file before modifying. +cp "${internal_users_file}" "${internal_users_file}.tmp" + +# Add an autogenerated comment and remove the original 1st and 2nd lines. +{ + echo "# ------------------------------------------------- #" + echo "# Auto-generated from 'internal_users.template.yml' #" + echo "# ------------------------------------------------- #" + sed '1d;2d' "${internal_users_file}" +} > "${internal_users_file}.tmp" + +# Update the password hashes while preserving all other content. +sed -E \ + -e "s|^( *hash: )[^\s]+( # ADMIN_PW)|\1${admin_hash}\2|" \ + -e "s|^( *hash: )[^\s]+( # DASHBOARDS_PW)|\1${dashboards_hash}\2|" \ + -e "s|^( *hash: )[^\s]+( # LOGGING_PW)|\1${logging_hash}\2|" \ + "${internal_users_file}.tmp" > "${internal_users_file}" + +# Manual cleanup as we persist configuration (see 'docker-compose.base.yml'). +rm "${internal_users_file}.tmp" + +# Start OpenSearch (securityadmin.sh needs the security index). +opensearch -Eplugins.security.allow_default_init_securityindex=true -Ediscovery.type=single-node & +os_pid=$! +retries=10 + +# Wait until OpenSearch is ready to accept connections. +while [[ "${retries}" -gt 0 ]] ; do + echo "[INFO] Waiting for opensearch to be up. $retries" + sleep 5s + if curl -ksu "admin:admin" "https://localhost:9200"; then + break + fi + retries=$(( ${retries} - 1 )) +done + +if [[ "${retries}" -eq 0 ]]; then + echo &>2 "[ERROR] Exceeded maximum number of retries!" + exit 1 +fi + +# Wait for opensearch to be healthy. +curl -ku "admin:admin" "https://localhost:9200/_cluster/health?wait_for_status=yellow" + +# The securityadmin script needs admin certificates or a keystore to operate. +"${security_tools_path}/securityadmin.sh" \ + -rev -icl -nhnv \ + -cacert ./config/root-ca.pem \ + -cert ./config/admin-crt.pem \ + -key ./config/admin-key.pem \ + -cd "${security_config_path}" + +# Shut down opensearch again. +sleep 5s +kill ${os_pid} diff --git a/opensearch/opensearch.yml b/docker/opensearch/opensearch.yml similarity index 97% rename from opensearch/opensearch.yml rename to docker/opensearch/opensearch.yml index ce4348e8692b685d755fa58ef128000630a8bf95..81ede486e95451fd5e56a1b94abe245a9774c577 100644 --- a/opensearch/opensearch.yml +++ b/docker/opensearch/opensearch.yml @@ -26,6 +26,7 @@ plugins.security.allow_default_init_securityindex: false plugins.security.ssl.http.enabled: true plugins.security.ssl.http.pemkey_filepath: cluster-key.pem plugins.security.ssl.http.pemcert_filepath: cluster-crt.pem +plugins.security.ssl.http.pemtrustedcas_filepath: root-ca.pem # transport layer TLS, for inter-node communication plugins.security.ssl.transport.pemkey_filepath: node-key.pem diff --git a/opensearch/security/internal_users.template.yml b/docker/opensearch/security/internal_users.template.yml similarity index 71% rename from opensearch/security/internal_users.template.yml rename to docker/opensearch/security/internal_users.template.yml index ce5731e911b9902003e55a85bf87fcdfee5929c9..1d72ad3fde94738ca6fd1c69ec66abae3d016903 100644 --- a/opensearch/security/internal_users.template.yml +++ b/docker/opensearch/security/internal_users.template.yml @@ -1,13 +1,16 @@ ---- -# This is the internal user database (for initial setup) +# This file serves as a template. +# Contents will be written to the 'internal_users.yml' file. +# +# This is the internal user database (for initial setup). # # The hash value is a bcrypt hash and can be generated with -# './plugins/opensearch-security/tools/hash.sh' (in an OS node container) +# './plugins/opensearch-security/tools/hash.sh' (in an OS node container). # -# note: reserved users can't be changed, except for with 'securityadmin.sh' +# NOTE: Reserved users can't be changed, except for with 'securityadmin.sh'. # -# pre-defined roles can be seen in the documentation: +# Pre-defined roles can be seen in the documentation: # https://opensearch.org/docs/latest/security-plugin/access-control/users-roles/#predefined-roles +# _meta: type: "internalusers" @@ -24,7 +27,7 @@ admin: kibanaserver: hash: "$2y$12$LuNY.MSV7dGzyy85c8v1oOxIRlP0TFEk6qgpGRYHZW0Qk0YVc1qdm" # DASHBOARDS_PW reserved: true - description: "Machine account for the OpenSeaerch Dashboards server" + description: "Machine account for the OpenSearch Dashboards server" # the 'logging_user' with the 'logstash' role has permissions to manage the 'logstash-*' indices logging_user: diff --git a/opensearch/security/tenants.yml b/docker/opensearch/security/tenants.yml similarity index 97% rename from opensearch/security/tenants.yml rename to docker/opensearch/security/tenants.yml index 60f61005f947bc300510cadfba0f158522f61bfe..7d923c9245c8bf5571a1103410a392f8ff3bbc7f 100644 --- a/opensearch/security/tenants.yml +++ b/docker/opensearch/security/tenants.yml @@ -1,4 +1,3 @@ ---- _meta: type: "tenants" config_version: 2 diff --git a/opensearch/init-security.sh b/opensearch/init-security.sh deleted file mode 100755 index 987542ff7fdfbdcc2d076f416d9ed890481e979b..0000000000000000000000000000000000000000 --- a/opensearch/init-security.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/bin/bash -# -# update the internal_users via 'securityadmin.sh' to set their passwords -# this should only be called on a new cluster -# -# usage: docker compose run os-node-1 ./init-security.sh - -set -euo pipefail - - -# get the passwords from the env vars, and hash them -sec_plugin_path="./plugins/opensearch-security" -admin_pw="${OPENSEARCH_ADMIN_PASSWORD:?admin password required}" -kibanaserver_pw="${OPENSEARCH_KIBANASERVER_PASSWORD:?kibana server password required}" -logginguser_pw="${OPENSEARCH_LOGGINGUSER_PASSWORD:?logging user password required}" - -admin_hash=$(${sec_plugin_path}/tools/hash.sh -p "${admin_pw}" | tr " " "\n" | tail -n 1) -dashboards_hash=$(${sec_plugin_path}/tools/hash.sh -p "${kibanaserver_pw}" | tr " " "\n" | tail -n 1) -logging_hash=$(${sec_plugin_path}/tools/hash.sh -p "${logginguser_pw}" | tr " " "\n" | tail -n 1) - -# write the passwords to 'internal_users.yml' -# (the lines to be replaced are marked with comments) -# this is because the '-rev' flag for 'securityadmin.sh' does not seem to be working for 'internal_users.yml' -source="./config/internal_users.template.yml" -target="${sec_plugin_path}/securityconfig/internal_users.yml" -sed -e "s|^\(\s\+hash:\).*\s\+# ADMIN_PW|\1 ${admin_hash}|" \ - -e "s|^\(\s\+hash:\).*\s\+# DASHBOARDS_PW|\1 ${dashboards_hash}|" \ - -e "s|^\(\s\+hash:\).*\s\+# LOGGING_PW|\1 ${logging_hash}|" \ - "${source}" > "${target}" - - -# start opensearch (securityadmin.sh needs the security index) -opensearch -Eplugins.security.allow_default_init_securityindex=true -Ediscovery.type=single-node & -os_pid=$! -retries=10 - -# wait until opensearch is ready to accept connections -while [[ "${retries}" -gt 0 ]] ; do - echo "waiting for opensearch to be up... $retries" - sleep 5s - if curl -ksu "admin:admin" "https://localhost:9200"; then - break - fi - retries=$(( ${retries} - 1 )) -done - -if [[ "${retries}" -eq 0 ]]; then - echo &>2 "error: exceeded maximum number of retries!" - exit 1 -fi - -# wait for opensearch to be healthy -curl -ku "admin:admin" "https://localhost:9200/_cluster/health?wait_for_status=yellow" - - -# securityadmin needs admin certificates or a keystore to operate -"${sec_plugin_path}/tools/securityadmin.sh" \ - -rev -icl -nhnv \ - -cacert ./config/root-ca.pem \ - -cert ./config/admin-crt.pem \ - -key ./config/admin-key.pem \ - -cd "${sec_plugin_path}/securityconfig" - - -# shut down opensearch again -sleep 5s -kill ${os_pid} diff --git a/scripts/dump-vars.sh b/scripts/dump-vars.sh new file mode 100755 index 0000000000000000000000000000000000000000..d54d0f089a7bed5a1789c23a51558f942993499f --- /dev/null +++ b/scripts/dump-vars.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# +# Dump environment variables to a file. +# +# This script is intended to be run by the Gitlab Runner, to dump all Gitlab +# variables to the secrets file. +# + +# The user can optionally specify the output file +OUTPUT_FILE="${1:-.env}" + +# Delete the old file if it exists, and create one with secure permissions +rm -f "${OUTPUT_FILE}" + +# Set 'rwx' only for user +umask 077 +touch "${OUTPUT_FILE}" + +# Dump each exported variable +# According to the current setup: +# DASHBOARDS_ prefix for variables relevant to OpenSearch Dashboards. +# METRICBEAT_ prefix for variables relevant to Metricbeat. +# OPENSEARCH_ prefix for variables relevant to OpenSearch nodes. +while read -r var; do + echo $var >> "${OUTPUT_FILE}" +done <<- EOF + $(env | grep -E "^CERTBOT_.*|^DASHBOARDS_.*|^METRICBEAT_.*|^OPENSEARCH_.*") +EOF diff --git a/scripts/generate-ssl.sh b/scripts/generate-ssl.sh index 1e14f06ac7ecf1bb49f877232106d7990a9b976a..363e8d49c29cd94a7532a3e9171bdd3f1a5ebd0b 100755 --- a/scripts/generate-ssl.sh +++ b/scripts/generate-ssl.sh @@ -1,17 +1,48 @@ #!/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}" + +certbot_email="${CERTBOT_EMAIL:-}" +certbot_rsa_key_size="${CERTBOT_RSA_KEY_SIZE:-4096}" +certbot_staging="${CERTBOT_STAGING:-}" + +# --------------------- # +# Utilities definitions # +# --------------------- # +# Common certificate details organization="TU Wien" org_unit="Center for Research Data Management" country="AT" @@ -19,35 +50,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,15 +86,71 @@ 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 +} + +function append_letsencrypt_ca() { + local hostname="$1" + local ca_chain="${project_dir}/docker/certbot/conf/live/${hostname}/chain.pem" + local root_ca="${project_dir}/ssl/root-ca-crt.pem" + + # Append Let's Encrypt CA to root-ca-crt.pem only if it's not already present. + # OpenSearch needs it to recognize the new certificates. + chain_cert_content=$(cat "$ca_chain") + root_ca_content=$(cat "$root_ca") + + if [[ "$root_ca_content" == *"$chain_cert_content"* ]]; then + : + else + echo -e "\n$chain_cert_content" >> "$root_ca" # Append correctly formatted certificate. + echo "[INFO] Appending Let's Encrypt CA to 'root-ca-crt.pem'." + fi +} +# ------------ # +# Run sequence # +# ------------ # # Root CA -subject="/C=${country}/ST=${state}/L=${locality}/O=${organization}/OU=${org_unit}/CN=ROOT" +subject="/C=${country}/ST=${state}/L=${locality}/O=${organization}/OU=${org_unit}/CN=ROOT" openssl genrsa -out root-ca-key.pem 2048 openssl req -new -x509 -sha256 -key root-ca-key.pem -subj "${subject}" -out root-ca-crt.pem -days 365 @@ -76,8 +163,81 @@ 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 + echo "[INFO] Detected local setup, generating local certificates for Dashboards and Cluster." + + # REST HTTPS cert (for outside communication) + generate_if_not_user_defined "cluster" "${opensearch_hostname}" + + # 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." + + # 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" + + ssl_base_path="${project_dir}/docker/certbot/conf/live" + + # 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="${ssl_base_path}/${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}" + append_letsencrypt_ca "${dashboards_hostname}" + fi + + else + echo "[INFO] Hostnames for are different. Generating separate certificates for each." + live_dashboards_crt_path="${ssl_base_path}/${dashboards_hostname}/fullchain.pem" + live_opensearch_crt_path="${ssl_base_path}/${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}" + append_letsencrypt_ca "${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}" + append_letsencrypt_ca "${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"