diff --git a/CHANGELOG.md b/CHANGELOG.md index 25da663..0e78e93 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 идёт только список иностранных сервисов (по умолчанию YouTube, ChatGPT и Claude), а всё остальное напрямую. Правила приезжают вместе с подпиской: домашним ничего настраивать не нужно. DNS не течёт: имена из списка у клиента не разрешаются, а запросы про них идут по DoH через VPN. Список в `/etc/kit-sub/rules.yaml`, проверка `kit net split check`, он входит в `kit backup`. Идея и первый набросок – [VasilevVitalii](https://github.com/VasilevVitalii), спасибо. У Happ, Hiddify, v2rayN и sing-box подписка прежняя. +* Раздельная маршрутизация: `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), спасибо. У Happ, Hiddify, v2rayN и sing-box подписка прежняя. * Исправил `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 3688bb6..2cacc1e 100644 --- a/manuals/3x-ui.md +++ b/manuals/3x-ui.md @@ -180,7 +180,7 @@ kit net on reality2 # создать второй REALITY на высо kit net fp firefox # сменить отпечаток клиента у всех подключений kit net masq on # Hysteria2 отвечает на чужой запрос страницей сайта kit net panel domain # панель и подписка открываются и по домену (только со своим доменом); kit net panel ip – как раньше -kit net split on # через VPN только список (по умолчанию YouTube, ChatGPT и Claude), остальное напрямую; kit net split check | off +kit net split on # через VPN только список (по умолчанию популярные иностранные сервисы), остальное напрямую; kit net split check | off ``` Со своим доменом панель и подписка по умолчанию открываются по IP (`https://IP/путь/`). Если нужен адрес с доменом (например, для Telegram-бота панели @@ -189,7 +189,7 @@ kit net split on # через VPN только список (по ум ### Раздельная маршрутизация (`kit net split`) Если нужно, чтобы через VPN шли только выбранные сервисы, а всё остальное напрямую, включите: `kit net split on`. На сервере появится -файл `/etc/kit-sub/rules.yaml` со списком иностранных сервисов (по умолчанию YouTube, ChatGPT и Claude; остальные – в комментариях, раскомментируйте нужные). Российские сайты в список не добавляйте: всё, чего в нём нет, и так идёт напрямую. Правила приезжают вместе с подпиской, на телефонах и компьютерах +файл `/etc/kit-sub/rules.yaml` со списком иностранных сервисов (по умолчанию популярные: нейросети, видео и музыка, соцсети и мессенджеры, GitHub, Google и другие, всего около 50 категорий; тяжёлые и региональные – в комментариях, раскомментируйте нужные). Российские сайты в список не добавляйте: всё, чего в нём нет, и так идёт напрямую. Правила приезжают вместе с подпиской, на телефонах и компьютерах ничего настраивать не нужно: достаточно обновить подписку в приложении. Работает в приложениях на Mihomo (Clash Verge, FlClash, Mihomo Party); в Happ, Hiddify, v2rayN и sing-box подписка остаётся прежней, весь трафик идёт через VPN. diff --git a/scripts/kit-sub.py b/scripts/kit-sub.py index 691182e..d6ec434 100644 --- a/scripts/kit-sub.py +++ b/scripts/kit-sub.py @@ -16,6 +16,7 @@ https://github.com/itsnotkubrick/3X-UI_KIT import base64 import http.client import http.server +import ipaddress import json import os import re @@ -218,7 +219,7 @@ def load_rules(report=None): say(f"правила: файл больше {RULES_MAX_BYTES // 1024} КБ – не применяю") return None try: - data = yaml.safe_load(raw.decode("utf-8")) or {} + data = yaml.safe_load(raw.decode("utf-8-sig")) or {} except (yaml.YAMLError, UnicodeError) as e: say(f"правила: файл не разобран ({e}) – не применяю") return None @@ -229,12 +230,27 @@ def load_rules(report=None): rules, seen = [], set() for item in items[:RULES_MAX_COUNT + 50]: r = re.sub(r"\s*,\s*", ",", str(item).strip()) + kind, _, value = r.partition(",") + r = kind.upper() + "," + value # «domain-suffix,…» тоже понимаем m = RULE_RE.match(r) if not m: say(f"правила: пропускаю непонятное правило {str(item)[:80]!r}") - elif r not in seen and len(rules) < RULES_MAX_COUNT: - seen.add(r) - rules.append((m.group(1), m.group(2))) + continue + kind, value = m.group(1), m.group(2) + if kind in ("IP-CIDR", "IP-CIDR6"): + try: + net = ipaddress.ip_network(value, strict=False) + except ValueError: + net = None + if net is None or net.version != (6 if kind == "IP-CIDR6" else 4): + say(f"правила: неверная подсеть {value!r} – пропускаю") + continue + if kind == "DOMAIN-KEYWORD" and len(value) < 4: + say(f"правила: слово {value!r} слишком короткое (под него попадёт почти всё) – пропускаю") + continue + if (kind, value) not in seen and len(rules) < RULES_MAX_COUNT: + seen.add((kind, value)) + rules.append((kind, value)) if not rules: say("правила: ни одного пригодного – не применяю") return None @@ -399,26 +415,21 @@ class Server(http.server.ThreadingHTTPServer): address_family = socket.AF_INET6 if ":" in CONF.get("listen", "") else socket.AF_INET -GEOSITE_URL = "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat" +GEO_URL = "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/{}.dat" -def unknown_geosites(names): - """Сверяет категории GEOSITE с базой, которую скачают приложения: неизвестное имя ломает запуск конфига у клиента. - Нет доступа к GitHub – не проверяем (None-результат пустой).""" +def unknown_geo(kind, names): + """Сверяет категории GEOSITE/GEOIP с базой, которую скачают приложения: неизвестное имя ломает запуск конфига у клиента. + Нет доступа к GitHub – не проверяем.""" if not names: return [] try: - with urllib.request.urlopen(GEOSITE_URL, timeout=30) as r: - data = r.read(32 * 1024 * 1024) - except (urllib.error.URLError, OSError): - print("Категории GEOSITE не проверил: база недоступна. Названия сверьте по github.com/MetaCubeX/meta-rules-dat (geo/geosite).") + with urllib.request.urlopen(GEO_URL.format(kind), timeout=30) as r: + data = r.read(48 * 1024 * 1024) + except (urllib.error.URLError, OSError) as e: + print(f"Категории {kind.upper()} не проверил: база недоступна ({e}). Названия сверьте по github.com/MetaCubeX/meta-rules-dat.") return [] - bad = [] - for n in names: - b = n.upper().encode() - if b"\x0a" + bytes([len(b)]) + b not in data: - bad.append(n) - return bad + return [n for n in names if b"\x0a" + bytes([len(n)]) + n.upper().encode() not in data] def main(): @@ -427,9 +438,9 @@ def main(): loaded = load_rules(report=print) if loaded: print(f"Правил через VPN: {len(loaded[0])}; DNS для остального: {', '.join(loaded[1]) or 'системный'}") - unknown = unknown_geosites([v for k, v in loaded[0] if k == "GEOSITE"]) - if unknown: - print("Нет такой категории GEOSITE: " + ", ".join(unknown) + ". Приложение не сможет запустить конфиг – исправьте или удалите эти строки.") + bad = [f"{k} {n}" for k in ("geosite", "geoip") for n in unknown_geo(k, [v for kk, v in loaded[0] if kk == k.upper()])] + if bad: + print("Нет такой категории: " + ", ".join(bad) + ". Приложение не сможет запустить конфиг – исправьте или удалите эти строки.") raise SystemExit(2) raise SystemExit(0 if loaded else 1) cert, key = CONF.get("cert"), CONF.get("key") diff --git a/scripts/kit.sh b/scripts/kit.sh index 3bd3837..11612d6 100644 --- a/scripts/kit.sh +++ b/scripts/kit.sh @@ -1746,29 +1746,77 @@ split_template() { # DOMAIN,api.example.org – ровно этот домен # DOMAIN-KEYWORD,example – домен, в котором есть слово # IP-CIDR,203.0.113.0/24 – подсеть -# GEOSITE: приложение один раз скачивает базу категорий с GitHub. +# GEOSITE и GEOIP: приложение один раз скачивает базу категорий с GitHub. via_vpn: - - GEOSITE,youtube + # Нейросети + - GEOSITE,category-ai-!cn # ChatGPT, Claude, Gemini, Perplexity и другие - GEOSITE,openai - GEOSITE,anthropic - # Раскомментируйте то, чем пользуетесь (уберите «# » в начале строки): - # - GEOSITE,telegram - # - GEOSITE,meta # Instagram, Facebook, WhatsApp - # - GEOSITE,twitter # X (Twitter) - # - GEOSITE,discord - # - GEOSITE,github - # - GEOSITE,linkedin - # - GEOSITE,netflix - # - GEOSITE,spotify - # - GEOSITE,tiktok - # - GEOSITE,reddit - # - GEOSITE,twitch - # - GEOSITE,notion - # - GEOSITE,figma - # - GEOSITE,zoom - # - GEOSITE,google # все сервисы Google - # - GEOSITE,perplexity + - GEOSITE,perplexity + - GEOSITE,huggingface + # Видео и музыка + - GEOSITE,youtube + - GEOSITE,netflix + - GEOSITE,spotify + - GEOSITE,twitch + - GEOSITE,disney + - GEOSITE,hbo + - GEOSITE,primevideo + - GEOSITE,soundcloud + - GEOSITE,vimeo + - GEOSITE,tiktok + # Соцсети и мессенджеры + - GEOSITE,telegram + - GEOIP,telegram # Telegram подключается по IP-адресам, без имён + - GEOSITE,meta # Instagram, Facebook, WhatsApp + - GEOSITE,twitter # X (Twitter) + - GEOSITE,discord + - GEOSITE,reddit + - GEOSITE,linkedin + - GEOSITE,pinterest + - GEOSITE,tumblr + - GEOSITE,quora + - GEOSITE,medium + - GEOSITE,signal + - GEOSITE,viber + # Работа и разработка + - GEOSITE,google # все сервисы Google + - GEOSITE,github + - GEOSITE,gitlab + - GEOSITE,docker + - GEOSITE,npmjs + - GEOSITE,jetbrains + - GEOSITE,atlassian + - GEOSITE,slack + - GEOSITE,notion + - GEOSITE,figma + - GEOSITE,canva + - GEOSITE,adobe + - GEOSITE,zoom + - GEOSITE,trello + - GEOSITE,dropbox + - GEOSITE,protonmail + - GEOSITE,mega + - GEOSITE,imgur + - GEOSITE,duckduckgo + - GEOSITE,wikimedia + # Иностранные СМИ + - GEOSITE,bbc + - GEOSITE,cnn + - GEOSITE,nytimes + - GEOSITE,reuters + - GEOSITE,bloomberg + # Раскомментируйте, если нужно (уберите «# » в начале строки). Это тяжёлый трафик или сервисы с привязкой к региону: + # - GEOSITE,microsoft + # - GEOSITE,apple + # - GEOSITE,steam + # - GEOSITE,epicgames + # - GEOSITE,playstation + # - GEOSITE,xbox + # - GEOSITE,nintendo + # - GEOSITE,cloudflare + # - GEOSITE,paypal # - DOMAIN-SUFFIX,example.org # Необязательно: DNS для всего остального (по умолчанию – системный DNS приложения). Только адреса https://… @@ -1777,12 +1825,22 @@ via_vpn: YAML } +# kit-sub работает без root и читает файл сам: если права закрыты (после правки редактором, копирования), список молча не применится. +split_perms() { + [[ -f $SPLIT_FILE ]] || return 0 + if ! [[ $(stat -c %a "$SPLIT_FILE") =~ ^[0-7][0-7][4-7]$ && $(stat -c %a /etc/kit-sub) =~ ^[0-7][0-7][1357]$ ]]; then + chmod 644 "$SPLIT_FILE"; chmod 755 /etc/kit-sub + warn "Права на $SPLIT_FILE были закрыты для подписки (kit-sub работает не от root) – исправил." + fi +} + net_split() { # [on|off|check] local act=${1:-} n + split_perms case $act in "") if [[ -f $SPLIT_FILE ]]; then - n=$(python3 /usr/local/lib/kit-sub/kit_sub.py --check-rules 2>&1 | tail -1 || true) + 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; Xray и sing-box приложения получают подписку как раньше." @@ -1795,7 +1853,7 @@ 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" - echo "Сейчас через VPN идут только перечисленные сервисы (YouTube, ChatGPT, Claude), остальное напрямую." + echo "Сейчас через VPN идут только перечисленные сервисы (популярные иностранные сервисы: YouTube, ChatGPT, Telegram и другие), остальное напрямую." echo "Список правится так: nano $SPLIT_FILE, затем kit net split check." echo "Приложения на Mihomo подхватят при обновлении подписки." ;; off) diff --git a/tools/test/rules_test.py b/tools/test/rules_test.py index 68fcca2..c95cc4a 100644 --- a/tools/test/rules_test.py +++ b/tools/test/rules_test.py @@ -86,6 +86,31 @@ class Rules(unittest.TestCase): loaded = ks.load_rules(report=lambda m: None) self.assertEqual(ks.apply_rules(b"proxies: []\n", loaded), b"proxies: []\n") + def test_forgiving_input(self): + # Что если: строчные типы, BOM и CRLF из Windows-редактора, лишние пробелы + with open(ks.RULES_FILE, "wb") as f: + f.write("\ufeffvia_vpn:\r\n - geosite , youtube\r\n - domain-suffix,Example.ORG\r\n".encode("utf-8")) + rules, _ = ks.load_rules(report=lambda m: None) + self.assertEqual(rules, [("GEOSITE", "youtube"), ("DOMAIN-SUFFIX", "Example.ORG")]) + + def test_bad_cidr_and_short_keyword(self): + write("via_vpn:\n - IP-CIDR,999.1.1.1/8\n - IP-CIDR,1.2.3.4/99\n - IP-CIDR6,1.2.3.0/24\n - IP-CIDR,2001:db8::/32\n" + " - IP-CIDR,203.0.113.0/24\n - IP-CIDR6,2001:db8::/32\n - DOMAIN-KEYWORD,a\n - DOMAIN-KEYWORD,video\n") + rules, _ = ks.load_rules(report=lambda m: None) + self.assertEqual(rules, [("IP-CIDR", "203.0.113.0/24"), ("IP-CIDR6", "2001:db8::/32"), ("DOMAIN-KEYWORD", "video")]) + + def test_template_from_kit_sh_is_valid(self): + # Что если: шаблон kit net split on сам окажется с ошибкой + import re + 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) + rules, _ = ks.load_rules(report=lambda m: self.fail(m)) + self.assertGreater(len(rules), 40) + self.assertIn(("GEOIP", "telegram"), rules) + self.assertNotIn(("DOMAIN-SUFFIX", "example.org"), rules) # пример закомментирован + if __name__ == "__main__": unittest.main(verbosity=1)