#!/usr/bin/env bash set -e SCRIPT_COMMIT_SHA="${SCRIPT_COMMIT_SHA:-__SCRIPT_COMMIT_SHA__}" SCRIPT_DIR="${PG_NODE_SCRIPT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)}" # The shared libraries are inlined below so this file is self-contained. # Upstream bootstraps them with four separate downloads and discards the # whole set if any one fails, which is fragile on a flaky resolver. # ===== inlined lib/common.sh ===== # Print colored text to standard output with optional ANSI styling. colorized_echo() { local color="$1" local text="$2" local style="${3:-0}" case "$color" in red) printf "\e[${style};91m%s\e[0m\n" "$text" ;; green) printf "\e[${style};92m%s\e[0m\n" "$text" ;; yellow) printf "\e[${style};93m%s\e[0m\n" "$text" ;; blue) printf "\e[${style};94m%s\e[0m\n" "$text" ;; magenta) printf "\e[${style};95m%s\e[0m\n" "$text" ;; cyan) printf "\e[${style};96m%s\e[0m\n" "$text" ;; *) printf "%s\n" "$text" ;; esac } # Print an error message in red and terminate execution with exit status 1. die() { colorized_echo red "$*" exit 1 } # Normalize redundant leading and trailing slashes without requiring the path # to exist. This keeps legacy SQLite URLs containing five slashes compatible # while producing the same path used by normal four-slash absolute URLs. normalize_posix_path() { local path="$1" while [[ "$path" == //* ]]; do path="${path#/}" done while [[ "$path" != "/" && "$path" == */ ]]; do path="${path%/}" done printf '%s\n' "$path" } # Return the filesystem path represented by a SQLAlchemy SQLite URL. # sqlite:///relative.db -> relative.db # sqlite:////absolute/db -> /absolute/db # sqlite://///absolute/db -> /absolute/db (legacy installer output) sqlite_database_path_from_url() { local url="$1" local url_part="" local path="" [[ "$url" =~ ^sqlite[^:]*:// ]] || return 1 url_part="${url#*://}" url_part="${url_part%%\?*}" url_part="${url_part%%#*}" if [[ "$url_part" == //* ]]; then path="/${url_part#//}" elif [[ "$url_part" == /* ]]; then path="${url_part#/}" else path="$url_part" fi normalize_posix_path "$path" } # Build a SQLAlchemy URL for an absolute SQLite database path. Stripping the # path's leading slash before adding the URL prefix guarantees exactly four # slashes after the scheme separator. sqlite_absolute_database_url() { local driver="$1" local path="" path=$(normalize_posix_path "$2") [[ "$driver" =~ ^sqlite([+][A-Za-z0-9_]+)?$ ]] || return 1 [[ "$path" == /* ]] || return 1 printf '%s:////%s\n' "$driver" "${path#/}" } # rsync excludes for DATA_DIR, shared by backup and restore so they cannot drift # apart: database server storage (backed up as SQL dumps instead) and downloaded # Xray binaries. Backup skips them and restore's `rsync --delete` leaves them in # place. The leading slash anchors each pattern to the top of DATA_DIR, so a # nested directory with the same name is still part of the data. # shellcheck disable=SC2034 # used by pasarguard-backup.sh and pasarguard-restore.sh PASARGUARD_DATA_DIR_EXCLUDES=( --exclude=/xray-core --exclude=/mysql --exclude=/mariadb --exclude=/postgresql --exclude=/timescaledb ) # Ensure a secret-bearing file (e.g. .env, TLS private key) is only readable by # its owner. Creates the file with 0600 if it is missing so callers can harden # it *before* writing secrets; tightens it to 0600 if it already exists. A # truncating writer such as `curl -o` preserves the inode's mode, so writing # into the pre-created file keeps it private. harden_secret_file() { local path="$1" [ -n "$path" ] || return 1 if [ ! -e "$path" ]; then (umask 077 && : >"$path") || return 1 fi chmod 600 "$path" } # Resolve and create the base directory used for temporary files and directories. temp_root_dir() { local root="" if [ -n "${APP_TMP_DIR:-}" ]; then root="$APP_TMP_DIR" elif [ -n "${DATA_DIR:-}" ]; then root="$DATA_DIR/tmp" elif [ -n "${APP_NAME:-}" ]; then root="/var/lib/$APP_NAME/tmp" else root="/var/lib/pasarguard-scripts/tmp" fi mkdir -p "$root" printf '%s\n' "$root" } # Create a secure uniquely-named temporary directory inside the temp root directory. create_temp_dir() { local prefix="${1:-tmpdir}" local root="" local candidate="" local attempt=0 root=$(temp_root_dir) while [ "$attempt" -lt 20 ]; do candidate="${root}/${prefix}-$$-${RANDOM}-${attempt}" if mkdir "$candidate" 2>/dev/null; then printf '%s\n' "$candidate" return 0 fi attempt=$((attempt + 1)) done die "Failed to create temporary directory in $root" } # Create a secure uniquely-named temporary file inside the temp root directory. create_temp_file() { local prefix="${1:-tmpfile}" local suffix="${2:-}" local root="" root=$(temp_root_dir) create_temp_file_in_dir "$root" "$prefix" "$suffix" } # Create a secure uniquely-named temporary file with atomic creation in a given directory. create_temp_file_in_dir() { local dir="$1" local prefix="${2:-tmpfile}" local suffix="${3:-}" local candidate="" local attempt=0 mkdir -p "$dir" while [ "$attempt" -lt 20 ]; do candidate="${dir}/${prefix}-$$-${RANDOM}-${attempt}${suffix}" if (set -C; : >"$candidate") 2>/dev/null; then printf '%s\n' "$candidate" return 0 fi attempt=$((attempt + 1)) done die "Failed to create temporary file in $dir" } # Fetch the latest commit SHA for a remote branch via the GitHub REST API. resolve_remote_commit_sha() { local repo="${1:-PasarGuard/scripts}" local branch="${2:-main}" local response="" local sha="" [ -n "${PASARGUARD_SKIP_REMOTE_COMMIT_LOOKUP:-}" ] && return 1 command -v curl >/dev/null 2>&1 || return 1 response=$(curl -fsSL --connect-timeout 3 --max-time 5 \ "https://git.nimbaha.org/pgnode/api/repos/${repo}/commits/${branch}" 2>/dev/null) || return 1 sha=$(printf '%s' "$response" | grep -m1 -o '"sha"[[:space:]]*:[[:space:]]*"[0-9a-f]\{7,40\}"' | grep -o '[0-9a-f]\{7,40\}') [ -n "$sha" ] || return 1 printf '%s\n' "$sha" } # Resolve the active commit SHA from baked tokens, environment, git repo, or remote lookup. get_script_commit_sha() { local script_dir="${1:-}" local baked_sha="${2:-}" local commit_sha="" if [ -n "$baked_sha" ] && [ "$baked_sha" != "__SCRIPT_COMMIT_SHA__" ]; then printf '%s\n' "$baked_sha" return 0 fi if [ -n "${PASARGUARD_SCRIPT_COMMIT:-}" ] && [ "$PASARGUARD_SCRIPT_COMMIT" != "__SCRIPT_COMMIT_SHA__" ]; then printf '%s\n' "$PASARGUARD_SCRIPT_COMMIT" return 0 fi if [ -n "${SCRIPT_COMMIT_SHA:-}" ] && [ "$SCRIPT_COMMIT_SHA" != "__SCRIPT_COMMIT_SHA__" ]; then printf '%s\n' "$SCRIPT_COMMIT_SHA" return 0 fi if [ -n "$script_dir" ] && command -v git >/dev/null 2>&1; then commit_sha=$(git -C "$script_dir" rev-parse HEAD 2>/dev/null || true) if [ -n "$commit_sha" ]; then printf '%s\n' "$commit_sha" return 0 fi fi # Scripts are normally installed via `curl ... | bash`, so there is no git # checkout and no baked-in SHA to fall back on. Ask GitHub which commit # "main" currently points to instead of printing the literal branch name. commit_sha=$(resolve_remote_commit_sha "${PASARGUARD_SCRIPT_REPO:-}" "${PASARGUARD_SCRIPT_BRANCH:-}") if [ -n "$commit_sha" ]; then printf '%s\n' "$commit_sha" return 0 fi printf '%s\n' "main" } # Print a standardized execution banner with script name, action, and resolved commit SHA. print_script_execution_header() { local script_name="$1" local baked_sha="${2:-}" local action_type="${3:-}" local script_dir="${4:-${SCRIPT_DIR:-}}" local commit_sha="" commit_sha=$(get_script_commit_sha "$script_dir" "$baked_sha") if [ -n "$action_type" ]; then printf '# Executing %s %s script, commit: %s\n' "$script_name" "$action_type" "$commit_sha" else printf '# Executing %s script, commit: %s\n' "$script_name" "$commit_sha" fi } # ===== inlined lib/system.sh ===== # Verify that the effective user ID is 0 (root), terminating with an error otherwise. check_running_as_root() { if [ "$(id -u)" != "0" ]; then die "This command must be run as root." fi } # Detect and populate the running Linux distribution name in the global OS variable. detect_os() { if [ -f /etc/lsb-release ] && command -v lsb_release >/dev/null 2>&1; then OS=$(lsb_release -si) elif [ -f /etc/os-release ]; then OS=$(awk -F= '/^NAME/{print $2}' /etc/os-release | tr -d '"') elif [ -f /etc/redhat-release ]; then OS=$(awk '{print $1}' /etc/redhat-release) elif [ -f /etc/arch-release ]; then OS="Arch Linux" else die "Unsupported operating system" fi } # Check whether the detected operating system belongs to the Red Hat enterprise family. is_redhat_family_os() { [[ "${OS:-}" == "CentOS"* ]] || [[ "${OS:-}" == "AlmaLinux"* ]] || [[ "${OS:-}" == "Rocky"* ]] || [[ "${OS:-}" == "Red Hat"* ]] || [[ "${OS:-}" == "Oracle Linux"* ]] || [[ "${OS:-}" == "Amazon Linux"* ]] } # Select either dnf or yum as the active package manager for Red Hat distributions. select_redhat_package_manager() { if command -v dnf >/dev/null 2>&1; then PKG_MANAGER="dnf" elif command -v yum >/dev/null 2>&1; then PKG_MANAGER="yum" else die "Neither yum nor dnf was found. Please install packages manually." fi } # Attempt to install and enable the EPEL repository on Red Hat family distributions. enable_epel_if_available() { if $PKG_MANAGER install -y -q epel-release >/dev/null 2>&1; then return fi colorized_echo yellow "Could not enable EPEL automatically; continuing with configured repositories." } # Emit a warning when package manager repository metadata refresh cannot be completed. warn_package_metadata_refresh_failed() { colorized_echo yellow "Could not refresh package metadata; continuing with configured repositories." } # Identify the system package manager and refresh available repository metadata. detect_and_update_package_manager() { if [ -z "${OS:-}" ]; then detect_os fi colorized_echo blue "Updating package manager" if [[ "$OS" == "Ubuntu"* ]] || [[ "$OS" == "Debian"* ]]; then PKG_MANAGER="apt-get" $PKG_MANAGER update -qq >/dev/null 2>&1 || warn_package_metadata_refresh_failed elif is_redhat_family_os; then select_redhat_package_manager $PKG_MANAGER -y -q makecache >/dev/null 2>&1 || warn_package_metadata_refresh_failed enable_epel_if_available elif [[ "$OS" == "Fedora"* ]]; then PKG_MANAGER="dnf" $PKG_MANAGER -q -y makecache >/dev/null 2>&1 || warn_package_metadata_refresh_failed elif [[ "$OS" == "Arch Linux" ]] || [[ "$OS" == "Arch"* ]]; then PKG_MANAGER="pacman" $PKG_MANAGER -Sy --noconfirm --quiet >/dev/null 2>&1 || warn_package_metadata_refresh_failed elif [[ "$OS" == "openSUSE"* ]]; then PKG_MANAGER="zypper" $PKG_MANAGER refresh --quiet >/dev/null 2>&1 || warn_package_metadata_refresh_failed else die "Unsupported operating system" fi } # Attempt to install a package, returning non-zero on failure instead of # aborting. Use this when the caller wants to handle a failed install itself # (e.g. fall back to an alternative package name); most callers want # install_package, which aborts on failure. _try_install_package_impl() { local package="$1" if [ -z "${OS:-}" ]; then detect_os fi if [ -z "${PKG_MANAGER:-}" ]; then detect_and_update_package_manager fi colorized_echo blue "Installing $package" if [[ "$OS" == "Ubuntu"* ]] || [[ "$OS" == "Debian"* ]]; then $PKG_MANAGER -y -qq install "$package" >/dev/null 2>&1 elif is_redhat_family_os; then $PKG_MANAGER install -y -q "$package" >/dev/null 2>&1 elif [[ "$OS" == "Fedora"* ]]; then $PKG_MANAGER install -y -q "$package" >/dev/null 2>&1 elif [[ "$OS" == "Arch Linux" ]] || [[ "$OS" == "Arch"* ]]; then $PKG_MANAGER -S --noconfirm --quiet "$package" >/dev/null 2>&1 elif [[ "$OS" == "openSUSE"* ]]; then $PKG_MANAGER --quiet install -y "$package" >/dev/null 2>&1 else die "Unsupported operating system" fi } # Install a system package via the active package manager or exit with a failure message. install_package() { local pkg="$1" local cmd="$pkg" case "$pkg" in net-tools) cmd=netstat ;; esac # An already present binary never needs a repository. if command -v "$cmd" >/dev/null 2>&1; then return 0 fi # jq is required by nearly every code path; a static build ships with the # mirror so a minimal offline host can still finish the install. if [ "$pkg" = "jq" ] && _install_jq_static; then return 0 fi if try_install_package "$pkg"; then return 0 fi die "The offline mirror cannot install '$pkg' on this host. Install it manually and re-run." } # jq from a static binary, so the distro repositories are never required. _install_jq_static() { local arch dest=/usr/local/bin/jq case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) return 1 ;; esac colorized_echo blue "Installing jq from the mirror (static build)" if curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 20 "https://git.nimbaha.org/pgnode/bin/jq/jq-linux-${arch}" -o "$dest.tmp"; then chmod 755 "$dest.tmp" && mv -f "$dest.tmp" "$dest" return $? fi rm -f "$dest.tmp" return 1 } # Verify or install an interactive terminal editor (nano or vi) and assign EDITOR. check_editor() { if [ -z "${EDITOR:-}" ]; then if command -v nano >/dev/null 2>&1; then EDITOR="nano" elif command -v vi >/dev/null 2>&1; then EDITOR="vi" else detect_os try_install_package nano || true EDITOR="nano" fi fi } # Identify the Linux machine architecture and assign the normalized ARCH variable. identify_the_operating_system_and_architecture() { if [[ "$(uname)" != "Linux" ]]; then die "error: This operating system is not supported." fi case "$(uname -m)" in i386 | i686) ARCH='32' ;; amd64 | x86_64) ARCH='64' ;; armv5tel) ARCH='arm32-v5' ;; armv6l) ARCH='arm32-v6' grep Features /proc/cpuinfo | grep -qw 'vfp' || ARCH='arm32-v5' ;; armv7 | armv7l) ARCH='arm32-v7a' grep Features /proc/cpuinfo | grep -qw 'vfp' || ARCH='arm32-v5' ;; armv8 | aarch64) ARCH='arm64-v8a' ;; mips) ARCH='mips32' ;; mipsle) ARCH='mips32le' ;; mips64) ARCH='mips64' lscpu | grep -q "Little Endian" && ARCH='mips64le' ;; mips64le) ARCH='mips64le' ;; ppc64) ARCH='ppc64' ;; ppc64le) ARCH='ppc64le' ;; riscv64) ARCH='riscv64' ;; s390x) ARCH='s390x' ;; *) die "error: The architecture is not supported." ;; esac } # Download and install the architecture-appropriate yq YAML processor binary into /usr/local/bin. install_yq() { local base_url="https://git.nimbaha.org/pgnode/github/mikefarah/yq/releases/latest/download" local yq_binary="" local yq_url="" local binary_tmp="" if command -v yq >/dev/null 2>&1; then colorized_echo green "yq is already installed." return fi identify_the_operating_system_and_architecture case "$ARCH" in 64 | x86_64) yq_binary="yq_linux_amd64" ;; arm32-v7a | arm32-v6 | arm32-v5 | armv7l) yq_binary="yq_linux_arm" ;; arm64-v8a | aarch64) yq_binary="yq_linux_arm64" ;; 32 | i386 | i686) yq_binary="yq_linux_386" ;; *) die "Unsupported architecture: $ARCH" ;; esac yq_url="${base_url}/${yq_binary}" colorized_echo blue "Downloading yq from ${yq_url}..." if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then colorized_echo yellow "Neither curl nor wget is installed. Attempting to install curl." install_package curl || die "Failed to install curl. Please install curl or wget manually." fi binary_tmp=$(create_temp_file "yq" ".bin") if command -v curl >/dev/null 2>&1; then curl -fsSL "$yq_url" -o "$binary_tmp" || die "Failed to download yq using curl. Please check your internet connection." elif command -v wget >/dev/null 2>&1; then wget -q -O "$binary_tmp" "$yq_url" || die "Failed to download yq using wget. Please check your internet connection." fi install -m 755 "$binary_tmp" /usr/local/bin/yq colorized_echo green "yq installed successfully!" if ! echo "$PATH" | grep -q "/usr/local/bin"; then export PATH="/usr/local/bin:$PATH" fi rm -f "$binary_tmp" } # --------------------------------------------------------------------------- # Temporary Iranian package mirrors # # Nothing below runs unless a package install has already failed, i.e. the host # cannot reach its configured repositories. The original configuration is # copied aside before a single byte is rewritten and an EXIT trap puts it back, # so the host is left exactly as it was found even if the installer dies or is # interrupted half way. # # The caller supplies the wrapper (see the end of this file's integration in # each patcher): it must call _pg_ir_enable only after its own normal install # path has failed once. # --------------------------------------------------------------------------- IR_SRC_BACKUP=/var/backups/ir-mirror-sources _pg_ir_mirror_active=0 # Self-contained so this block can be inlined into scripts that do not define # an is_redhat_family_os helper. _pg_ir_is_redhat() { case "${OS:-}" in CentOS*|AlmaLinux*|Rocky*|Red\ Hat*|Oracle*|Fedora*) return 0 ;; esac return 1 } # Probe for each base URL read from stdin; print the first that answers. _pg_ir_pick() { local suffix="$1" base while IFS= read -r base; do [ -n "$base" ] || continue if curl -fsSL --connect-timeout 6 --max-time 12 -o /dev/null -r 0-0 "$base$suffix" 2>/dev/null; then printf '%s\n' "$base" return 0 fi done return 1 } _pg_ir_backup() { mkdir -p "$IR_SRC_BACKUP" || return 1 rm -rf "$IR_SRC_BACKUP"/* 2>/dev/null if [ -f /etc/apt/sources.list ]; then cp -a /etc/apt/sources.list "$IR_SRC_BACKUP/sources.list" || return 1 fi if [ -d /etc/apt/sources.list.d ]; then mkdir -p "$IR_SRC_BACKUP/sources.list.d" cp -a /etc/apt/sources.list.d/. "$IR_SRC_BACKUP/sources.list.d/" 2>/dev/null || true fi if [ -d /etc/yum.repos.d ]; then mkdir -p "$IR_SRC_BACKUP/yum.repos.d" cp -a /etc/yum.repos.d/. "$IR_SRC_BACKUP/yum.repos.d/" 2>/dev/null || true fi return 0 } _pg_ir_restore() { [ "$_pg_ir_mirror_active" = 1 ] || return 0 _pg_ir_mirror_active=0 trap - EXIT INT TERM if [ -d "$IR_SRC_BACKUP/sources.list.d" ]; then rm -rf /etc/apt/sources.list.d mkdir -p /etc/apt/sources.list.d cp -a "$IR_SRC_BACKUP/sources.list.d/." /etc/apt/sources.list.d/ 2>/dev/null || true fi if [ -f "$IR_SRC_BACKUP/sources.list" ]; then cp -a "$IR_SRC_BACKUP/sources.list" /etc/apt/sources.list 2>/dev/null || true fi if [ -d "$IR_SRC_BACKUP/yum.repos.d" ]; then rm -rf /etc/yum.repos.d mkdir -p /etc/yum.repos.d cp -a "$IR_SRC_BACKUP/yum.repos.d/." /etc/yum.repos.d/ 2>/dev/null || true fi # Deliberately no `apt-get update` here: the original repositories are # unreachable by definition, so refreshing would just hang the exit. colorized_echo green "Package repositories restored to their original configuration." } _pg_ir_enable() { [ "$_pg_ir_mirror_active" = 1 ] && return 0 [ -n "${OS:-}" ] || detect_os local codename base sec rv arch comps codename=$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"') if [ -z "$codename" ]; then colorized_echo yellow "No release codename found; cannot select an Iranian mirror." return 1 fi if [ "$OS" = "Ubuntu" ] || [ "${OS#Debian}" != "$OS" ]; then if [ "$OS" = "Ubuntu" ]; then base=$(_pg_ir_pick "/dists/$codename/Release" <<'CAND' https://mirror.arvancloud.ir/ubuntu https://repo.iut.ac.ir/ubuntu https://mirror.iranserver.com/ubuntu CAND ) else base=$(_pg_ir_pick "/dists/$codename/Release" <<'CAND' https://mirror.arvancloud.ir/debian https://repo.iut.ac.ir/debian CAND ) sec=$(_pg_ir_pick "/dists/$codename-security/Release" <<'CAND' https://mirror.arvancloud.ir/debian-security CAND ) fi if [ -z "$base" ]; then colorized_echo yellow "No reachable Iranian mirror for $OS $codename." return 1 fi _pg_ir_backup || return 1 mkdir -p /etc/apt/sources.list.d # Third-party lists may point abroad as well; park them for the duration. find /etc/apt/sources.list.d -maxdepth 1 -name '*.list' -exec rm -f {} + 2>/dev/null || true if [ "$OS" = "Ubuntu" ]; then cat > /etc/apt/sources.list < /etc/apt/sources.list <> /etc/apt/sources.list fi fi _pg_ir_mirror_active=1 trap '_pg_ir_restore' EXIT INT TERM colorized_echo yellow "Package repositories temporarily switched to $base; they will be restored on exit." apt-get update -qq >/dev/null 2>&1 || true return 0 fi if _pg_ir_is_redhat; then rv=$(rpm -E %{rhel} 2>/dev/null) case "$(uname -m)" in x86_64|amd64) arch=x86_64 ;; aarch64|arm64) arch=aarch64 ;; *) return 1 ;; esac [ -n "$rv" ] || return 1 base=$(_pg_ir_pick "/almalinux/$rv/BaseOS/$arch/os/repodata/repomd.xml" <<'CAND' https://mirror.iranserver.com/almalinux https://mirror.arvancloud.ir/almalinux CAND ) if [ -z "$base" ]; then colorized_echo yellow "No reachable Iranian mirror for $OS $rv." return 1 fi _pg_ir_backup || return 1 mkdir -p /etc/yum.repos.d rm -f /etc/yum.repos.d/*.repo cat > /etc/yum.repos.d/ir-temp-mirror.repo </dev/null 2>&1 || true return 0 fi colorized_echo yellow "No Iranian mirror recipe for $OS." return 1 } # Retry a failed install through a temporary Iranian mirror. This is the only # entry point: the mirror is never enabled speculatively. try_install_package() { local package="$1" if _try_install_package_impl "$package"; then return 0 fi colorized_echo yellow "Package repositories are unreachable; retrying through an Iranian mirror." _pg_ir_enable || return 1 _try_install_package_impl "$package" } # ===== inlined lib/docker.sh ===== # Download and install the official Docker Engine package via get.docker.com script. install_docker() { colorized_echo blue "Installing Docker (offline mirror)" local arch tarball tmp case "$(uname -m)" in x86_64|amd64) arch=x86_64 ;; aarch64|arm64) arch=aarch64 ;; *) die "Unsupported architecture for the offline Docker bundle: $(uname -m)" ;; esac tarball="https://git.nimbaha.org/pgnode/bin/docker-static/docker-29.9.3-${arch}.tgz" tmp=$(mktemp -d) || die "mktemp failed" colorized_echo cyan " Downloading $tarball" if ! curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 20 "$tarball" -o "$tmp/docker.tgz"; then rm -rf "$tmp" die "Failed to download the Docker bundle from the mirror" fi tar -xzf "$tmp/docker.tgz" -C "$tmp" || { rm -rf "$tmp"; die "Failed to extract the Docker bundle"; } install -m 755 "$tmp"/docker/* /usr/local/bin/ || { rm -rf "$tmp"; die "Failed to install the Docker binaries"; } rm -rf "$tmp" _install_compose_plugin "$arch" || die "Failed to install the docker compose v2 plugin" # Static builds ship no unit file; dockerd starts containerd itself. cat > /etc/systemd/system/docker.service <<'DOCKERUNIT' [Unit] Description=Docker Application Container Engine (offline static build) After=network-online.target Wants=network-online.target [Service] Type=notify ExecStart=/usr/local/bin/dockerd -H fd:// ExecReload=/bin/kill -s HUP $MAINPID LimitNOFILE=infinity LimitNPROC=infinity LimitCORE=infinity TasksMax=infinity TimeoutStartSec=0 Delegate=yes KillMode=process Restart=on-failure StartLimitBurst=3 StartLimitInterval=60s [Install] WantedBy=multi-user.target DOCKERUNIT systemctl daemon-reload >/dev/null 2>&1 || true ensure_docker_service_running # Systemd may be absent (containers) or the unit may refuse to start; the # daemon still has to exist before anything can be loaded into it. ensure_docker_daemon >/dev/null 2>&1 || true colorized_echo green "Docker installed successfully from the mirror" } # Docker Compose v2 is not part of the static Docker tarball, so it comes from # the mirror instead of the distro package manager. _install_compose_plugin() { local arch="$1" local dest=/usr/local/lib/docker/cli-plugins/docker-compose mkdir -p /usr/local/lib/docker/cli-plugins || return 1 if [ -x "$dest" ]; then return 0 fi colorized_echo blue "Installing Docker Compose v2 plugin (offline mirror)" if curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 20 "https://git.nimbaha.org/pgnode/bin/compose/docker-compose-linux-${arch}" -o "$dest.tmp"; then chmod 755 "$dest.tmp" && mv -f "$dest.tmp" "$dest" return $? fi rm -f "$dest.tmp" return 1 } # Load the node image from the bundled docker-archive tarball. The image is # saved by crane under the source registry's name, so it is re-tagged to the # reference the compose file expects. # Start the daemon if it is not answering. ensure_docker_service_running() returns # silently when there is no systemd (containers) or no unit file, which leaves no # socket at all and makes every docker call fail with a confusing message. ensure_docker_daemon() { docker info >/dev/null 2>&1 && return 0 if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker >/dev/null 2>&1 || true fi local i for i in $(seq 1 15); do docker info >/dev/null 2>&1 && return 0 sleep 1 done if command -v dockerd >/dev/null 2>&1 && { ! command -v pgrep >/dev/null 2>&1 || ! pgrep -x dockerd >/dev/null 2>&1; }; then colorized_echo yellow " The Docker service did not come up; starting dockerd directly" nohup dockerd -H unix:///var/run/docker.sock >/var/log/dockerd.log 2>&1 & fi for i in $(seq 1 30); do docker info >/dev/null 2>&1 && return 0 sleep 1 done return 1 } _load_node_image() { local ver="${1:-latest}" local arch tag tmp src case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) return 1 ;; esac tag="pasarguard/node:${ver}" if docker image inspect "$tag" >/dev/null 2>&1; then return 0 fi colorized_echo blue "Loading the node image from the mirror (${arch})" if ! ensure_docker_daemon; then colorized_echo red "✗ The Docker daemon is not running and could not be started" (systemctl status docker --no-pager -l 2>/dev/null || true) | tail -8 | sed 's/^/ /' if [ -s /var/log/dockerd.log ]; then tail -8 /var/log/dockerd.log | sed 's/^/ /' fi return 1 fi tmp=$(mktemp) || return 1 if ! curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 20 "https://git.nimbaha.org/pgnode/images/node-${arch}.tar" -o "$tmp"; then rm -f "$tmp" colorized_echo red "✗ Could not download the node image from the mirror" return 1 fi if ! load_out=$(docker load -i "$tmp" 2>&1); then colorized_echo red "✗ Could not load the node image tarball" printf '%s ' "$load_out" | tail -6 | sed 's/^/ /' root=$(docker info -f '{{.DockerRootDir}}' 2>/dev/null) if [ -n "$root" ] && [ -d "$root" ]; then df -h "$root" | tail -2 | sed 's/^/ /' fi rm -f "$tmp" return 1 fi rm -f "$tmp" for src in "hub.rat.dev/pasarguard/node:latest" "dockerproxy.net/pasarguard/node:latest" \ "docker.1ms.run/pasarguard/node:latest" "pasarguard/node:latest"; do if docker tag "$src" "$tag" 2>/dev/null; then break fi done if ! docker image inspect "$tag" >/dev/null 2>&1; then return 1 fi # The bundle carries one snapshot; never let a different --version pass # silently as if that exact release had been installed. case "$ver" in latest|pre-release|dev) return 0 ;; esac mirrored=$(curl -fsSL --retry 2 --retry-delay 1 --connect-timeout 15 "https://git.nimbaha.org/pgnode/versions.json" 2>/dev/null | jq -r '.node_image_tag // empty' 2>/dev/null) if [ -n "$mirrored" ] && [ "${mirrored#v}" != "${ver#v}" ]; then colorized_echo yellow "⚠ The mirror ships image $mirrored, but $ver was requested." colorized_echo yellow " Installing the mirrored image; re-run the sync to publish $ver." fi return 0 } # Verify that the systemd docker service unit is active and started. ensure_docker_service_running() { if ! command -v systemctl >/dev/null 2>&1; then return fi if [ ! -d /run/systemd/system ]; then return fi if ! systemctl list-unit-files docker.service >/dev/null 2>&1; then return fi if systemctl is-active --quiet docker; then return fi colorized_echo blue "Starting Docker service" if ! systemctl enable --now docker >/dev/null 2>&1; then systemctl start docker >/dev/null 2>&1 || die "Failed to start Docker service" fi } # Detect whether Docker Compose v2 is functional and assign the COMPOSE variable. detect_compose() { if docker compose version >/dev/null 2>&1; then COMPOSE='docker compose' else die "docker compose v2 not found. Install the Docker Compose v2 plugin (e.g. 'apt-get install docker-compose-plugin' or 'docker-compose-v2'), then re-run." fi } # Ensure the Docker Compose v2 plugin is available. A bare 'command -v docker' # check is not enough: a machine can have the docker engine (e.g. the distro # docker.io package) without the compose v2 plugin. Installs the plugin via the # package manager when missing -- the package is named docker-compose-plugin in # Docker's official repo and docker-compose-v2 in Debian/Ubuntu's -- and aborts # with actionable guidance if it still cannot be provided. ensure_docker_compose() { if docker compose version >/dev/null 2>&1; then return 0 fi colorized_echo blue "Docker Compose v2 plugin not found; installing it" # Offline mirror: the distro repos are unreachable, the plugin is bundled. local _arch case "$(uname -m)" in x86_64|amd64) _arch=x86_64 ;; aarch64|arm64) _arch=aarch64 ;; *) _arch=x86_64 ;; esac _install_compose_plugin "$_arch" || true if ! docker compose version >/dev/null 2>&1; then die "docker compose v2 is required but could not be installed automatically. Install the Docker Compose v2 plugin (e.g. 'apt-get install docker-compose-plugin' or 'docker-compose-v2'), then re-run." fi colorized_echo green "Docker Compose v2 plugin installed" } # Start all containers defined in COMPOSE_FILE in detached mode with orphan removal. compose_up() { ensure_docker_service_running $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" up -d --remove-orphans } # Stop and remove containers and networks defined in COMPOSE_FILE. compose_down() { $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" down } # View recent aggregate logs from services defined in COMPOSE_FILE. compose_logs() { $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" logs } # Stream and follow live log output from services defined in COMPOSE_FILE. compose_logs_follow() { $COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" logs -f } # ===== inlined lib/github.sh ===== SHARED_LIB_INSTALL_DIR="${SHARED_LIB_INSTALL_DIR:-/usr/local/lib/pasarguard-scripts/lib}" # Build the raw GitHub content URL for a path in a given repository. github_raw_url() { local repo="$1" local path="$2" printf 'https://git.nimbaha.org/pgnode/github/%s/raw/main/%s\n' "$repo" "$path" } # Download a file from a URL to a local destination path using curl. github_download_file() { local url="$1" local target_path="$2" curl -fsSL "$url" -o "$target_path" } # Back up installed executables and shared libraries into a temporary directory. backup_scripts() { local backup_dir="" backup_dir=$(create_temp_dir "scripts-backup") # Backup main scripts [ -f "/usr/local/bin/pasarguard" ] && cp "/usr/local/bin/pasarguard" "$backup_dir/" [ -f "/usr/local/bin/pg-node" ] && cp "/usr/local/bin/pg-node" "$backup_dir/" # Backup shared libraries if [ -d "$SHARED_LIB_INSTALL_DIR" ]; then mkdir -p "$backup_dir/lib" # Only copy if directory is not empty if [ "$(ls -A "$SHARED_LIB_INSTALL_DIR")" ]; then cp -r "$SHARED_LIB_INSTALL_DIR/"* "$backup_dir/lib/" fi fi printf '%s\n' "$backup_dir" } # Restore backed-up executables and shared libraries to their system installation locations. restore_scripts() { local backup_dir="$1" [ -z "$backup_dir" ] && return 1 # Restore main scripts [ -f "$backup_dir/pasarguard" ] && install -m 755 "$backup_dir/pasarguard" "/usr/local/bin/pasarguard" [ -f "$backup_dir/pg-node" ] && install -m 755 "$backup_dir/pg-node" "/usr/local/bin/pg-node" # Restore shared libraries if [ -d "$backup_dir/lib" ]; then mkdir -p "$SHARED_LIB_INSTALL_DIR" if [ "$(ls -A "$backup_dir/lib")" ]; then install -m 644 "$backup_dir/lib/"* "$SHARED_LIB_INSTALL_DIR/" fi fi } # Delete a temporary backup directory created during script update processes. cleanup_backup() { local backup_dir="$1" if [ -n "$backup_dir" ]; then rm -rf "$backup_dir" fi } # Download and install an executable shell script from GitHub into /usr/local/bin. github_install_script_from_repo() { local repo="$1" local script_name="$2" local install_name="$3" local tmp_file="" tmp_file=$(mktemp) || return 1 trap 'rm -f "$tmp_file"' RETURN if ! curl -fSL "$(github_raw_url "$repo" "$script_name")" -o "$tmp_file"; then trap - RETURN rm -f "$tmp_file" return 1 fi if ! chmod 755 "$tmp_file"; then trap - RETURN rm -f "$tmp_file" return 1 fi if ! install -m 755 "$tmp_file" "/usr/local/bin/$install_name"; then trap - RETURN rm -f "$tmp_file" return 1 fi trap - RETURN rm -f "$tmp_file" } # Copy shared library files from a local source directory into SHARED_LIB_INSTALL_DIR. install_shared_libs_from_local() { local source_dir="$1" shift local lib_name="" mkdir -p "$SHARED_LIB_INSTALL_DIR" for lib_name in "$@"; do if [ -f "$source_dir/lib/$lib_name" ]; then install -m 644 "$source_dir/lib/$lib_name" "$SHARED_LIB_INSTALL_DIR/$lib_name" fi done } # Download and install shared library files from GitHub into SHARED_LIB_INSTALL_DIR. install_shared_libs_from_repo() { local fetch_repo="$1" shift local tmp_dir="" local lib_name="" tmp_dir=$(create_temp_dir "shared-libs") mkdir -p "$SHARED_LIB_INSTALL_DIR" for lib_name in "$@"; do if ! github_download_file "$(github_raw_url "$fetch_repo" "lib/$lib_name")" "$tmp_dir/$lib_name"; then rm -rf "$tmp_dir" return 1 fi if ! install -m 644 "$tmp_dir/$lib_name" "$SHARED_LIB_INSTALL_DIR/$lib_name"; then rm -rf "$tmp_dir" return 1 fi done rm -rf "$tmp_dir" } # Validate a user-supplied instance name (--name). The value flows into # filesystem paths, the systemd unit (body + filename), sed/yq programs and # the network service's command word, so it must be restricted to a safe # character set to prevent path traversal and command/directive injection. validate_app_name() { local name="$1" [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$ ]] } # Handle global options AUTO_CONFIRM=false APP_NAME="" CUSTOM_NAME_SET=false ARGS=() while [[ $# -gt 0 ]]; do case "$1" in -y | --yes) AUTO_CONFIRM=true shift ;; --name) if [[ -z "${2:-}" ]]; then echo "Error: --name requires a value." >&2 exit 1 fi if ! validate_app_name "$2"; then echo "Error: invalid --name '$2'. Use 1-63 chars: letters, digits, '_' or '-', starting with a letter or digit." >&2 exit 1 fi APP_NAME="$2" CUSTOM_NAME_SET=true shift 2 ;; --name=*) APP_NAME="${1#*=}" if [[ -z "$APP_NAME" ]]; then echo "Error: --name requires a value." >&2 exit 1 fi if ! validate_app_name "$APP_NAME"; then echo "Error: invalid --name '$APP_NAME'. Use 1-63 chars: letters, digits, '_' or '-', starting with a letter or digit." >&2 exit 1 fi CUSTOM_NAME_SET=true shift ;; *) ARGS+=("$1") shift ;; esac done set -- "${ARGS[@]}" COMMAND="${1:-}" # Fetch IP address from ifconfig.io API NODE_IP_V4=$(curl -s -4 --fail --max-time 5 ifconfig.io 2>/dev/null || echo "") NODE_IP_V6=$(curl -s -6 --fail --max-time 5 ifconfig.io 2>/dev/null || echo "") NODE_IP="${NODE_IP_V4:-}" if [ -z "$NODE_IP" ]; then NODE_IP="${NODE_IP_V6:-}" fi if [ -z "$NODE_IP" ]; then NODE_IP="127.0.0.1" fi if [[ "${1:-}" == "install" || "${1:-}" == "install-script" ]] && [ -z "${APP_NAME:-}" ]; then APP_NAME="pg-node" fi # Set script name if APP_NAME is not set if [ -z "${APP_NAME:-}" ]; then SCRIPT_NAME=$(basename "$0") APP_NAME="${SCRIPT_NAME%.*}" fi if [[ "$CUSTOM_NAME_SET" == true && "$COMMAND" =~ ^(install|install-script)$ ]]; then if command -v "$APP_NAME" >/dev/null 2>&1; then echo "Error: '$APP_NAME' is an existing Linux command. Please choose a different --name." >&2 exit 1 fi fi INSTALL_DIR="/opt" if [ -z "${APP_DIR:-}" ]; then if [ -d "$INSTALL_DIR/$APP_NAME" ]; then APP_DIR="$INSTALL_DIR/$APP_NAME" else APP_DIR="$INSTALL_DIR/$APP_NAME" fi fi DATA_DIR="${DATA_DIR:-/var/lib/$APP_NAME}" COMPOSE_FILE="$APP_DIR/docker-compose.yml" ENV_FILE="$APP_DIR/.env" SSL_CERT_FILE="$DATA_DIR/certs/ssl_cert.pem" SSL_KEY_FILE="$DATA_DIR/certs/ssl_key.pem" LAST_XRAY_CORES=5 FETCH_REPO="PasarGuard/scripts" NODE_SERVICE_REPO="PasarGuard/node-serviced" NODE_SERVICE_RELEASE_API="https://git.nimbaha.org/pgnode/api/repos/${NODE_SERVICE_REPO}/releases/latest" NODE_SERVICE_BINARY_NAME="node-serviced" # Configure service paths based on APP_NAME. set_service_paths() { SERVICE_NAME="${APP_NAME}-service" SERVICE_BINARY_PATH="/usr/local/bin/${SERVICE_NAME}" SERVICE_UNIT="/etc/systemd/system/${SERVICE_NAME}.service" } # Require systemd (systemctl) on the system. require_systemd() { if ! command -v systemctl >/dev/null 2>&1; then colorized_echo red "systemd is required to manage the service (systemctl not found)." exit 1 fi } # Check if the node systemd service is installed. service_installed() { if ! command -v systemctl >/dev/null 2>&1; then return 1 fi set_service_paths if [ -f "$SERVICE_UNIT" ] || systemctl list-unit-files | grep -q "^${SERVICE_NAME}.service"; then return 0 fi return 1 } # Restart the node systemd service if currently installed. restart_service_if_installed() { if ! service_installed; then return fi if [ "$(id -u)" != "0" ]; then colorized_echo yellow "$SERVICE_NAME is installed; run as root to restart it." return fi systemctl restart "$SERVICE_NAME" colorized_echo blue "$SERVICE_NAME service restarted." } # Update and restart the node systemd service if currently installed. update_service_if_installed() { if ! service_installed; then return fi if [ "$(id -u)" != "0" ]; then colorized_echo yellow "$SERVICE_NAME is installed; run as root to update/restart it." return fi install_node_service_script systemctl daemon-reload systemctl restart "$SERVICE_NAME" colorized_echo blue "$SERVICE_NAME service updated and restarted." } # Detect system platform architecture string for node-serviced releases. detect_node_serviced_platform() { local arch os platform os=$(uname -s 2>/dev/null || echo "") if [ "$os" != "Linux" ]; then colorized_echo red "Unsupported OS for node-serviced: $os" exit 1 fi arch=$(uname -m 2>/dev/null || echo "") case "$arch" in x86_64 | amd64) platform="Linux_x86_64" ;; aarch64 | arm64 | armv8* ) platform="Linux_arm64" ;; armv7l | armv7) platform="Linux_armv7" ;; armv6l | armv6) platform="Linux_armv6" ;; *) colorized_echo red "Unsupported architecture for node-serviced: $arch" exit 1 ;; esac echo "$platform" } # Display firewall hints for opening a given port and protocol. configure_firewall_for_port() { local port="$1" local proto="${2:-tcp}" local hint="If a firewall is enabled (e.g., UFW or firewalld), allow ${port}/${proto}." colorized_echo yellow "$hint" } # Download and install the pg-node CLI script to /usr/local/bin. install_node_script() { print_script_execution_header "pg-node" "$SCRIPT_COMMIT_SHA" "install" colorized_echo blue "Installing node script" TARGET_PATH="/usr/local/bin/$APP_NAME" TEMP_FILE=$(create_temp_file "pg-node-script" ".sh") # Download script to temp file first colorized_echo cyan " Downloading script from GitHub..." if ! github_download_file "$(github_raw_url "$FETCH_REPO" "pg-node.sh")" "$TEMP_FILE"; then colorized_echo red "✗ Failed to download script from $(github_raw_url "$FETCH_REPO" "pg-node.sh")" rm -f "$TEMP_FILE" exit 1 fi # Replace APP_NAME in the script - the script has APP_NAME="" on line 5 # We need to set it to the current APP_NAME value if grep -q "^APP_NAME=" "$TEMP_FILE"; then sed -i "s|^APP_NAME=.*|APP_NAME=\"$APP_NAME\"|" "$TEMP_FILE" fi install_shared_libs_from_repo "$FETCH_REPO" common.sh system.sh docker.sh github.sh # Remove old file if it exists if [ -f "$TARGET_PATH" ]; then colorized_echo cyan " Replacing existing script at $TARGET_PATH..." rm -f "$TARGET_PATH" fi # Move temp file to target location mv "$TEMP_FILE" "$TARGET_PATH" chmod 755 "$TARGET_PATH" # Verify the installation if [ -f "$TARGET_PATH" ] && [ -x "$TARGET_PATH" ]; then colorized_echo green "✓ node script installed successfully at $TARGET_PATH" else colorized_echo red "✗ Failed to install script - file may not be executable" exit 1 fi } # Download and install the node-serviced binary release from GitHub. install_node_service_script() { set_service_paths if ! command -v jq >/dev/null 2>&1; then detect_os install_package jq fi colorized_echo blue "Installing node-serviced binary" local platform release_json latest_tag latest_version asset_name asset_url tmp_dir archive_path platform=$(detect_node_serviced_platform) if ! release_json=$(curl -fsSL "$NODE_SERVICE_RELEASE_API"); then colorized_echo red "Failed to query latest node-serviced release from $NODE_SERVICE_RELEASE_API" exit 1 fi latest_tag=$(echo "$release_json" | jq -r '.tag_name // empty') latest_version="${latest_tag#v}" if [ -z "$latest_version" ] || [ "$latest_version" = "null" ]; then colorized_echo red "Failed to resolve latest node-serviced version from $NODE_SERVICE_RELEASE_API" exit 1 fi asset_name="${NODE_SERVICE_BINARY_NAME}_${latest_version}_${platform}.tar.gz" asset_url=$(echo "$release_json" | jq -r --arg name "$asset_name" '.assets[]? | select(.name==$name) | .browser_download_url' | head -n 1) if [ -z "$asset_url" ] || [ "$asset_url" = "null" ]; then colorized_echo red "node-serviced asset not found for platform $platform (expected $asset_name)" exit 1 fi tmp_dir=$(create_temp_dir "node-serviced") archive_path="${tmp_dir}/${asset_name}" colorized_echo cyan " Downloading ${asset_name}..." if ! curl -sSL "$asset_url" -o "$archive_path"; then colorized_echo red "Failed to download node-serviced from $asset_url" rm -rf "$tmp_dir" exit 1 fi colorized_echo cyan " Extracting node-serviced..." if ! tar -xzf "$archive_path" -C "$tmp_dir" "$NODE_SERVICE_BINARY_NAME" 2>/dev/null; then colorized_echo red "Failed to extract node-serviced binary from archive." rm -rf "$tmp_dir" exit 1 fi install -m 755 "${tmp_dir}/${NODE_SERVICE_BINARY_NAME}" "$SERVICE_BINARY_PATH" rm -rf "$tmp_dir" colorized_echo green "node-serviced installed successfully at $SERVICE_BINARY_PATH (v${latest_version})" } # Get a list of occupied ports get_occupied_ports() { if command -v ss &>/dev/null; then OCCUPIED_PORTS=$(ss -tuln | awk '{print $5}' | grep -Eo '[0-9]+$' | sort | uniq) elif command -v netstat &>/dev/null; then OCCUPIED_PORTS=$(netstat -tuln | awk '{print $4}' | grep -Eo '[0-9]+$' | sort | uniq) else colorized_echo yellow "Neither ss nor netstat found. Attempting to install net-tools." detect_os install_package net-tools if command -v netstat &>/dev/null; then OCCUPIED_PORTS=$(netstat -tuln | awk '{print $4}' | grep -Eo '[0-9]+$' | sort | uniq) else colorized_echo red "Failed to install net-tools. Please install it manually." exit 1 fi fi } # Function to check if a port is occupied is_port_occupied() { if echo "$OCCUPIED_PORTS" | grep -q -w "$1"; then return 0 else return 1 fi } # Function to detect if a string is an IP address (IPv4 or IPv6) is_ip_address() { local input="$1" # Check for IPv4 (e.g., 192.168.1.1) if [[ "$input" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then # Validate each octet is 0-255 IFS='.' read -ra octets <<< "$input" for octet in "${octets[@]}"; do if [[ $octet -lt 0 || $octet -gt 255 ]]; then return 1 fi done return 0 fi # Check for IPv6 (simplified check - contains colons and hex digits) if [[ "$input" =~ ^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$ ]] || [[ "$input" =~ ^:: ]] || [[ "$input" =~ :: ]]; then return 0 fi return 1 } # Function to normalize SAN entry (add DNS: or IP: prefix if missing) normalize_san_entry() { local entry="$1" local normalized="" # Remove leading/trailing whitespace entry=$(echo "$entry" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') # If already has prefix, return as-is if [[ "$entry" =~ ^DNS:.+ ]]; then echo "$entry" return 0 elif [[ "$entry" =~ ^IP:.+ ]]; then echo "$entry" return 0 fi # Auto-detect and add prefix if is_ip_address "$entry"; then normalized="IP:$entry" else # Assume it's a domain name normalized="DNS:$entry" fi echo "$normalized" } # Validate a Subject Alternative Name (SAN) entry for certificate generation. validate_san_entry() { local entry="$1" # Remove leading/trailing whitespace entry=$(echo "$entry" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') # Empty entry is invalid if [ -z "$entry" ]; then return 1 fi # Normalize the entry (add prefix if missing) local normalized normalized=$(normalize_san_entry "$entry") # Check if normalized entry is valid if [[ "$normalized" =~ ^DNS:.+ ]] || [[ "$normalized" =~ ^IP:.+ ]]; then return 0 else return 1 fi } # Generate a random UUID v4 using available system tools. generate_uuid_v4() { cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null || python -c "import uuid; print(uuid.uuid4())" 2>/dev/null } # Check if the installed openssl binary supports the -addext option. openssl_supports_addext() { openssl req -help 2>&1 | grep -q -- '-addext' } # Generate an EC self-signed certificate using openssl -addext. generate_self_signed_cert_with_addext() { local san_string="$1" openssl req -x509 -newkey ec \ -pkeyopt ec_paramgen_curve:P-256 \ -keyout "$SSL_KEY_FILE" \ -out "$SSL_CERT_FILE" -days 3650 -nodes \ -subj "/CN=$NODE_IP" \ -addext "subjectAltName = $san_string" >/dev/null 2>&1 } # Generate an EC self-signed certificate using a temporary openssl config file. generate_self_signed_cert_with_config() { local san_string="$1" local openssl_config="" local status=0 openssl_config=$(create_temp_file "pg-node-openssl" ".cnf") { echo "[req]" echo "distinguished_name = req_distinguished_name" echo "x509_extensions = v3_req" echo "prompt = no" echo "" echo "[req_distinguished_name]" echo "CN = $NODE_IP" echo "" echo "[v3_req]" echo "subjectAltName = $san_string" } >"$openssl_config" openssl req -x509 -newkey ec \ -pkeyopt ec_paramgen_curve:P-256 \ -keyout "$SSL_KEY_FILE" \ -out "$SSL_CERT_FILE" -days 3650 -nodes \ -config "$openssl_config" \ -extensions v3_req >/dev/null 2>&1 || status=$? rm -f "$openssl_config" return "$status" } # Generate a self-signed SSL/TLS certificate with Subject Alternative Names (SANs). gen_self_signed_cert() { local san_entries=("DNS:localhost" "IP:127.0.0.1") local extra_san="" local user_san_entries=() # Add IPv4 if it exists if [ -n "$NODE_IP_V4" ]; then san_entries+=("IP:$NODE_IP_V4") fi # Add IPv6 if it exists if [ -n "$NODE_IP_V6" ]; then san_entries+=("IP:$NODE_IP_V6") fi colorized_echo cyan "================================" colorized_echo cyan "Current SAN (Subject Alternative Name) entries:" for entry in "${san_entries[@]}"; do if [[ "$entry" =~ ^DNS: ]]; then colorized_echo green " ✓ DNS: ${entry#DNS:}" elif [[ "$entry" =~ ^IP: ]]; then colorized_echo green " ✓ IP: ${entry#IP:}" fi done colorized_echo cyan "================================" if [ -n "${INSTALL_SAN_ENTRIES:-}" ]; then extra_san="$INSTALL_SAN_ENTRIES" IFS=',' read -ra user_entries <<<"$extra_san" local valid_entries=() local invalid_entries=() for entry in "${user_entries[@]}"; do entry=$(echo "$entry" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ -n "$entry" ]; then if validate_san_entry "$entry"; then valid_entries+=("$(normalize_san_entry "$entry")") else invalid_entries+=("$entry") fi fi done if [ ${#invalid_entries[@]} -gt 0 ]; then colorized_echo red "ERROR: Invalid SAN entries provided via argument: ${invalid_entries[*]}" exit 1 fi user_san_entries=("${valid_entries[@]}") elif [ "$AUTO_CONFIRM" = true ]; then : else while true; do # Temporarily disable exit on error for user input set +e colorized_echo cyan "" colorized_echo yellow "You can add additional SAN entries (IP addresses or domain names)." colorized_echo yellow "Examples:" colorized_echo cyan " • IP addresses: 192.168.1.100, 203.0.113.45" colorized_echo cyan " • Domain names: node.example.com, vpn.mydomain.com" colorized_echo cyan " • Wildcard domains: *.example.com" colorized_echo cyan " • IPv6: 2001:db8::1" colorized_echo yellow "" read -rp "Enter additional SAN entries (comma separated), or press ENTER to keep current: " extra_san local read_status=$? set -e # Check if read was interrupted (Ctrl+C) if [ $read_status -ne 0 ]; then colorized_echo yellow "Input cancelled, using default SAN entries only" break fi if [[ -z "$extra_san" ]]; then break fi # Split input by comma and validate each entry IFS=',' read -ra user_entries <<<"$extra_san" local valid_entries=() local invalid_entries=() local skipped_entries=() colorized_echo cyan "Validating SAN entries..." for entry in "${user_entries[@]}"; do # Trim whitespace entry=$(echo "$entry" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ -z "$entry" ]; then skipped_entries+=("(empty)") continue fi if validate_san_entry "$entry"; then # Normalize the entry to get the proper format local normalized normalized=$(normalize_san_entry "$entry") valid_entries+=("$normalized") if [[ "$normalized" =~ ^DNS: ]]; then colorized_echo green " ✓ Valid: ${normalized#DNS:} (detected as DNS)" elif [[ "$normalized" =~ ^IP: ]]; then colorized_echo green " ✓ Valid: ${normalized#IP:} (detected as IP)" fi else invalid_entries+=("$entry") colorized_echo red " ✗ Invalid: '$entry'" colorized_echo yellow " → Please enter a valid IP address (e.g., 192.168.1.100) or domain name (e.g., node.example.com)" fi done if [ ${#skipped_entries[@]} -gt 0 ]; then colorized_echo yellow " ⚠ Skipped ${#skipped_entries[@]} empty entry/entries" fi if [ ${#invalid_entries[@]} -gt 0 ]; then colorized_echo red "" colorized_echo red "ERROR: ${#invalid_entries[@]} invalid SAN entry/entries found:" for invalid in "${invalid_entries[@]}"; do colorized_echo red " • '$invalid'" done colorized_echo yellow "" colorized_echo yellow "Valid format examples:" colorized_echo cyan " • IP addresses: 192.168.1.100, 203.0.113.45" colorized_echo cyan " • Domain names: node.example.com, vpn.mydomain.com" colorized_echo cyan " • Wildcard domains: *.example.com" colorized_echo cyan " • IPv6 addresses: 2001:db8::1, ::1" colorized_echo yellow "" colorized_echo yellow "Note: Enter IPs and domains directly (no DNS: or IP: prefix needed)." colorized_echo yellow "The script will automatically detect the type." colorized_echo yellow "" colorized_echo yellow "Please correct the invalid entries and try again." continue fi if [ ${#valid_entries[@]} -gt 0 ]; then user_san_entries=("${valid_entries[@]}") colorized_echo green "" colorized_echo green "✓ Successfully accepted ${#valid_entries[@]} SAN entry/entries" fi break done fi if [ ${#user_san_entries[@]} -gt 0 ]; then san_entries+=("${user_san_entries[@]}") fi # Join SAN entries into a comma-separated string and remove duplicates local san_string san_string=$(printf '%s\n' "${san_entries[@]}" | sort -u | paste -sd, - 2>/dev/null) # Check if san_string was created successfully if [ -z "$san_string" ]; then colorized_echo red "Error: Failed to process SAN entries" exit 1 fi # Display final SAN entries colorized_echo cyan "" colorized_echo cyan "Final SAN entries that will be used:" IFS=',' read -ra final_entries <<<"$san_string" for entry in "${final_entries[@]}"; do if [[ "$entry" =~ ^DNS: ]]; then colorized_echo green " • DNS: ${entry#DNS:}" elif [[ "$entry" =~ ^IP: ]]; then colorized_echo green " • IP: ${entry#IP:}" fi done colorized_echo cyan "" # Generate certificate colorized_echo blue "Generating self-signed certificate..." colorized_echo cyan " Command: openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 ..." if ! command -v openssl >/dev/null 2>&1; then colorized_echo yellow "OpenSSL not found. Attempting to install openssl." detect_os install_package openssl fi local cert_generated=false if openssl_supports_addext; then if generate_self_signed_cert_with_addext "$san_string"; then cert_generated=true fi else colorized_echo yellow " OpenSSL -addext is unavailable; using config-file SAN fallback." if generate_self_signed_cert_with_config "$san_string"; then cert_generated=true fi fi if [ "$cert_generated" = true ]; then # openssl -keyout preserves a pre-existing file's mode, so tighten the # private key to owner-only after every (re)generation. harden_secret_file "$SSL_KEY_FILE" colorized_echo green "✓ Certificate generated successfully!" colorized_echo green " Certificate: $SSL_CERT_FILE" colorized_echo green " Private Key: $SSL_KEY_FILE" else colorized_echo red "✗ Error: Failed to generate certificate" colorized_echo red " Please check that openssl is installed and you have write permissions." exit 1 fi } # Read multiline content or copy a file path into a target file. read_and_save_file() { local prompt_message=$1 local output_file=$2 local allow_file_input=$3 local first_line_read=0 # Check if the file exists before clearing it if [ -f "$output_file" ]; then : >"$output_file" fi colorized_echo cyan "$prompt_message" colorized_echo yellow "Press ENTER on a new line when finished: " while IFS= read -r line; do [[ -z $line ]] && break if [[ "$first_line_read" -eq 0 && "$allow_file_input" -eq 1 && -f "$line" ]]; then first_line_read=1 colorized_echo cyan " Detected file path, copying: $line" cp "$line" "$output_file" break fi echo "$line" >>"$output_file" done } # Replace the image tag of the node service in a compose file (e.g. :latest -> :dev). # Only a tag after the last path segment is replaced (an untagged image gets one), # so a registry port such as registry:5000/pasarguard/node is kept. Needs mikefarah yq v4. # Arguments: # $1 - Image tag (a release tag such as v0.5.4, or dev). # $2 - Path to the docker-compose.yml to edit in place. set_node_image_tag() { local tag="$1" local compose_file="$2" yq eval ".services[\"node\"].image = (.services[\"node\"].image | sub(\"(:[^/:]*)?$\"; \":${tag}\"))" -i "$compose_file" } # Point a fresh install's compose file at the requested image tag. latest is the # downloaded default, so nothing is rewritten; any other tag must be set, or the # install would finish on the wrong image, so a failed rewrite stops the install. # Arguments: # $1 - Requested node version (latest, dev or a release tag). # $2 - Path to the docker-compose.yml to edit in place. set_install_image_tag() { local node_version="$1" local compose_file="$2" [ "$node_version" = "latest" ] && return 0 colorized_echo cyan " Command: yq eval ...image = ...:${node_version}..." if set_node_image_tag "$node_version" "$compose_file"; then colorized_echo green " ✓ Docker image version set to: ${node_version}" else colorized_echo red " ✗ Failed to set the node image tag to ${node_version} (mikefarah yq v4 is required)" exit 1 fi } # Download compose files, set up certificates, configure .env, and deploy the node. install_node() { local node_version=$1 FILES_URL_PREFIX="https://git.nimbaha.org/pgnode/raw/PasarGuard/node/main" COMPOSE_FILES_URL_PREFIX="https://git.nimbaha.org/pgnode/raw/PasarGuard/scripts/main/docker-compose" colorized_echo blue "Creating directories..." colorized_echo cyan " Command: mkdir -p $DATA_DIR $DATA_DIR/certs $APP_DIR" mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR/certs" # The certs dir holds the TLS private key; keep it owner-only. chmod 700 "$DATA_DIR/certs" 2>/dev/null || true mkdir -p "$APP_DIR" colorized_echo green " ✓ Directories created" colorized_echo cyan "" colorized_echo yellow "A self-signed certificate will be generated by default." if [ "${INSTALL_SELF_SIGNED:-false}" = true ]; then use_public_cert="" elif [ -n "${INSTALL_CERT_PATH:-}" ] && [ -n "${INSTALL_KEY_PATH:-}" ]; then use_public_cert="y" elif [ "$AUTO_CONFIRM" = true ]; then use_public_cert="" else read -r -p "Do you want to use your own public certificate instead? (Y/n): " use_public_cert fi if [[ "$use_public_cert" =~ ^[Yy]$ ]]; then if [ -n "${INSTALL_CERT_PATH:-}" ] && [ -f "$INSTALL_CERT_PATH" ]; then cp "$INSTALL_CERT_PATH" "$SSL_CERT_FILE" colorized_echo blue "Certificate copied to $SSL_CERT_FILE" else read_and_save_file "Please paste the content OR the path to the Client Certificate file." "$SSL_CERT_FILE" 1 colorized_echo blue "Certificate saved to $SSL_CERT_FILE" fi if [ -n "${INSTALL_KEY_PATH:-}" ] && [ -f "$INSTALL_KEY_PATH" ]; then cp "$INSTALL_KEY_PATH" "$SSL_KEY_FILE" colorized_echo blue "Private key copied to $SSL_KEY_FILE" else read_and_save_file "Please paste the content OR the path to the Private Key file." "$SSL_KEY_FILE" 1 colorized_echo blue "Private key saved to $SSL_KEY_FILE" fi # cp/paste create the key with the default umask (0644); restrict it. harden_secret_file "$SSL_KEY_FILE" else gen_self_signed_cert colorized_echo blue "self-signed certificate successfully generated" fi if [ -n "${INSTALL_API_KEY:-}" ]; then API_KEY="$INSTALL_API_KEY" elif [ "$AUTO_CONFIRM" = true ]; then API_KEY="" else read -p "Enter your API Key (must be a valid UUID (any version), leave blank to auto-generate): " -r API_KEY fi if [[ -z "$API_KEY" ]]; then # Generate a valid UUIDv4 API_KEY=$(generate_uuid_v4) colorized_echo green "No API Key provided. A random UUID version 4 has been generated" fi if [ "${INSTALL_USE_REST:-}" = "true" ]; then USE_REST=1 elif [ "${INSTALL_USE_REST:-}" = "false" ]; then USE_REST=0 else if [ "$AUTO_CONFIRM" = true ]; then use_rest="" else read -p "GRPC is recommended by default. Do you want to use REST protocol instead? (y/N): " -r use_rest fi # Default to GRPC (the recommended default) when the user just presses ENTER if [[ "$use_rest" =~ ^[Yy]$ ]]; then USE_REST=1 else USE_REST=0 fi fi get_occupied_ports if [ -n "${INSTALL_SERVICE_PORT:-}" ]; then SERVICE_PORT="$INSTALL_SERVICE_PORT" if is_port_occupied "$SERVICE_PORT"; then colorized_echo red "Port $SERVICE_PORT is already in use." exit 1 fi if ! [[ "$SERVICE_PORT" -ge 1 && "$SERVICE_PORT" -le 65535 ]]; then colorized_echo red "Invalid port. Please enter a port between 1 and 65535." exit 1 fi elif [ "$AUTO_CONFIRM" = true ]; then SERVICE_PORT=62050 if is_port_occupied "$SERVICE_PORT"; then colorized_echo red "Port $SERVICE_PORT is already in use. Run without -y to choose another port." exit 1 fi else # Prompt user to enter the service port, ensuring the selected port is not already in use while true; do read -p "Enter the SERVICE_PORT (default 62050): " -r SERVICE_PORT if [[ -z "$SERVICE_PORT" ]]; then SERVICE_PORT=62050 fi if [[ "$SERVICE_PORT" -ge 1 && "$SERVICE_PORT" -le 65535 ]]; then if is_port_occupied "$SERVICE_PORT"; then colorized_echo red "Port $SERVICE_PORT is already in use. Please enter another port." else break fi else colorized_echo red "Invalid port. Please enter a port between 1 and 65535." fi done fi colorized_echo blue "Fetching .env and compose file" colorized_echo cyan " Command: curl -fsL $FILES_URL_PREFIX/.env.example -o $APP_DIR/.env" # Pre-create .env as 0600 (and tighten any pre-existing copy) so the node # API_KEY written below is never world-readable. harden_secret_file "$APP_DIR/.env" if curl -fsL "$FILES_URL_PREFIX/.env.example" -o "$APP_DIR/.env"; then colorized_echo green " ✓ File saved: $APP_DIR/.env" else colorized_echo red " ✗ Failed to download .env.example" exit 1 fi colorized_echo cyan " Command: curl -fsL $COMPOSE_FILES_URL_PREFIX/node.yml -o $APP_DIR/docker-compose.yml" if curl -fsL "$COMPOSE_FILES_URL_PREFIX/node.yml" -o "$APP_DIR/docker-compose.yml"; then colorized_echo green " ✓ File saved: $APP_DIR/docker-compose.yml" else colorized_echo red " ✗ Failed to download node.yml" exit 1 fi # Modifying .env file sed -i "s/^SERVICE_PORT *= *.*/SERVICE_PORT= ${SERVICE_PORT}/" "$APP_DIR/.env" sed -i "s/^API_KEY *= *.*/API_KEY= ${API_KEY}/" "$APP_DIR/.env" if [ "$USE_REST" -eq 1 ]; then sed -i 's/^# \(SERVICE_PROTOCOL *=.*\)/SERVICE_PROTOCOL= "rest"/' "$APP_DIR/.env" else sed -i 's/^# \(SERVICE_PROTOCOL *=.*\)/SERVICE_PROTOCOL= "grpc"/' "$APP_DIR/.env" fi colorized_echo green ".env file modified successfully" # Modifying compose file colorized_echo blue "Modifying docker-compose.yml..." service_name="node" if [ "$APP_NAME" != "pg-node" ]; then colorized_echo cyan " Command: yq eval ...container_name = \"$APP_NAME\"..." if yq eval ".services[\"$service_name\"].container_name = \"$APP_NAME\"" -i "$APP_DIR/docker-compose.yml" 2>/dev/null; then colorized_echo green " ✓ Container name set to: $APP_NAME" else colorized_echo yellow " ⚠ Failed to set container name (may not be critical)" fi fi container_path="" existing_volume=$(yq eval -r ".services[\"$service_name\"].volumes[0]" "$APP_DIR/docker-compose.yml" 2>/dev/null) if [ -n "$existing_volume" ] && [ "$existing_volume" != "null" ]; then # Extract container path (everything after the colon) if [[ "$existing_volume" == *:* ]]; then container_path="${existing_volume#*:}" else # If no colon found, use the existing volume as container path container_path="$existing_volume" fi fi # For custom names, keep host/container paths aligned to the APP_NAME data dir if [ "$APP_NAME" != "pg-node" ] || [ -z "$container_path" ]; then container_path="$DATA_DIR" fi colorized_echo cyan " Command: yq eval ...volumes[0] = \"${DATA_DIR}:${container_path}\"..." if yq eval ".services[\"$service_name\"].volumes[0] = \"${DATA_DIR}:${container_path}\"" -i "$APP_DIR/docker-compose.yml" 2>/dev/null; then colorized_echo green " ✓ Volume path configured: ${DATA_DIR}:${container_path}" else colorized_echo yellow " ⚠ Failed to configure volume (may not be critical)" fi # Keep SSL paths in .env aligned with the mapped volume (important for node-serviced on host) ssl_cert_env="${container_path}/certs/ssl_cert.pem" ssl_key_env="${container_path}/certs/ssl_key.pem" sed -i "s|^SSL_CERT_FILE *=.*|SSL_CERT_FILE= ${ssl_cert_env}|" "$APP_DIR/.env" sed -i "s|^SSL_KEY_FILE *=.*|SSL_KEY_FILE= ${ssl_key_env}|" "$APP_DIR/.env" set_install_image_tag "$node_version" "$APP_DIR/docker-compose.yml" if ! _load_node_image "$node_version"; then colorized_echo red "✗ Could not prepare the node image — see the error above." colorized_echo red " If the version is missing from the mirror, check https://git.nimbaha.org/pgnode/versions.json" exit 1 fi # Never let compose reach for a registry on an offline host. if command -v yq >/dev/null 2>&1; then yq eval '.services.node.pull_policy = "never"' -i "$APP_DIR/docker-compose.yml" 2>/dev/null || true fi # Final sync to ensure env has the correct SSL paths for custom names sync_env_ssl_paths colorized_echo green "✓ docker-compose.yml modified successfully" } # Remove the pg-node script from /usr/local/bin. uninstall_node_script() { if [ -f "/usr/local/bin/$APP_NAME" ]; then colorized_echo yellow "Removing node script" rm "/usr/local/bin/$APP_NAME" fi } # Remove the node-serviced binary from /usr/local/bin. uninstall_node_service_script() { set_service_paths if [ -f "$SERVICE_BINARY_PATH" ]; then colorized_echo yellow "Removing node-serviced binary" rm "$SERVICE_BINARY_PATH" fi } # Remove the node application directory. uninstall_node() { if [ -d "$APP_DIR" ]; then colorized_echo yellow "Removing directory: $APP_DIR" rm -r "$APP_DIR" fi } # Remove unused pasarguard/node Docker images. uninstall_node_docker_images() { local images images=$(docker images --format '{{.Repository}} {{.ID}}' | awk '$1 ~ /^pasarguard\/node(:|$)/ {print $2}' | sort -u) if [ -z "$images" ]; then colorized_echo yellow "pasarguard/node images not found" return 0 fi colorized_echo yellow "Checking pasarguard/node images for removal..." for image in $images; do if docker ps -a --filter "ancestor=$image" -q | grep -q .; then local container container=$(docker ps -a --filter "ancestor=$image" --format '{{.Names}}' | tr '\n' ' ') colorized_echo yellow "Skipping image $image (still used by: $container)" continue fi if docker rmi "$image" >/dev/null 2>&1; then colorized_echo yellow "Image $image removed" else colorized_echo yellow "Failed to remove image $image" fi done } # Remove the node data and certificates directory. uninstall_node_data_files() { if [ -d "$DATA_DIR" ]; then colorized_echo yellow "Removing directory: $DATA_DIR" rm -r "$DATA_DIR" fi } # Start node Docker Compose services in background. up_node() { compose_up } # Stop and remove node Docker Compose containers. down_node() { compose_down } # Display node Docker Compose logs without following. show_node_logs() { compose_logs } # Follow node Docker Compose logs in real time. follow_node_logs() { compose_logs_follow } # Update shared libraries and the node script from the remote repository. update_node_script() { colorized_echo blue "Updating node script" local backup_dir backup_dir=$(backup_scripts) if ! install_shared_libs_from_repo "$FETCH_REPO" common.sh system.sh docker.sh github.sh; then colorized_echo red "Failed to update shared libraries. Restoring from backup..." restore_scripts "$backup_dir" cleanup_backup "$backup_dir" exit 1 fi if ! github_install_script_from_repo "$FETCH_REPO" "pg-node.sh" "$APP_NAME"; then colorized_echo red "Failed to update node script. Restoring from backup..." restore_scripts "$backup_dir" cleanup_backup "$backup_dir" exit 1 fi cleanup_backup "$backup_dir" colorized_echo green "node script updated successfully" } # Pull latest Docker images for the node services. update_node() { # Offline mirror: `compose pull` always contacts the registry, so reload the # bundled image instead and only fall back to a real pull when it is absent. if ! _load_node_image "${1:-latest}"; then colorized_echo yellow "No bundled image available; falling back to a registry pull" $COMPOSE -f $COMPOSE_FILE -p "$APP_NAME" pull fi } # Check if node application directory exists. is_node_installed() { if [ -d $APP_DIR ]; then return 0 else return 1 fi } # Verify that the environment file exists. ensure_env_exists() { if [ ! -f "$ENV_FILE" ]; then colorized_echo red "Environment file not found at $ENV_FILE. Please install the node first." exit 1 fi } # Synchronize SSL certificate and key paths in .env with custom APP_NAME. sync_env_ssl_paths() { # Adjust SSL_CERT_FILE/SSL_KEY_FILE in .env if a custom APP_NAME still points to the default pg-node path if [ "$APP_NAME" = "pg-node" ]; then return fi if [ ! -f "$ENV_FILE" ]; then return fi local desired_cert="${DATA_DIR}/certs/ssl_cert.pem" local desired_key="${DATA_DIR}/certs/ssl_key.pem" local current_cert current_key updated=false current_cert=$(grep -E '^[[:space:]]*SSL_CERT_FILE[[:space:]]*=' "$ENV_FILE" | head -n1 | sed "s/^[[:space:]]*SSL_CERT_FILE[[:space:]]*=[[:space:]]*//;s/[\"']//g") current_key=$(grep -E '^[[:space:]]*SSL_KEY_FILE[[:space:]]*=' "$ENV_FILE" | head -n1 | sed "s/^[[:space:]]*SSL_KEY_FILE[[:space:]]*=[[:space:]]*//;s/[\"']//g") if [[ -z "$current_cert" || "$current_cert" =~ /var/lib/pg-node/ ]]; then sed -i "s|^[[:space:]]*SSL_CERT_FILE[[:space:]]*=.*|SSL_CERT_FILE= ${desired_cert}|" "$ENV_FILE" grep -q '^[[:space:]]*SSL_CERT_FILE[[:space:]]*=' "$ENV_FILE" || echo "SSL_CERT_FILE= ${desired_cert}" >>"$ENV_FILE" updated=true fi if [[ -z "$current_key" || "$current_key" =~ /var/lib/pg-node/ ]]; then sed -i "s|^[[:space:]]*SSL_KEY_FILE[[:space:]]*=.*|SSL_KEY_FILE= ${desired_key}|" "$ENV_FILE" grep -q '^[[:space:]]*SSL_KEY_FILE[[:space:]]*=' "$ENV_FILE" || echo "SSL_KEY_FILE= ${desired_key}" >>"$ENV_FILE" updated=true fi if [ "$updated" = true ]; then colorized_echo cyan "Updated SSL file paths in $ENV_FILE to match APP_NAME ($APP_NAME)." fi } # Check if node Docker containers are created or running. is_node_up() { if [ -z "$($COMPOSE -f $COMPOSE_FILE ps -q -a)" ]; then return 1 else return 0 fi } # Check that a node image version exists as a GitHub release of PasarGuard/node. # Arguments: # $1 - latest, pre-release, dev, or a release tag such as v0.5.4. # Globals: # node_version - for pre-release, set to the newer of the latest stable and latest pre-release tags. # Returns: # 0 if the version can be used, 1 otherwise. check_node_version_exists() { local version=$1 local repo_url="https://git.nimbaha.org/pgnode/api/repos/PasarGuard/node/releases" local repo_list_url="https://git.nimbaha.org/pgnode/api/_node_releases.json" # dev is the Docker image built from the node dev branch, not a GitHub release if [ "$version" == "dev" ]; then return 0 fi if [ "$version" == "latest" ]; then local latest_tag=$(curl -s ${repo_url}/latest | jq -r '.tag_name') # Check if there is any stable release of node v1 if [ "$latest_tag" == "null" ]; then return 1 fi return 0 fi if [ "$version" == "pre-release" ]; then local latest_stable_tag=$(curl -s "$repo_url/latest" | jq -r '.tag_name') local latest_pre_release_tag=$(curl -s "$repo_list_url" | jq -r '[.[] | select(.prerelease == true)][0].tag_name') # An unreachable or rate-limited API leaves these empty; treat that like "no release" [ -n "$latest_stable_tag" ] || latest_stable_tag="null" [ -n "$latest_pre_release_tag" ] || latest_pre_release_tag="null" if [ "$latest_stable_tag" == "null" ] && [ "$latest_pre_release_tag" == "null" ]; then return 1 # No releases found at all elif [ "$latest_stable_tag" == "null" ]; then node_version=$latest_pre_release_tag elif [ "$latest_pre_release_tag" == "null" ]; then node_version=$latest_stable_tag else # sort -V puts v1.0.0-rc.1 after v1.0.0, which would pick an older release # candidate over the stable release it led to. Compare the base versions # (the part before the first "-") instead: a pre-release wins only when its # base is newer, so the result does not depend on which sort -V is installed. local stable_base="${latest_stable_tag%%-*}" local pre_release_base="${latest_pre_release_tag%%-*}" local newest_base newest_base=$(printf "%s\n" "$stable_base" "$pre_release_base" | sort -V | tail -n 1) if [ "$pre_release_base" != "$stable_base" ] && [ "$newest_base" = "$pre_release_base" ]; then node_version=$latest_pre_release_tag else node_version=$latest_stable_tag fi fi return 0 fi # Check if the repos contains the version tag # Offline mirror: only the mirrored tags exist locally, so do not probe # the API for an arbitrary tag. _load_node_image fails with a clear message # if the requested version was not mirrored. if true; then return 0 else return 1 fi } # Validate node_version (format, then GitHub release) and print why it cannot be used. # Globals: # node_version - the requested version; pre-release is resolved to a release tag. # Returns: # 0 if the version can be used, 1 otherwise. validate_node_version() { if ! [[ "$node_version" == "latest" || "$node_version" == "pre-release" || "$node_version" == "dev" || "$node_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then colorized_echo red "✗ Invalid version format. Please enter a valid version (e.g. v1.0.0)" return 1 fi if ! check_node_version_exists "$node_version"; then colorized_echo red "✗ Version $node_version does not exist. Please enter a valid version (e.g. v0.1.2)" return 1 fi # Never hand an empty or malformed resolved tag to the compose rewrite local release_tag_regex='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' if ! [[ "$node_version" == "latest" || "$node_version" == "dev" || "$node_version" =~ $release_tag_regex ]]; then colorized_echo red "✗ Could not resolve a node release tag (got '$node_version'). Check that api.github.com is reachable." return 1 fi return 0 } # Warn that the dev image is an unreleased build and how to leave it. print_dev_image_note() { colorized_echo yellow "Note: pasarguard/node:dev is an unreleased build of the node dev branch, meant for testing only." colorized_echo yellow " '$APP_NAME update' pulls the newest dev build; '$APP_NAME update --version latest' returns to the latest release." } # Execute node installation workflow with option parsing and setup. install_command() { check_running_as_root print_script_execution_header "pg-node" "$SCRIPT_COMMIT_SHA" "install" # Default values node_version="latest" node_version_set="false" # Parse options while [[ $# -gt 0 ]]; do key="$1" case $key in -v | --version) if [[ "$node_version_set" == "true" ]]; then colorized_echo red "Error: Cannot use --pre-release, --dev and --version options simultaneously." exit 1 fi node_version="$2" node_version_set="true" shift 2 ;; --pre-release) if [[ "$node_version_set" == "true" ]]; then colorized_echo red "Error: Cannot use --pre-release, --dev and --version options simultaneously." exit 1 fi node_version="pre-release" node_version_set="true" shift ;; --dev) if [[ "$node_version_set" == "true" ]]; then colorized_echo red "Error: Cannot use --pre-release, --dev and --version options simultaneously." exit 1 fi node_version="dev" node_version_set="true" shift ;; --name) # --name is handled globally; ignore here to prevent unknown option errors shift 2 ;; --override) INSTALL_OVERRIDE=true shift ;; --api-key) INSTALL_API_KEY="$2" shift 2 ;; --use-rest) INSTALL_USE_REST=true shift ;; --use-grpc) INSTALL_USE_REST=false shift ;; --service-port) INSTALL_SERVICE_PORT="$2" shift 2 ;; --cert-path) INSTALL_CERT_PATH="$2" shift 2 ;; --key-path) INSTALL_KEY_PATH="$2" shift 2 ;; --self-signed) INSTALL_SELF_SIGNED=true shift ;; --api-port) INSTALL_API_PORT="$2" shift 2 ;; --install-service) INSTALL_SERVICE_CHOICE="y" shift ;; --no-install-service) INSTALL_SERVICE_CHOICE="n" shift ;; --san-entries) INSTALL_SAN_ENTRIES="$2" shift 2 ;; *) echo "Unknown option: $1" exit 1 ;; esac done # Check if node is already installed if is_node_installed; then colorized_echo red "node is already installed at $APP_DIR" if [ "${INSTALL_OVERRIDE:-false}" = true ] || [ "$AUTO_CONFIRM" = true ]; then REPLY="y" else read -p "Do you want to override the previous installation? (y/n) " fi if [[ ! $REPLY =~ ^[Yy]$ ]]; then colorized_echo red "Aborted installation" exit 1 fi fi detect_os if ! command -v jq >/dev/null 2>&1; then install_package jq fi if ! command -v curl >/dev/null 2>&1; then install_package curl fi if ! command -v docker >/dev/null 2>&1; then install_docker fi ensure_docker_compose if ! command -v yq >/dev/null 2>&1; then install_yq fi detect_compose # Check if the version is valid and exists if ! validate_node_version; then exit 1 fi colorized_echo cyan "================================" colorized_echo cyan "Installing PasarGuard Node" colorized_echo cyan "Version: $node_version" colorized_echo cyan "================================" if [ "$node_version" == "dev" ]; then print_dev_image_note fi install_node "$node_version" colorized_echo green "✓ Node installation completed for version: $node_version" install_node_script install_completion up_node show_node_logs local install_service_choice="" if [ -n "${INSTALL_SERVICE_CHOICE:-}" ]; then install_service_choice="$INSTALL_SERVICE_CHOICE" elif [ "$AUTO_CONFIRM" = true ]; then install_service_choice="y" else read -p "Do you want to install and start the systemd service for $APP_NAME? (Y/n): " install_service_choice fi if [[ -z "$install_service_choice" || "$install_service_choice" =~ ^[Yy]$ ]]; then install_service_command else colorized_echo yellow "Skipped installing systemd service for $APP_NAME." fi colorized_echo blue "================================" colorized_echo magenta " node is set up with the following IP: $NODE_IP and Port: $SERVICE_PORT." colorized_echo magenta "Please use the following Certificate in pasarguard Panel (it's located in ${DATA_DIR}/certs):" cat "$SSL_CERT_FILE" colorized_echo blue "================================" colorized_echo magenta "Next, use the API Key (UUID v4) in pasarguard Panel: " colorized_echo red "${API_KEY}" } # Uninstall node containers, configuration, scripts, and optionally data directories. uninstall_command() { check_running_as_root # Check if node is installed if ! is_node_installed; then colorized_echo red "node not installed!" exit 1 fi if [ "$AUTO_CONFIRM" = true ]; then REPLY="y" else read -p "Do you really want to uninstall node? (y/n) " fi if [[ ! $REPLY =~ ^[Yy]$ ]]; then colorized_echo red "Aborted" exit 1 fi detect_compose if is_node_up; then down_node fi if service_installed; then uninstall_service_command fi uninstall_completion uninstall_node_script uninstall_node uninstall_node_docker_images if [ "$AUTO_CONFIRM" = true ]; then REPLY="y" else read -p "Do you want to remove node data files too ($DATA_DIR)? (y/n) " fi if [[ ! $REPLY =~ ^[Yy]$ ]]; then colorized_echo green "node uninstalled successfully" else uninstall_node_data_files colorized_echo green "node uninstalled successfully" fi } # Start node services and optionally stream container logs. up_command() { # Display help message for up command options. help() { colorized_echo red "Usage: node up [options]" echo "" echo "OPTIONS:" echo " -h, --help display this help message" echo " -n, --no-logs do not follow logs after starting" } local no_logs=false while [[ "$#" -gt 0 ]]; do case "$1" in -n | --no-logs) no_logs=true ;; -h | --help) help exit 0 ;; *) echo "Error: Invalid option: $1" >&2 help exit 0 ;; esac shift done # Check if node is installed if ! is_node_installed; then colorized_echo red "node's not installed!" exit 1 fi detect_compose if is_node_up; then colorized_echo red "node's already up" exit 1 fi up_node if [ "$no_logs" = false ]; then follow_node_logs fi } # Stop running node services. down_command() { # Check if node is installed if ! is_node_installed; then colorized_echo red "node not installed!" exit 1 fi detect_compose if ! is_node_up; then colorized_echo red "node already down" exit 1 fi down_node } # Restart node services and restart systemd service if present. restart_command() { # Display help message for restart command options. help() { colorized_echo red "Usage: node restart [options]" echo echo "OPTIONS:" echo " -h, --help display this help message" echo " -n, --no-logs do not follow logs after starting" echo " --no-restart-service do not restart the systemd service (if installed)" } local no_logs=false local no_restart_service=false while [[ "$#" -gt 0 ]]; do case "$1" in -n | --no-logs) no_logs=true ;; --no-restart-service) no_restart_service=true ;; -h | --help) help exit 0 ;; *) echo "Error: Invalid option: $1" >&2 help exit 1 ;; esac shift done # Check if node is installed if ! is_node_installed; then colorized_echo red "node not installed!" exit 1 fi detect_compose down_node up_node if [ "$no_restart_service" = false ]; then restart_service_if_installed else colorized_echo yellow "Skipped restarting $SERVICE_NAME (due to --no-restart-service)" fi if [ "$no_logs" = false ]; then follow_node_logs fi } # Configure, install, and start the node systemd service unit. install_service_command() { check_running_as_root require_systemd set_service_paths while [[ $# -gt 0 ]]; do case "$1" in --api-port) INSTALL_API_PORT="$2" shift 2 ;; *) echo "Unknown option: $1" exit 1 ;; esac done detect_os if ! command -v jq >/dev/null 2>&1; then install_package jq fi if ! is_node_installed; then colorized_echo red "node not installed! Install it before setting up the service." exit 1 fi ensure_env_exists sync_env_ssl_paths get_occupied_ports local api_port existing_api_port="" local default_api_port=62051 if existing_api_port=$(grep -E '^API_PORT[[:space:]]*=' "$ENV_FILE" | head -n1 | sed 's/^API_PORT[[:space:]]*=[[:space:]]*//'); then existing_api_port=$(echo "$existing_api_port" | tr -d '"'\') fi if [[ "$existing_api_port" =~ ^[0-9]+$ ]] && [ "$existing_api_port" -ge 1 ] && [ "$existing_api_port" -le 65535 ]; then colorized_echo blue "Existing API_PORT found in $ENV_FILE: $existing_api_port" default_api_port="$existing_api_port" fi if [ -n "${INSTALL_API_PORT:-}" ]; then api_port="$INSTALL_API_PORT" if is_port_occupied "$api_port"; then colorized_echo red "Port $api_port is already in use." exit 1 fi if ! [[ "$api_port" =~ ^[0-9]+$ && "$api_port" -ge 1 && "$api_port" -le 65535 ]]; then colorized_echo red "Invalid port. Please enter a port between 1 and 65535." exit 1 fi elif [ "$AUTO_CONFIRM" = true ]; then api_port="$default_api_port" if is_port_occupied "$api_port"; then colorized_echo red "Port $api_port is already in use. Run without -y to choose another port." exit 1 fi else while true; do read -p "Enter the API_PORT for node service (default ${default_api_port}): " -r api_port if [[ -z "$api_port" ]]; then api_port="$default_api_port" fi if [[ "$api_port" =~ ^[0-9]+$ && "$api_port" -ge 1 && "$api_port" -le 65535 ]]; then if is_port_occupied "$api_port"; then colorized_echo red "Port $api_port is already in use. Please enter another port." else break fi else colorized_echo red "Invalid port. Please enter a port between 1 and 65535." fi done fi local api_port_comment="# API_PORT is used by the node service API ($APP_NAME)" if grep -q '^API_PORT[[:space:]]*=' "$ENV_FILE"; then sed -i "s/^API_PORT[[:space:]]*=.*/API_PORT= ${api_port}/" "$ENV_FILE" if ! grep -q '^# *API_PORT' "$ENV_FILE"; then sed -i "/^API_PORT[[:space:]]*=.*/i ${api_port_comment}" "$ENV_FILE" fi else { echo "" echo "$api_port_comment" echo "API_PORT= ${api_port}" } >>"$ENV_FILE" fi colorized_echo magenta "API_PORT selected: ${api_port}" configure_firewall_for_port "$api_port" "tcp" install_node_service_script colorized_echo blue "Creating systemd unit at $SERVICE_UNIT" cat >"$SERVICE_UNIT" </dev/null 2>&1 || true systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true if [ -f "$SERVICE_UNIT" ]; then colorized_echo yellow "Removing systemd unit $SERVICE_UNIT" rm "$SERVICE_UNIT" fi uninstall_node_service_script systemctl daemon-reload colorized_echo green "$SERVICE_NAME service uninstalled." } # Start the node systemd service unit. service_start_command() { check_running_as_root require_systemd if ! service_installed; then colorized_echo red "Service not installed. Run service-install first." exit 1 fi systemctl start "$SERVICE_NAME" colorized_echo green "$SERVICE_NAME service started." } # Stop the running node systemd service unit. service_stop_command() { check_running_as_root require_systemd if ! service_installed; then colorized_echo red "Service not installed. Run service-install first." exit 1 fi systemctl stop "$SERVICE_NAME" colorized_echo green "$SERVICE_NAME service stopped." } # Update the node-serviced binary and restart the systemd service. service_update_command() { check_running_as_root require_systemd if ! service_installed; then colorized_echo red "Service not installed. Run service-install first." exit 1 fi install_node_service_script systemctl daemon-reload systemctl restart "$SERVICE_NAME" colorized_echo green "$SERVICE_NAME service updated and restarted." } # Display or follow journalctl logs for the node systemd service. service_logs_command() { require_systemd if ! service_installed; then colorized_echo red "Service not installed. Run service-install first." exit 1 fi local no_follow=false while [[ "$#" -gt 0 ]]; do case "$1" in -n | --no-follow) no_follow=true ;; -h | --help) colorized_echo red "Usage: $APP_NAME service-logs [options]" echo " -n, --no-follow Show logs without following" exit 0 ;; *) echo "Error: Invalid option: $1" >&2 exit 1 ;; esac shift done if [ "$no_follow" = true ]; then journalctl -u "$SERVICE_NAME" --no-pager else journalctl -u "$SERVICE_NAME" -f fi } # Restart the node systemd service. restart_service_command() { check_running_as_root require_systemd if ! service_installed; then colorized_echo red "Service not installed. Run service-install first." exit 1 fi restart_service_if_installed } # Display status of the node systemd service via systemctl. status_service_command() { require_systemd if ! service_installed; then colorized_echo red "Service not installed. Run service-install first." exit 1 fi systemctl status --no-pager "$SERVICE_NAME" } # Display status and individual container states of the node services. status_command() { # Check if node is installed if ! is_node_installed; then echo -n "Status: " colorized_echo red "Not Installed" exit 1 fi detect_compose if ! is_node_up; then echo -n "Status: " colorized_echo blue "Down" exit 1 fi echo -n "Status: " colorized_echo green "Up" json=$($COMPOSE -f $COMPOSE_FILE ps -a --format=json) services=$(echo "$json" | jq -r 'if type == "array" then .[] else . end | .Service') states=$(echo "$json" | jq -r 'if type == "array" then .[] else . end | .State') # Print out the service names and statuses for i in $(seq 0 $(expr $(echo $services | wc -w) - 1)); do service=$(echo $services | cut -d' ' -f $(expr $i + 1)) state=$(echo $states | cut -d' ' -f $(expr $i + 1)) echo -n "- $service: " if [ "$state" == "running" ]; then colorized_echo green $state else colorized_echo red $state fi done } # Display or follow Docker Compose logs for the node. logs_command() { # Display help message for logs command options. help() { colorized_echo red "Usage: node logs [options]" echo "" echo "OPTIONS:" echo " -h, --help display this help message" echo " -n, --no-follow do not show follow logs" } local no_follow=false while [[ "$#" -gt 0 ]]; do case "$1" in -n | --no-follow) no_follow=true ;; -h | --help) help exit 0 ;; *) echo "Error: Invalid option: $1" >&2 help exit 0 ;; esac shift done # Check if node is installed if ! is_node_installed; then colorized_echo red "node's not installed!" exit 1 fi detect_compose if ! is_node_up; then colorized_echo red "node is not up." exit 1 fi if [ "$no_follow" = true ]; then show_node_logs else follow_node_logs fi } # Update node script, completions, container images, and restart services. update_command() { check_running_as_root local no_update_service=false # Image tag to switch to (--dev or --version); empty keeps the tag in the compose file local node_version="" local node_version_set="false" # Parse args while [[ "$#" -gt 0 ]]; do case "$1" in --no-update-service) no_update_service=true shift ;; --dev) if [[ "$node_version_set" == "true" ]]; then colorized_echo red "Error: Cannot use --dev and --version options simultaneously." exit 1 fi node_version="dev" node_version_set="true" shift ;; -v | --version) if [[ "$node_version_set" == "true" ]]; then colorized_echo red "Error: Cannot use --dev and --version options simultaneously." exit 1 fi if [[ -z "${2:-}" ]]; then colorized_echo red "Error: --version requires a value." exit 1 fi node_version="$2" node_version_set="true" shift 2 ;; *) break ;; esac done # Check if node is installed if ! is_node_installed; then colorized_echo red "node not installed!" exit 1 fi if [ "$node_version_set" = true ]; then if ! command -v jq >/dev/null 2>&1; then detect_os install_package jq fi if ! command -v yq >/dev/null 2>&1; then install_yq fi if ! validate_node_version; then exit 1 fi fi detect_compose update_node_script uninstall_completion install_completion if [ "$node_version_set" = true ]; then # Only the image tag changes; .env, the API key and certificates stay as they are colorized_echo blue "Switching node image to: $node_version" if [ "$node_version" == "dev" ]; then print_dev_image_note fi if ! set_node_image_tag "$node_version" "$COMPOSE_FILE"; then colorized_echo red "✗ Failed to set the node image tag in $COMPOSE_FILE (mikefarah yq v4 is required)" exit 1 fi colorized_echo green "✓ Docker image version set to: $node_version" fi colorized_echo blue "Pulling latest version" update_node colorized_echo blue "Restarting node services" down_node up_node if [ "$no_update_service" = false ]; then update_service_if_installed else colorized_echo yellow "Skipped updating $SERVICE_NAME (due to --no-update-service)" fi colorized_echo blue "node updated successfully" } # Function to update the Xray core get_xray_core() { local requested_version="${1:-}" identify_the_operating_system_and_architecture if ! command -v curl >/dev/null 2>&1; then colorized_echo yellow "curl is required. Attempting to install curl." detect_os install_package curl fi # Systemd/non-TTY environments may not have TERM set; ignore clear failures to avoid exiting under set -e safe_clear() { clear 2>/dev/null || true; } safe_clear # Validate whether a specified Xray-core version exists on GitHub. validate_version() { local version="$1" local response local curl_exit_code # Use curl with timeout and error handling response=$(curl -s --max-time 10 --connect-timeout 5 "https://git.nimbaha.org/pgnode/api/repos/XTLS/Xray-core/releases/tags/$version" 2>&1) curl_exit_code=$? # Check if curl failed (network error, timeout, etc.) if [ $curl_exit_code -ne 0 ] || [ -z "$response" ]; then echo -e "\033[1;31mError: Failed to validate version. Network error or GitHub API unavailable.\033[0m" >&2 echo "network_error" return fi # Check if version exists if echo "$response" | grep -q '"message": "Not Found"'; then echo "invalid" else echo "valid" fi } # Display the interactive selection menu for available Xray-core versions. print_menu() { safe_clear echo -e "\033[1;32m==============================\033[0m" echo -e "\033[1;32m Xray-core Installer \033[0m" echo -e "\033[1;32m==============================\033[0m" current_version=$(get_current_xray_core_version) echo -e "\033[1;33m>>>> Current Xray-core version: \033[1;1m$current_version\033[0m" echo -e "\033[1;32m==============================\033[0m" echo -e "\033[1;33mAvailable Xray-core versions:\033[0m" for ((i = 0; i < ${#versions[@]}; i++)); do echo -e "\033[1;34m$((i + 1)):\033[0m ${versions[i]}" done echo -e "\033[1;32m==============================\033[0m" echo -e "\033[1;35mM:\033[0m Enter a version manually" echo -e "\033[1;31mQ:\033[0m Quit" echo -e "\033[1;32m==============================\033[0m" } latest_releases=$(curl -s "https://git.nimbaha.org/pgnode/api/repos/XTLS/Xray-core/releases?per_page=$LAST_XRAY_CORES") versions=($(echo "$latest_releases" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')) if [ ${#versions[@]} -eq 0 ]; then echo -e "\033[1;31mNo Xray-core releases found.\033[0m" exit 1 fi if [[ -n "$requested_version" ]]; then if [[ "$requested_version" == "latest" ]]; then selected_version=${versions[0]} else local validation_result validation_result=$(validate_version "$requested_version") if [ "$validation_result" == "valid" ]; then selected_version="$requested_version" elif [ "$validation_result" == "network_error" ]; then echo -e "\033[1;31mError: Failed to validate version due to network error. Please check your internet connection and try again.\033[0m" >&2 exit 1 else echo -e "\033[1;31mInvalid version or version does not exist: $requested_version. Please try again.\033[0m" >&2 exit 1 fi fi elif [ "$AUTO_CONFIRM" = true ]; then selected_version=${versions[0]} else while true; do print_menu read -p "Choose a version to install (1-${#versions[@]}), or press M to enter manually, Q to quit: " choice if [[ "$choice" =~ ^[1-9][0-9]*$ ]] && [ "$choice" -le "${#versions[@]}" ]; then choice=$((choice - 1)) selected_version=${versions[choice]} break elif [ "$choice" == "M" ] || [ "$choice" == "m" ]; then while true; do read -p "Enter the version manually (e.g., v1.2.3): " custom_version if [ "$(validate_version "$custom_version")" == "valid" ]; then selected_version="$custom_version" break 2 else echo -e "\033[1;31mInvalid version or version does not exist. Please try again.\033[0m" fi done elif [ "$choice" == "Q" ] || [ "$choice" == "q" ]; then echo -e "\033[1;31mExiting.\033[0m" exit 0 else echo -e "\033[1;31mInvalid choice. Please try again.\033[0m" sleep 2 fi done fi echo -e "\033[1;32mSelected version $selected_version for installation.\033[0m" if ! command -v unzip >/dev/null 2>&1; then echo -e "\033[1;33mInstalling required packages...\033[0m" detect_os install_package unzip fi mkdir -p "$DATA_DIR/xray-core" cd "$DATA_DIR/xray-core" xray_filename="Xray-linux-$ARCH.zip" xray_download_url="https://git.nimbaha.org/pgnode/github/XTLS/Xray-core/releases/download/${selected_version}/${xray_filename}" echo -e "\033[1;33mDownloading Xray-core version ${selected_version}...\033[0m" curl -fsSL "$xray_download_url" -o "$xray_filename" || die "Failed to download Xray-core from $xray_download_url" echo -e "\033[1;33mExtracting Xray-core...\033[0m" unzip -o "$xray_filename" >/dev/null 2>&1 || die "Failed to extract $xray_filename" rm -f "$xray_filename" } # Retrieve the currently installed Xray-core version from binary or running container. get_current_xray_core_version() { XRAY_BINARY="$DATA_DIR/xray-core/xray" if [ -f "$XRAY_BINARY" ]; then version_output=$("$XRAY_BINARY" -version 2>/dev/null) if [ $? -eq 0 ]; then version=$(echo "$version_output" | head -n1 | awk '{print $2}') echo "$version" return fi fi # If local binary is not found or failed, check in the Docker container CONTAINER_NAME="$APP_NAME" if docker ps --format '{{.Names}}' | grep -q "^$CONTAINER_NAME$"; then version_output=$(docker exec "$CONTAINER_NAME" xray -version 2>/dev/null) if [ $? -eq 0 ]; then # Extract the version number from the first line version=$(echo "$version_output" | head -n1 | awk '{print $2}') echo "$version (in container)" return fi fi echo "Not installed" } # Download, extract, configure, and install a chosen Xray-core binary release. update_core_command() { check_running_as_root local core_version_arg="" while [[ $# -gt 0 ]]; do case "$1" in -v | --version) if [[ -z "${2:-}" ]]; then colorized_echo red "Error: --version requires a value." exit 1 fi core_version_arg="$2" shift 2 ;; -h | --help) colorized_echo red "Usage: node core-update [--version VERSION]" echo " --version VERSION Install a specific Xray-core version (use 'latest' for newest release)" exit 0 ;; *) echo "Unknown option: $1" exit 1 ;; esac done get_xray_core "$core_version_arg" # Ensure volumes match DATA_DIR when custom name is used service_name="node" existing_volume=$(yq eval -r ".services[\"$service_name\"].volumes[0]" "$APP_DIR/docker-compose.yml") if [ -n "$existing_volume" ] && [ "$existing_volume" != "null" ]; then # Extract container path (everything after the colon) if [[ "$existing_volume" == *:* ]]; then container_path="${existing_volume#*:}" else # If no colon found, use the existing volume as container path container_path="$existing_volume" fi # Update volumes to use DATA_DIR (which is based on APP_NAME) yq eval ".services[\"$service_name\"].volumes[0] = \"${DATA_DIR}:${container_path}\"" -i "$APP_DIR/docker-compose.yml" # Set XRAY_EXECUTABLE_PATH to the container path, not host path sed -i "s|^# *XRAY_EXECUTABLE_PATH *=.*|XRAY_EXECUTABLE_PATH= ${container_path}/xray-core/xray|" "$APP_DIR/.env" grep -q '^XRAY_EXECUTABLE_PATH=' "$APP_DIR/.env" || echo "XRAY_EXECUTABLE_PATH= ${container_path}/xray-core/xray" >>"$APP_DIR/.env" else # Fallback to APP_NAME-based path if no volume mapping is detected local fallback_path="${DATA_DIR}/xray-core/xray" sed -i "s|^# *XRAY_EXECUTABLE_PATH *=.*|XRAY_EXECUTABLE_PATH= ${fallback_path}|" "$APP_DIR/.env" grep -q '^XRAY_EXECUTABLE_PATH=' "$APP_DIR/.env" || echo "XRAY_EXECUTABLE_PATH= ${fallback_path}" >>"$APP_DIR/.env" fi # Restart node colorized_echo red "Restarting node..." restart_command -n --no-restart-service colorized_echo blue "Installation of XRAY-CORE version $selected_version completed." } # Open docker-compose.yml in default editor. edit_command() { detect_os check_editor if [ -f "$COMPOSE_FILE" ]; then $EDITOR "$COMPOSE_FILE" else colorized_echo red "Compose file not found at $COMPOSE_FILE" exit 1 fi } # Open .env in default editor. edit_env_command() { detect_os check_editor if [ -f "$ENV_FILE" ]; then $EDITOR "$ENV_FILE" else colorized_echo red "Environment file not found at $ENV_FILE" exit 1 fi } # Generate bash auto-completion definition script. generate_bash_completion() { cat <<'EOF' _node_completions() { local cur cmds COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" cmds="up down restart status logs install update uninstall install-script uninstall-script core-update geofiles renew-cert version-script script-version edit edit-env completion service-install service-uninstall service-restart service-status service-logs service-update service-start service-stop" COMPREPLY=( $(compgen -W "$cmds" -- "$cur") ) return 0 } EOF echo "complete -F _node_completions node.sh" echo "complete -F _node_completions $APP_NAME" } # Generate zsh auto-completion definition script. generate_zsh_completion() { cat <"$bash_completion_file" chmod 644 "$bash_completion_file" colorized_echo green "✓ Bash completion installed to $bash_completion_file" mkdir -p "$zsh_completion_dir" generate_zsh_completion >"$zsh_completion_file" chmod 644 "$zsh_completion_file" colorized_echo green "✓ Zsh completion installed to $zsh_completion_file" } # Remove installed bash and zsh completion files. uninstall_completion() { local bash_completion_dir="/etc/bash_completion.d" local bash_completion_file="$bash_completion_dir/$APP_NAME" local zsh_completion_dir="/usr/local/share/zsh/site-functions" local zsh_completion_file="$zsh_completion_dir/_$APP_NAME" if [ -f "$bash_completion_file" ]; then rm "$bash_completion_file" colorized_echo yellow "Bash completion removed from $bash_completion_file" fi if [ -f "$zsh_completion_file" ]; then rm "$zsh_completion_file" colorized_echo yellow "Zsh completion removed from $zsh_completion_file" fi } # Display help and usage information for pg-node worker node commands and options. usage() { colorized_echo blue "================================" colorized_echo magenta " $APP_NAME Node CLI Help" colorized_echo blue "================================" colorized_echo cyan "Usage:" echo " $APP_NAME [command] [options]" echo colorized_echo cyan "Options:" colorized_echo yellow " -y, --yes $(tput sgr0)✓ Use default answers for all prompts" colorized_echo yellow " --name NAME $(tput sgr0)✓ Target a specific node instance" echo colorized_echo cyan "Commands:" colorized_echo yellow " up $(tput sgr0)✓ Start services" colorized_echo yellow " down $(tput sgr0)✓ Stop services" colorized_echo yellow " restart $(tput sgr0)✓ Restart services" colorized_echo yellow " status $(tput sgr0)✓ Show status" colorized_echo yellow " logs $(tput sgr0)✓ Show logs" colorized_echo yellow " install $(tput sgr0)✓ Install/reinstall node" colorized_echo yellow " update $(tput sgr0)✓ Update to latest version" colorized_echo yellow " uninstall $(tput sgr0)✓ Uninstall node" colorized_echo yellow " install-script $(tput sgr0)✓ Install node script" colorized_echo yellow " uninstall-script $(tput sgr0)✓ Uninstall node script" colorized_echo yellow " service-install $(tput sgr0)✓ Install and start pg-node-service (systemd)" colorized_echo yellow " service-uninstall $(tput sgr0)✓ Remove pg-node-service (systemd)" colorized_echo yellow " service-restart $(tput sgr0)✓ Restart pg-node-service (systemd)" colorized_echo yellow " service-status $(tput sgr0)✓ Show pg-node-service status" colorized_echo yellow " service-logs $(tput sgr0)✓ View systemd service logs" colorized_echo yellow " service-update $(tput sgr0)✓ Update pg-node-service script" colorized_echo yellow " service-start $(tput sgr0)✓ Start pg-node-service (systemd)" colorized_echo yellow " service-stop $(tput sgr0)✓ Stop pg-node-service" colorized_echo yellow " edit $(tput sgr0)✓ Edit docker-compose.yml (via nano or vi)" colorized_echo yellow " edit-env $(tput sgr0)✓ Edit .env file (via nano or vi)" colorized_echo yellow " core-update $(tput sgr0)✓ Update/Change Xray core" colorized_echo yellow " geofiles $(tput sgr0)✓ Download geoip and geosite files for specific regions" colorized_echo yellow " renew-cert $(tput sgr0)✓ Regenerate SSL/TLS certificate" colorized_echo yellow " version-script $(tput sgr0)✓ Show script version and commit" colorized_echo yellow " completion $(tput sgr0)✓ Install bash/zsh tab completion" echo colorized_echo cyan "Restart Options:" colorized_echo yellow " -n, --no-logs $(tput sgr0)✓ Do not follow logs after restart" colorized_echo yellow " --no-restart-service $(tput sgr0)✓ Skip restarting systemd service" colorized_echo cyan "Update Options:" colorized_echo yellow " --no-update-service $(tput sgr0)✓ Skip updating systemd service" colorized_echo yellow " --dev $(tput sgr0)✓ Switch to the dev image (unreleased, for testing)" colorized_echo yellow " -v, --version VERSION $(tput sgr0)✓ Switch to a release tag, latest or pre-release" colorized_echo cyan "Uninstall Options:" colorized_echo yellow " -y, --yes $(tput sgr0)✓ Auto-confirm uninstall and data removal" colorized_echo yellow " --name NAME $(tput sgr0)✓ Uninstall specific node instance" colorized_echo cyan "Install Options:" colorized_echo yellow " -v, --version VERSION $(tput sgr0)✓ Install specific version" colorized_echo yellow " --pre-release $(tput sgr0)✓ Install pre-release version" colorized_echo yellow " --dev $(tput sgr0)✓ Install the dev image (unreleased, for testing)" colorized_echo yellow " --name NAME $(tput sgr0)✓ Install with custom name" colorized_echo yellow " --override $(tput sgr0)✓ Override existing installation" colorized_echo yellow " --api-key KEY $(tput sgr0)✓ Set API Key" colorized_echo yellow " --use-rest $(tput sgr0)✓ Use REST protocol instead of GRPC" colorized_echo yellow " --use-grpc $(tput sgr0)✓ Use GRPC protocol (default)" colorized_echo yellow " --service-port PORT $(tput sgr0)✓ Set service port" colorized_echo yellow " --cert-path PATH $(tput sgr0)✓ Set public certificate path" colorized_echo yellow " --key-path PATH $(tput sgr0)✓ Set private key path" colorized_echo yellow " --self-signed $(tput sgr0)✓ Generate self-signed certificate" colorized_echo yellow " --api-port PORT $(tput sgr0)✓ Set API port for node service" colorized_echo yellow " --install-service $(tput sgr0)✓ Install systemd service" colorized_echo yellow " --no-install-service $(tput sgr0)✓ Skip systemd service installation" colorized_echo yellow " --san-entries ENTRIES $(tput sgr0)✓ Add SAN entries (comma separated)" colorized_echo cyan "Core-update Options:" colorized_echo yellow " --version VERSION $(tput sgr0)✓ Update Xray-core to specific version (use 'latest' for newest)" colorized_echo cyan "Service Logs Options:" colorized_echo yellow " -n, --no-follow $(tput sgr0)✓ Show logs once without following" echo colorized_echo cyan "Node Information:" colorized_echo magenta " Node IP: $NODE_IP_V4" local service_port="" local api_key="" if [ -f "$APP_DIR/.env" ]; then service_port=$(grep '^SERVICE_PORT[[:space:]]*=' "$APP_DIR/.env" 2>/dev/null | sed 's/^SERVICE_PORT[[:space:]]*=[[:space:]]*//' || true) api_key=$(grep '^API_KEY[[:space:]]*=' "$APP_DIR/.env" 2>/dev/null | sed 's/^API_KEY[[:space:]]*=[[:space:]]*//' || true) fi colorized_echo magenta " Service port: $service_port" colorized_echo magenta " Cert file path: $SSL_CERT_FILE" colorized_echo magenta " API Key : $api_key" echo current_version=$(get_current_xray_core_version) colorized_echo cyan "Current Xray-core version: " 1 # 1 for bold colorized_echo magenta "$current_version" 1 echo colorized_echo blue "=================================" echo } # Download regional geoip and geosite rule files and restart node services. geofiles_command() { check_running_as_root mkdir -p "$DATA_DIR/assets" local restart_needed=false local args_provided=false if [[ $# -eq 0 ]]; then colorized_echo blue "No region specified, defaulting to Iran geofiles..." set -- "--iran" fi while [[ $# -gt 0 ]]; do case "$1" in --iran) colorized_echo blue "Downloading Iran geofiles..." curl -sL "https://git.nimbaha.org/pgnode/github/Chocolate4U/Iran-v2ray-rules/releases/latest/download/geoip.dat" -o "$DATA_DIR/assets/geoip.dat" curl -sL "https://git.nimbaha.org/pgnode/github/Chocolate4U/Iran-v2ray-rules/releases/latest/download/geosite.dat" -o "$DATA_DIR/assets/geosite.dat" colorized_echo green "Iran geofiles downloaded to $DATA_DIR/assets" restart_needed=true args_provided=true shift ;; --russia) colorized_echo blue "Downloading Russia geofiles..." curl -sL "https://git.nimbaha.org/pgnode/github/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -o "$DATA_DIR/assets/geoip.dat" curl -sL "https://git.nimbaha.org/pgnode/github/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -o "$DATA_DIR/assets/geosite.dat" colorized_echo green "Russia geofiles downloaded to $DATA_DIR/assets" restart_needed=true args_provided=true shift ;; --china) colorized_echo blue "Downloading China geofiles..." curl -sL "https://git.nimbaha.org/pgnode/github/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -o "$DATA_DIR/assets/geoip.dat" curl -sL "https://git.nimbaha.org/pgnode/github/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -o "$DATA_DIR/assets/geosite.dat" colorized_echo green "China geofiles downloaded to $DATA_DIR/assets" restart_needed=true args_provided=true shift ;; *) colorized_echo red "Unknown option: $1" exit 1 ;; esac done if [ "$restart_needed" = true ]; then # Get the container path from the volume mapping service_name="node" existing_volume=$(yq eval -r ".services[\"$service_name\"].volumes[0]" "$APP_DIR/docker-compose.yml") if [ -n "$existing_volume" ] && [ "$existing_volume" != "null" ]; then # Extract container path (everything after the colon) if [[ "$existing_volume" == *:* ]]; then container_path="${existing_volume#*:}" # XRAY_ASSETS_PATH should point to the container path xray_assets_path="${container_path}/assets" else xray_assets_path="$DATA_DIR/assets" fi else xray_assets_path="$DATA_DIR/assets" fi sed -i "s|^# *XRAY_ASSETS_PATH *=.*|XRAY_ASSETS_PATH = $xray_assets_path|" "$ENV_FILE" grep -q '^XRAY_ASSETS_PATH =' "$ENV_FILE" || echo "XRAY_ASSETS_PATH = $xray_assets_path" >> "$ENV_FILE" colorized_echo blue "XRAY_ASSETS_PATH updated in $ENV_FILE" colorized_echo blue "Restarting node services..." restart_command -n --no-restart-service colorized_echo green "Geofiles updated and node restarted." else colorized_echo yellow "No geofiles specified for download." fi } # Backup existing certificates and generate a renewed self-signed TLS certificate. renew_cert_command() { check_running_as_root # Check if node is installed if ! is_node_installed; then colorized_echo red "✗ Node is not installed. Please install node first." exit 1 fi colorized_echo cyan "================================" colorized_echo cyan "Renewing SSL/TLS Certificate" colorized_echo cyan "================================" colorized_echo yellow "This will create a new SSL/TLS certificate for your node." # Check if existing certificate is self-signed (generated by script) local is_self_signed=false if [ -f "$SSL_CERT_FILE" ]; then # Check if certificate is self-signed (subject == issuer) local subject=$(openssl x509 -in "$SSL_CERT_FILE" -noout -subject 2>/dev/null | sed 's/^subject= *//') local issuer=$(openssl x509 -in "$SSL_CERT_FILE" -noout -issuer 2>/dev/null | sed 's/^issuer= *//') if [ "$subject" = "$issuer" ]; then is_self_signed=true fi fi # Only backup if it's a self-signed certificate (generated by script) if [ "$is_self_signed" = true ] && [ -f "$SSL_CERT_FILE" ]; then # Clean up old backups first (keep only the 2 most recent) local cert_backups=($(ls -t "${SSL_CERT_FILE}.backup."* 2>/dev/null | tail -n +3 2>/dev/null)) local key_backups=($(ls -t "${SSL_KEY_FILE}.backup."* 2>/dev/null | tail -n +3 2>/dev/null)) if [ ${#cert_backups[@]} -gt 0 ] || [ ${#key_backups[@]} -gt 0 ]; then colorized_echo blue "Cleaning up old backups (keeping 2 most recent)..." for backup in "${cert_backups[@]}"; do if [ -f "$backup" ]; then rm -f "$backup" 2>/dev/null && colorized_echo cyan " Removed old backup: $(basename "$backup")" fi done for backup in "${key_backups[@]}"; do if [ -f "$backup" ]; then rm -f "$backup" 2>/dev/null && colorized_echo cyan " Removed old backup: $(basename "$backup")" fi done fi # Create new backup local backup_cert="${SSL_CERT_FILE}.backup.$(date +%Y%m%d_%H%M%S)" local backup_key="${SSL_KEY_FILE}.backup.$(date +%Y%m%d_%H%M%S)" colorized_echo blue "Backing up existing self-signed certificate..." cp "$SSL_CERT_FILE" "$backup_cert" 2>/dev/null || true if [ -f "$SSL_KEY_FILE" ]; then cp "$SSL_KEY_FILE" "$backup_key" 2>/dev/null || true fi colorized_echo green " ✓ Backup created: $(basename "$backup_cert")" if [ -f "$backup_key" ]; then colorized_echo green " ✓ Backup created: $(basename "$backup_key")" fi elif [ -f "$SSL_CERT_FILE" ]; then # User-provided certificate - don't backup, just warn colorized_echo yellow "⚠ Existing certificate appears to be user-provided (not self-signed)." colorized_echo yellow " It will be replaced with a new self-signed certificate." if [ "$AUTO_CONFIRM" != true ]; then read -p "Continue? (y/N): " confirm if [[ ! "$confirm" =~ ^[Yy]$ ]]; then colorized_echo yellow "Cancelled." exit 0 fi fi fi # Generate new certificate gen_self_signed_cert # Ask user if they want to restart the node if docker ps --format '{{.Names}}' | grep -q "^$APP_NAME$"; then colorized_echo cyan "" colorized_echo yellow "The node needs to be restarted to apply the new certificate." local restart_choice="" if [ "$AUTO_CONFIRM" = true ]; then restart_choice="n" else read -p "Do you want to restart the node now? (y/N): " restart_choice fi if [[ "$restart_choice" =~ ^[Yy]$ ]]; then colorized_echo blue "Restarting node to apply new certificate..." restart_command -n --no-restart-service colorized_echo green "✓ Node restarted with new certificate" else colorized_echo yellow "Skipped restart. Please restart the node manually to apply the new certificate." colorized_echo yellow "You can restart it later with: $APP_NAME restart" fi fi colorized_echo cyan "" colorized_echo cyan "================================" colorized_echo green "✓ Certificate renewal completed!" colorized_echo cyan "================================" colorized_echo magenta "Please use the following Certificate in pasarguard Panel (it's located in ${DATA_DIR}/certs):" cat "$SSL_CERT_FILE" colorized_echo cyan "================================" restart_command } # Main CLI dispatch handler for pg-node. pg_node_main() { # Bring existing env SSL paths in line with the current APP_NAME (safe no-op if not installed/default) sync_env_ssl_paths case "$1" in install) shift install_command "$@" ;; update) shift update_command "$@" ;; uninstall) uninstall_command ;; up) shift up_command "$@" ;; down) down_command ;; restart) shift restart_command "$@" ;; status) status_command ;; logs) shift logs_command "$@" ;; core-update) shift update_core_command "$@" ;; geofiles) shift geofiles_command "$@" ;; renew-cert) shift renew_cert_command "$@" ;; install-script) install_node_script ;; uninstall-script) uninstall_node_script ;; service-install) shift install_service_command "$@" ;; service-uninstall) uninstall_service_command ;; service-restart) restart_service_command ;; service-status) status_service_command ;; service-logs) shift service_logs_command "$@" ;; service-update) service_update_command ;; service-start) service_start_command ;; service-stop) service_stop_command ;; edit) edit_command ;; edit-env) edit_env_command ;; version-script | script-version) print_script_execution_header "pg-node" "$SCRIPT_COMMIT_SHA" ;; completion) check_running_as_root install_completion colorized_echo cyan "" colorized_echo yellow "To activate completion in this session:" colorized_echo cyan " bash: source /etc/bash_completion.d/$APP_NAME" colorized_echo cyan " zsh : autoload -Uz compinit && compinit" colorized_echo yellow "Or simply restart your terminal." ;; *) usage ;; esac } if [ "${PG_NODE_SOURCE_ONLY:-false}" != "true" ]; then pg_node_main "$@" fi