#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd) ROOT_DIR=$(cd -- "${SCRIPT_DIR}/../.." && pwd) EXAMPLE_FILE="${SCRIPT_DIR}/.env.example" DEFAULT_TARGET_FILE="${SCRIPT_DIR}/.env" TARGET_FILE="${1:-${DEFAULT_TARGET_FILE}}" COMPOSE_FILE="${ROOT_DIR}/compose.prod.yaml" if [ ! -f "${EXAMPLE_FILE}" ]; then echo "Missing template: ${EXAMPLE_FILE}" >&2 exit 1 fi if ! command -v openssl >/dev/null 2>&1; then echo "openssl is required." >&2 exit 1 fi command_exists() { command -v "$1" >/dev/null 2>&1 } load_existing_env() { if [ -f "${TARGET_FILE}" ]; then set -a # shellcheck disable=SC1090 . "${TARGET_FILE}" set +a fi } generate_uuid() { if command_exists uuidgen; then uuidgen | tr '[:upper:]' '[:lower:]' return fi if [ -r /proc/sys/kernel/random/uuid ]; then tr '[:upper:]' '[:lower:]' < /proc/sys/kernel/random/uuid return fi openssl rand -hex 16 | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/' } generate_secret() { openssl rand -hex "${1}" } detect_public_ipv4() { if ! command_exists curl; then return 0 fi for url in \ "https://api.ipify.org" \ "https://ifconfig.me/ip" do ip=$(curl -fsSL --max-time 3 "${url}" 2>/dev/null || true) if printf '%s' "${ip}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$'; then printf '%s\n' "${ip}" return 0 fi done } prompt() { local prompt_text="${1}" local default_value="${2:-}" local answer if [ -n "${default_value}" ]; then printf "%s [%s]: " "${prompt_text}" "${default_value}" >&2 else printf "%s: " "${prompt_text}" >&2 fi IFS= read -r answer if [ -z "${answer}" ]; then printf '%s\n' "${default_value}" else printf '%s\n' "${answer}" fi } prompt_required() { local prompt_text="${1}" local default_value="${2:-}" local value while true; do value=$(prompt "${prompt_text}" "${default_value}") if [ -n "${value}" ]; then printf '%s\n' "${value}" return 0 fi echo "This value is required." >&2 done } prompt_yes_no() { local prompt_text="${1}" local default_answer="${2:-y}" local suffix="[Y/n]" if [ "${default_answer}" = "n" ]; then suffix="[y/N]" fi while true; do printf "%s %s: " "${prompt_text}" "${suffix}" IFS= read -r answer answer=${answer:-${default_answer}} case "${answer}" in y|Y|yes|YES) return 0 ;; n|N|no|NO) return 1 ;; esac echo "Please answer y or n." done } sanitize_database_suffix() { printf '%s' "${1}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/_/g; s/^_+//; s/_+$//' } escape_regex() { printf '%s' "${1}" | sed -E 's/[][(){}.^$*+?|\\-]/\\&/g' } write_env_file() { cat > "${TARGET_FILE}" </dev/null; then echo "docker compose validation: OK" return 0 fi echo "docker compose validation failed." >&2 return 1 } load_existing_env echo "Classeo VPS env generator" echo PUBLIC_IP_DEFAULT=${PUBLIC_IP:-$(detect_public_ipv4 || true)} if [ -n "${PUBLIC_IP_DEFAULT}" ]; then echo "Detected public IPv4: ${PUBLIC_IP_DEFAULT}" echo fi DEFAULT_BASE_DOMAIN=${PUBLIC_BASE_DOMAIN:-example.com} PUBLIC_BASE_DOMAIN=$(prompt_required "Base domain" "${DEFAULT_BASE_DOMAIN}") DEFAULT_TENANT_SUBDOMAIN=${TENANT_SUBDOMAIN:-demo} TENANT_SUBDOMAIN=$(prompt_required "Tenant subdomain" "${DEFAULT_TENANT_SUBDOMAIN}") DEFAULT_APP_DOMAIN=${APP_DOMAIN:-${TENANT_SUBDOMAIN}.${PUBLIC_BASE_DOMAIN}} APP_DOMAIN=$(prompt_required "Full application domain" "${DEFAULT_APP_DOMAIN}") DEFAULT_ADMIN_ALERT_EMAIL=${ADMIN_ALERT_EMAIL:-admin@${PUBLIC_BASE_DOMAIN}} ADMIN_ALERT_EMAIL=$(prompt_required "Admin alert email" "${DEFAULT_ADMIN_ALERT_EMAIL}") DEFAULT_POSTGRES_USER=${POSTGRES_USER:-classeo} POSTGRES_USER=$(prompt_required "PostgreSQL user" "${DEFAULT_POSTGRES_USER}") DEFAULT_MASTER_DATABASE_NAME=${MASTER_DATABASE_NAME:-classeo_master} MASTER_DATABASE_NAME=$(prompt_required "Master database name" "${DEFAULT_MASTER_DATABASE_NAME}") DEFAULT_TENANT_ID=${TENANT_ID:-$(generate_uuid)} TENANT_ID=$(prompt_required "Tenant UUID" "${DEFAULT_TENANT_ID}") DATABASE_SUFFIX=$(sanitize_database_suffix "${TENANT_SUBDOMAIN}") DEFAULT_TENANT_DATABASE_NAME=${TENANT_DATABASE_NAME:-classeo_tenant_${DATABASE_SUFFIX}} TENANT_DATABASE_NAME=$(prompt_required "Tenant database name" "${DEFAULT_TENANT_DATABASE_NAME}") DEFAULT_MAILER_DSN=${MAILER_DSN:-null://null} MAILER_DSN=$(prompt_required "Mailer DSN" "${DEFAULT_MAILER_DSN}") DEFAULT_SENTRY_DSN=${SENTRY_DSN:-} SENTRY_DSN=$(prompt "Sentry DSN (optional)" "${DEFAULT_SENTRY_DSN}") if prompt_yes_no "Use Cloudflare Turnstile test keys" "y"; then TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA" TURNSTILE_FAIL_OPEN="true" PUBLIC_TURNSTILE_SITE_KEY="1x00000000000000000000AA" else TURNSTILE_SECRET_KEY=$(prompt_required "Turnstile secret key" "${TURNSTILE_SECRET_KEY:-}") TURNSTILE_FAIL_OPEN=$(prompt_required "Turnstile fail open (true/false)" "${TURNSTILE_FAIL_OPEN:-false}") PUBLIC_TURNSTILE_SITE_KEY=$(prompt_required "Turnstile site key" "${PUBLIC_TURNSTILE_SITE_KEY:-}") fi TRUSTED_PROXIES=${TRUSTED_PROXIES:-127.0.0.1/32,172.16.0.0/12} ESCAPED_BASE_DOMAIN=$(escape_regex "${PUBLIC_BASE_DOMAIN}") TRUSTED_HOSTS="^(.+\\.)?${ESCAPED_BASE_DOMAIN}\$" CORS_ALLOW_ORIGIN="^https://([\\w-]+\\.)?${ESCAPED_BASE_DOMAIN}\$" POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-$(generate_secret 16)} APP_SECRET=${APP_SECRET:-$(generate_secret 32)} JWT_PASSPHRASE=${JWT_PASSPHRASE:-$(generate_secret 24)} SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-production} echo echo "Summary" echo "- App domain: ${APP_DOMAIN}" echo "- Base domain: ${PUBLIC_BASE_DOMAIN}" echo "- Tenant subdomain: ${TENANT_SUBDOMAIN}" echo "- Tenant UUID: ${TENANT_ID}" echo "- Target file: ${TARGET_FILE}" if [ -n "${PUBLIC_IP_DEFAULT}" ]; then echo "- Public IPv4: ${PUBLIC_IP_DEFAULT}" fi echo if ! prompt_yes_no "Write ${TARGET_FILE}" "y"; then echo "Aborted." exit 1 fi mkdir -p "$(dirname "${TARGET_FILE}")" if [ -f "${TARGET_FILE}" ]; then cp "${TARGET_FILE}" "${TARGET_FILE}.bak" echo "Backup created: ${TARGET_FILE}.bak" fi write_env_file echo "Generated ${TARGET_FILE}" validate_compose_config echo echo "Next steps:" echo "1. Review ${TARGET_FILE}" echo "2. Run ./deploy/vps/generate-jwt.sh" echo "3. Run docker compose --env-file ${TARGET_FILE} -f compose.prod.yaml up -d --build"