1.1: подписанное автообновление, kit backup и --restore, случайная заглушка

- kit update ставит только релизы, у которых SHA256SUMS подписан ключом автора
  (ssh-keygen -Y verify), версия внутри подписи совпадает, файлы сошлись с хешами;
  откат на старую версию запрещён; после обновления проверяется подписка, иначе возврат
- автообновление: systemd-таймер раз в сутки ночью со случайной задержкой,
  kit update --manual / --auto, журнал /var/log/kit-update.log
- hy2 update тоже проверяет подпись
- tools/release.sh: проверка версий и хеша установщика 3X-UI, SHA256SUMS и подпись
- kit backup и 3x-ui.sh --restore: архив 600, строгая проверка архива, конфиги nginx
  и kit-sub собираются заново, смена IP в подключениях
- сайт-заглушка: случайный из шести шаблонов внутри скрипта
- zashboard закреплён на v3.29.1, короткие тире и 192.168.x.x в текстах и картинках
- CI: ключ подписи одинаковый в kit и hy2, проверка подписи релиза
- CHANGELOG, README, инструкция, SECURITY.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7GrBSRPVXhrz3RAprWDnt
This commit is contained in:
Claude
2026-09-30 19:15:19 +00:00
parent d33a8bd9a6
commit 0a19858efa
30 changed files with 944 additions and 323 deletions
+15 -15
View File
@@ -1,16 +1,16 @@
#!/usr/bin/env python3
"""Подписка с учётом приложения — посредник перед подпиской 3X-UI.
"""Подписка с учётом приложения – посредник перед подпиской 3X-UI.
https://github.com/itsnotkubrick/3X-UI_KIT
Слушает публичный адрес подписки (HTTPS) и ходит в подписку 3X-UI на 127.0.0.1:
* Clash / Mihomo (Clash Verge, FlClash, Mihomo Party…) — конфиг 3X-UI плюс AmneziaWG
* Clash / Mihomo (Clash Verge, FlClash, Mihomo Party…) – конфиг 3X-UI плюс AmneziaWG
из подписки «<id>-awg»: Mihomo умеет AmneziaWG, а остальные приложения нет;
* остальные приложения и браузер — ответ 3X-UI как есть (ссылки или страница);
* заголовок Subscription-Userinfo: expire=0 («бессрочно») убирается — иначе
* остальные приложения и браузер – ответ 3X-UI как есть (ссылки или страница);
* заголовок Subscription-Userinfo: expire=0 («бессрочно») убирается – иначе
приложения показывают срок «01.01.1970».
Настройки — /etc/kit-sub/config.json. Сертификат перечитывается сам после продления.
Настройки – /etc/kit-sub/config.json. Сертификат перечитывается сам после продления.
"""
import base64
@@ -64,14 +64,14 @@ def upstream(sub_id, ua, host, accept):
def fix_userinfo(value):
# «expire=0» значит «бессрочно», но приложения рисуют 01.01.1970 — убираем.
# «expire=0» значит «бессрочно», но приложения рисуют 01.01.1970 – убираем.
parts = [p.strip() for p in value.split(";") if p.strip() and p.strip() != "expire=0"]
return "; ".join(parts)
def strip_links(body):
"""Список ссылок (base64 или текст) без vpn:// и tg:// — их не умеет ни одно VPN-приложение
со ссылками: vpn:// — конфиг для AmneziaVPN, tg:// — прокси для Telegram."""
"""Список ссылок (base64 или текст) без vpn:// и tg:// – их не умеет ни одно VPN-приложение
со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram."""
text = body.decode("utf-8", "replace").strip()
encoded = "://" not in text
if encoded:
@@ -85,7 +85,7 @@ def strip_links(body):
def strip_awg(clash_yaml):
"""Clash-конфиг без AmneziaWG — для приложений, которые его не умеют."""
"""Clash-конфиг без AmneziaWG – для приложений, которые его не умеют."""
cfg = yaml.safe_load(clash_yaml)
if not isinstance(cfg, dict):
return clash_yaml
@@ -110,7 +110,7 @@ def merge_awg(main_yaml, awg_yaml):
return main_yaml
names = {p.get("name") for p in main.get("proxies") or []}
for p in extra:
# 3X-UI дописывает к имени запись-«двойника» («AmneziaWG-3.1-sasha-awg») — убираем хвост.
# 3X-UI дописывает к имени запись-«двойника» («AmneziaWG-3.1-sasha-awg») – убираем хвост.
p["name"] = re.sub(r"-[^-\s]+-awg\d*$", "", p["name"]) or p["name"]
base, n = p["name"], 2
while p["name"] in names:
@@ -133,7 +133,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
timeout = 20 # зависшие соединения не держим
def setup(self):
# TLS-рукопожатие — в потоке запроса, а не в общем цикле приёма соединений.
# TLS-рукопожатие – в потоке запроса, а не в общем цикле приёма соединений.
# Без сертификата (за nginx, на 127.0.0.1) работаем по обычному HTTP.
self.request.settimeout(self.timeout)
if self.server.ssl_ctx is not None:
@@ -177,13 +177,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
clash = bool(CLASH_UA.search(ua)) and "yaml" in headers.get("content-type", "")
awg = clash and not NO_AWG_UA.search(ua)
# В журнал — только приложение и что ему отдали, без IP.
# В журнал – только приложение и что ему отдали, без IP.
log(f"{ua[:80]!r} → {'clash+awg' if awg else 'clash' if clash else headers.get('content-type', '?').split(';')[0]}")
try:
if code == 200 and clash and not awg:
body = strip_awg(body)
elif code == 200 and awg and not sub_id.endswith(("-awg", "-tg")):
# Установки до kit 1.1 держали AmneziaWG в подписке «<id>-awg» — подмешиваем её.
# Установки до kit 1.1 держали AmneziaWG в подписке «<id>-awg» – подмешиваем её.
acode, _, abody = upstream(sub_id + "-awg", ua, host, accept)
if acode == 200 and abody:
body = merge_awg(body, abody)
@@ -208,7 +208,7 @@ class Server(http.server.ThreadingHTTPServer):
daemon_threads = True
ssl_ctx = None
def handle_error(self, request, client_address): # обрывы TLS от сканеров — не ошибка
def handle_error(self, request, client_address): # обрывы TLS от сканеров – не ошибка
pass
address_family = socket.AF_INET6 if ":" in CONF.get("listen", "") else socket.AF_INET
@@ -228,7 +228,7 @@ def main():
stamp = [os.path.getmtime(cert)]
def reload_cert():
# Let's Encrypt на IP живёт 6 дней — после продления берём новый сертификат без перезапуска.
# Let's Encrypt на IP живёт 6 дней – после продления берём новый сертификат без перезапуска.
while True:
time.sleep(600)
try: