346 lines
9.2 KiB
Bash
346 lines
9.2 KiB
Bash
#!/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 <url> Git repository URL (required if app is not already in /opt/proxpanel)
|
|
--branch <name> Git branch to deploy (default: main)
|
|
--app-dir <path> Deployment directory (default: /opt/proxpanel)
|
|
--public-url <url> Public base URL (example: http://102.69.243.167)
|
|
--admin-email <email> Initial admin email (default: admin@proxpanel.local)
|
|
--admin-password <pass> Initial admin password (auto-generated if omitted)
|
|
--postgres-password <pass> Postgres password (auto-generated if omitted)
|
|
--frontend-port <port> Public frontend port (default: 80)
|
|
--backend-port <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 awscli
|
|
}
|
|
|
|
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}" <<EOF
|
|
POSTGRES_USER=proxpanel
|
|
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
|
POSTGRES_DB=proxpanel
|
|
|
|
JWT_SECRET=${jwt_secret}
|
|
JWT_REFRESH_SECRET=${jwt_refresh_secret}
|
|
JWT_EXPIRES_IN=15m
|
|
JWT_REFRESH_EXPIRES_IN=30d
|
|
|
|
CORS_ORIGIN=${PUBLIC_URL}
|
|
ADMIN_EMAIL=${ADMIN_EMAIL}
|
|
ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
|
|
|
ENABLE_SCHEDULER=true
|
|
RATE_LIMIT_WINDOW_MS=60000
|
|
RATE_LIMIT_MAX=600
|
|
AUTH_RATE_LIMIT_WINDOW_MS=60000
|
|
AUTH_RATE_LIMIT_MAX=20
|
|
PROXMOX_TIMEOUT_MS=15000
|
|
|
|
FRONTEND_PORT=${FRONTEND_PORT}
|
|
BACKEND_PORT=${BACKEND_PORT}
|
|
EOF
|
|
}
|
|
|
|
deploy_stack() {
|
|
log "Building and starting production stack..."
|
|
cd "${APP_DIR}"
|
|
docker compose --env-file .env.production -f infra/deploy/docker-compose.production.yml pull || true
|
|
docker compose --env-file .env.production -f infra/deploy/docker-compose.production.yml up -d --build
|
|
}
|
|
|
|
wait_for_health() {
|
|
log "Waiting for API health..."
|
|
local max_tries=60
|
|
local i
|
|
for ((i=1; i<=max_tries; i++)); do
|
|
if curl -fsS "http://127.0.0.1:${BACKEND_PORT}/api/health" >/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}" <<EOF
|
|
ProxPanel production deployment completed.
|
|
|
|
Public URL: ${PUBLIC_URL}
|
|
Server IP: $(echo "${PUBLIC_URL}" | sed -E 's#^https?://##')
|
|
Admin Email: ${ADMIN_EMAIL}
|
|
Admin Password: ${ADMIN_PASSWORD}
|
|
|
|
Deployment Directory: ${APP_DIR}
|
|
Compose File: infra/deploy/docker-compose.production.yml
|
|
Env File: ${APP_DIR}/.env.production
|
|
|
|
Quick checks:
|
|
curl -fsS http://127.0.0.1:${BACKEND_PORT}/api/health
|
|
docker compose --env-file .env.production -f infra/deploy/docker-compose.production.yml ps
|
|
|
|
IMPORTANT:
|
|
1) Change admin password immediately after first login.
|
|
2) Configure Proxmox credentials in Settings -> 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 "$@"
|