#!/bin/bash set -Eeuo pipefail # Run this script as root or as an administrator with passwordless sudo. # Configuration is read from a trusted shell-style .env file or standard input. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) ENV_FILE="${SCRIPT_DIR}/.env" usage() { cat <<'EOF' Usage: ./provision-server.sh [--env-file PATH|-] Provision the Gardomatic server and install its runtime configuration. The default configuration file is .env next to this script. Use - to stream the configuration over SSH without storing the source file on the server. EOF } die() { printf 'Error: %s\n' "$*" >&2 exit 1 } while (($# > 0)); do case "$1" in --env-file) (($# >= 2)) || die "--env-file requires a path" ENV_FILE=$2 shift 2 ;; -h|--help) usage exit 0 ;; *) die "unknown argument: $1" ;; esac done if [[ "$ENV_FILE" == - ]]; then ENV_SOURCE=/dev/stdin ENV_LABEL='standard input' else [[ -f "$ENV_FILE" ]] || die "configuration file not found: $ENV_FILE" env_permissions=$(stat -c '%a' "$ENV_FILE") (( (8#$env_permissions & 077) == 0 )) || die "$ENV_FILE must not be readable or writable by group or others (run: chmod 600 '$ENV_FILE')" ENV_SOURCE=$ENV_FILE ENV_LABEL=$ENV_FILE fi # The file is deliberately sourced so quoted values work. It must therefore be # controlled by the administrator running this script. set -a # shellcheck disable=SC1090 source "$ENV_SOURCE" set +a require_variable() { local name=$1 [[ -n "${!name:-}" ]] || die "required variable $name is missing in $ENV_LABEL" } require_identifier() { local name=$1 local value=${!name:-} [[ "$value" =~ ^[a-z_][a-z0-9_]*$ ]] || die "$name must be a lowercase PostgreSQL identifier" } require_variable GARDOMATIC_DB_PASSWORD require_variable GARDOMATIC_DB_DSN require_variable GARDOMATIC_WEB_BASE_URL require_identifier GARDOMATIC_DB_NAME require_identifier GARDOMATIC_DB_USER GARDOMATIC_DEMO_RESET_ENABLED=${GARDOMATIC_DEMO_RESET_ENABLED:-false} [[ "$GARDOMATIC_DEMO_RESET_ENABLED" == true || "$GARDOMATIC_DEMO_RESET_ENABLED" == false ]] || die "GARDOMATIC_DEMO_RESET_ENABLED must be true or false" if [[ "$GARDOMATIC_DEMO_RESET_ENABLED" == true ]]; then require_variable GARDOMATIC_DEMO_ACCOUNT_EMAIL fi case "${GARDOMATIC_SMTP_MODE:-file}" in smtp) require_variable GARDOMATIC_SMTP_HOST require_variable GARDOMATIC_SMTP_USERNAME require_variable GARDOMATIC_SMTP_PASSWORD require_variable GARDOMATIC_SMTP_SENDER ;; file) require_variable GARDOMATIC_SMTP_FILE_PATH ;; *) die "GARDOMATIC_SMTP_MODE must be smtp or file" ;; esac GARDOMATIC_SETUP_TIMEZONE=${GARDOMATIC_SETUP_TIMEZONE:-Europe/Berlin} GARDOMATIC_MIGRATE_VERSION=${GARDOMATIC_MIGRATE_VERSION:-4.19.1} GARDOMATIC_REBOOT=${GARDOMATIC_REBOOT:-false} [[ "$GARDOMATIC_REBOOT" == true || "$GARDOMATIC_REBOOT" == false ]] || die "GARDOMATIC_REBOOT must be true or false" readonly GARDOMATIC_SERVICE_USER=gardomatic run_as_root() { if [[ $(id -u) -eq 0 ]]; then "$@" else sudo -n "$@" fi } run_as_postgres() { if [[ $(id -u) -eq 0 ]]; then runuser -u postgres -- "$@" else sudo -n -u postgres "$@" fi } if [[ $(id -u) -ne 0 ]]; then sudo -n true || die "the SSH administrator needs passwordless sudo" fi # Force consistent command output while locales are being installed. export LC_ALL=en_US.UTF-8 run_as_root apt update run_as_root apt install --yes software-properties-common locales curl rsync ufw run_as_root add-apt-repository --yes universe run_as_root apt update run_as_root timedatectl set-timezone "$GARDOMATIC_SETUP_TIMEZONE" run_as_root apt --yes install locales-all # Gardomatic runs under a dedicated service account. It has no login shell, SSH # keys, password or sudo privileges; deployments continue through the configured # server administrator account. if id "$GARDOMATIC_SERVICE_USER" >/dev/null 2>&1; then if ! getent group "$GARDOMATIC_SERVICE_USER" >/dev/null 2>&1; then run_as_root groupadd --system "$GARDOMATIC_SERVICE_USER" fi run_as_root usermod --lock --shell /usr/sbin/nologin "$GARDOMATIC_SERVICE_USER" run_as_root usermod --gid "$GARDOMATIC_SERVICE_USER" "$GARDOMATIC_SERVICE_USER" run_as_root deluser --quiet "$GARDOMATIC_SERVICE_USER" sudo >/dev/null 2>&1 || true else run_as_root useradd --system --user-group --create-home --home-dir /var/lib/gardomatic \ --shell /usr/sbin/nologin "$GARDOMATIC_SERVICE_USER" fi run_as_root install -d -m 0750 -o gardomatic -g gardomatic /var/lib/gardomatic run_as_root ufw allow 22 run_as_root ufw allow 4040/tcp run_as_root ufw --force enable run_as_root apt --yes install fail2ban # Install the migrate CLI for the host architecture. case "$(uname -m)" in x86_64|amd64) migrate_arch=amd64 ;; aarch64|arm64) migrate_arch=arm64 ;; *) die "unsupported architecture for migrate: $(uname -m)" ;; esac download_dir=$(mktemp -d) runtime_env=$(mktemp) cleanup() { rm -rf -- "$download_dir" rm -f -- "$runtime_env" } trap cleanup EXIT migrate_archive="$download_dir/migrate.tar.gz" curl --fail --location --show-error \ "https://github.com/golang-migrate/migrate/releases/download/v${GARDOMATIC_MIGRATE_VERSION}/migrate.linux-${migrate_arch}.tar.gz" \ --output "$migrate_archive" tar -xzf "$migrate_archive" -C "$download_dir" run_as_root install -m 0755 "$download_dir/migrate" /usr/local/bin/migrate run_as_root apt --yes install postgresql postgresql-contrib if ! run_as_postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '${GARDOMATIC_DB_NAME}'" | grep -qx 1; then run_as_postgres createdb "$GARDOMATIC_DB_NAME" fi run_as_postgres psql -d "$GARDOMATIC_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS citext" run_as_postgres psql -d "$GARDOMATIC_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS pgcrypto" if ! run_as_postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname = '${GARDOMATIC_DB_USER}'" | grep -qx 1; then printf '%s\n' "CREATE ROLE \"${GARDOMATIC_DB_USER}\" WITH LOGIN PASSWORD :'db_password';" | \ run_as_postgres psql -v db_password="$GARDOMATIC_DB_PASSWORD" else printf '%s\n' "ALTER ROLE \"${GARDOMATIC_DB_USER}\" WITH LOGIN PASSWORD :'db_password';" | \ run_as_postgres psql -v db_password="$GARDOMATIC_DB_PASSWORD" fi run_as_postgres psql -c "ALTER DATABASE \"${GARDOMATIC_DB_NAME}\" OWNER TO \"${GARDOMATIC_DB_USER}\";" # Generate a dedicated systemd environment file. Setup-only values such as the # raw database password are intentionally not copied into the service environment. write_environment_variable() { local name=$1 local value=${!name:-} [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$name must not contain newlines" value=${value//\\/\\\\} value=${value//\"/\\\"} value=${value//\$/\\$} value=${value//\`/\\\`} printf '%s="%s"\n' "$name" "$value" >>"$runtime_env" } runtime_variables=( GARDOMATIC_ENV GARDOMATIC_DB_DSN GARDOMATIC_DB_MAX_OPEN_CONNS GARDOMATIC_DB_MAX_IDLE_CONNS GARDOMATIC_DB_MAX_IDLE_TIME GARDOMATIC_API_HOST GARDOMATIC_API_PORT GARDOMATIC_WEB_HOST GARDOMATIC_WEB_PORT GARDOMATIC_API_BASE_URL GARDOMATIC_WEB_BASE_URL GARDOMATIC_SESSION_COOKIE_NAME GARDOMATIC_SESSION_LIFETIME GARDOMATIC_SESSION_IDLE_TIMEOUT GARDOMATIC_COOKIE_SECURE GARDOMATIC_RATE_LIMIT_ENABLED GARDOMATIC_RATE_LIMIT_RPS GARDOMATIC_RATE_LIMIT_BURST GARDOMATIC_CORS_TRUSTED_ORIGINS GARDOMATIC_DEMO_RESET_ENABLED GARDOMATIC_DEMO_ACCOUNT_EMAIL GARDOMATIC_SMTP_MODE GARDOMATIC_SMTP_HOST GARDOMATIC_SMTP_PORT GARDOMATIC_SMTP_USERNAME GARDOMATIC_SMTP_PASSWORD GARDOMATIC_SMTP_SENDER GARDOMATIC_SMTP_FILE_PATH ) for variable in "${runtime_variables[@]}"; do write_environment_variable "$variable" done run_as_root install -D -m 0600 -o root -g root "$runtime_env" /etc/gardomatic/gardomatic.env run_as_root apt --yes -o Dpkg::Options::="--force-confnew" upgrade printf 'Server setup complete. Runtime configuration installed at /etc/gardomatic/gardomatic.env.\n' if [[ "$GARDOMATIC_REBOOT" == true ]]; then run_as_root reboot else printf 'Reboot skipped. Set GARDOMATIC_REBOOT=true in %s to reboot automatically.\n' "$ENV_LABEL" fi