From 73bbaadd48c28fed8f8ab689ac0e800083ede01b Mon Sep 17 00:00:00 2001 From: maxdmitrov <56165502+maxdmitrov@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:16:20 +0300 Subject: [PATCH] =?UTF-8?q?kit=20net=20split:=20=D0=BC=D0=B0=D1=80=D1=88?= =?UTF-8?q?=D1=80=D1=83=D1=82=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20Happ=20(=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=BA=20Routing)=20=D0=B8=20JSON-=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20Xray=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8,=20kit=20ne?= =?UTF-8?q?t=20split=20apply;=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20Mihomo=20=D0=B8=20Xray?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- manuals/3x-ui.md | 13 +++++++++--- scripts/kit-sub.py | 40 +++++++++++++++++++++++++++++++++++++ scripts/kit.sh | 43 ++++++++++++++++++++++++++++++++++++---- tools/test/rules_test.py | 27 +++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c4a19c..f1b1f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ * Сайт для маскировки подбирается среди соседей по подсети, а не «у всех один». Свой домен по-прежнему лучший вариант. REALITY теперь с `xtls-rprx-vision`, Hysteria2 отвечает на чужой запрос страницей сайта. * `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` – и в приложениях на Mihomo (Clash Verge, FlClash, Mihomo Party) через VPN идёт только список иностранных сервисов (по умолчанию популярные иностранные сервисы: нейросети, видео, соцсети, мессенджеры, GitHub, Google и другие), а всё остальное напрямую. Правила приезжают вместе с подпиской: домашним ничего настраивать не нужно. DNS не течёт: имена из списка у клиента не разрешаются, а запросы про них идут по DoH через VPN. Список в `/etc/kit-sub/rules.yaml`, проверка `kit net split check`, он входит в `kit backup`. Идея и первый набросок – [VasilevVitalii](https://github.com/VasilevVitalii), спасибо. У Karing, Happ, Hiddify, v2rayN и других на sing-box или Xray подписка прежняя. +* Раздельная маршрутизация: `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 и приложений с обычным списком ссылок подписка прежняя. * Исправил `kit backup`: из `/root` он падал с «No such file or directory», работал только из `/`. Теперь работает откуда угодно. Если панель не принимает запрос, `kit` говорит по-человечески (код ответа и вероятная причина), а не «Unsupported HTTP version in response». Сервер обновится сам этой ночью, нетерпеливым – `kit update`. diff --git a/manuals/3x-ui.md b/manuals/3x-ui.md index 31dada3..ec65423 100644 --- a/manuals/3x-ui.md +++ b/manuals/3x-ui.md @@ -189,14 +189,21 @@ kit net split on # через VPN только список (по ум ### Раздельная маршрутизация (`kit net split`) Если нужно, чтобы через VPN шли только выбранные сервисы, а всё остальное напрямую, включите: `kit net split on`. На сервере появится -файл `/etc/kit-sub/rules.yaml` со списком иностранных сервисов (по умолчанию популярные: нейросети, видео и музыка, соцсети и мессенджеры, GitHub, Google и другие, всего около 50 категорий; тяжёлые и региональные – в комментариях, раскомментируйте нужные). Российские сайты в список не добавляйте: всё, чего в нём нет, и так идёт напрямую. Правила приезжают вместе с подпиской, на телефонах и компьютерах -ничего настраивать не нужно: достаточно обновить подписку в приложении. Работает в приложениях на Mihomo (Clash Verge, FlClash, Mihomo Party); -в Karing, Happ, Hiddify, v2rayN и других на ядре sing-box или Xray подписка остаётся прежней, весь трафик идёт через VPN (там раздельную маршрутизацию настраивают в самом приложении). +файл `/etc/kit-sub/rules.yaml` со списком иностранных сервисов (по умолчанию популярные: нейросети, видео и музыка, соцсети и мессенджеры, GitHub, Google и другие, всего около 50 категорий; тяжёлые и региональные – в комментариях, раскомментируйте нужные). Российские сайты в список не добавляйте: всё, чего в нём нет, и так идёт напрямую. +Правила приезжают вместе с подпиской, на телефонах и компьютерах ничего настраивать не нужно: достаточно обновить подписку в приложении. + +| Приложение | Как получает правила | +|---|---| +| Clash Verge, FlClash, Mihomo Party (ядро Mihomo) | из подписки, сразу (режим «Rule», не «Global») | +| Happ | заголовком подписки (профиль «3X-UI KIT» включается сам) | +| Приложения, которые берут JSON-подписку Xray (адрес «JSON» со страницы подписки) | из конфига подписки | +| v2rayN, v2rayNG и другие с обычным списком ссылок, Karing, Hiddify, sing-box | не получают: подписка прежняя, весь трафик через VPN; раздельную маршрутизацию там настраивают в самом приложении | ``` kit net split on # создать список и включить nano /etc/kit-sub/rules.yaml kit net split check # проверить файл: сколько правил применится и что отброшено +kit net split apply # после правки списка: обновить профили Happ и Xray в панели (Mihomo берёт файл сам) kit net split off # выключить (список сохранится в rules.yaml.off) ``` diff --git a/scripts/kit-sub.py b/scripts/kit-sub.py index c266f5a..514470f 100644 --- a/scripts/kit-sub.py +++ b/scripts/kit-sub.py @@ -417,6 +417,39 @@ class Server(http.server.ThreadingHTTPServer): address_family = socket.AF_INET6 if ":" in CONF.get("listen", "") else socket.AF_INET +def routing_payload(kind, loaded): + """Профиль маршрутизации для самой панели 3X-UI (она раздаёт его приложениям на Xray): kind = happ | json. + happ – заголовок Routing для Happ: через VPN только список, GlobalProxy=false. + json – правила для JSON-подписки Xray (серверы DoH 1.1.1.1 и 8.8.8.8 – тоже через VPN); конструктор панели всегда ставит в конец «всё через прокси», поэтому остальное + отправляем напрямую раньше (порядок block-proxy-direct, прямые regexp:.* и 0.0.0.0/0).""" + rules, direct_dns = loaded + sites, ips = [], [] + for k, v in rules: + if k == "GEOSITE": + sites.append("geosite:" + v) + elif k == "DOMAIN-SUFFIX": + sites.append("domain:" + v) + elif k == "DOMAIN": + sites.append("full:" + v) + elif k == "DOMAIN-KEYWORD": + sites.append(v) + elif k == "GEOIP": + ips.append("geoip:" + v) + else: + ips.append(v) + if kind == "happ": + data = {"Name": "3X-UI KIT", "GlobalProxy": "false", "DomainStrategy": "IPIfNonMatch", "ProxySites": sites, + "ProxyIp": ips, "DirectIp": ["geoip:private"]} + else: + # localhost – системный DNS клиента для прямых сайтов (как в подписке Mihomo); список direct_dns его заменяет. + data = {"Name": "3X-UI KIT", "DomainStrategy": "IPIfNonMatch", "RouteOrder": "block-proxy-direct", + "ProxySites": sites, "ProxyIp": ["1.1.1.1", "8.8.8.8"] + ips, "DirectSites": ["regexp:.*"], + "DirectIp": ["geoip:private", "0.0.0.0/0", "::/0"], "DomesticDNSDomain": direct_dns[0] if direct_dns else "localhost"} + text = json.dumps(data, ensure_ascii=False, separators=(",", ":")) + # Happ ждёт в заголовке ссылку happ://routing/onadd/; панели для JSON-подписки нужен обычный JSON. + return "happ://routing/onadd/" + base64.b64encode(text.encode()).decode() if kind == "happ" else text + + GEO_URL = "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/{}.dat" @@ -435,6 +468,13 @@ def unknown_geo(kind, names): def main(): + if "--routing-payload" in sys.argv: + # kit net split: профиль для панели (для Happ и Xray); пусто и код 1, если правил нет. + loaded = load_rules(report=lambda m: print(m, file=sys.stderr)) + if not loaded: + raise SystemExit(1) + print(routing_payload(sys.argv[sys.argv.index("--routing-payload") + 1], loaded)) + raise SystemExit(0) if "--check-rules" in sys.argv: # kit net split check: тот же разбор, что и при выдаче подписки, но с отчётом на экран. loaded = load_rules(report=print) diff --git a/scripts/kit.sh b/scripts/kit.sh index 0ea14ca..ec0eb03 100644 --- a/scripts/kit.sh +++ b/scripts/kit.sh @@ -1825,6 +1825,35 @@ via_vpn: YAML } +# Приложениям на Xray правила раздаёт сама панель (3X-UI 3.9+): заголовок Routing для Happ и правила JSON-подписки. +# Собираем их из того же rules.yaml. Чужой профиль, уже стоящий в панели, не трогаем. +split_panel_ours() { # наш профиль: JSON или ссылка happ://routing/onadd/ с Name "3X-UI KIT" + local v=$1 + [[ $v == happ://routing/onadd/* ]] && v=$(printf '%s' "${v#happ://routing/onadd/}" | base64 -d 2>/dev/null || true) + grep -q '"Name":"3X-UI KIT"' <<<"$v" +} + +split_panel_sync() { # on|off + local all cur_h cur_j h j out + all=$(api POST setting/all '{}') || return 1 + cur_h=$(jq -r '.subRoutingRules // ""' <<<"$all"); cur_j=$(jq -r '.subJsonRoutingRules // ""' <<<"$all") + if [[ $1 == on ]]; then + if { [[ -n $cur_h ]] && ! split_panel_ours "$cur_h"; } || { [[ -n $cur_j ]] && ! split_panel_ours "$cur_j"; }; then + warn "В панели уже настроена своя маршрутизация подписки (Happ или JSON) – не меняю. Приложения на Mihomo получат список как обычно." + return 0 + fi + h=$(python3 /usr/local/lib/kit-sub/kit_sub.py --routing-payload happ 2>/dev/null) && j=$(python3 /usr/local/lib/kit-sub/kit_sub.py --routing-payload json 2>/dev/null) \ + || { warn "Список не разобран – для Happ и Xray ничего не менял (kit net split check)."; return 1; } + out=$(jq -c --arg h "$h" --arg j "$j" '.subEnableRouting = true | .subRoutingRules = $h | .subJsonRoutingRules = $j' <<<"$all") + else + [[ -n $cur_h || -n $cur_j ]] || return 0 + if { [[ -n $cur_h ]] && ! split_panel_ours "$cur_h"; } || { [[ -n $cur_j ]] && ! split_panel_ours "$cur_j"; }; then return 0; fi + out=$(jq -c '.subEnableRouting = false | .subRoutingRules = "" | .subJsonRoutingRules = ""' <<<"$all") + fi + api POST setting/update "$out" >/dev/null || { warn "Панель не приняла маршрутизацию для Happ и Xray."; return 1; } + systemctl restart x-ui; panel_healthy || warn "Панель не ответила после перезапуска: проверьте kit check." +} + # kit-sub работает без root и читает файл сам: если права закрыты (после правки редактором, копирования), список молча не применится. split_perms() { [[ -f $SPLIT_FILE ]] || return 0 @@ -1843,7 +1872,7 @@ net_split() { # [on|off|check] n=$(python3 /usr/local/lib/kit-sub/kit_sub.py --check-rules 2>&1 | grep -a '^Правил через VPN' || true) echo "Раздельная маршрутизация включена: $n" echo "Файл: $SPLIT_FILE (править, проверить: kit net split check, выключить: kit net split off)" - echo "Работает в приложениях на Mihomo; Karing, Hiddify, Happ и другие получают подписку как раньше." + echo "Mihomo-приложения берут список из подписки сами; Happ и JSON-подписка Xray – из панели (после правки файла: kit net split apply)." else echo "Раздельная маршрутизация выключена: через VPN идёт весь трафик. Включить: kit net split on" fi ;; @@ -1853,12 +1882,14 @@ net_split() { # [on|off|check] if [[ -f $SPLIT_FILE.off ]]; then install -m 644 "$SPLIT_FILE.off" "$SPLIT_FILE"; else split_template >"$SPLIT_FILE"; fi chmod 755 /etc/kit-sub; chmod 644 "$SPLIT_FILE" say "Включил: $SPLIT_FILE" + split_panel_sync on || true echo "Сейчас через VPN идут только перечисленные сервисы (популярные иностранные сервисы: YouTube, ChatGPT, Telegram и другие), остальное напрямую." echo "Список правится так: nano $SPLIT_FILE, затем kit net split check." - echo "Приложения на Mihomo подхватят при обновлении подписки." ;; + echo "Приложения на Mihomo подхватят при обновлении подписки; после правки списка для Happ и Xray: kit net split apply." ;; off) [[ -f $SPLIT_FILE ]] || { say "Уже выключено."; return 0; } mv "$SPLIT_FILE" "$SPLIT_FILE.off" + split_panel_sync off || true say "Выключил: подписка снова отдаёт весь трафик через VPN (список сохранён: $SPLIT_FILE.off)." ;; check) [[ -f $SPLIT_FILE ]] || die "Файла нет: $SPLIT_FILE (включить: kit net split on)" @@ -1867,7 +1898,11 @@ net_split() { # [on|off|check] 2) die "Исправьте список: с неизвестной категорией приложения на Mihomo не смогут запустить конфиг." ;; *) die "Список не применится – подписка останется без раздельной маршрутизации." ;; esac ;; - *) die "kit net split [on|off|check]" ;; + apply) + [[ -f $SPLIT_FILE ]] || die "Файла нет: $SPLIT_FILE (включить: kit net split on)" + python3 /usr/local/lib/kit-sub/kit_sub.py --check-rules >/dev/null 2>&1 || die "Список не применится: сначала kit net split check." + split_panel_sync on && say "Списки для Happ и Xray в панели обновлены (приложения на Mihomo подхватывают файл сами)." ;; + *) die "kit net split [on|off|check|apply]" ;; esac } @@ -2224,7 +2259,7 @@ cmd_net() { panel) net_panel "$@" ;; split) net_split "$@" ;; vision) case ${1:-} in on) cmd_vision --all ;; off) cmd_vision --all off ;; *) die "kit net vision on|off" ;; esac ;; - *) die "kit net [site | port имя порт | off имя | on имя | fp отпечаток | masq on|off | dns on|off | panel domain|ip | split on|off|check | vision on|off]" ;; + *) die "kit net [site | port имя порт | off имя | on имя | fp отпечаток | masq on|off | dns on|off | panel domain|ip | split on|off|check|apply | vision on|off]" ;; esac } diff --git a/tools/test/rules_test.py b/tools/test/rules_test.py index 1db8d16..f7c460a 100644 --- a/tools/test/rules_test.py +++ b/tools/test/rules_test.py @@ -121,6 +121,33 @@ class Rules(unittest.TestCase): for ua in ("Karing/1.2.5", "Karing/1.2.5 clash.meta", "Hiddify/2.0 clash", "sing-box/1.9", "Happ/1.0", "Stash/2.7", "v2rayN/6.0", "Mozilla/5.0"): self.assertFalse(gets_rules(ua), ua) + def test_routing_payloads_for_panel(self): + import base64 + write("via_vpn:\n - GEOSITE,youtube\n - DOMAIN-SUFFIX,example.org\n - DOMAIN,api.example.com\n - GEOIP,telegram\n - IP-CIDR,203.0.113.0/24\n") + loaded = ks.load_rules(report=lambda m: None) + link = ks.routing_payload("happ", loaded) + self.assertTrue(link.startswith("happ://routing/onadd/")) + h = json.loads(base64.b64decode(link.split("/onadd/")[1])) + self.assertEqual(h["Name"], "3X-UI KIT") + self.assertEqual(h["GlobalProxy"], "false") + self.assertEqual(h["ProxySites"], ["geosite:youtube", "domain:example.org", "full:api.example.com"]) + self.assertEqual(h["ProxyIp"], ["geoip:telegram", "203.0.113.0/24"]) + j = json.loads(ks.routing_payload("json", loaded)) + self.assertEqual(j["RouteOrder"], "block-proxy-direct") + self.assertEqual(j["DirectSites"], ["regexp:.*"]) # остальное напрямую: панель сама добавляет «всё через прокси» в конец + self.assertIn("0.0.0.0/0", j["DirectIp"]) + self.assertEqual(j["ProxyIp"][:2], ["1.1.1.1", "8.8.8.8"]) + self.assertEqual(j["DomesticDNSDomain"], "localhost") + self.assertLess(len(link), 8192) # предел панели на значение заголовка Routing + + def test_full_default_template_fits_panel_limit(self): + t = open(os.path.join(root, "scripts", "kit.sh"), encoding="utf-8").read() + body = t[t.index("split_template() {"):] + body = body[body.index("<<'YAML'\n") + 9: body.index("\nYAML\n")] + write(body) + loaded = ks.load_rules(report=lambda m: self.fail(m)) + self.assertLess(len(ks.routing_payload("happ", loaded)), 8192) + if __name__ == "__main__": unittest.main(verbosity=1)