#!/usr/bin/env bash set -Eeuo pipefail REPO="yulcaribe/vpnengine" DEFAULT_PORT=51821 STATE=/etc/vpn-engine/state.json ENGINE=/usr/local/bin/vpn-engine ALLOW_HTTP_FILE=/etc/vpn-engine/allow-http SERVICE=/etc/systemd/system/vpn-engine.service HTTPS_IP_FILE=/etc/vpn-engine/https-ip NGINX_SITE=/etc/nginx/sites-available/vpn-engine NGINX_ENABLED=/etc/nginx/sites-enabled/vpn-engine ACME_WEBROOT=/var/lib/vpn-engine/acme CERTBOT_DIR=/opt/vpn-engine-certbot RENEW_SCRIPT=/usr/local/sbin/vpn-engine-renew-cert RENEW_SERVICE=/etc/systemd/system/vpn-engine-cert-renew.service RENEW_TIMER=/etc/systemd/system/vpn-engine-cert-renew.timer say(){ printf '%s\n' "$*"; } fail(){ say "ERROR: $*" >&2; exit 1; } open_tty() { { exec {TTY_FD}<>/dev/tty; } 2>/dev/null; } close_tty() { exec {TTY_FD}>&-; } valid_ipv4() { local ip="$1" a="" b="" c="" d="" x IFS=. read -r a b c d <<<"$ip" for x in "$a" "$b" "$c" "$d"; do [[ "$x" =~ ^[0-9]+$ ]] || return 1 [ "$x" -ge 0 ] && [ "$x" -le 255 ] || return 1 done [ -n "$d" ] } port_listening() { ss -ltnH 2>/dev/null | awk '{print $4}' | grep -E ":$1$" >/dev/null } write_service() { local proxy="${1:-no}" proxy_line="" [ "$proxy" = yes ] && proxy_line="Environment=VPN_ENGINE_PROXY_MODE=1" cat > "$SERVICE" </dev/null 2>&1 && port_listening "$input"; then say "Port $input is already in use. Choose another." continue fi PANEL_PORT="$input" close_tty return 0 done } verify_artifact() { local asset="$1" expected_hash magic machine expected_hash="$(awk -v name="$asset" '$2 == name || $2 == "dist/" name {print $1}' "$WORK_DIR/SHA256SUMS")" || return 1 [[ "$expected_hash" =~ ^[a-fA-F0-9]{64}$ ]] || { say "Invalid or missing checksum for $asset."; return 1; } (cd "$WORK_DIR" && printf '%s %s\n' "$expected_hash" "$asset" | sha256sum -c -) || return 1 magic="$(od -An -tx1 -N6 "$WORK_DIR/$asset" | tr -d ' \n')" || return 1 machine="$(od -An -tx1 -j18 -N2 "$WORK_DIR/$asset" | tr -d ' \n')" || return 1 [ "$magic" = 7f454c460201 ] || { say "The release asset is not a 64-bit Linux ELF binary."; return 1; } case "$ARCH:$machine" in amd64:3e00|arm64:b700) return 0;; esac say "The release asset has the wrong CPU architecture." return 1 } download_release() { local resolved asset="vpn-engine-linux-$ARCH" base candidate_version resolved="$(curl --proto '=https' --proto-redir '=https' -fsSLI --retry 3 --connect-timeout 15 --max-time 120 \ -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest")" || return 1 base="https://github.com/$REPO/releases/tag/" [[ "$resolved" == "$base"* ]] || { say "Could not resolve the latest release tag."; return 1; } RELEASE_TAG="${resolved#"$base"}" [[ "$RELEASE_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || return 1 if ! [[ "$RELEASE_TAG" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || [ "$(printf '%s\n' 1.1.10 "${RELEASE_TAG#v}" | sort -V | head -n 1)" != 1.1.10 ]; then say "The no-confirmation admin setup requires release 1.1.10 or newer. The current installation was not changed." return 1 fi base="https://github.com/$REPO/releases/download/$RELEASE_TAG" say "Downloading VPN Engine $RELEASE_TAG for linux/$ARCH..." curl --proto '=https' --proto-redir '=https' -fL --retry 4 --retry-delay 2 --connect-timeout 15 --max-time 300 \ "$base/$asset" -o "$WORK_DIR/$asset" || return 1 curl --proto '=https' --proto-redir '=https' -fL --retry 4 --retry-delay 2 --connect-timeout 15 --max-time 120 \ "$base/SHA256SUMS" -o "$WORK_DIR/SHA256SUMS" || return 1 verify_artifact "$asset" || return 1 chmod 0700 "$WORK_DIR/$asset" || return 1 candidate_version="$(timeout 10s "$WORK_DIR/$asset" --version)" || return 1 [ "$candidate_version" = "VPN Engine $RELEASE_TAG" ] || { say "The candidate version does not match its release tag."; return 1; } CANDIDATE="$WORK_DIR/$asset" } health_check() { local port="$1" expected="${2:-}" body observed attempt # Existing protocol configuration migrations can delay the first listener by 75s. for attempt in {1..100}; do if systemctl is-active --quiet vpn-engine && \ body="$(curl --noproxy '*' -fsS --connect-timeout 2 --max-time 3 "http://127.0.0.1:$port/api/state")"; then observed="$(sed -n 's/.*"version":[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$body")" if [ -n "$observed" ] && { [ -z "$expected" ] || [ "$observed" = "$expected" ]; }; then return 0 fi fi sleep 1 done return 1 } https_health_check() { local ip="$1" expected="${2:-}" body observed valid_ipv4 "$ip" || return 1 body="$(curl --noproxy '*' --proto '=https' --connect-to "$ip:443:127.0.0.1:443" \ -fsS --connect-timeout 3 --max-time 10 "https://$ip/api/state")" || return 1 observed="$(sed -n 's/.*"version":[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$body")" [ -n "$observed" ] && { [ -z "$expected" ] || [ "$observed" = "$expected" ]; } } backup_installation() { local index path BACKUP_PATHS=("$ENGINE" "$SERVICE" "$NGINX_SITE" "$NGINX_ENABLED" "$HTTPS_IP_FILE" "$ALLOW_HTTP_FILE" "$RENEW_SCRIPT" "$RENEW_SERVICE" "$RENEW_TIMER") mkdir -m 0700 "$WORK_DIR/backup" || return 1 for index in "${!BACKUP_PATHS[@]}"; do path="${BACKUP_PATHS[$index]}" if [ -e "$path" ] || [ -L "$path" ]; then cp -a -- "$path" "$WORK_DIR/backup/$index" || return 1; fi done OLD_PANEL_ACTIVE=no; OLD_PANEL_ENABLED=no; OLD_NGINX_ACTIVE=no; OLD_NGINX_ENABLED=no OLD_TIMER_ACTIVE=no; OLD_TIMER_ENABLED=no # A missing unit is expected on a fresh install; systemctl may report # "Failed to get unit file state" even with --quiet. Suppress only probes. if systemctl is-active --quiet vpn-engine 2>/dev/null; then OLD_PANEL_ACTIVE=yes; fi if systemctl is-enabled --quiet vpn-engine 2>/dev/null; then OLD_PANEL_ENABLED=yes; fi if systemctl is-active --quiet nginx 2>/dev/null; then OLD_NGINX_ACTIVE=yes; fi if systemctl is-enabled --quiet nginx 2>/dev/null; then OLD_NGINX_ENABLED=yes; fi if systemctl is-active --quiet vpn-engine-cert-renew.timer 2>/dev/null; then OLD_TIMER_ACTIVE=yes; fi if systemctl is-enabled --quiet vpn-engine-cert-renew.timer 2>/dev/null; then OLD_TIMER_ENABLED=yes; fi } restore_file() { local index="$1" path="${BACKUP_PATHS[$1]}" backup="$WORK_DIR/backup/$1" if [ -e "$backup" ] || [ -L "$backup" ]; then rm -f -- "$path" || return 1 cp -a -- "$backup" "$path" || return 1 else rm -f -- "$path" || return 1 fi } restore_unit_state() { local unit="$1" enabled="$2" active="$3" result=0 if [ "$enabled" = yes ]; then systemctl enable "$unit" >/dev/null || result=1; fi if [ "$active" = yes ]; then systemctl restart "$unit" || result=1 elif systemctl is-active --quiet "$unit"; then systemctl stop "$unit" || result=1 fi return "$result" } block_panel_proxy() { local marker="$1" BLOCKED_PROXY=no if [ -f "$NGINX_SITE" ] && grep -qF "Managed by VPN Engine" "$NGINX_SITE"; then awk -v marker="$marker" ' BEGIN {print marker} /^[[:space:]]*proxy_pass[[:space:]]/ {$0 = " return 503;"} {print} ' "$NGINX_SITE" > "$WORK_DIR/blocked-nginx" || return 1 install -o root -g root -m 0644 "$WORK_DIR/blocked-nginx" "$NGINX_SITE" || return 1 BLOCKED_PROXY=yes fi } restore_https_configuration() { local block="${1:-no}" index result=0 if [ "$OLD_TIMER_ENABLED" = no ]; then systemctl disable vpn-engine-cert-renew.timer >/dev/null 2>&1 || true; fi if [ "$OLD_NGINX_ENABLED" = no ]; then systemctl disable nginx >/dev/null 2>&1 || true; fi for index in 2 3 4 6 7 8; do restore_file "$index" || result=1; done # Restrict restored proxy routes before a running nginx can reload them. if [ "$block" != no ]; then block_panel_proxy "$block" || return 1; fi systemctl daemon-reload || result=1 restore_unit_state vpn-engine-cert-renew.timer "$OLD_TIMER_ENABLED" "$OLD_TIMER_ACTIVE" || result=1 if [ "$OLD_NGINX_ACTIVE" = yes ]; then nginx -t && systemctl reload nginx || result=1 elif systemctl is-active --quiet nginx; then systemctl stop nginx || result=1 fi if [ "$OLD_NGINX_ENABLED" = yes ]; then systemctl enable nginx >/dev/null || result=1; fi return "$result" } rollback_installation() { local index result=0 systemctl stop vpn-engine >/dev/null 2>&1 || true if [ "$OLD_PANEL_ENABLED" = no ]; then systemctl disable vpn-engine >/dev/null 2>&1 || true; fi for index in 0 1 5; do restore_file "$index" || result=1; done if [ "$OLD_SETUP_COMPLETE" = no ] && [ -f "$WORK_DIR/backup/0" ] && [ -f "$SERVICE" ]; then # Older binaries do not enforce setup codes. Never reopen their unfinished setup. awk -v engine="$ENGINE" ' /^\[Service\]$/ {print; print "Environment=VPN_ENGINE_PROXY_MODE=1"; next} /^Environment=VPN_ENGINE_PROXY_MODE=/ {next} $0 == "ExecStart=" engine {$0 = "ExecStart=/usr/bin/env VPN_ENGINE_PROXY_MODE=1 " engine} {print} ' "$SERVICE" > "$WORK_DIR/recovered-service" && \ install -o root -g root -m 0644 "$WORK_DIR/recovered-service" "$SERVICE" || return 1 say "The unfinished legacy setup was restricted to loopback. Recover it over SSH; the older binary does not enforce setup codes." fi if [ "$OLD_SETUP_COMPLETE" = no ]; then restore_https_configuration '# VPN Engine: insecure legacy recovery' || return 1 say "Any recovered nginx application proxy was blocked until a secure update is installed; certificates and ACME renewal were retained." else restore_https_configuration || result=1 fi systemctl daemon-reload || result=1 restore_unit_state vpn-engine "$OLD_PANEL_ENABLED" "$OLD_PANEL_ACTIVE" || result=1 if [ "$OLD_PANEL_ACTIVE" = yes ]; then health_check "$OLD_PANEL_PORT" "$OLD_VERSION" || result=1; fi if [ "$OLD_HTTPS_HEALTHY" = yes ] && [ "$OLD_SETUP_COMPLETE" = yes ]; then https_health_check "$OLD_HTTPS_IP" "$OLD_VERSION" || result=1; fi return "$result" } finish_installation() { local status=$? rollback_failed=no trap - EXIT INT TERM if [ "$TRANSACTION_STARTED" = yes ] && [ "$COMMITTED" = no ]; then [ "$status" -ne 0 ] || status=1 say "ERROR: Installation or update failed. Restoring the previous panel installation." if rollback_installation; then if [ -f "$WORK_DIR/backup/0" ]; then say "The previous binary and configuration were restored. Existing VPN data was preserved." else say "The incomplete first installation was removed. Any bootstrap state and VPN data were preserved." fi else rollback_failed=yes say "ERROR: Automatic rollback was incomplete. Check systemctl status vpn-engine and journalctl -u vpn-engine." say "The original installation backup was retained in $WORK_DIR/backup." fi fi if [ "$rollback_failed" = no ]; then rm -rf -- "$WORK_DIR"; fi exit "$status" } write_http_nginx() { cat > "$NGINX_SITE" < "$NGINX_SITE" < "$RENEW_SCRIPT" <