diff --git a/CHANGELOG.md b/CHANGELOG.md index 52195b1..13b8e80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * Раздельная маршрутизация: `kit net split on` – и через VPN идёт только список популярных иностранских сервисов (нейросети, видео, соцсети, мессенджеры, GitHub, Google и другие), а всё остальное напрямую. Правила приезжают вместе с подпиской, домашним ничего настраивать не нужно: приложения на Mihomo (Clash Verge, FlClash, Mihomo Party) берут их из подписки, Happ – заголовком, приложения на JSON-подписке Xray – из конфига. DNS не течёт: имена из списка у клиента не разрешаются, а запросы про них идут по DoH через VPN (проверено настоящими клиентами 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, а проверка панели после запуска могла ложно сообщить «Панель не отвечает», если панель стартует не сразу. * Исправил `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`. Сервер обновится сам этой ночью, нетерпеливым – `kit update`. diff --git a/scripts/kit-sub.py b/scripts/kit-sub.py index 514470f..3ebf000 100644 --- a/scripts/kit-sub.py +++ b/scripts/kit-sub.py @@ -91,6 +91,42 @@ def fix_userinfo(value): return "; ".join(parts) +JSON_FIX_MAX = 2 * 1024 * 1024 + + +def fix_vless_encryption(body): + """JSON-подписка панели: у vless-пользователя без encryption (нет поля или пусто) ставим "none" – Xray иначе + не принимает конфиг. Две формы: vnext[].users[] и плоская (settings.id/encryption). Непустое значение + (например, mlkem) не трогаем. Не разобралось, слишком большое или менять нечего – тело как пришло.""" + if len(body) > JSON_FIX_MAX: + return body + try: + data = json.loads(body.decode("utf-8")) + except (ValueError, UnicodeError): + log("JSON-подписка: тело не разобралось, отдаю как есть") + return body + changed = False + + def fix(user): + nonlocal changed + if isinstance(user, dict) and (user.get("encryption") is None or user.get("encryption") == ""): + user["encryption"] = "none" + changed = True + + for cfg in (data if isinstance(data, list) else [data]): + outs = cfg.get("outbounds") if isinstance(cfg, dict) else None + for o in outs if isinstance(outs, list) else []: + st = o.get("settings") if isinstance(o, dict) and o.get("protocol") == "vless" else None + if not isinstance(st, dict): + continue + for v in st.get("vnext") if isinstance(st.get("vnext"), list) else []: + for u in v.get("users") if isinstance(v, dict) and isinstance(v.get("users"), list) else []: + fix(u) + if "id" in st: + fix(st) + return json.dumps(data, ensure_ascii=False).encode() if changed else body + + def strip_links(body): """Список ссылок (base64 или текст) без vpn:// и tg:// – их не умеет ни одно VPN-приложение со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram.""" @@ -383,6 +419,8 @@ class Handler(http.server.BaseHTTPRequestHandler): body = merge_awg(body, abody) elif code == 200 and "text/plain" in headers.get("content-type", ""): body = strip_links(body) + elif code == 200 and "json" in headers.get("content-type", ""): + body = fix_vless_encryption(body) if code == 200 and clash and CONF.get("auto", True): body = add_auto(body) if code == 200 and clash and CONF.get("dns", True): diff --git a/tools/test/jsonfix_test.py b/tools/test/jsonfix_test.py new file mode 100644 index 0000000..85826cd --- /dev/null +++ b/tools/test/jsonfix_test.py @@ -0,0 +1,77 @@ +"""Проверки fix_vless_encryption в kit-sub: python3 tools/test/jsonfix_test.py""" +import json, os, tempfile, unittest, importlib.util + +root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +tmp = tempfile.mkdtemp() +cfgp = os.path.join(tmp, "config.json") +json.dump({"listen": "127.0.0.1", "port": 1, "path": "/sub/", "upstream": "http://127.0.0.1:1/"}, open(cfgp, "w")) +os.environ["KIT_SUB_CONFIG"] = cfgp +os.environ["KIT_SUB_RULES"] = os.path.join(tmp, "rules.yaml") +spec = importlib.util.spec_from_file_location("kit_sub", os.path.join(root, "scripts", "kit-sub.py")) +ks = importlib.util.module_from_spec(spec); spec.loader.exec_module(ks) + + +def vnext(**u): + return {"protocol": "vless", "settings": {"vnext": [{"address": "1.2.3.4", "port": 443, "users": [dict(id="x", **u)]}]}} + + +def flat(**u): + return {"protocol": "vless", "settings": dict(address="1.2.3.4", port=443, id="x", **u)} + + +def run(obj): + return json.loads(ks.fix_vless_encryption(json.dumps(obj).encode())) + + +def enc_vnext(o): + return o["outbounds"][0]["settings"]["vnext"][0]["users"][0].get("encryption") + + +class Fix(unittest.TestCase): + def test_vnext(self): + self.assertEqual(enc_vnext(run({"outbounds": [vnext()]})), "none") + self.assertEqual(enc_vnext(run({"outbounds": [vnext(encryption="")]})), "none") + self.assertEqual(enc_vnext(run({"outbounds": [vnext(encryption="none")]})), "none") + self.assertEqual(enc_vnext(run({"outbounds": [vnext(encryption="mlkem768x25519plus.native.0rtt.abc")]})), + "mlkem768x25519plus.native.0rtt.abc") + + def test_flat(self): + for u, want in (({}, "none"), ({"encryption": ""}, "none"), ({"encryption": "none"}, "none"), ({"encryption": "mlkem.x"}, "mlkem.x")): + self.assertEqual(run({"outbounds": [flat(**u)]})["outbounds"][0]["settings"]["encryption"], want) + + def test_array_and_many(self): + r = run([{"outbounds": [vnext(), flat(), vnext(encryption="mlkem.x")]}, {"outbounds": [vnext()]}]) + self.assertEqual(enc_vnext(r[0]), "none") + self.assertEqual(r[0]["outbounds"][1]["settings"]["encryption"], "none") + self.assertEqual(r[0]["outbounds"][2]["settings"]["vnext"][0]["users"][0]["encryption"], "mlkem.x") + self.assertEqual(enc_vnext(r[1]), "none") + + def test_not_vless_untouched(self): + src = {"outbounds": [{"protocol": "trojan", "settings": {"servers": [{"address": "a", "password": "p"}]}}, + {"protocol": "freedom", "settings": {}}, {"protocol": "blackhole"}, + {"protocol": "shadowsocks", "settings": {"id": "x"}}]} + raw = json.dumps(src).encode() + self.assertEqual(ks.fix_vless_encryption(raw), raw) + + def test_bytes_as_is(self): + for raw in (b"", b"not json", b"{broken", b"\xff\xfe\x00", b"123", b"null", b'"s"', b"[1,2]", b'{"outbounds": 5}', + b'{"outbounds": [null, 1, {"protocol": "vless", "settings": 3}]}'): + self.assertEqual(ks.fix_vless_encryption(raw), raw) + + def test_big(self): + raw = json.dumps({"outbounds": [vnext()], "pad": "a" * (ks.JSON_FIX_MAX + 1)}).encode() + self.assertEqual(ks.fix_vless_encryption(raw), raw) + + def test_unchanged_is_identical_and_idempotent(self): + raw = json.dumps({"outbounds": [vnext(encryption="none")]}, indent=2).encode() + self.assertEqual(ks.fix_vless_encryption(raw), raw) + once = ks.fix_vless_encryption(json.dumps({"outbounds": [vnext()]}).encode()) + self.assertEqual(ks.fix_vless_encryption(once), once) + + def test_utf8(self): + out = ks.fix_vless_encryption(json.dumps({"remarks": "Привет", "outbounds": [vnext()]}, ensure_ascii=False).encode()) + self.assertIn("Привет".encode(), out) + + +if __name__ == "__main__": + unittest.main()