From 43835928bbe74efb8c88903810b84ea52565ec8a Mon Sep 17 00:00:00 2001 From: maxdmitrov <56165502+maxdmitrov@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:41:16 +0300 Subject: [PATCH] =?UTF-8?q?--restore:=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8=20=E2=80=93=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D0=BE=20=D0=B1=D0=B5=D0=BB?= =?UTF-8?q?=D0=BE=D0=BC=D1=83=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D1=83=20?= =?UTF-8?q?=D0=B8=D0=BC=D1=91=D0=BD,=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2?= =?UTF-8?q?=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kit-backup.env больше не подключается через «.» в установщике: значения читаются в подоболочке (env_get). safe_env проверяет имена для каждого файла (kit-backup.env, install-result.env, kit.env); лишнее имя – «Это не резервная копия 3X-UI KIT…». RESULT, XUI_ENV, PATH, IFS, KIT_* из архива не принимаются, HOST в kit.env должен совпадать с адресом копии, порт панели – число. Тест: tools/test/restore_env_test.py. --- CHANGELOG.md | 2 +- manuals/3x-ui.md | 3 ++ scripts/3x-ui.sh | 50 +++++++++++++++------ tools/test/restore_env_test.py | 79 ++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 15 deletions(-) create mode 100644 tools/test/restore_env_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 56db016..b48d1a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ * `kit check --deep` проверяет подключение к серверу, `--fix` лечит безопасное. Общий лимит трафика считается по всем подключениям пользователя, а в подписке Clash есть «Авто»: клиент сам выбирает быстрейший протокол. DNS в подписках теперь по DoH через прокси: запросы имён не гуляют где попало. * Ссылка подписки в браузере открывается страницей с трафиком, сроком и ссылками (а не белым экраном). Рядом с подпиской теперь ссылки «открыть в приложении»: одно нажатие, и подписка добавлена с названием. Со своим доменом панель и подписку можно открывать и по домену: `--panel-on domain` при установке или `kit net panel domain` потом. Вернулся `--restore`: чистый сервер поднимается из `kit backup`, как из резервной копии настроек телефона. Ссылки `kit user link --amnezia` и `--telegram` для AmneziaVPN и Telegram теперь одной командой, а исправление времени на Debian 13 прислал [xlok1e](https://github.com/xlok1e), спасибо. * Раздельная маршрутизация: `kit net split on` – и через VPN идёт только список популярных иностранских сервисов (нейросети, видео, соцсети, мессенджеры, GitHub, Google и другие), а всё остальное напрямую. Правила приезжают вместе с подпиской, домашним ничего настраивать не нужно: приложения на Mihomo (Clash Verge, FlClash, Mihomo Party) берут их из подписки, Happ – заголовком, приложения на JSON-подписке Xray – из конфига. DNS не течёт, проверено клиентами Mihomo и Xray. Список в `/etc/kit-sub/rules.yaml`, проверка по базам категорий `kit net split check`, после правки `kit net split apply`, он входит в `kit backup`. Идея и первый набросок – [VasilevVitalii](https://github.com/VasilevVitalii), спасибо. У v2rayN, Karing, Hiddify и приложений с обычным списком ссылок подписка прежняя. -* Исправил `--restore` на сервер с другим IP: ссылки части протоколов (адреса в таблице `hosts` панели и в настройках kit-sub) оставались со старым IP, а проверка панели после запуска могла ложно сообщить «Панель не отвечает», если панель стартует не сразу. +* Исправил `--restore` на сервер с другим IP: ссылки части протоколов (адреса в таблице `hosts` панели и в настройках kit-sub) оставались со старым IP, а проверка панели после запуска могла ложно сообщить «Панель не отвечает», если панель стартует не сразу. А файлы настроек из копии `--restore` теперь читает строго по списку, как охранник на входе: лишнее имя – и копия не принимается, ничего не меняется. * Исправил `kit backup`: из `/root` он падал с «No such file or directory», работал только из `/`. Теперь работает откуда угодно. Если токен API панели в `/etc/x-ui/install-result.env` не подошёл (так бывает после обновления с 1.0), `kit` сам выпускает новый и сохраняет его, а если панель всё же не отвечает – говорит по-человечески (код ответа и вероятная причина), а не «Unsupported HTTP version in response». * JSON-подписка снова открывается в приложениях на Xray: там, где у vless не было `encryption`, подписка теперь дописывает `none`. diff --git a/manuals/3x-ui.md b/manuals/3x-ui.md index 363be8e..1eb9186 100644 --- a/manuals/3x-ui.md +++ b/manuals/3x-ui.md @@ -238,6 +238,9 @@ bash 3x-ui.sh --restore /root/kit-backup-ДАТА.tar.gz -y Подключения, ключи, пользователи и их лимиты вернутся прежними. Если адрес сервера сменился, подписку клиентам нужно добавить заново, а на домене ничего менять не придётся: достаточно переключить DNS. +Восстанавливайте только из своей копии. Архив с чужими путями, ссылками или лишними настройками скрипт +не примет («Это не резервная копия 3X-UI KIT…») и ничего не изменит. + ## Параметры | Параметр | Что делает | diff --git a/scripts/3x-ui.sh b/scripts/3x-ui.sh index a7ec1c3..fcebe5b 100755 --- a/scripts/3x-ui.sh +++ b/scripts/3x-ui.sh @@ -1653,14 +1653,25 @@ sub_links() { if grep -q '://' <<<"$raw"; then echo "$raw"; else base64 -d <<<"$raw" 2>/dev/null || true; fi } -# Файл настроек из копии можно подключать, только если в нём нет ничего, кроме -# ИМЯ=значение без подстановок и команд. -safe_env() { - [[ -r $1 ]] || die "В копии нет файла ${1##*/}." - grep -qvE "^([A-Z][A-Z0-9_]*=([A-Za-z0-9._:/@%+,=_-]*|'[^']*'))?$" "$1" && die "В копии подозрительный файл ${1##*/} – не восстанавливаю." +# Файл настроек из копии можно читать, только если в нём нет ничего, кроме +# ИМЯ=значение без подстановок и команд, и все имена – из списка для этого файла. +# Иначе чужой архив подменил бы переменные установщика или kit (RESULT, PATH, KIT_VERSION…). +safe_env() { # файл имя... + local f=$1 n; shift + [[ -r $f ]] || die "В копии нет файла ${f##*/}." + grep -qvE "^([A-Z][A-Z0-9_]*=([A-Za-z0-9._:/@%+,=_-]*|'[^']*'))?$" "$f" \ + && die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял." + while IFS= read -r n; do + [[ " $* " == *" $n "* ]] || die "Это не резервная копия 3X-UI KIT или она повреждена (лишнее имя $n в ${f##*/}). Ничего не менял." + done < <(grep -oE '^[A-Z][A-Z0-9_]*' "$f") return 0 } +# Одно значение из файла, проверенного safe_env: читаем в подоболочке, основной shell не трогаем. +env_get() { # файл имя + ( . "$1"; printf '%s' "${!2:-}" ) +} + # Меняет старый IP на новый в базе панели и файлах kit (только целиком, 1.2.3.4 не заденет 11.2.3.45). replace_host() { # старый новый файлы... python3 - "$@" <<'PY' @@ -1736,13 +1747,22 @@ with tarfile.open(sys.argv[1], "r:gz") as t: t.extractall(sys.argv[2], members=ms) PY local f - for f in "$tmp/kit-backup.env" "$tmp/etc/x-ui/install-result.env" "$tmp/etc/kit/kit.env"; do if [[ -f $f ]]; then safe_env "$f"; fi; done + safe_env "$tmp/kit-backup.env" BACKUP_KIT_VERSION BACKUP_HOST BACKUP_SSL BACKUP_DATE + safe_env "$tmp/etc/x-ui/install-result.env" XUI_USERNAME XUI_PASSWORD XUI_PANEL_PORT XUI_WEB_BASE_PATH XUI_ACCESS_URL XUI_API_TOKEN XUI_DB_TYPE + [[ $(env_get "$tmp/etc/x-ui/install-result.env" XUI_PANEL_PORT) =~ ^[0-9]{1,5}$ ]] \ + || die "Это не резервная копия 3X-UI KIT или она повреждена (порт панели). Ничего не менял." + if [[ -f $tmp/etc/kit/kit.env ]]; then + safe_env "$tmp/etc/kit/kit.env" HOST LINK_HOST PANEL_ON SUB_BASE SUB_PATH SUB_INTERNAL SINGLE MTPROTO_INNER + fi python3 -c "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); assert c.execute('PRAGMA integrity_check').fetchone()[0]=='ok'; c.execute('SELECT count(*) FROM inbounds')" \ "$tmp/etc/x-ui/x-ui.db" 2>/dev/null || die "База панели в копии повреждена. Ничего не менял." - local BACKUP_HOST="" BACKUP_SSL="" BACKUP_DATE="" BACKUP_KIT_VERSION="" - # shellcheck disable=SC1090 - . "$tmp/kit-backup.env" + local BACKUP_HOST BACKUP_SSL BACKUP_DATE BACKUP_KIT_VERSION + BACKUP_HOST=$(env_get "$tmp/kit-backup.env" BACKUP_HOST) + BACKUP_SSL=$(env_get "$tmp/kit-backup.env" BACKUP_SSL) + BACKUP_DATE=$(env_get "$tmp/kit-backup.env" BACKUP_DATE) + BACKUP_KIT_VERSION=$(env_get "$tmp/kit-backup.env" BACKUP_KIT_VERSION) + [[ $BACKUP_HOST =~ ^[A-Za-z0-9.:-]+$ ]] || die "Это не резервная копия 3X-UI KIT или она повреждена (адрес сервера). Ничего не менял." [[ $BACKUP_SSL =~ ^(ip|custom|none)$ ]] || die "В копии нет данных о сертификате." PANEL_SSL=$BACKUP_SSL # Домен переезжает вместе с сервером (поменяйте A-запись), IP – нет. @@ -1777,12 +1797,14 @@ PY # проверенные значения (путь подписки, порты, режим). SINGLE=no; SUB_PATH=""; SUB_INTERNAL=""; SUB_PORT="" if [[ -f $tmp/etc/kit/kit.env ]]; then - SINGLE=$(. "$tmp/etc/kit/kit.env"; echo "${SINGLE:-no}") - SUB_PATH=$(. "$tmp/etc/kit/kit.env"; echo "${SUB_PATH:-}") - LINK_HOST=$(. "$tmp/etc/kit/kit.env"; echo "${LINK_HOST:-}") + SINGLE=$(env_get "$tmp/etc/kit/kit.env" SINGLE); SINGLE=${SINGLE:-no} + SUB_PATH=$(env_get "$tmp/etc/kit/kit.env" SUB_PATH) + LINK_HOST=$(env_get "$tmp/etc/kit/kit.env" LINK_HOST) [[ $LINK_HOST =~ ^[A-Za-z0-9.:-]*$ ]] || LINK_HOST="" - SUB_INTERNAL=$(. "$tmp/etc/kit/kit.env"; echo "${SUB_INTERNAL:-}") - [[ $SINGLE =~ ^(yes|no)$ && $SUB_PATH =~ ^/[A-Za-z0-9_-]+/$ && $SUB_INTERNAL =~ ^[0-9]{1,5}$ ]] \ + SUB_INTERNAL=$(env_get "$tmp/etc/kit/kit.env" SUB_INTERNAL) + # kit потом подключает этот файл от root: адрес в нём должен быть тем же, что в копии. + [[ $SINGLE =~ ^(yes|no)$ && $SUB_PATH =~ ^/[A-Za-z0-9_-]+/$ && $SUB_INTERNAL =~ ^[0-9]{1,5}$ \ + && $(env_get "$tmp/etc/kit/kit.env" HOST) == "$BACKUP_HOST" ]] \ || die "В копии странные настройки kit (kit.env) – не восстанавливаю." fi if [[ -f $tmp/etc/kit-sub/config.json ]]; then diff --git a/tools/test/restore_env_test.py b/tools/test/restore_env_test.py new file mode 100644 index 0000000..2fcbe08 --- /dev/null +++ b/tools/test/restore_env_test.py @@ -0,0 +1,79 @@ +"""Проверки разбора файлов настроек из резервной копии (safe_env, env_get в 3x-ui.sh): +python3 tools/test/restore_env_test.py""" +import os, re, subprocess, tempfile, unittest + +root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +src = open(os.path.join(root, "scripts", "3x-ui.sh"), encoding="utf-8").read() +funcs = "".join(re.search(r"^%s\(\) \{.*?^\}\n" % n, src, re.S | re.M).group(0) for n in ("safe_env", "env_get")) + +BACKUP = ("BACKUP_KIT_VERSION", "BACKUP_HOST", "BACKUP_SSL", "BACKUP_DATE") +XUI = ("XUI_USERNAME", "XUI_PASSWORD", "XUI_PANEL_PORT", "XUI_WEB_BASE_PATH", "XUI_ACCESS_URL", "XUI_API_TOKEN", "XUI_DB_TYPE") +KIT = ("HOST", "LINK_HOST", "PANEL_ON", "SUB_BASE", "SUB_PATH", "SUB_INTERNAL", "SINGLE", "MTPROTO_INNER") + +# Как пишут kit backup, установщик 3X-UI и install_kit_cli (printf %q). +GOOD = { + "kit-backup.env": "BACKUP_KIT_VERSION=1.2\nBACKUP_HOST=1.2.3.4\nBACKUP_SSL=ip\nBACKUP_DATE=2026-10-06\n", + "install-result.env": "XUI_USERNAME=abcDEF1234\nXUI_PASSWORD=Pass1234567890abcdef\nXUI_PANEL_PORT=31234\n" + "XUI_WEB_BASE_PATH=AbCdEf123456789012\nXUI_ACCESS_URL=https://1.2.3.4:31234/AbCdEf123456789012\n" + "XUI_API_TOKEN=tok_ABCdef0123456789\nXUI_DB_TYPE=sqlite\n", + "kit.env": "HOST=1.2.3.4\nLINK_HOST=1.2.3.4\nPANEL_ON=ip\nSUB_BASE=https://1.2.3.4/AbC123/\nSUB_PATH=/AbC123/\n" + "SUB_INTERNAL=2096\nSINGLE=yes\nMTPROTO_INNER=''\n", +} +NAMES = {"kit-backup.env": BACKUP, "install-result.env": XUI, "kit.env": KIT} + + +def run(name, text, extra=""): + d = tempfile.mkdtemp() + p = os.path.join(d, name) + open(p, "w").write(text) + script = ('set -Eeuo pipefail\ndie() { echo "DIE: $*"; exit 1; }\n' + funcs + + 'RESULT=/root/3x-ui.txt\nsafe_env "$1" ' + " ".join(NAMES[name]) + '\necho OK\n' + extra) + r = subprocess.run(["bash", "-c", script, "t", p], capture_output=True, text=True) + return r.returncode, r.stdout + r.stderr + + +class SafeEnv(unittest.TestCase): + def test_good_copy(self): + for name, text in GOOD.items(): + code, out = run(name, text) + self.assertEqual(code, 0, out) + self.assertIn("OK", out) + + def test_values(self): + code, out = run("kit-backup.env", GOOD["kit-backup.env"], + 'echo "[$(env_get "$1" BACKUP_HOST)|$(env_get "$1" BACKUP_SSL)|$(env_get "$1" NOPE)]"\n') + self.assertIn("[1.2.3.4|ip|]", out) + code, out = run("kit.env", GOOD["kit.env"], 'echo "[$(env_get "$1" SUB_PATH)|$(env_get "$1" MTPROTO_INNER)]"\n') + self.assertIn("[/AbC123/|]", out) + + def test_env_get_does_not_touch_shell(self): + code, out = run("kit-backup.env", GOOD["kit-backup.env"], 'env_get "$1" BACKUP_HOST >/dev/null; echo "[${BACKUP_HOST:-none}]"\n') + self.assertIn("[none]", out) + + def rejected(self, name, text): + code, out = run(name, text, 'echo "RESULT=$RESULT"\n') + self.assertNotEqual(code, 0, out) + self.assertIn("Это не резервная копия 3X-UI KIT", out) + self.assertNotIn("OK", out) + + def test_result_in_backup_env(self): + self.rejected("kit-backup.env", GOOD["kit-backup.env"] + "RESULT=/etc/cron.d/x\n") + + def test_kit_version_in_kit_env(self): + self.rejected("kit.env", GOOD["kit.env"] + "KIT_VERSION=9.9\n") + + def test_foreign_names(self): + for bad in ("PATH=/tmp", "IFS=x", "XUI_ENV=/etc/cron.d/x", "HOST=5.6.7.8", "KIT_RAW=https://example.org/"): + self.rejected("kit-backup.env", GOOD["kit-backup.env"] + bad + "\n") + for bad in ("PATH=/tmp", "RESULT=/etc/cron.d/x", "XUI_ENV=/tmp/x", "KIT_VERSION=9.9"): + self.rejected("install-result.env", GOOD["install-result.env"] + bad + "\n") + for bad in ("RESULT=/etc/cron.d/x", "XUI_PIN=v0", "BACKUP_HOST=1.2.3.4"): + self.rejected("kit.env", GOOD["kit.env"] + bad + "\n") + + def test_substitutions(self): + for bad in ("BACKUP_DATE=$(id)", "BACKUP_DATE=`id`", 'BACKUP_DATE="$HOME"', "export BACKUP_DATE=1", "BACKUP_DATE=1; id", " BACKUP_DATE=1"): + self.rejected("kit-backup.env", GOOD["kit-backup.env"] + bad + "\n") + + +if __name__ == "__main__": + unittest.main()