#!/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