#!/usr/bin/env bash # x-ui-backup.sh — backup and restore 3x-ui panel + nginx + certs # Usage: # x-ui-backup.sh backup — create timestamped backup # x-ui-backup.sh restore — restore from backup archive # x-ui-backup.sh list — list available backups set -Eeuo pipefail BACKUP_STORE="/var/backups/x-ui" PACKAGES="nginx-full certbot python3 sqlite3 curl wget jq ufw mtr-tiny" # ── paths to back up ────────────────────────────────────────────────────────── BACKUP_PATHS=( /etc/nginx /etc/x-ui /usr/local/x-ui /usr/bin/x-ui /usr/local/lib/3x-ui-pro /etc/letsencrypt /root/cert /var/www/html /var/www/diagnostics /var/www/subpage /etc/ufw/user.rules /etc/ufw/user6.rules ) SYSTEMD_UNITS=(x-ui.service mtr-backend.service) # ── colours ─────────────────────────────────────────────────────────────────── red() { printf '\033[31m%s\033[0m\n' "$*"; } green() { printf '\033[32m%s\033[0m\n' "$*"; } blue() { printf '\033[34m%s\033[0m\n' "$*"; } die() { red "ERROR: $*" >&2; exit 1; } require_root() { [[ $EUID -eq 0 ]] || die "Run as root (sudo $0 $*)"; } # free space in KB on the filesystem containing $1 avail_kb() { df -Pk "$1" | awk 'NR==2 {print $4}'; } # staging must NOT live in /tmp: on Ubuntu 24.10+ /tmp is a size-limited tmpfs # and a full uncompressed copy of the panel + web roots does not fit there make_staging() { local prefix="$1" mkdir -p "${BACKUP_STORE}" mktemp -d "${BACKUP_STORE}/.${prefix}-XXXXXX" } # ── backup ──────────────────────────────────────────────────────────────────── cmd_backup() { require_root local ts name staging dest ts=$(date +%Y%m%d-%H%M%S) name="x-ui-backup-${ts}" # estimate size and verify free space before touching anything: # staging holds an uncompressed copy, the archive lands next to it local est_kb need_kb have_kb est_kb=$(du -skc "${BACKUP_PATHS[@]}" 2>/dev/null | awk 'END {print $1}') need_kb=$(( est_kb * 2 + 102400 )) # copy + archive + 100 MB margin mkdir -p "${BACKUP_STORE}" have_kb=$(avail_kb "${BACKUP_STORE}") (( have_kb >= need_kb )) || die "Not enough free space in ${BACKUP_STORE}: need ~$(( need_kb / 1024 )) MB, have $(( have_kb / 1024 )) MB" staging=$(make_staging staging) trap 'rm -rf "${staging}"' EXIT dest="${BACKUP_STORE}/${name}.tar.gz" blue "==> Stopping x-ui for consistent DB snapshot..." systemctl stop x-ui 2>/dev/null || true # ── collect filesystem paths ─────────────────────────────────────────── blue "==> Collecting files..." local files_root="${staging}/files" for path in "${BACKUP_PATHS[@]}"; do [[ -e "${path}" ]] || continue local dst="${files_root}${path}" mkdir -p "$(dirname "${dst}")" cp -a "${path}" "${dst}" done # systemd units mkdir -p "${files_root}/etc/systemd/system" for unit in "${SYSTEMD_UNITS[@]}"; do [[ -f "/etc/systemd/system/${unit}" ]] && \ cp "/etc/systemd/system/${unit}" "${files_root}/etc/systemd/system/" done # ── crontab ─────────────────────────────────────────────────────────── crontab -l 2>/dev/null > "${staging}/root-crontab" || true if [[ -d /etc/cron.d ]]; then cp -a /etc/cron.d "${staging}/cron.d" fi # ── metadata ────────────────────────────────────────────────────────── local xui_ver xui_ver=$(x-ui version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown") cat > "${staging}/meta.json" </dev/null || hostname)", "x-ui": "${xui_ver}", "kernel": "$(uname -r)", "packages": "${PACKAGES}" } JSON blue "==> Restarting x-ui..." systemctl start x-ui 2>/dev/null || true # ── compress ────────────────────────────────────────────────────────── blue "==> Compressing..." tar -czf "${dest}" -C "${staging}" . local size size=$(du -sh "${dest}" | cut -f1) green "==> Backup saved: ${dest} (${size})" } # ── restore ─────────────────────────────────────────────────────────────────── cmd_restore() { require_root local backup_file="${1:-}" [[ -n "${backup_file}" ]] || die "Usage: $0 restore " [[ -f "${backup_file}" ]] || die "File not found: ${backup_file}" # verify free space for the extracted copy before unpacking local unpacked_kb have_kb unpacked_kb=$(( $(gzip -l "${backup_file}" | awk 'NR==2 {print $2}') / 1024 )) mkdir -p "${BACKUP_STORE}" have_kb=$(avail_kb "${BACKUP_STORE}") (( have_kb >= unpacked_kb * 2 + 102400 )) || \ die "Not enough free space in ${BACKUP_STORE}: need ~$(( (unpacked_kb * 2 + 102400) / 1024 )) MB, have $(( have_kb / 1024 )) MB" local staging staging=$(make_staging restore) trap 'rm -rf "${staging}"' EXIT blue "==> Extracting backup: ${backup_file}" tar -xzf "${backup_file}" -C "${staging}" if [[ -f "${staging}/meta.json" ]]; then blue "==> Backup metadata:" cat "${staging}/meta.json" echo fi # ── install packages ────────────────────────────────────────────────── blue "==> Installing packages..." apt-get update -qq # shellcheck disable=SC2086 DEBIAN_FRONTEND=noninteractive apt-get install -y ${PACKAGES} # ── stop running services ───────────────────────────────────────────── blue "==> Stopping services..." for svc in nginx x-ui mtr-backend; do systemctl stop "${svc}" 2>/dev/null || true done # ── restore files ───────────────────────────────────────────────────── blue "==> Restoring files..." if [[ -d "${staging}/files" ]]; then cp -a "${staging}/files/." / fi # ── permissions ─────────────────────────────────────────────────────── chown -R www-data:www-data /var/www/html 2>/dev/null || true chown -R www-data:www-data /var/www/diagnostics 2>/dev/null || true chown -R www-data:www-data /var/www/subpage 2>/dev/null || true [[ -f /usr/local/x-ui/x-ui ]] && chmod +x /usr/local/x-ui/x-ui [[ -f /usr/bin/x-ui ]] && chmod +x /usr/bin/x-ui find /usr/local/lib/3x-ui-pro -name "*.py" -exec chmod +x {} \; 2>/dev/null || true # ── panel cert symlinks (/root/cert/ → letsencrypt) ────────── # Backups made before /root/cert was in BACKUP_PATHS lack the symlinks # the panel's webCertFile points to — without them x-ui serves plain # HTTP and every nginx proxy_pass https:// (panel + diag bridge) breaks. # Recreate them from the restored DB ("-e" follows symlinks, so a # dangling link reads as missing). local db=/etc/x-ui/x-ui.db web_cert cert_domain if [[ -f "${db}" ]] && command -v sqlite3 &>/dev/null; then web_cert=$(sqlite3 "${db}" "SELECT value FROM settings WHERE key='webCertFile';" 2>/dev/null || true) if [[ "${web_cert}" =~ ^/root/cert/([^/]+)/ ]]; then cert_domain="${BASH_REMATCH[1]}" if [[ ! -e "${web_cert}" && -d "/etc/letsencrypt/live/${cert_domain}" ]]; then blue "==> Recreating panel cert symlinks in /root/cert/${cert_domain}..." mkdir -p "/root/cert/${cert_domain}" chmod 755 /root/cert/* 2>/dev/null || true ln -sf "/etc/letsencrypt/live/${cert_domain}/fullchain.pem" "/root/cert/${cert_domain}/fullchain.pem" ln -sf "/etc/letsencrypt/live/${cert_domain}/privkey.pem" "/root/cert/${cert_domain}/privkey.pem" fi fi fi # ── recreate mtr-backend system user if missing ─────────────────────── id mtr-backend &>/dev/null || \ useradd --system --no-create-home --shell /usr/sbin/nologin mtr-backend # grant mtr net_raw capability if command -v setcap &>/dev/null && command -v mtr &>/dev/null; then setcap cap_net_raw+ep "$(command -v mtr)" 2>/dev/null || true fi # ── systemd ─────────────────────────────────────────────────────────── blue "==> Enabling and starting services..." systemctl daemon-reload for svc in x-ui mtr-backend; do systemctl enable "${svc}" 2>/dev/null || true systemctl start "${svc}" 2>/dev/null || true done # nginx: test config before starting if nginx -t 2>/dev/null; then systemctl enable nginx systemctl restart nginx green " nginx restarted OK" else red " nginx config test failed — fix manually:" nginx -t fi # ── crontab ─────────────────────────────────────────────────────────── blue "==> Restoring cron..." if [[ -s "${staging}/root-crontab" ]]; then crontab - < "${staging}/root-crontab" green " Root crontab restored" fi if [[ -d "${staging}/cron.d" ]]; then cp -a "${staging}/cron.d/." /etc/cron.d/ green " /etc/cron.d restored" fi # ── UFW ─────────────────────────────────────────────────────────────── blue "==> Restoring UFW..." # user.rules were already copied by file restore; just (re-)enable ufw --force enable 2>/dev/null || true green " UFW enabled" echo green "==> Restore complete." green " Check status with:" green " systemctl status x-ui nginx mtr-backend" } # ── list ────────────────────────────────────────────────────────────────────── cmd_list() { if [[ ! -d "${BACKUP_STORE}" ]]; then echo "No backups found (${BACKUP_STORE} does not exist)" return fi local archives mapfile -t archives < <(ls -t "${BACKUP_STORE}"/*.tar.gz 2>/dev/null) if [[ ${#archives[@]} -eq 0 ]]; then echo "No backups in ${BACKUP_STORE}" return fi blue "Backups in ${BACKUP_STORE}:" for f in "${archives[@]}"; do printf " %-55s %s\n" "$(basename "${f}")" "$(du -sh "${f}" | cut -f1)" done } # ── entry point ─────────────────────────────────────────────────────────────── case "${1:-}" in backup) cmd_backup ;; restore) cmd_restore "${2:-}" ;; list) cmd_list ;; *) cat <|list} backup create timestamped backup in ${BACKUP_STORE}/ restore restore from backup archive (installs packages first) list list available backups What is backed up: /etc/nginx nginx config /etc/x-ui panel DB + config /usr/local/x-ui panel binary + xray core /usr/bin/x-ui x-ui management CLI /usr/local/lib/3x-ui-pro mtr-backend script /etc/letsencrypt SSL certificates /root/cert panel cert symlinks /var/www/{html,diagnostics,subpage} web content /etc/systemd/system/{x-ui,mtr-backend}.service /etc/ufw/user*.rules firewall rules root crontab + /etc/cron.d/ EOF exit 1 ;; esac