diff --git a/install-keycloak.sh b/ALT/install-keycloak.sh similarity index 100% rename from install-keycloak.sh rename to ALT/install-keycloak.sh diff --git a/install-keycloak-pgsql.sh b/install-keycloak-pgsql.sh new file mode 100755 index 0000000..feafb26 --- /dev/null +++ b/install-keycloak-pgsql.sh @@ -0,0 +1,536 @@ +#!/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."