--restore: файлы настроек копии – только печатные ASCII, нулевой байт больше не выключает проверку

Нулевой байт в kit.env, install-result.env или kit-backup.env заставлял GNU grep считать
файл двоичным и молчать: safe_env «проходил», а bash при «.» выкидывал нулевые байты
и выполнял остальное (лишние имена вроде PATH и KIT_CLI_URL).

- проверка архива отклоняет три файла настроек с байтами вне 0x20–0x7E и \n (и больше 64 КиБ),
  3x-ui.txt – не UTF-8 или с управляющими символами (кроме \n и \t), больше 1 МиБ;
  проверяются все записи с таким именем, в том числе повторные;
- safe_env: сначала tr – только печатные ASCII и \n, затем grep -a под LC_ALL=C;
- grep -a там, где kit и установщик читают свои файлы настроек (kit.sh: замена токена API);
- отрицательные тесты: \0, \r, \x1b, \x80, \xff в начале, в середине, в конце, отдельной строкой
  и в значении в каждом из трёх файлов, повторная запись в архиве, 3x-ui.txt.
This commit is contained in:
maxdmitrov
2026-10-06 18:58:38 +03:00
parent 436acffc22
commit 018d9cefa2
6 changed files with 92 additions and 6 deletions
+1 -1
View File
@@ -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` теперь читает строго по списку, как охранник на входе: лишнее имя – и копия не принимается, ничего не меняется. Охранник теперь смотрит и в лицо: каждое значение проверяется по своему формату (порт – цифры, путь – буквы и цифры, адрес – адрес), а странное – повод не пустить копию, а не молча что-то поправить.
* Исправил `--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».
* Сайт-заглушка теперь у каждого сервера своя: при установке она собирается из двадцати тем и пяти вёрсток со случайным названием, текстами и цветами. Пекарня, переводчик или мастерская по велосипедам – как повезёт. Раньше у многих серверов был один и тот же «облачный» сосед. Hysteria2 показывает ту же страницу байт в байт: раньше теряла по дороге последний перевод строки – мелочь, а близнецы должны быть одинаковыми.
* `kit-sub` на своём порту (`--multi-port`) отвечает по HTTP/1.1 и с `Server: nginx` без лишнего пробела, а на бессмыслицу вместо запроса – честным `400 Bad Request` со строкой статуса. Ещё он говорит `Connection: keep-alive` и соглашается на `http/1.1` при знакомстве по TLS. Как настоящий nginx, а не его двоюродный брат.
+1 -1
View File
@@ -250,7 +250,7 @@ bash 3x-ui.sh --restore /root/kit-backup-ДАТА.tar.gz -y
не запустилось, скрипт не скажет «Готово», а покажет, где искать причину.
Восстанавливайте только из своей копии. Архив с чужими путями, ссылками, лишними настройками или значениями
не в своём формате (порт – не число, путь со странными символами) скрипт не примет («Это не резервная копия 3X-UI KIT…») и ничего не изменит.
не в своём формате (порт – не число, путь со странными символами, непечатные байты в файлах настроек) скрипт не примет («Это не резервная копия 3X-UI KIT…») и ничего не изменит.
## Параметры
+25 -3
View File
@@ -1703,10 +1703,14 @@ sub_links() {
# Иначе чужой архив подменил бы переменные установщика или kit (RESULT, PATH, KIT_VERSION…).
# Значение каждого имени – тоже по его формату: kit потом подставляет их в sed, nginx и ссылки.
# Кавычки – только пустые (''): так printf %q пишет пустое значение, остальное у нас без кавычек.
# Сначала – только печатные ASCII и перевод строки: файл с нулевым байтом GNU grep считает двоичным и молчит
# (проверка «прошла» бы), а bash при «.» нулевые байты выкидывает и выполняет остальное. Поэтому и grep -a.
safe_env() { # файл имя...
local f=$1 n re; shift
[[ -r $f ]] || die "В копии нет файла ${f##*/}."
grep -qvE "^([A-Z][A-Z0-9_]*=([A-Za-z0-9._:/@%+,=_-]*|''))?$" "$f" \
[[ $(LC_ALL=C tr -d '\n -~' <"$f" | wc -c) -eq 0 ]] \
|| die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял."
LC_ALL=C grep -aqvE "^([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##*/}). Ничего не менял."
@@ -1731,7 +1735,7 @@ safe_env() { # файл имя...
esac
[[ $(env_get "$f" "$n") =~ $re ]] \
|| die "Это не резервная копия 3X-UI KIT или она повреждена (значение $n в ${f##*/}). Ничего не менял."
done < <(grep -oE '^[A-Z][A-Z0-9_]*' "$f")
done < <(LC_ALL=C grep -aoE '^[A-Z][A-Z0-9_]*' "$f")
return 0
}
@@ -1888,6 +1892,24 @@ with tarfile.open(sys.argv[1], "r:gz") as t:
for need in ("etc/x-ui/x-ui.db", "etc/x-ui/install-result.env", "kit-backup.env"):
if need not in names:
sys.exit("нет " + need)
# Файлы настроек потом подключаются от root: в них только печатные ASCII и перевод строки. Нулевой байт,
# \r или не-ASCII ломают построчную проверку (GNU grep считает такой файл двоичным). Данные для входа
# (3x-ui.txt) показываются в терминале: только UTF-8 без управляющих символов, кроме \n и \t.
for m in ms:
if not m.isfile():
continue
if m.name in ("etc/x-ui/install-result.env", "etc/kit/kit.env", "kit-backup.env"):
data = t.extractfile(m).read(65537)
if len(data) > 65536 or data.translate(None, bytes(range(0x20, 0x7f)) + b"\n"):
sys.exit("байты " + m.name)
elif m.name == "root/3x-ui.txt":
data = t.extractfile(m).read(1048577)
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
sys.exit("байты " + m.name)
if len(data) > 1048576 or any((c < " " and c not in "\n\t") or "\x7f" <= c <= "\x9f" for c in text):
sys.exit("байты " + m.name)
# Каталог сертификата есть – в нём оба файла, иначе установка споткнётся на полпути.
files = {m.name for m in ms if m.isfile() and m.size > 0}
for c in ("root/cert/self", "root/cert/custom"):
@@ -2023,7 +2045,7 @@ PY
if [[ -f /etc/kit/kit.env ]]; then
install_kit_file
# Панель по домену: nginx собран заново, значит блок домена нужно вернуть (домен должен указывать на этот сервер).
if grep -q '^PANEL_ON=domain' /etc/kit/kit.env; then
if grep -aq '^PANEL_ON=domain' /etc/kit/kit.env; then
sed -i 's/^PANEL_ON=.*/PANEL_ON=ip/' /etc/kit/kit.env
/usr/local/bin/kit net panel domain >/dev/null 2>&1 || { /usr/local/bin/kit __xver 0 >/dev/null 2>&1 || true; warn "Панель по домену не включилась (домен должен указывать на этот сервер): kit net panel domain"; }
fi
+1 -1
View File
@@ -61,7 +61,7 @@ if [[ $API_OK == no && -x /usr/local/x-ui/x-ui ]]; then
done
if [[ $API_OK == yes ]]; then
XUI_API_TOKEN=$new_token
env_tmp=$(mktemp); { grep -v '^XUI_API_TOKEN=' "$XUI_ENV" || true; printf 'XUI_API_TOKEN=%q\n' "$new_token"; } >"$env_tmp"
env_tmp=$(mktemp); { grep -av '^XUI_API_TOKEN=' "$XUI_ENV" || true; printf 'XUI_API_TOKEN=%q\n' "$new_token"; } >"$env_tmp"
install -m 600 "$env_tmp" "$XUI_ENV"; rm -f "$env_tmp"
warn "Токен API панели в $XUI_ENV не подошёл – выпустил новый и сохранил (старый остаётся в панели, его можно отозвать: Настройки – API-токены)."
fi
+32
View File
@@ -123,6 +123,38 @@ class RestoreEnv(unittest.TestCase):
for v in (b"XUI_WEB_BASE_PATH='x/ { return 200; } location /zz'", b"XUI_WEB_BASE_PATH=x/../y", b"XUI_WEB_BASE_PATH=$(id)"):
self.bad({"etc/x-ui/install-result.env": ENVS["etc/x-ui/install-result.env"].replace(b"XUI_WEB_BASE_PATH=AbCdEf123456789012", v)})
def test_binary_bytes_every_file(self):
# Аудит 1.2 (N6): нулевой байт и прочее непечатное в файлах настроек отклоняет уже проверка архива.
for name, body in ENVS.items():
first = body.index(b"\n") + 1
for bad in (b"\0", b"\r", b"\x1b", b"\x80", b"\xff", b"\t", b"\x7f"):
for variant in (bad + body, body[:first] + bad + b"\n" + body[first:],
body.replace(b"1.2.3.4", b"1.2" + bad + b".3.4", 1), body + b"PATH=/tmp/x" + bad + b"\n",
body + b"PATH=/tmp/x\n" + bad, body + bad + b"\n"):
r = check(archive([(k, v) for k, v in dict(ENVS, **{name: variant}).items()]))
self.assertNotEqual(r.returncode, 0, "%s %r" % (name, variant))
self.assertIn("байты " + name, r.stderr)
# Слишком большой файл (одна длинная строка) – тоже отказ.
r = check(archive([(k, v) for k, v in dict(ENVS, **{name: body + b"X" * 70000 + b"\n"}).items()]))
self.assertIn("байты " + name, r.stderr)
# Тот же файл в архиве дважды (tar распакует последний) – проверяются оба.
p = archive([(k, v) for k, v in ENVS.items()])
with tarfile.open(p, "r:gz") as t:
ms = [(m, t.extractfile(m).read()) for m in t.getmembers()]
with tarfile.open(p, "w:gz") as t:
for m, data in ms + [(tarfile.TarInfo(name), body + b"\0")]:
m.size = len(data)
t.addfile(m, io.BytesIO(data))
self.assertIn("байты " + name, check(p).stderr)
def test_3xui_txt(self):
good = "3X-UI KIT – данные для входа\n\nПанель: https://1.2.3.4/x/\n\tvless://a@1.2.3.4:443?x=1#Мой 🙂\n".encode()
r = check(archive([(k, v) for k, v in ENVS.items()] + [("root/3x-ui.txt", good)]))
self.assertEqual(r.returncode, 0, r.stderr)
for bad in (b"\0", b"\x1b]0;x\x07", b"\x1b[2J", b"\r", b"\xff", b"\xc2\x9b", b"\x7f"):
r = check(archive([(k, v) for k, v in ENVS.items()] + [("root/3x-ui.txt", good + bad + b"\n")]))
self.assertIn("байты root/3x-ui.txt", r.stderr, repr(bad))
def test_values_every_file(self):
for name in ENVS:
for line in ENVS[name].splitlines():
+32
View File
@@ -119,6 +119,38 @@ class SafeEnv(unittest.TestCase):
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_binary_bytes(self):
# Аудит 1.2 (N6): с нулевым байтом GNU grep без -a считал файл двоичным и молчал – проверка «проходила»,
# а bash при «.» выкидывает нулевые байты и выполняет остальное. Лишнее имя PATH плюс один байт в каждом месте.
for name, text in GOOD.items():
body = text.encode()
first = body.index(b"\n") + 1
for bad in (b"\0", b"\r", b"\x1b", b"\x80", b"\xff"):
for variant in (bad + body, # в начале
body[:first] + bad + b"\n" + body[first:], # отдельной строкой
body[:5] + bad + body[5:], # в середине имени или в начале значения
body.replace(b"1.2.3.4", b"1.2" + bad + b".3.4", 1), # в середине значения
body + b"PATH=/tmp/x" + bad + b"\n", # в значении лишнего имени
body + b"PATH=/tmp/x\n" + bad, # в конце, без перевода строки
body + b"PATH=/tmp/x\n" + bad + b"\n"): # в конце, одиночный
self.rejected_bytes(name, variant)
# Без лишнего имени, только байт – тоже отказ.
self.rejected_bytes(name, body + b"\0\n")
self.rejected_bytes(name, body.replace(b"\n", b"\r\n"))
def rejected_bytes(self, name, data):
d = tempfile.mkdtemp()
p = os.path.join(d, name)
with open(p, "wb") as fh:
fh.write(data)
script = ('set -Eeuo pipefail\ndie() { echo "DIE: $*"; exit 1; }\n' + funcs
+ 'safe_env "$1" ' + " ".join(NAMES[name]) + '\necho OK\n')
r = subprocess.run(["bash", "-c", script, "t", p], capture_output=True)
out = (r.stdout + r.stderr).decode("utf-8", "replace")
self.assertNotEqual(r.returncode, 0, "%s %r: %s" % (name, data, out))
self.assertIn("Это не резервная копия 3X-UI KIT", out, "%s %r" % (name, data))
self.assertNotIn("\nOK\n", "\n" + out)
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")