Redesign install-keycloak.sh and rename to install-keycloak-pgsql.sh.

This commit is contained in:
2026-09-18 10:42:16 +02:00
parent 99633664a9
commit 7081e52c92
2 changed files with 536 additions and 0 deletions
+536
View File
@@ -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" <<EOF
# Keycloak behind an nginx TLS-terminating reverse proxy
db=postgres
db-url=jdbc:postgresql://localhost/${DB_NAME}
db-username=${DB_USER}
db-password=${DB_PASS}
hostname=${FQHN_HOSTNAME}
hostname-strict=true
http-enabled=true
http-host=127.0.0.1
http-port=8080
proxy-headers=xforwarded
health-enabled=true
EOF
chmod 0640 "${KEYCLOAK_INSTALL_DIR}/conf/keycloak.conf"
echo_ok
echononl "Create a new and optimized Keycloak server image.."
"${KEYCLOAK_INSTALL_DIR}/bin/kc.sh" build >"${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 <<EOF
[Unit]
Description=Keycloak Server
After=network.target postgresql.service
Requires=postgresql.service
[Service]
User=${KEYCLOAK_USER}
Group=${KEYCLOAK_GROUP}
ExecStart=${KEYCLOAK_LINK}/bin/kc.sh start --optimized
Restart=on-failure
RestartSec=5s
SuccessExitStatus=143
LimitNOFILE=102400
[Install]
WantedBy=multi-user.target
EOF
echo_ok
echononl "Make systemd load the new unit.."
systemctl daemon-reload
echo_ok
echononl "Set Keycloak Service to start on machine startup.."
systemctl enable 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}" <<EOF
# -- ${FQHN_HOSTNAME} --
server {
listen 80;
listen [::]:80;
server_name ${FQHN_HOSTNAME};
include snippets/letsencrypt-acme-challenge.conf;
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${FQHN_HOSTNAME};
include snippets/letsencrypt-acme-challenge.conf;
ssl_certificate ${CERT_DIR}/fullchain.pem;
ssl_certificate_key ${CERT_DIR}/privkey.pem;
ssl_trusted_certificate ${CERT_DIR}/chain.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 10m;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=15768000" always;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Proto https;
}
}
EOF
chmod 0644 "${NGINX_AVAILABLE}"
echo_ok
echononl "Enable created NGINX configuration.."
ln -s "../sites-available/${FQHN_HOSTNAME}.conf" "${NGINX_ENABLED}"
echo_ok
echononl "Check NGINX configuration.."
if ! nginx -t >"${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}" <<EOF
FQHN_HOSTNAME="${FQHN_HOSTNAME}"
KEYCLOAK_USER="${KEYCLOAK_USER}"
KEYCLOAK_GROUP="${KEYCLOAK_GROUP}"
KEYCLOAK_BASE_INSTALL_PATH="${KEYCLOAK_BASE_INSTALL_PATH}"
DB_TYPE="pgsql"
DB_NAME="${DB_NAME}"
DB_USER="${DB_USER}"
DB_PASS="${DB_PASS}"
EOF
chmod 0600 "${conf_file}"
echo_ok
echononl "Save temporary administrator credentials.."
credentials_file="${working_dir}/login-credentials-temp-admin.txt"
cat >"${credentials_file}" <<EOF
Temporary Keycloak bootstrap administrator
URL: https://${FQHN_HOSTNAME}
Username: ${ADMIN_USER}
Password: ${ADMIN_PASS}
Create a permanent administrator and remove this temporary account promptly.
EOF
chmod 0600 "${credentials_file}"
echo_ok
echononl "Save effective Keycloak configuration to 'current-configuration.txt'.."
"${KEYCLOAK_LINK}/bin/kc.sh" show-config >"${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."