#!/usr/bin/env bash set -Eeuo pipefail APP_DIR="/opt/proxpanel" REPO_URL="" BRANCH="main" PUBLIC_URL="" ADMIN_EMAIL="admin@proxpanel.local" ADMIN_PASSWORD="" POSTGRES_PASSWORD="" FRONTEND_PORT="80" BACKEND_PORT="8080" CONFIGURE_UFW="false" log() { printf '\n[%s] %s\n' "$(date +'%Y-%m-%d %H:%M:%S')" "$*" } die() { printf '\n[ERROR] %s\n' "$*" >&2 exit 1 } usage() { cat <<'EOF' Usage: bash infra/deploy/install-proxpanel.sh [options] Options: --repo-url Git repository URL (required if app is not already in /opt/proxpanel) --branch Git branch to deploy (default: main) --app-dir Deployment directory (default: /opt/proxpanel) --public-url Public base URL (example: http://102.69.243.167) --admin-email Initial admin email (default: admin@proxpanel.local) --admin-password Initial admin password (auto-generated if omitted) --postgres-password Postgres password (auto-generated if omitted) --frontend-port Public frontend port (default: 80) --backend-port Local backend bind port (default: 8080 on 127.0.0.1) --configure-ufw Allow OpenSSH + frontend port via UFW (if available) -h, --help Show this help Examples: bash infra/deploy/install-proxpanel.sh \ --repo-url https://github.com/your-org/proxpanel.git \ --branch main \ --public-url http://102.69.243.167 \ --admin-email admin@yourdomain.com EOF } random_secret() { openssl rand -base64 72 | tr -d '\n' } random_db_password() { # URL-safe hex string to avoid DATABASE_URL parsing issues. openssl rand -hex 32 | tr -d '\n' } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --repo-url) REPO_URL="${2:-}" shift 2 ;; --branch) BRANCH="${2:-}" shift 2 ;; --app-dir) APP_DIR="${2:-}" shift 2 ;; --public-url) PUBLIC_URL="${2:-}" shift 2 ;; --admin-email) ADMIN_EMAIL="${2:-}" shift 2 ;; --admin-password) ADMIN_PASSWORD="${2:-}" shift 2 ;; --postgres-password) POSTGRES_PASSWORD="${2:-}" shift 2 ;; --frontend-port) FRONTEND_PORT="${2:-}" shift 2 ;; --backend-port) BACKEND_PORT="${2:-}" shift 2 ;; --configure-ufw) CONFIGURE_UFW="true" shift ;; -h|--help) usage exit 0 ;; *) die "Unknown argument: $1" ;; esac done } ensure_root() { if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then die "Run as root (or with sudo)." fi } install_prereqs() { log "Installing OS prerequisites..." export DEBIAN_FRONTEND=noninteractive apt-get update -y apt-get install -y ca-certificates curl git openssl jq rsync } install_docker_if_needed() { if command -v docker >/dev/null 2>&1; then log "Docker already installed." else log "Installing Docker..." curl -fsSL https://get.docker.com | sh fi systemctl enable docker >/dev/null 2>&1 || true systemctl start docker docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required but not available." } sync_source() { if [[ -d "${APP_DIR}/.git" ]]; then log "Updating existing repository in ${APP_DIR}..." git -C "${APP_DIR}" fetch --all --prune git -C "${APP_DIR}" checkout "${BRANCH}" git -C "${APP_DIR}" pull --ff-only origin "${BRANCH}" return fi if [[ -n "${REPO_URL}" ]]; then log "Cloning repository into ${APP_DIR}..." mkdir -p "$(dirname "${APP_DIR}")" git clone --branch "${BRANCH}" --single-branch "${REPO_URL}" "${APP_DIR}" return fi if [[ -f "./package.json" && -d "./backend" && -d "./infra" ]]; then log "No repo URL provided; copying current directory into ${APP_DIR}..." mkdir -p "${APP_DIR}" rsync -a --delete --exclude .git --exclude node_modules --exclude backend/node_modules ./ "${APP_DIR}/" return fi die "Could not determine source. Provide --repo-url or run this script from project root." } validate_project_layout() { [[ -f "${APP_DIR}/infra/deploy/docker-compose.production.yml" ]] || die "Missing infra/deploy/docker-compose.production.yml" [[ -f "${APP_DIR}/backend/Dockerfile" ]] || die "Missing backend/Dockerfile" [[ -f "${APP_DIR}/Dockerfile" ]] || die "Missing frontend Dockerfile" } infer_public_url() { if [[ -n "${PUBLIC_URL}" ]]; then return fi local ip ip="$(hostname -I | awk '{print $1}')" [[ -n "${ip}" ]] || ip="127.0.0.1" PUBLIC_URL="http://${ip}" } write_env_file() { [[ -n "${ADMIN_PASSWORD}" ]] || ADMIN_PASSWORD="$(openssl rand -base64 18 | tr -d '\n' | tr '/+' 'ab')A9!" [[ -n "${POSTGRES_PASSWORD}" ]] || POSTGRES_PASSWORD="$(random_db_password)" local jwt_secret jwt_refresh_secret env_file jwt_secret="$(random_secret)" jwt_refresh_secret="$(random_secret)" env_file="${APP_DIR}/.env.production" log "Writing production env file..." umask 077 cat > "${env_file}" </dev/null 2>&1; then log "Backend is healthy." return fi sleep 3 done die "Backend health check failed." } apply_database_schema() { log "Applying database schema..." cd "${APP_DIR}" if docker compose --env-file .env.production -f infra/deploy/docker-compose.production.yml exec -T backend npm run prisma:deploy; then log "Schema migration deploy completed." return fi log "prisma:deploy failed or no migrations found. Falling back to prisma:push..." docker compose --env-file .env.production -f infra/deploy/docker-compose.production.yml exec -T backend npm run prisma:push log "Schema push completed." } seed_database() { log "Running Prisma seed (idempotent)..." cd "${APP_DIR}" docker compose --env-file .env.production -f infra/deploy/docker-compose.production.yml exec -T backend npm run prisma:seed } verify_login() { log "Verifying login endpoint with seeded admin credentials..." local status status="$(curl -s -o /tmp/proxpanel-login.json -w "%{http_code}" \ -X POST "http://127.0.0.1:${FRONTEND_PORT}/api/auth/login" \ -H "Content-Type: application/json" \ -d "{\"email\":\"${ADMIN_EMAIL}\",\"password\":\"${ADMIN_PASSWORD}\"}")" if [[ "${status}" != "200" ]]; then cat /tmp/proxpanel-login.json >&2 || true die "Login verification failed with status ${status}." fi jq -e '.token and .refresh_token' /tmp/proxpanel-login.json >/dev/null 2>&1 || die "Login response missing token." log "Login verification passed." } configure_ufw_if_requested() { if [[ "${CONFIGURE_UFW}" != "true" ]]; then return fi if ! command -v ufw >/dev/null 2>&1; then log "UFW not installed; skipping firewall configuration." return fi log "Configuring UFW rules..." ufw allow OpenSSH >/dev/null 2>&1 || true ufw allow "${FRONTEND_PORT}/tcp" >/dev/null 2>&1 || true ufw --force enable >/dev/null 2>&1 || true } write_summary() { local summary_file="/root/proxpanel-install-summary.txt" cat > "${summary_file}" < Proxmox. 3) Add TLS/reverse-proxy (Nginx/Caddy) if exposing publicly. EOF chmod 600 "${summary_file}" log "Summary saved to ${summary_file}" } main() { parse_args "$@" ensure_root install_prereqs install_docker_if_needed sync_source validate_project_layout infer_public_url write_env_file deploy_stack wait_for_health apply_database_schema seed_database verify_login configure_ufw_if_requested write_summary log "Deployment finished successfully." printf '\nOpen: %s\n' "${PUBLIC_URL}" printf 'Admin email: %s\n' "${ADMIN_EMAIL}" printf 'Admin password: %s\n' "${ADMIN_PASSWORD}" } main "$@"