#!/usr/bin/env bash # ============================================================================== # 🚀 NginxForge One-Line Installer # # Fresh install: # curl -fsSL https://nginxforge.labirynt.id/install.sh | bash # # Options: # curl -fsSL https://nginxforge.labirynt.id/install.sh | bash -s -- -y # ./install.sh [-y] [--dry-run] [--non-interactive] [--app-dir DIR] # # The installer is self-contained: it pulls the release tarball from the same # origin that served this script, so it does not depend on any external Git # host. Every privileged step is explicit and reported; nothing is silently # assumed to have worked. # ============================================================================== set -euo pipefail # ── Release channel ─────────────────────────────────────────────────────────── NGINXFORGE_RELEASE_BASE="${NGINXFORGE_RELEASE_BASE:-https://nginxforge.labirynt.id}" NGINXFORGE_TARBALL_URL="${NGINXFORGE_TARBALL_URL:-$NGINXFORGE_RELEASE_BASE/nginxforge.tar.gz}" NGINXFORGE_VERSION_URL="${NGINXFORGE_VERSION_URL:-$NGINXFORGE_RELEASE_BASE/version.json}" # ── Install layout ──────────────────────────────────────────────────────────── INSTALL_DIR="${NGINXFORGE_APP_DIR:-/var/lib/nginxforge/app}" DATA_DIR="/var/lib/nginxforge" LOG_DIR="/var/log/nginxforge" ETC_DIR="/etc/nginxforge" SERVICE_NAME="nginxforge.service" AGENT_SERVICE_NAME="nginxforge-agent.service" PORT="${PORT:-3222}" AGENT_PORT=9190 # ── Colors ──────────────────────────────────────────────────────────────────── COLOR_RESET="\033[0m" COLOR_GREEN="\033[32m" COLOR_YELLOW="\033[33m" COLOR_CYAN="\033[36m" COLOR_RED="\033[31m" COLOR_BOLD="\033[1m" say() { echo -e "$1"; } ok() { echo -e "${COLOR_GREEN}✔${COLOR_RESET} $1"; } info() { echo -e "${COLOR_CYAN}ℹ${COLOR_RESET} $1"; } warn() { echo -e "${COLOR_YELLOW}⚠${COLOR_RESET} $1"; } err() { echo -e "${COLOR_RED}✖${COLOR_RESET} $1"; } # ── Argument parsing ────────────────────────────────────────────────────────── INSTALLER_ARGS=() while [ $# -gt 0 ]; do case "$1" in --app-dir) INSTALL_DIR="$2"; shift 2 ;; *) INSTALLER_ARGS+=("$1"); shift ;; esac done say "" say "=================================================================" say " ${COLOR_BOLD}🚀 NGINXFORGE PRODUCTION VPS INSTALLER${COLOR_RESET} " say "=================================================================" say "" # ── 1. Root / sudo ──────────────────────────────────────────────────────────── if [ "$(id -u)" -ne 0 ]; then if command -v sudo >/dev/null 2>&1; then SUDO="sudo" else err "This installer must run as root or with sudo privileges." exit 1 fi else SUDO="" fi # ── 2. Detect distribution and package manager ──────────────────────────────── if [ -f /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release OS="${ID:-unknown}" PRETTY="${PRETTY_NAME:-$OS}" else OS="$(uname -s | tr '[:upper:]' '[:lower:]')" PRETTY="$OS" fi ok "Detected OS: ${COLOR_BOLD}${PRETTY}${COLOR_RESET}" pm_install() { case "$OS" in ubuntu|debian) $SUDO apt-get update -y && $SUDO apt-get install -y "$@" ;; centos|rhel|fedora|rocky|almalinux) $SUDO dnf install -y "$@" || $SUDO yum install -y "$@" ;; arch|manjaro) $SUDO pacman -Sy --noconfirm "$@" ;; alpine) $SUDO apk add "$@" ;; *) warn "Unknown package manager for $OS; install manually: $*" ;; esac } need_cmd() { command -v "$1" >/dev/null 2>&1; } # ── 3. Base dependencies ────────────────────────────────────────────────────── info "Ensuring base dependencies (curl, git, tar, gzip, python3, sudo)..." MISSING_BASE=() for c in curl git tar gzip sudo; do need_cmd "$c" || MISSING_BASE+=("$c"); done need_cmd python3 || MISSING_BASE+=(python3) if [ "${#MISSING_BASE[@]}" -gt 0 ]; then warn "Installing missing base packages: ${MISSING_BASE[*]}" pm_install "${MISSING_BASE[@]}" || true fi # ── 4. Node.js LTS (v22) ────────────────────────────────────────────────────── # # The systemd units run as the unprivileged `nginxforge` user, which cannot # traverse /root at all (0700, Permission denied). A Node that only exists # under /root/.nvm therefore kills the service with 203/EXEC. We always # install a SYSTEM-WIDE Node into /usr/bin and point the units at it. NEED_SYSTEM_NODE=false SYSTEM_NODE_OK=false if [ -x /usr/bin/node ]; then if /usr/bin/node -v 2>/dev/null | grep -Eq "^v(2[0-9]|[3-9][0-9])"; then SYSTEM_NODE_OK=true fi fi if [ "$SYSTEM_NODE_OK" = true ]; then info "System Node.js $(/usr/bin/node -v) already present." elif ! need_cmd node; then NEED_SYSTEM_NODE=true else NODE_PATH="$(command -v node)" if [[ "$NODE_PATH" == /root/* ]] || [[ "$NODE_PATH" == "$HOME/.nvm"* ]]; then warn "Node found only under $NODE_PATH — invisible to the service account." warn "Installing a system-wide Node.js 22 LTS..." NEED_SYSTEM_NODE=true fi fi if [ "$NEED_SYSTEM_NODE" = true ]; then info "Installing system-wide Node.js 22 LTS..." # Prefer the distro package on well-supported LTS releases; on anything else # (bleeding-edge releases like Ubuntu 26.04, unknown forks) fall back to the # official Node.js binary tarball, which needs no repo script at all. INSTALL_NODE_OK=false case "$OS" in ubuntu|debian) RELEASE_OK=true if [ -f /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release case "${VERSION_ID:-}" in 20.04|22.04|24.04|11|12) RELEASE_OK=true ;; *) RELEASE_OK=false ;; esac fi if [ "$RELEASE_OK" = true ]; then if $SUDO curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource-setup.sh \ && $SUDO bash /tmp/nodesource-setup.sh \ && $SUDO apt-get install -y nodejs; then INSTALL_NODE_OK=true fi rm -f /tmp/nodesource-setup.sh fi ;; centos|rhel|fedora|rocky|almalinux) if $SUDO curl -fsSL https://rpm.nodesource.com/setup_22.x -o /tmp/nodesource-setup.sh \ && $SUDO bash /tmp/nodesource-setup.sh \ && ( $SUDO dnf install -y nodejs || $SUDO yum install -y nodejs ); then INSTALL_NODE_OK=true fi rm -f /tmp/nodesource-setup.sh ;; arch|manjaro) if pm_install nodejs npm; then INSTALL_NODE_OK=true; fi ;; alpine) if pm_install nodejs npm; then INSTALL_NODE_OK=true; fi ;; *) warn "Install Node.js >= 20 manually before continuing." ;; esac # Official-binary fallback: version-pinned, checksum-free (HTTPS + nodejs.org # are the trust anchor), architecture-aware. Never touches a package repo. if [ "$INSTALL_NODE_OK" != true ] && [ "$NEED_SYSTEM_NODE" = true ]; then NODE_ARCH="$(uname -m)" case "$NODE_ARCH" in x86_64) NODE_ARCH="x64" ;; aarch64|arm64) NODE_ARCH="arm64" ;; *) warn "Unsupported architecture for the official binary: $NODE_ARCH" ;; esac if [[ "$NODE_ARCH" == "x64" || "$NODE_ARCH" == "arm64" ]]; then warn "Distro Node install unavailable; using the official Node.js binary." NODE_VER="${NGINXFORGE_NODE_VERSION:-v22.23.3}" NODE_URL="https://nodejs.org/dist/${NODE_VER}/node-${NODE_VER}-linux-${NODE_ARCH}.tar.xz" TMP_NODE="$(mktemp /tmp/nginxforge-node.XXXXXX.tar.xz)" if curl -fsSL --retry 3 --max-time 300 -o "$TMP_NODE" "$NODE_URL"; then $SUDO mkdir -p /usr/local $SUDO tar -xJf "$TMP_NODE" -C /usr/local --strip-components=1 rm -f "$TMP_NODE" # World-executable so the unprivileged service account can run it. $SUDO chmod -R a+rX /usr/local/bin/node /usr/local/lib/node_modules 2>/dev/null || true if [ -x /usr/local/bin/node ] && /usr/local/bin/node -v 2>/dev/null | grep -Eq "^v(2[0-9]|[3-9][0-9])"; then $SUDO ln -sf /usr/local/bin/node /usr/bin/node 2>/dev/null || true $SUDO ln -sf /usr/local/bin/npm /usr/bin/npm 2>/dev/null || true $SUDO ln -sf /usr/local/bin/npx /usr/bin/npx 2>/dev/null || true INSTALL_NODE_OK=true fi else rm -f "$TMP_NODE" fi fi fi if [ "$INSTALL_NODE_OK" != true ] && [ "$NEED_SYSTEM_NODE" = true ]; then err "Could not install a system-wide Node.js. Install Node.js >= 20 manually, then re-run." exit 1 fi fi if need_cmd node; then ok "Node.js $(node -v) detected." else err "Node.js is required and could not be installed automatically." exit 1 fi # ── 5. pnpm ─────────────────────────────────────────────────────────────────── # # pnpm is installed via npm, deliberately NOT via `corepack enable`. A corepack # shim enforces the exact packageManager hash from package.json and fails hard # on any mismatch, which breaks installs on machines whose corepack metadata # differs. npm-installed pnpm has no such gate and behaves identically. if ! need_cmd pnpm; then info "Installing pnpm..." $SUDO npm install -g "pnpm@10.4.1" >/dev/null 2>&1 || npm install -g "pnpm@10.4.1" >/dev/null 2>&1 || true fi if need_cmd pnpm; then ok "pnpm $(pnpm -v) ready." else warn "pnpm not available; falling back to npm." fi # ── 6. Fetch the release tarball ────────────────────────────────────────────── say "" info "Fetching NginxForge release from ${NGINXFORGE_RELEASE_BASE} ..." REMOTE_VERSION="unknown" if need_cmd curl; then REMOTE_VERSION="$(curl -fsSL --max-time 10 "$NGINXFORGE_VERSION_URL" 2>/dev/null \ | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" fi [ -z "$REMOTE_VERSION" ] && REMOTE_VERSION="unknown" [ "$REMOTE_VERSION" != "unknown" ] && ok "Latest published release: v${REMOTE_VERSION}" TMP_TARBALL="$(mktemp /tmp/nginxforge-release.XXXXXX.tar.gz)" if ! curl -fsSL --retry 3 --max-time 300 -o "$TMP_TARBALL" "$NGINXFORGE_TARBALL_URL"; then err "Could not download $NGINXFORGE_TARBALL_URL" err "Check network access and that the release feed is reachable." exit 1 fi ok "Release downloaded ($(du -h "$TMP_TARBALL" | cut -f1))." # ── 7. Extract into the application directory ───────────────────────────────── if [ -d "$INSTALL_DIR/.git" ] || [ -f "$INSTALL_DIR/package.json" ]; then info "Existing installation detected at $INSTALL_DIR — updating code in place." BACKUP_CODE="$DATA_DIR/backups/code-$(date -u +%Y%m%dT%H%M%SZ)" $SUDO mkdir -p "$BACKUP_CODE" # Only the code tree is snapshotted here; persistent data lives outside it. $SUDO tar -czf "$BACKUP_CODE/code.tar.gz" -C "$INSTALL_DIR" \ --exclude=node_modules --exclude=.env . 2>/dev/null || true fi $SUDO mkdir -p "$INSTALL_DIR" info "Extracting release into $INSTALL_DIR ..." $SUDO tar -xzf "$TMP_TARBALL" -C "$INSTALL_DIR" rm -f "$TMP_TARBALL" ok "Release extracted." # ── 8. System dependencies & runtimes (interactive detector) ────────────────── if [ -f "$INSTALL_DIR/scripts/system-installer.mjs" ]; then info "Scanning host for required tools (Nginx, databases, runtimes)..." node "$INSTALL_DIR/scripts/system-installer.mjs" "${INSTALLER_ARGS[@]:-}" fi # ── 9. Install Node modules & build the control plane ───────────────────────── say "" info "Installing dependencies and building the production bundle..." ( cd "$INSTALL_DIR" if need_cmd pnpm; then pnpm install --frozen-lockfile 2>/dev/null || pnpm install pnpm build else npm install npm run build fi ) ok "Production bundle built." # ── Early CLI symlink ───────────────────────────────────────────────────────── # Create the symlink immediately after the bundle is compiled so `nginxforge` # is available in PATH from this point forward, even if any later step runs # into an environment warning. if [ -f "$INSTALL_DIR/bin/nginxforge.mjs" ]; then $SUDO chmod +x "$INSTALL_DIR/bin/nginxforge.mjs" 2>/dev/null || true $SUDO ln -sf "$INSTALL_DIR/bin/nginxforge.mjs" /usr/local/bin/nginxforge 2>/dev/null || true $SUDO ln -sf "$INSTALL_DIR/bin/nginxforge.mjs" /usr/bin/nginxforge 2>/dev/null || true ok "CLI symlink created (available as 'nginxforge')." fi # ── 10. Service account and privilege boundary ──────────────────────────────── # # The panel runs as `nginxforge` and performs no privileged work itself. The agent # runs as the same unprivileged account and reaches root only through the commands # enumerated in /etc/sudoers.d/nginxforge-agent. That file, not a code-level # denylist, is the real boundary. info "Creating the unprivileged service account..." # NOTE: useradd returns non-zero when the home directory already exists (a # warning, not an error). With `set -e` that would silently kill the install # at this step — exactly the silent-death the reporter saw. Guard it. set +e if ! id -u nginxforge >/dev/null 2>&1; then $SUDO useradd --system --create-home --home-dir "$DATA_DIR" --shell /usr/sbin/nologin nginxforge 2>/dev/null $SUDO useradd --system --no-create-home --shell /usr/sbin/nologin nginxforge 2>/dev/null fi set -e for dir in "$DATA_DIR" "$DATA_DIR/sites_data" "$LOG_DIR" "$ETC_DIR"; do $SUDO mkdir -p "$dir" done $SUDO chown -R nginxforge:nginxforge "$LOG_DIR" # The panel writes its SQLite database (panel.db) into $DATA_DIR itself, so it # must own the directory — a root-owned 0755 here would fail the first write # with SQLITE_CANTOPEN under the unprivileged service account. $SUDO chown nginxforge:nginxforge "$DATA_DIR" $SUDO chown nginxforge:nginxforge "$DATA_DIR/sites_data" $SUDO chown root:nginxforge "$ETC_DIR" $SUDO chmod 750 "$ETC_DIR" # The persistent data root must be traversable by every site account (nfx_). # A root-only 700 here makes every systemd start fail with 200/CHDIR. $SUDO chmod 755 "$DATA_DIR" $SUDO chmod 755 "$DATA_DIR/sites_data" # The agent's sudo allowlist. Validate with visudo first: a syntax error here # would lock the agent out of every privileged action. # # `visudo` ships with the `sudo` package, which minimal cloud images often omit. # A missing visudo must NOT abort the install — it is a validation tool, not a # dependency of the panel. We install sudo, then validate; if validation is # impossible we warn loudly and continue so the rest of the install still lands. if [ -f "$INSTALL_DIR/agent/nginxforge-agent.sudoers" ]; then info "Installing the agent sudo allowlist..." set +e $SUDO install -D -m 0440 -o root -g root "$INSTALL_DIR/agent/nginxforge-agent.sudoers" /etc/sudoers.d/nginxforge-agent INSTALL_RC=$? set -e if [ $INSTALL_RC -ne 0 ]; then err "Could not install /etc/sudoers.d/nginxforge-agent (exit $INSTALL_RC)." warn "Privileged agent actions will not work until the allowlist is in place." fi if ! need_cmd visudo; then warn "visudo is not installed; attempting to install the 'sudo' package for validation..." pm_install sudo >/dev/null 2>&1 || true fi if need_cmd visudo; then # NOTE: set -e aborts the whole script when a command substitution fails, # so validation must run with errexit temporarily disabled. Otherwise a # single visudo failure kills the install before the CLI symlink is created. set +e VISUDO_OUT="$($SUDO visudo -cf /etc/sudoers.d/nginxforge-agent 2>&1)" VISUDO_RC=$? set -e if [ $VISUDO_RC -ne 0 ]; then err "The agent sudoers file FAILED validation (exit $VISUDO_RC):" echo "$VISUDO_OUT" | sed 's/^/ /' warn "Leaving the allowlist in place but NOT validated. Privileged agent actions" warn "(nginx reload, useradd, certbot, firewall) may not work until this is fixed." else ok "Agent sudo allowlist validated." fi else warn "visudo is unavailable, so the allowlist could not be validated. The file was" warn "installed at /etc/sudoers.d/nginxforge-agent. Install the 'sudo' package and run" warn "'visudo -cf /etc/sudoers.d/nginxforge-agent' to check it manually." fi fi # ── 11. Panel secrets (.env) ────────────────────────────────────────────────── if [ ! -f "$INSTALL_DIR/.env" ]; then info "Generating panel secrets (.env)..." _jwt="$(head -c 48 /dev/urandom | base64 | tr -d '\n' | head -c 64)" _boot="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" $SUDO bash -c "cat > '$INSTALL_DIR/.env'" < '$AGENT_CONFIG'" < /etc/systemd/system/$AGENT_SERVICE_NAME" </dev/null || true info "Registering the control plane service ($SERVICE_NAME)..." $SUDO bash -c "cat > /etc/systemd/system/$SERVICE_NAME" </dev/null || true $SUDO ln -sf "$INSTALL_DIR/bin/nginxforge.mjs" /usr/local/bin/nginxforge $SUDO ln -sf "$INSTALL_DIR/bin/nginxforge.mjs" /usr/bin/nginxforge ok "CLI symlink created (available as 'nginxforge')." fi # ── 16. Enable & start services ─────────────────────────────────────────────── if need_cmd systemctl; then $SUDO systemctl daemon-reload $SUDO systemctl enable --now "$AGENT_SERVICE_NAME" >/dev/null 2>&1 || true $SUDO systemctl enable --now "$SERVICE_NAME" >/dev/null 2>&1 || true $SUDO systemctl restart "$AGENT_SERVICE_NAME" >/dev/null 2>&1 || true $SUDO systemctl restart "$SERVICE_NAME" >/dev/null 2>&1 || true sleep 2 if $SUDO systemctl is-active --quiet "$AGENT_SERVICE_NAME"; then ok "Node agent is active; privileged work is delegated to it." else err "The node agent did not start. Privileged actions will fail until it does:" echo " journalctl -u $AGENT_SERVICE_NAME -n 30 --no-pager" fi if $SUDO systemctl is-active --quiet "$SERVICE_NAME"; then ok "Control plane is active." else err "The control plane did not start:" echo " journalctl -u $SERVICE_NAME -n 30 --no-pager" fi fi # ── 17. Firewall ────────────────────────────────────────────────────────────── if need_cmd ufw && $SUDO ufw status 2>/dev/null | grep -qw active; then info "Opening NginxForge ports in UFW..." for rule in 22/tcp 80/tcp 443/tcp "$PORT/tcp" 8085/tcp 8086/tcp; do $SUDO ufw allow "$rule" >/dev/null 2>&1 || true done ok "UFW rules updated (22, 80, 443, $PORT, 8085, 8086)." fi # ── 18. Report ──────────────────────────────────────────────────────────────── SERVER_IP="$(curl -s4 --max-time 3 https://api.ipify.org 2>/dev/null \ || curl -s4 --max-time 3 https://ifconfig.me 2>/dev/null \ || hostname -I 2>/dev/null | awk '{print $1}' || true)" [ -z "${SERVER_IP:-}" ] && SERVER_IP="YOUR-SERVER-IP" say "" say "=================================================================" say " ${COLOR_GREEN}✔ NGINXFORGE IS INSTALLED & RUNNING${COLOR_RESET} " say "=================================================================" say "" say "Access the dashboard at:" say " 🌐 ${COLOR_BOLD}${COLOR_GREEN}http://${SERVER_IP}:${PORT}${COLOR_RESET}" say "" if [ -n "${BOOTSTRAP_TOKEN:-}" ]; then say "Initial superadmin setup token:" say " 🔑 ${COLOR_BOLD}${BOOTSTRAP_TOKEN}${COLOR_RESET}" say "" fi say "Management commands:" say " • Status: ${COLOR_CYAN}systemctl status nginxforge${COLOR_RESET}" say " • Restart: ${COLOR_CYAN}systemctl restart nginxforge${COLOR_RESET}" say " • Logs: ${COLOR_CYAN}journalctl -u nginxforge -f${COLOR_RESET}" say " • Diagnose: ${COLOR_CYAN}nginxforge doctor${COLOR_RESET}" say " • Update: ${COLOR_CYAN}curl -fsSL https://nginxforge.labirynt.id/update.sh | bash${COLOR_RESET}" say "" say "Docs: ${COLOR_CYAN}https://nginxforge.labirynt.id${COLOR_RESET}" say ""