#!/usr/bin/env bash
# Pushpendra Panel installer
#
#   curl -fsSL https://ptpanel.in/install.sh | sudo bash
#   curl -fsSL https://ptpanel.in/install.sh | sudo bash -s -- --admin-email you@example.com
#
# Options:
#   --admin-email EMAIL     Email for the first Super Admin (prompted if omitted and interactive)
#   --hostname NAME         Panel hostname (default: this server's FQDN)
#   --panel-port PORT       HTTPS port for the panel (default 7800)
#   --version X.Y.Z         Install a specific release (default: latest for the channel)
#   --channel stable|beta   Release channel (default stable)
#   --with-mysql / --without-mysql   Install MariaDB for hosted databases (default: with)
#   --with-mail             Install Postfix + Dovecot + Rspamd (default: without)
#   --with-dns              Install PowerDNS authoritative server (default: without)
#   --skip-firewall         Don't configure UFW
#   --tarball PATH          Install from a local release tarball (air-gapped / testing)
#   --insecure-skip-signature  Skip release signature check (NOT recommended)
#   --upgrade               Upgrade an existing installation
#   --uninstall [--purge]   Remove the panel. Hosted sites/data are kept unless --purge.
#   -y, --yes               Non-interactive; accept defaults
set -Eeuo pipefail   # -E: the ERR trap also fires inside functions
# Never exit silently: report where an unexpected failure happened.
trap 'echo "error: installer stopped at line $LINENO while running: $BASH_COMMAND" >&2' ERR

BASE_URL="${PTPANEL_BASE_URL:-https://ptpanel.in}"
PREFIX=/opt/ptpanel
ETC=/etc/ptpanel
STATE=/var/lib/ptpanel
LOGS=/var/log/ptpanel
SRV=/srv/ptpanel
NODE_MAJOR=22
# Ed25519 public key used to verify release tarballs. Replaced by the release
# owner (scripts/release-keygen.sh prints it). Installs refuse unsigned releases.
RELEASE_PUBKEY='MCowBQYDK2VwAyEApeWuhYIe32JmNO0vp1n+mag1XaR44QNJy+LCnt6JAL0='

ADMIN_EMAIL=""
PANEL_HOST=""
PANEL_PORT=7800
VERSION=""
CHANNEL=stable
WITH_MYSQL=1
WITH_MAIL=0
WITH_DNS=0
SKIP_FIREWALL=0
TARBALL=""
SKIP_SIG=0
MODE=install
PURGE=0
YES=0

# ------------------------------------------------------------------ helpers
if [[ -t 1 ]]; then B=$'\e[1m'; P=$'\e[38;5;99m'; O=$'\e[38;5;214m'; G=$'\e[32m'; R=$'\e[31m'; N=$'\e[0m'; else B='' P='' O='' G='' R='' N=''; fi

# Brand banner: "PTPANEL" logo ("PT" purple, "PANEL" saffron) with credits.
banner() {
  local D=''
  [[ -t 1 ]] && D=$'\e[2m'
  echo
  echo "  ${P}${B}██████╗ ████████╗${O}██████╗  █████╗ ███╗   ██╗███████╗██╗     ${N}"
  echo "  ${P}${B}██╔══██╗╚══██╔══╝${O}██╔══██╗██╔══██╗████╗  ██║██╔════╝██║     ${N}"
  echo "  ${P}${B}██████╔╝   ██║   ${O}██████╔╝███████║██╔██╗ ██║█████╗  ██║     ${N}"
  echo "  ${P}${B}██╔═══╝    ██║   ${O}██╔═══╝ ██╔══██║██║╚██╗██║██╔══╝  ██║     ${N}"
  echo "  ${P}${B}██║        ██║   ${O}██║     ██║  ██║██║ ╚████║███████╗███████╗${N}"
  echo "  ${P}${B}╚═╝        ╚═╝   ${O}╚═╝     ╚═╝  ╚═╝╚═╝  ╚═══╝╚══════╝╚══════╝${N}"
  echo
  echo "  ${D}────────────────────────────────────────────────────────────────${N}"
  echo "   ${B}PTPanel${N}  ${D}·${N}  Powered by ${P}${B}Pushpendra Technology Pvt Ltd${N}"
  echo "   ${D}Host:${N}    ${O}${B}Adarsh Pushpendra Pandey${N}"
  echo "   ${D}Web:${N}     ${BASE_URL:-https://ptpanel.in}"
  echo "  ${D}────────────────────────────────────────────────────────────────${N}"
  [[ -n ${1:-} ]] && echo "   $1"
  echo
}
step() { echo "${P}==>${N} ${B}$*${N}"; }
info() { echo "    $*"; }
warn() { echo "${O}warning:${N} $*" >&2; }
die()  { echo "${R}error:${N} $*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
ask() { # ask VAR "Question" default
  local __var=$1 q=$2 def=${3:-} ans
  if [[ $YES -eq 1 || ! -r /dev/tty ]]; then printf -v "$__var" '%s' "$def"; return; fi
  read -r -p "$q${def:+ [$def]}: " ans </dev/tty || true
  printf -v "$__var" '%s' "${ans:-$def}"
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --admin-email) ADMIN_EMAIL=${2:?}; shift ;;
    --hostname) PANEL_HOST=${2:?}; shift ;;
    --panel-port) PANEL_PORT=${2:?}; shift ;;
    --version) VERSION=${2:?}; shift ;;
    --channel) CHANNEL=${2:?}; shift ;;
    --with-mysql) WITH_MYSQL=1 ;;
    --without-mysql) WITH_MYSQL=0 ;;
    --with-mail) WITH_MAIL=1 ;;
    --with-dns) WITH_DNS=1 ;;
    --skip-firewall) SKIP_FIREWALL=1 ;;
    --tarball) TARBALL=${2:?}; shift ;;
    --insecure-skip-signature) SKIP_SIG=1 ;;
    --upgrade) MODE=upgrade ;;
    --uninstall) MODE=uninstall ;;
    --purge) PURGE=1 ;;
    -y|--yes) YES=1 ;;
    -h|--help) echo "Usage: install.sh [--admin-email E] [--hostname H] [--panel-port P] [--version V] [--channel C] [--with-mail] [--with-dns] [--upgrade] [--uninstall [--purge]] — see $BASE_URL/docs"; exit 0 ;;
    *) die "unknown option: $1" ;;
  esac
  shift
done

[[ $EUID -eq 0 ]] || die "run as root: curl -fsSL $BASE_URL/install.sh | sudo bash"
[[ "$CHANNEL" =~ ^(stable|beta)$ ]] || die "--channel must be stable or beta"
[[ "$PANEL_PORT" =~ ^[0-9]+$ && $PANEL_PORT -ge 1024 && $PANEL_PORT -le 65535 ]] || die "--panel-port must be 1024-65535"
[[ -z "$VERSION" || "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "invalid --version"

# ------------------------------------------------------------------ uninstall
uninstall() {
  step "Uninstalling Pushpendra Panel"
  if [[ $PURGE -eq 1 ]]; then
    warn "--purge deletes ALL hosted websites, applications, backups and panel data under $SRV and $ETC."
    local confirm=""
    [[ -r /dev/tty ]] && read -r -p "Type 'DELETE EVERYTHING' to continue: " confirm </dev/tty || true
    [[ "$confirm" == "DELETE EVERYTHING" ]] || die "purge not confirmed; nothing removed"
  fi
  systemctl disable --now ptpanel-web ptpanel-api ptpanel-agent 2>/dev/null || true
  rm -f /etc/systemd/system/ptpanel-{agent,api,web}.service
  systemctl daemon-reload
  rm -f /etc/nginx/sites-enabled/ptpanel-panel.conf /etc/nginx/sites-available/ptpanel-panel.conf
  nginx -t >/dev/null 2>&1 && systemctl reload nginx || true
  rm -rf "$PREFIX" /usr/local/bin/ptpanel
  if [[ $PURGE -eq 1 ]]; then
    rm -f /etc/nginx/sites-enabled/ptpanel-* /etc/nginx/sites-available/ptpanel-* /etc/cron.d/ptpanel-*
    rm -rf "$SRV" "$ETC" "$STATE" "$LOGS"
    if have psql; then sudo -u postgres psql -qc 'DROP DATABASE IF EXISTS ptpanel' -c 'DROP ROLE IF EXISTS ptpanel' || true; fi
    info "Purged panel data. Workspace Unix users (ptp-*) were left in place; remove with userdel if needed."
  else
    info "Panel removed. Hosted sites, databases, backups ($SRV) and configuration ($ETC) were kept."
    info "Hosted websites keep running under Nginx."
  fi
  echo "${G}Done.${N}"
}
if [[ $MODE == uninstall ]]; then uninstall; exit 0; fi

# ------------------------------------------------------------------ preflight
preflight() {
  step "Checking system"
  [[ -r /etc/os-release ]] || die "cannot detect OS"
  # Read in a subshell: os-release defines VERSION, ID, NAME… which must not
  # overwrite this script's own variables (VERSION = panel release to install).
  local os_id os_ver os_name
  os_id=$(. /etc/os-release && echo "$ID")
  os_ver=$(. /etc/os-release && echo "$VERSION_ID")
  os_name=$(. /etc/os-release && echo "$PRETTY_NAME")
  case "$os_id:$os_ver" in
    ubuntu:22.04|ubuntu:24.04|debian:12) info "OS: $os_name" ;;
    *) die "unsupported OS $os_name (supported: Ubuntu 22.04/24.04, Debian 12)" ;;
  esac
  case "$(uname -m)" in
    x86_64) ARCH=amd64; NODE_ARCH=x64 ;;
    aarch64) ARCH=arm64; NODE_ARCH=arm64 ;;
    *) die "unsupported architecture $(uname -m)" ;;
  esac
  info "Architecture: $ARCH"
  local mem_mb disk_gb
  mem_mb=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
  disk_gb=$(df -BG --output=avail / | tail -1 | tr -dc 0-9)
  [[ $mem_mb -ge 900 ]] || die "at least 1 GB RAM required (found ${mem_mb} MB)"
  [[ $disk_gb -ge 5 ]] || die "at least 5 GB free disk required (found ${disk_gb} GB)"
  info "Memory: ${mem_mb} MB, free disk: ${disk_gb} GB"
  [[ $mem_mb -ge 1900 ]] || warn "2 GB+ RAM recommended for Node.js builds"
  if [[ $MODE == install ]]; then
    # The marker is written only when an install finishes, so a run that
    # failed half-way can simply be re-run and resumes.
    [[ ! -f $ETC/.install-complete ]] || die "already installed — use --upgrade (or 'ptpanel upgrade')"
    [[ ! -e $PREFIX/current ]] || info "Resuming a previous, unfinished install"
    for port in 80 443 "$PANEL_PORT"; do
      if ss -ltnH "sport = :$port" 2>/dev/null | grep -q .; then
        # nginx already holding the port is fine (our own vhost, or distro default).
        ss -ltnpH "sport = :$port" | grep -q nginx || die "port $port is already in use"
      fi
    done
  fi
  # The panel only accepts logins from the address it was installed for
  # (CSRF origin check). VPS hostnames are rarely real DNS names, so default to
  # the public IP; pass --hostname panel.example.com to use a domain.
  # Upgrades keep the address chosen at install time.
  if [[ -z "$PANEL_HOST" && $MODE == upgrade && -r $ETC/install.env ]]; then
    PANEL_HOST=$(sed -n 's/^PANEL_HOST=//p' "$ETC/install.env" | head -1)
  fi
  if [[ -z "$PANEL_HOST" ]]; then
    PANEL_HOST=$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)
    [[ "$PANEL_HOST" =~ ^[0-9.]+$ ]] || PANEL_HOST=$( { ip route get 1.1.1.1 2>/dev/null || true; } | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')
    [[ -n "$PANEL_HOST" ]] || die "could not determine this server's IP address; pass --hostname"
  fi
  [[ "$PANEL_HOST" =~ ^[A-Za-z0-9.-]+$ ]] || die "invalid hostname '$PANEL_HOST' (use --hostname)"
  info "Panel address: $PANEL_HOST"
}

# ------------------------------------------------------------------ packages
# Ubuntu's automatic updates (unattended-upgrades / apt-daily) hold the apt and
# dpkg locks for minutes at a time. Wait for them instead of failing.
apt_wait() {
  command -v fuser >/dev/null 2>&1 || return 0
  local waited=0
  while fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock >/dev/null 2>&1; do
    [[ $waited -eq 0 ]] && info "Waiting for automatic system updates to finish (apt is busy)…"
    sleep 5
    waited=$((waited + 5))
    [[ $waited -ge 900 ]] && die "apt has been busy for 15 minutes (another package install is running). Try again later."
  done
}

# apt-get that waits for the locks and shows apt's own error output on failure.
apt_get() {
  apt_wait
  local out rc=0
  out=$(mktemp)
  apt-get -o DPkg::Lock::Timeout=900 "$@" >"$out" 2>&1 || rc=$?
  if [[ $rc -ne 0 ]]; then
    tail -n 25 "$out" >&2
  fi
  rm -f "$out"
  return "$rc"
}

install_packages() {
  step "Installing system packages"
  export DEBIAN_FRONTEND=noninteractive
  # Every apt-get on this server (including the panel's own installs) waits
  # for the dpkg lock rather than failing while automatic updates run.
  printf 'DPkg::Lock::Timeout "900";\n' > /etc/apt/apt.conf.d/90ptpanel-lock-timeout
  apt_get update -qq
  local pkgs=(curl ca-certificates jq tar gzip xz-utils openssl git acl cron nginx ufw certbot python3-certbot-dns-cloudflare postgresql unattended-upgrades fail2ban)
  # The distribution's PHP-FPM (8.3 on Ubuntu 24.04, 8.1 on 22.04, 8.2 on Debian 12)
  # so PHP websites work right away; more versions from Settings → Runtimes.
  pkgs+=(php-fpm php-cli php-mysql php-pgsql php-curl php-gd php-intl php-mbstring php-xml php-zip php-bcmath)
  [[ $WITH_MYSQL -eq 1 ]] && pkgs+=(mariadb-server mariadb-client)
  [[ $WITH_DNS -eq 1 ]] && pkgs+=(pdns-server pdns-backend-sqlite3)
  if [[ $WITH_MAIL -eq 1 ]]; then
    echo "postfix postfix/main_mailer_type select Internet Site" | debconf-set-selections
    echo "postfix postfix/mailname string $PANEL_HOST" | debconf-set-selections
    pkgs+=(postfix dovecot-imapd dovecot-lmtpd rspamd)
  fi
  apt_get install -y -qq "${pkgs[@]}" || die "installing system packages failed (see the apt output above)"
  # ModSecurity + OWASP CRS for the WAF (package names differ between releases).
  if apt_get install -y -qq libnginx-mod-http-modsecurity modsecurity-crs 2>/dev/null; then
    info "WAF engine: ModSecurity + OWASP CRS installed"
  else
    warn "ModSecurity nginx module not available from apt on this release; the WAF module will report 'engine not installed'"
  fi
  systemctl enable --now nginx postgresql cron >/dev/null
  local phpv; phpv=$(find /etc/php -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2>/dev/null | sort -V | tail -1 || true)
  if [[ -n "$phpv" ]]; then systemctl enable --now "php$phpv-fpm" >/dev/null 2>&1 || true; info "PHP $phpv (FPM) installed"; fi
  [[ $WITH_MYSQL -eq 1 ]] && systemctl enable --now mariadb >/dev/null
  info "Packages installed"
}

install_node() {
  step "Installing Node.js $NODE_MAJOR runtime for the panel"
  local base="https://nodejs.org/dist/latest-v${NODE_MAJOR}.x" tmp sums file
  tmp=$(mktemp -d)
  sums=$(curl -fsSL "$base/SHASUMS256.txt")
  file=$(awk -v a="linux-${NODE_ARCH}.tar.xz" '$2 ~ a {print $2; exit}' <<<"$sums")
  [[ -n "$file" ]] || die "could not find Node.js $NODE_MAJOR for linux-$NODE_ARCH"
  curl -fsSL "$base/$file" -o "$tmp/$file"
  (cd "$tmp" && grep " $file\$" <<<"$sums" | sha256sum -c --quiet -) || die "Node.js checksum mismatch"
  mkdir -p "$PREFIX/node.new"
  tar -xJf "$tmp/$file" -C "$PREFIX/node.new" --strip-components=1
  rm -rf "$PREFIX/node"; mv "$PREFIX/node.new" "$PREFIX/node"
  rm -rf "$tmp"
  # Same runtime is offered to hosted apps.
  mkdir -p "$PREFIX/runtimes"
  [[ -e "$PREFIX/runtimes/node-$NODE_MAJOR" ]] || cp -a "$PREFIX/node" "$PREFIX/runtimes/node-$NODE_MAJOR"
  info "Node.js $("$PREFIX/node/bin/node" --version)"
}

# ------------------------------------------------------------------ release
fetch_release() {
  step "Fetching Pushpendra Panel release"
  local tmp; tmp=$(mktemp -d); RELEASE_TMP=$tmp
  if [[ -n "$TARBALL" ]]; then
    [[ -f "$TARBALL" ]] || die "tarball not found: $TARBALL"
    cp "$TARBALL" "$tmp/release.tar.gz"
    # Version comes from the archive itself (VERSION file), else the file name.
    VERSION=$(tar -xzOf "$tmp/release.tar.gz" ./VERSION 2>/dev/null | head -1 || true)
    [[ -n "$VERSION" ]] || VERSION=$(basename "$TARBALL" | sed -nE 's/^ptpanel-([0-9]+\.[0-9]+\.[0-9]+[^-]*)-linux-.*$/\1/p')
    [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]] || die "cannot determine the release version of $TARBALL"
    info "Version $VERSION (local archive)"
    [[ -f "$TARBALL.sha256" ]] && cp "$TARBALL.sha256" "$tmp/release.tar.gz.sha256"
    [[ -f "$TARBALL.sig" ]] && cp "$TARBALL.sig" "$tmp/release.tar.gz.sig"
  else
    local meta url
    if [[ -n "$VERSION" ]]; then
      meta=$(curl -fsSL "$BASE_URL/api/releases" \
        | jq --arg v "$VERSION" '(if type == "array" then . else (.releases // .items // []) end) | map(select(.version == $v))[0] // empty')
    else
      meta=$(curl -fsSL "$BASE_URL/api/releases/latest?channel=$CHANNEL" 2>/dev/null || true)
    fi
    [[ -n "$meta" ]] || die "no ${VERSION:-$CHANNEL} release is published at $BASE_URL yet (Admin → Releases on the website)"
    VERSION=$(jq -r .version <<<"$meta")
    url=$(jq -r --arg a "linux-$ARCH.tar.gz" '.assets[] | select(.name | endswith($a)) | .url' <<<"$meta" | head -1)
    [[ -n "$url" && "$url" != null ]] || die "no linux-$ARCH asset in release $VERSION"
    info "Version $VERSION ($CHANNEL)"
    curl -fsSL "$url" -o "$tmp/release.tar.gz"
    curl -fsSL "$url.sha256" -o "$tmp/release.tar.gz.sha256" || die "missing checksum for release"
    curl -fsSL "$url.sig" -o "$tmp/release.tar.gz.sig" || true
  fi
  if [[ -f "$tmp/release.tar.gz.sha256" ]]; then
    local want got
    want=$(awk '{print $1}' "$tmp/release.tar.gz.sha256")
    got=$(sha256sum "$tmp/release.tar.gz" | awk '{print $1}')
    [[ "$want" == "$got" ]] || die "release checksum mismatch"
    info "Checksum OK"
  elif [[ -z "$TARBALL" ]]; then
    die "release checksum missing"
  fi
  if [[ $SKIP_SIG -eq 1 ]]; then
    warn "signature verification skipped (--insecure-skip-signature)"
  else
    [[ "$RELEASE_PUBKEY" != "__RELEASE_PUBKEY__" ]] || die "this installer has no release signing key configured; re-download it from $BASE_URL or pass --insecure-skip-signature"
    [[ -f "$tmp/release.tar.gz.sig" ]] || die "release signature missing"
    printf -- '-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n' "$RELEASE_PUBKEY" > "$tmp/pub.pem"
    base64 -d "$tmp/release.tar.gz.sig" > "$tmp/sig.bin" 2>/dev/null || cp "$tmp/release.tar.gz.sig" "$tmp/sig.bin"
    openssl pkeyutl -verify -pubin -inkey "$tmp/pub.pem" -rawin -in "$tmp/release.tar.gz" -sigfile "$tmp/sig.bin" >/dev/null \
      || die "release signature verification FAILED — refusing to install"
    info "Signature OK"
  fi
}

unpack_release() {
  local dest
  dest="$PREFIX/releases/$VERSION.$(date +%s)"
  mkdir -p "$dest"
  tar -xzf "$RELEASE_TMP/release.tar.gz" -C "$dest" --no-same-owner --warning=no-unknown-keyword 2>/dev/null \
    || tar -xzf "$RELEASE_TMP/release.tar.gz" -C "$dest" --no-same-owner
  # Archives built on macOS may carry AppleDouble "._*" metadata files; they
  # break tools that read whole directories (e.g. Prisma's schema folder).
  find "$dest" -name '._*' -type f -delete
  [[ -x "$dest/bin/ptpanel-agent" && -f "$dest/api/dist/src/main.js" ]] || die "release archive is incomplete"
  [[ -f "$dest/VERSION" ]] || echo "$VERSION" > "$dest/VERSION"
  chown -R root:root "$dest"
  chmod -R go-w "$dest"
  NEW_RELEASE=$dest
  rm -rf "$RELEASE_TMP"
}

switch_release() {
  if [[ -L "$PREFIX/current" ]]; then ln -sfn "$(readlink -f "$PREFIX/current")" "$PREFIX/previous"; fi
  ln -sfn "$NEW_RELEASE" "$PREFIX/current.new"
  mv -Tf "$PREFIX/current.new" "$PREFIX/current"
  # Keep the 3 newest releases (plus current/previous). Never fatal.
  local keep cur
  keep=$(readlink -f "$PREFIX/previous" 2>/dev/null || true)
  cur=$(readlink -f "$PREFIX/current" 2>/dev/null || true)
  { find "$PREFIX/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' 2>/dev/null || true; } \
    | sort -rn | tail -n +4 | cut -d' ' -f2- | while read -r d; do
      [[ "$d" == "$cur" || "$d" == "$keep" ]] || rm -rf "$d"
    done || true
}

# ------------------------------------------------------------------ system setup
setup_accounts_and_dirs() {
  step "Creating service account and directories"
  getent group ptpanel >/dev/null || groupadd --system ptpanel
  id ptpanel >/dev/null 2>&1 || useradd --system --gid ptpanel --home-dir "$STATE" --shell /usr/sbin/nologin ptpanel
  install -d -m 0750 -o root -g ptpanel "$ETC"
  # 0751 on the state dir: others (nginx's www-data) may traverse it to reach
  # $STATE/acme for Let's Encrypt HTTP-01 challenges, but cannot list it.
  install -d -m 0751 -o ptpanel -g ptpanel "$STATE"
  install -d -m 0750 -o ptpanel -g ptpanel "$LOGS"
  install -d -m 0755 -o root -g root "$SRV" "$SRV/workspaces" "$SRV/apps" "$PREFIX" "$PREFIX/releases" "$PREFIX/bin"
  install -d -m 0700 -o root -g root "$SRV/backups" "$ETC/certs" "$ETC/dkim"
  install -d -m 0755 -o root -g root "$STATE/acme"
  # Nginx (root) writes site logs here — never into tenant-writable dirs.
  install -d -m 0750 -o root -g ptpanel "$LOGS/sites"
  install -d -m 0770 -o root -g www-data "$LOGS/waf"
}

setup_secrets() {
  step "Generating secrets"
  if [[ ! -s $ETC/agent.token ]]; then
    (umask 027; openssl rand -hex 32 > "$ETC/agent.token"); chown root:ptpanel "$ETC/agent.token"; chmod 0640 "$ETC/agent.token"
  fi
  if [[ ! -s $ETC/master.key ]]; then
    (umask 077; openssl rand -hex 32 > "$ETC/master.key"); chown ptpanel:ptpanel "$ETC/master.key"; chmod 0400 "$ETC/master.key"
    info "Created $ETC/master.key — it encrypts stored secrets. ${B}Back it up offline.${N}"
  fi
}

setup_database() {
  step "Configuring PostgreSQL"
  if [[ ! -s $ETC/db.password ]]; then
    (umask 077; openssl rand -hex 24 > "$ETC/db.password")
  fi
  local pw; pw=$(cat "$ETC/db.password")
  sudo -u postgres psql -qtAc "SELECT 1 FROM pg_roles WHERE rolname='ptpanel'" | grep -q 1 \
    || sudo -u postgres psql -qc "CREATE ROLE ptpanel LOGIN PASSWORD '$pw'"
  sudo -u postgres psql -qtAc "SELECT 1 FROM pg_database WHERE datname='ptpanel'" | grep -q 1 \
    || sudo -u postgres psql -qc "CREATE DATABASE ptpanel OWNER ptpanel"
  DB_URL="postgresql://ptpanel:$pw@127.0.0.1:5432/ptpanel?schema=public"
}

write_env() {
  step "Writing configuration"
  umask 027
  cat > "$ETC/api.env" <<ENV
NODE_ENV=production
HOST=127.0.0.1
PORT=4000
DATABASE_URL=$DB_URL
PANEL_ORIGIN=https://$PANEL_HOST:$PANEL_PORT
PUBLIC_URL=https://$PANEL_HOST:$PANEL_PORT
COOKIE_SECURE=true
TRUST_PROXY_HOPS=1
AGENT_SOCKET=/run/ptpanel/agent.sock
AGENT_TOKEN_FILE=$ETC/agent.token
MASTER_KEY_FILE=$ETC/master.key
ENV
  chown root:ptpanel "$ETC/api.env"; chmod 0640 "$ETC/api.env"
  cat > "$ETC/agent.env" <<ENV
PTPANEL_API_UID=$(id -u ptpanel)
PTPANEL_GID=$(getent group ptpanel | cut -d: -f3)
ENV
  cat > "$ETC/install.env" <<ENV
PANEL_HOST=$PANEL_HOST
PANEL_PORT=$PANEL_PORT
CHANNEL=$CHANNEL
INSTALLED_AT=$(date -u +%FT%TZ)
ENV
  umask 022
}

setup_nginx() {
  step "Configuring Nginx"
  install -d -m 0750 -o root -g root "$ETC/panel-tls"
  # (Re)create the temporary certificate when missing or issued for another address.
  if [[ ! -s $ETC/panel-tls/cert.pem ]] || ! openssl x509 -noout -subject -in "$ETC/panel-tls/cert.pem" 2>/dev/null | grep -qF "$PANEL_HOST"; then
    local san="DNS:$PANEL_HOST"
    [[ "$PANEL_HOST" =~ ^[0-9.]+$ ]] && san="IP:$PANEL_HOST"
    openssl req -x509 -newkey rsa:2048 -sha256 -days 825 -nodes \
      -keyout "$ETC/panel-tls/key.pem" -out "$ETC/panel-tls/cert.pem" \
      -subj "/CN=$PANEL_HOST" -addext "subjectAltName=$san" >/dev/null 2>&1
    chmod 0600 "$ETC/panel-tls/key.pem"
    info "Temporary self-signed certificate created (replace it from Settings once a domain points here)"
  fi
  local src="$PREFIX/current/deploy/nginx"
  install -m 0644 "$src/proxy-params.conf" "$ETC/nginx-proxy-params.conf"
  install -d -m 0755 "$ETC/nginx-panel.d"   # agent-managed add-ons (phpMyAdmin)
  [[ -f $ETC/nginx-allowlist.conf ]] || echo "# Managed by Pushpendra Panel: allowed client networks (empty = all)" > "$ETC/nginx-allowlist.conf"
  sed -e "s/__PANEL_PORT__/$PANEL_PORT/g" -e "s#__CERT__#$ETC/panel-tls/cert.pem#" -e "s#__KEY__#$ETC/panel-tls/key.pem#" \
    "$src/ptpanel-panel.conf" > /etc/nginx/sites-available/ptpanel-panel.conf
  # Compatibility: nginx < 1.25.1 (Ubuntu 22.04/24.04) has no "http2 on;"
  # directive — use "listen … ssl http2;" which every version accepts.
  sed -i -e '/^[[:space:]]*http2 on;[[:space:]]*$/d' -e 's/^\([[:space:]]*listen [^;]*ssl\);/\1 http2;/' \
    /etc/nginx/sites-available/ptpanel-panel.conf
  ln -sfn /etc/nginx/sites-available/ptpanel-panel.conf /etc/nginx/sites-enabled/ptpanel-panel.conf
  # Catch-all server: domains (or the bare IP) that reach this server but are
  # not configured as a website get the branded "parked domain" page.
  # ACME challenges still work for every hosted domain.
  install -d -m 0755 /usr/share/ptpanel/pages
  cat > /etc/nginx/sites-available/ptpanel-default.conf <<NGX
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name _;
    server_tokens off;
    ssl_certificate     $ETC/panel-tls/cert.pem;
    ssl_certificate_key $ETC/panel-tls/key.pem;

    location /.well-known/acme-challenge/ { root $STATE/acme; }

    root /usr/share/ptpanel/pages;
    error_page 404 /404.html;
    location = /404.html { internal; }
    location / {
        add_header Cache-Control "no-store" always;
        try_files /parked.html =404;
    }
}
NGX
  ln -sfn /etc/nginx/sites-available/ptpanel-default.conf /etc/nginx/sites-enabled/ptpanel-default.conf
  rm -f /etc/nginx/sites-enabled/default
  install -m 0644 "$PREFIX/current/deploy/logrotate/ptpanel" /etc/logrotate.d/ptpanel
  nginx -t >/dev/null 2>&1 || { nginx -t; die "nginx configuration test failed"; }
  systemctl reload nginx
}

setup_firewall() {
  [[ $SKIP_FIREWALL -eq 1 ]] && { warn "skipping firewall setup"; return; }
  step "Configuring firewall (UFW)"
  # `sshd -T` fails on some systems (e.g. Ubuntu 24.04 socket-activated ssh
  # without /run/sshd); fall back to the config files, then to 22.
  local ssh_port
  ssh_port=$( { sshd -T 2>/dev/null || true; } | awk '/^port /{print $2; exit}')
  [[ -n "$ssh_port" ]] || ssh_port=$( { grep -hsiE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config.d/*.conf /etc/ssh/sshd_config || true; } | awk '{print $2; exit}')
  [[ "$ssh_port" =~ ^[0-9]+$ ]] || ssh_port=22
  # Allow SSH first so enabling the firewall can never lock you out.
  ufw allow "$ssh_port/tcp" comment 'ptpanel:system-ssh' >/dev/null
  ufw allow 80/tcp comment 'ptpanel:system-http' >/dev/null
  ufw allow 443/tcp comment 'ptpanel:system-https' >/dev/null
  ufw allow "$PANEL_PORT/tcp" comment 'ptpanel:system-panel' >/dev/null
  ufw --force enable >/dev/null
  info "Allowed: SSH $ssh_port, 80, 443, panel $PANEL_PORT"
}

install_services() {
  step "Installing services"
  install -m 0644 "$PREFIX/current/deploy/systemd/"ptpanel-{agent,api,web}.service /etc/systemd/system/
  install -m 0755 "$PREFIX/current/deploy/bin/ptpanel" /usr/local/bin/ptpanel
  ln -sfn "$PREFIX/current/bin/ptpanel-agent" "$PREFIX/bin/ptpanel-agent"
  systemctl daemon-reload
  systemctl enable ptpanel-agent ptpanel-api ptpanel-web >/dev/null 2>&1
  systemctl restart ptpanel-agent
  systemctl restart ptpanel-api
  systemctl restart ptpanel-web
}

wait_healthy() {
  local _
  for _ in $(seq 1 60); do
    if curl -fsS http://127.0.0.1:4000/api/v1/health >/dev/null 2>&1 && curl -fsS -o /dev/null http://127.0.0.1:3300/login 2>/dev/null; then
      return 0
    fi
    sleep 2
  done
  return 1
}

create_admin() {
  if [[ -z "$ADMIN_EMAIL" ]]; then ask ADMIN_EMAIL "Super Admin email" "admin@$PANEL_HOST"; fi
  [[ "$ADMIN_EMAIL" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || die "invalid admin email"
  if ! ADMIN_OUTPUT=$(/usr/local/bin/ptpanel create-admin "$ADMIN_EMAIL" "Administrator" 2>&1); then
    # Resumed install where the admin was already created: keep going.
    if grep -q "already exists" <<<"$ADMIN_OUTPUT"; then
      ADMIN_OUTPUT="Admin $ADMIN_EMAIL already exists (use your existing password)"
    else
      echo "$ADMIN_OUTPUT"
      die "could not create admin"
    fi
  fi
}

# ------------------------------------------------------------------ main
case $MODE in
  upgrade) banner "${B}Upgrade${N} — updating Pushpendra Panel on this server" ;;
  install) banner "${B}Installer${N} — installing Pushpendra Panel on this server" ;;
esac
preflight
if [[ $MODE == upgrade ]]; then
  [[ -e $PREFIX/current ]] || die "not installed — run without --upgrade"
  . "$ETC/install.env"
  CHANNEL=${CHANNEL:-stable}
  step "Upgrading Pushpendra Panel (current $(cat $PREFIX/current/VERSION))"
  # Bring older servers up to date with everything new releases expect:
  # new system packages (e.g. PHP-FPM, ModSecurity), directories and
  # permissions. All steps are idempotent and keep existing data.
  install_packages
  install_node
  setup_accounts_and_dirs
  fetch_release
  unpack_release
  switch_release
  PANEL_HOST=${PANEL_HOST:-localhost}
  setup_nginx        # refresh the panel vhost from the new release (keeps the certificate)
  install_services
  if wait_healthy; then
    echo "${G}Upgraded to $VERSION.${N}"
  else
    warn "new release failed its health check — rolling back"
    /usr/local/bin/ptpanel rollback
    die "upgrade to $VERSION failed; previous release restored (see: ptpanel logs api)"
  fi
  exit 0
fi

install_packages
install_node
setup_accounts_and_dirs
fetch_release
unpack_release
switch_release
setup_secrets
setup_database
write_env
setup_nginx
setup_firewall
install_services
step "Starting panel"
wait_healthy || die "panel did not become healthy (see: ptpanel logs api)"
create_admin
touch "$ETC/.install-complete"

banner
cat <<DONE
${G}${B}Pushpendra Panel $VERSION is installed.${N}

  Panel URL:   ${B}https://$PANEL_HOST:$PANEL_PORT${N}
  $(grep -E 'Created|One-time password|already exists' <<<"$ADMIN_OUTPUT" | sed 's/^/  /' | tr '\n' ' ')

  • The certificate is self-signed until you attach a domain — your browser will warn once.
  • Back up ${B}$ETC/master.key${N} offline; it decrypts stored secrets.
  • Manage from the shell with: ${B}ptpanel status | logs | upgrade | rollback${N}
  • Docs: $BASE_URL/docs

DONE
