#!/usr/bin/env bash # Fresh Keycloak installation with PostgreSQL and nginx TLS termination. # Refuses to upgrade or overwrite an existing installation. set -Eeuo pipefail umask 077 script_name="$(basename "$(realpath "$0")")" working_dir="$(dirname "$(realpath "$0")")" conf_dir="${working_dir}/conf" conf_file="${conf_dir}/keycloak.conf" lock_dir="/tmp/${script_name}.$$.LOCK" log_file="${lock_dir}/${script_name%.sh}.log" terminal=false [[ -t 1 ]] && terminal=true cleanup() { rm -rf "${lock_dir}" } fatal() { echo "" if ${terminal}; then echo -e " [ \033[31m\033[1mFatal\033[m ] $*" >&2 echo "" echo -e " \033[1mScript is canceled\033[m.." >&2 else echo " [ Fatal ] $*" >&2 echo "" echo " Script is canceled.." >&2 fi echo "" exit 1 } on_exit() { local status=$? cleanup exit "${status}" } trap on_exit EXIT trap 'fatal "Interrupted by signal."' HUP INT TERM mkdir "${lock_dir}" || exit 1 echononl() { if ${terminal}; then echo -e -n " $*" >&2 fi } echo_ok() { if ${terminal}; then echo -e "\033[85G[ \033[32mok\033[m ]" else echo " [ ok ]" fi } echo_failed() { if ${terminal}; then echo -e "\033[85G[ \033[1;31mfailed\033[m ]" else echo " [ failed ]" fi } echo_skipped() { if ${terminal}; then echo -e "\033[85G[ \033[33m\033[1mskipped\033[m ]" else echo " [ skipped ]" fi } blank_line() { echo "" } section() { blank_line echo -e "\033[37m\033[1m$*\033[m" blank_line } info() { blank_line if ${terminal}; then echo -e " [ \033[32m\033[1mInfo\033[m ] $*" else echo " [ Info ] $*" fi blank_line } require_command() { command -v "$1" >/dev/null 2>&1 || \ fatal "Required command '$1' is not installed." } validate_identifier() { [[ "$1" =~ ^[a-z_][a-z0-9_]*$ ]] } validate_fqdn() { [[ "$1" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] } generate_password() { printf 'A9_%s' "$(openssl rand -hex 16)" } read_default() { local variable_name=$1 local label=$2 local default_value=$3 local value echononl "${label} [${default_value}]: " IFS= read -r value printf -v "${variable_name}" '%s' "${value:-${default_value}}" } [[ ${EUID} -eq 0 ]] || fatal "This script must be run as root." ${terminal} || fatal "Script must run in a terminal." for cmd in \ createdb curl dropuser getent groupadd gzip id java nginx openssl \ pg_isready psql runuser sha256sum systemctl tar useradd wget; do require_command "${cmd}" done DEFAULT_FQHN_HOSTNAME="$(hostname -f)" DEFAULT_KEYCLOAK_USER="keycloak" DEFAULT_KEYCLOAK_GROUP="keycloak" DEFAULT_KEYCLOAK_BASE_INSTALL_PATH="/opt" DEFAULT_DB_NAME="keycloak" DEFAULT_DB_USER="keycloak" DEFAULT_DB_PASS="$(generate_password)" DEFAULT_ADMIN_USER="temp-admin" if [[ -f "${conf_file}" ]]; then # shellcheck disable=SC1090 source "${conf_file}" >"${log_file}" 2>&1 || fatal "Cannot read '${conf_file}'." [[ -n ${FQHN_HOSTNAME:-} ]] && DEFAULT_FQHN_HOSTNAME="${FQHN_HOSTNAME}" [[ -n ${KEYCLOAK_USER:-} ]] && DEFAULT_KEYCLOAK_USER="${KEYCLOAK_USER}" [[ -n ${KEYCLOAK_GROUP:-} ]] && DEFAULT_KEYCLOAK_GROUP="${KEYCLOAK_GROUP}" [[ -n ${KEYCLOAK_BASE_INSTALL_PATH:-} ]] && DEFAULT_KEYCLOAK_BASE_INSTALL_PATH="${KEYCLOAK_BASE_INSTALL_PATH}" [[ -n ${DB_NAME:-} ]] && DEFAULT_DB_NAME="${DB_NAME}" [[ -n ${DB_USER:-} ]] && DEFAULT_DB_USER="${DB_USER}" [[ -n ${DB_PASS:-} ]] && DEFAULT_DB_PASS="${DB_PASS}" fi blank_line echo -e "\033[1m----------\033[m" echo -e "\033[32m\033[1mRunning script \033[m\033[1m${script_name}\033[32m .. \033[m" echo -e "\033[1m----------\033[m" blank_line echo -e "\033[32m--\033[m" blank_line echo "Version Number of Keycloak Server to install" blank_line echo " see: https://keycloak.org/downloads" blank_line echononl "KEYCLOAK Server Version: " IFS= read -r KEYCLOAK_VERSION [[ ${KEYCLOAK_VERSION} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fatal "Invalid version. Expected format: 26.7.3" blank_line echo -e "\033[32m--\033[m" blank_line echo "Specify the base directory in which Keycloak is to be installed." blank_line read_default KEYCLOAK_BASE_INSTALL_PATH "Base installation directory" "${DEFAULT_KEYCLOAK_BASE_INSTALL_PATH}" [[ -d ${KEYCLOAK_BASE_INSTALL_PATH} ]] || fatal "Directory '${KEYCLOAK_BASE_INSTALL_PATH}' does not exist." KEYCLOAK_INSTALL_DIR="${KEYCLOAK_BASE_INSTALL_PATH}/keycloak-${KEYCLOAK_VERSION}" KEYCLOAK_LINK="${KEYCLOAK_BASE_INSTALL_PATH}/keycloak" blank_line echo -e "\033[32m--\033[m" blank_line echo "Enter user and group for Keycloak Service." blank_line read_default KEYCLOAK_USER "Keycloak system user" "${DEFAULT_KEYCLOAK_USER}" read_default KEYCLOAK_GROUP "Keycloak system group" "${DEFAULT_KEYCLOAK_GROUP}" blank_line echo -e "\033[32m--\033[m" blank_line echo "Insert full qualified hostname for Keycloak Service" blank_line read_default FQHN_HOSTNAME "Public Keycloak hostname" "${DEFAULT_FQHN_HOSTNAME}" validate_fqdn "${FQHN_HOSTNAME}" || fatal "Invalid FQDN '${FQHN_HOSTNAME}'." blank_line echo -e "\033[32m--\033[m" blank_line echo "Enter PostgreSQL database name and user for Keycloak Service" blank_line read_default DB_NAME "PostgreSQL database" "${DEFAULT_DB_NAME}" read_default DB_USER "PostgreSQL user" "${DEFAULT_DB_USER}" validate_identifier "${DB_NAME}" || fatal "Invalid PostgreSQL database name '${DB_NAME}'." validate_identifier "${DB_USER}" || fatal "Invalid PostgreSQL role name '${DB_USER}'." blank_line echo -e "\033[32m--\033[m" blank_line echo "Enter Database Password used by Keycloak Service" blank_line echononl "Database Password [generated/configured value]: " IFS= read -r -s DB_PASS_INPUT printf '\n' DB_PASS="${DB_PASS_INPUT:-${DEFAULT_DB_PASS}}" [[ ${#DB_PASS} -ge 12 ]] || fatal "Database password must contain at least 12 characters." unset DB_PASS_INPUT read_default ADMIN_USER "Temporary bootstrap administrator" "${DEFAULT_ADMIN_USER}" [[ ${ADMIN_USER} =~ ^[A-Za-z0-9._-]+$ ]] || fatal "Invalid administrator username." ADMIN_PASS="$(generate_password)" DOWNLOAD_ARCHIVE="keycloak-${KEYCLOAK_VERSION}.tar.gz" DOWNLOAD_URL="https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/${DOWNLOAD_ARCHIVE}" DOWNLOAD_PATH="${working_dir}/${DOWNLOAD_ARCHIVE}" NGINX_AVAILABLE="/etc/nginx/sites-available/${FQHN_HOSTNAME}.conf" NGINX_ENABLED="/etc/nginx/sites-enabled/${FQHN_HOSTNAME}.conf" CERT_DIR="/var/lib/dehydrated/certs/${FQHN_HOSTNAME}" blank_line blank_line echo -e "\t\033[32mStart install script for Keycloak Service with the following parameters\033[m" blank_line echo -e "\tKeycloak Server Version........: \033[33m\033[1m${KEYCLOAK_VERSION}\033[m" blank_line echo -e "\tFull qualified Hostname.......: ${FQHN_HOSTNAME}" echo -e "\tKeycloak user.................: ${KEYCLOAK_USER}" echo -e "\tKeycloak group................: ${KEYCLOAK_GROUP}" blank_line echo -e "\tKeycloak base install dir.....: ${KEYCLOAK_BASE_INSTALL_PATH}" echo -e "\tKeycloak install dir..........: ${KEYCLOAK_INSTALL_DIR}" blank_line echo -e "\tDatabase Type.................: PostgreSQL" echo -e "\tDatabase Name.................: ${DB_NAME}" echo -e "\tDatabase User.................: ${DB_USER}" echo -e "\tDatabase Password.............: ********" blank_line echo -e "\tTemporary administrator......: ${ADMIN_USER}" blank_line echononl "einverstanden (yes/no): " IFS= read -r answer [[ ${answer,,} == yes ]] || fatal "Installation cancelled." # A fresh installer must never become an accidental upgrade or reinstall. [[ ! -e ${KEYCLOAK_LINK} && ! -L ${KEYCLOAK_LINK} ]] || fatal "'${KEYCLOAK_LINK}' exists. Use the upgrade script." [[ ! -e ${KEYCLOAK_INSTALL_DIR} ]] || fatal "'${KEYCLOAK_INSTALL_DIR}' already exists." [[ ! -e /etc/systemd/system/keycloak.service ]] || fatal "Keycloak systemd unit already exists." if systemctl cat keycloak.service >/dev/null 2>&1; then fatal "A Keycloak systemd unit is already installed." fi [[ ! -e ${NGINX_AVAILABLE} && ! -L ${NGINX_ENABLED} ]] || fatal "nginx vHost for '${FQHN_HOSTNAME}' already exists." pg_isready -q || fatal "PostgreSQL is not ready." if runuser -u postgres -- psql -Atqc \ "SELECT 1 FROM pg_database WHERE datname = '${DB_NAME}'" | grep -qx 1; then fatal "Database '${DB_NAME}' already exists. Nothing was changed." fi if runuser -u postgres -- psql -Atqc \ "SELECT 1 FROM pg_roles WHERE rolname = '${DB_USER}'" | grep -qx 1; then fatal "Role '${DB_USER}' already exists. Nothing was changed." fi for file in fullchain.pem privkey.pem chain.pem; do [[ -r ${CERT_DIR}/${file} ]] || \ fatal "Certificate file '${CERT_DIR}/${file}' is missing." done openssl x509 -in "${CERT_DIR}/fullchain.pem" -noout -checkend 86400 >/dev/null || fatal "TLS certificate is invalid or expires within 24 hours." openssl x509 -in "${CERT_DIR}/fullchain.pem" -noout -ext subjectAltName | grep -Fq "DNS:${FQHN_HOSTNAME}" || fatal "Certificate does not cover '${FQHN_HOSTNAME}'." cert_hash="$(openssl x509 -in "${CERT_DIR}/fullchain.pem" -pubkey -noout | openssl pkey -pubin -outform DER | sha256sum | awk '{print $1}')" key_hash="$(openssl pkey -in "${CERT_DIR}/privkey.pem" -pubout -outform DER | sha256sum | awk '{print $1}')" [[ ${cert_hash} == "${key_hash}" ]] || \ fatal "TLS certificate and private key do not match." section "Some checks...." echononl "Check prerequisites, existing installation, database and certificate.." echo_ok section "Download Keycloak Service.." echononl "Download version ${KEYCLOAK_VERSION} of the Keycloak Server.." if [[ ! -f ${DOWNLOAD_PATH} ]]; then wget -O "${DOWNLOAD_PATH}.part" "${DOWNLOAD_URL}" >"${log_file}" 2>&1 || fatal "Download failed: $(cat "${log_file}")" mv "${DOWNLOAD_PATH}.part" "${DOWNLOAD_PATH}" echo_ok else echo_skipped fi echononl "Verify downloaded archive.." tar -tzf "${DOWNLOAD_PATH}" >/dev/null 2>"${log_file}" || fatal "Invalid archive: $(cat "${log_file}")" echo_ok section "Create Keycloak system user and group.." echononl "Create the Keycloak system group '${KEYCLOAK_GROUP}'.." if ! getent group "${KEYCLOAK_GROUP}" >/dev/null; then groupadd --system "${KEYCLOAK_GROUP}" || fatal "Cannot create group." echo_ok else echo_skipped fi echononl "Create the Keycloak system user '${KEYCLOAK_USER}'.." if ! id "${KEYCLOAK_USER}" >/dev/null 2>&1; then useradd --system --no-create-home --home-dir "${KEYCLOAK_LINK}" --shell /usr/sbin/nologin --gid "${KEYCLOAK_GROUP}" "${KEYCLOAK_USER}" || fatal "Cannot create user." echo_ok else echo_skipped fi [[ $(id -gn "${KEYCLOAK_USER}") == "${KEYCLOAK_GROUP}" ]] || fatal "User '${KEYCLOAK_USER}' has a different primary group." section "Installing Keycloak Service.." echononl "Extract the Keycloak Service files.." tar -C "${KEYCLOAK_BASE_INSTALL_PATH}" -xzf "${DOWNLOAD_PATH}" >"${log_file}" 2>&1 || fatal "Extraction failed: $(cat "${log_file}")" [[ -x ${KEYCLOAK_INSTALL_DIR}/bin/kc.sh ]] || fatal "Extracted installation is incomplete." echo_ok echononl "Backup original Keycloak configuration file.." cp -a "${KEYCLOAK_INSTALL_DIR}/conf/keycloak.conf" "${KEYCLOAK_INSTALL_DIR}/conf/keycloak.conf.ORIG" echo_ok echononl "Create new Keycloak configuration.." cat >"${KEYCLOAK_INSTALL_DIR}/conf/keycloak.conf" <"${log_file}" 2>&1 || fatal "Keycloak build failed: $(cat "${log_file}")" echo_ok echononl "Set ownership of installation directory '${KEYCLOAK_INSTALL_DIR}'.." chown -R "${KEYCLOAK_USER}:${KEYCLOAK_GROUP}" "${KEYCLOAK_INSTALL_DIR}" echo_ok section "Create PostgreSQL database and role.." echononl "Create PostgreSQL role '${DB_USER}'.." runuser -u postgres -- psql --set=db_user="${DB_USER}" --set=db_pass="${DB_PASS}" --no-psqlrc --set=ON_ERROR_STOP=1 postgres >"${log_file}" 2>&1 <<'SQL' || fatal "Creating PostgreSQL role failed: $(cat "${log_file}")" SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'db_user', :'db_pass') \gexec SQL echo_ok echononl "Create PostgreSQL database '${DB_NAME}'.." if ! runuser -u postgres -- createdb --owner="${DB_USER}" "${DB_NAME}" >"${log_file}" 2>&1; then runuser -u postgres -- dropuser "${DB_USER}" >/dev/null 2>&1 || true fatal "Creating PostgreSQL database failed: $(cat "${log_file}")" fi echo_ok new_link="${KEYCLOAK_BASE_INSTALL_PATH}/.keycloak.new.$$" echononl "Create symlink keycloak -> keycloak-${KEYCLOAK_VERSION} .." ln -s "keycloak-${KEYCLOAK_VERSION}" "${new_link}" || fatal "Cannot create temporary symlink." mv -Tf "${new_link}" "${KEYCLOAK_LINK}" || fatal "Cannot activate Keycloak symlink." echo_ok section "Setup Keycloak to use systemd for starting and stopping.." echononl "Create systemd unit file.." cat >/etc/systemd/system/keycloak.service <"${log_file}" 2>&1 || fatal "Enabling Keycloak failed: $(cat "${log_file}")" echo_ok section "Create temporary Keycloak administrator.." echononl "Generate temporary bootstrap administrator '${ADMIN_USER}'.." export KC_BOOTSTRAP_ADMIN_PASSWORD="${ADMIN_PASS}" runuser -u "${KEYCLOAK_USER}" --preserve-environment -- "${KEYCLOAK_LINK}/bin/kc.sh" bootstrap-admin user \ --username "${ADMIN_USER}" --password:env KC_BOOTSTRAP_ADMIN_PASSWORD --no-prompt >"${log_file}" 2>&1 || fatal "Creating bootstrap administrator failed: $(cat "${log_file}")" unset KC_BOOTSTRAP_ADMIN_PASSWORD echo_ok section "Configuring NGINX with SSL and HTTP/2.." echononl "Create NGINX virtual host configuration for '${FQHN_HOSTNAME}'.." cat >"${NGINX_AVAILABLE}" <"${log_file}" 2>&1; then rm -f "${NGINX_ENABLED}" echo_failed fatal "nginx test failed; vHost was disabled: $(cat "${log_file}")" fi echo_ok echononl "Reload NGINX Service.." systemctl reload nginx >"${log_file}" 2>&1 || fatal "Reloading nginx failed: $(cat "${log_file}")" echo_ok section "Start and check Keycloak Service.." echononl "Start Keycloak Service.." systemctl start keycloak.service >"${log_file}" 2>&1 || fatal "Starting Keycloak failed: $(cat "${log_file}")" echo_ok echononl "Wait until Keycloak answers with the expected OIDC issuer.." discovery_file="${lock_dir}/openid-configuration.json" healthy=false for ((second=0; second<90; second++)); do if systemctl is-active --quiet keycloak.service && curl --fail --silent --show-error \ -H "Host: ${FQHN_HOSTNAME}" -H "X-Forwarded-Host: ${FQHN_HOSTNAME}" -H "X-Forwarded-Proto: https" \ "http://127.0.0.1:8080/realms/master/.well-known/openid-configuration" -o "${discovery_file}" 2>"${log_file}" && \ grep -Eq '"issuer"[[:space:]]*:[[:space:]]*"https://'"${FQHN_HOSTNAME//./\.}"'/realms/master"' "${discovery_file}"; then healthy=true break fi sleep 1 done if ! ${healthy}; then journalctl -u keycloak.service -n 80 --no-pager >"${log_file}" 2>&1 || true fatal "Keycloak did not become healthy within 90 seconds. Last logs:\n$(cat "${log_file}")" fi echo_ok section "Some post-installation stuff.." echononl "Save installation configuration to 'conf/keycloak.conf'.." mkdir -p "${conf_dir}" cat >"${conf_file}" <"${credentials_file}" <"${working_dir}/current-configuration.txt" 2>"${log_file}" || fatal "Saving effective configuration failed: $(cat "${log_file}")" chmod 0600 "${working_dir}/current-configuration.txt" echo_ok info "Keycloak ${KEYCLOAK_VERSION} is running at https://${FQHN_HOSTNAME} Temporary administrator credentials: ${credentials_file} Create and test a permanent administrator, then delete the temporary account."