diff --git a/CHANGELOG.md b/CHANGELOG.md index 983d615..71e2fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ * Сайт для маскировки подбирается среди соседей по подсети, а не «у всех один». Свой домен по-прежнему лучший вариант. 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), а всё остальное напрямую. Правила приезжают вместе с подпиской: домашним ничего настраивать не нужно. 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 ca99e92..94f3369 100644 --- a/manuals/3x-ui.md +++ b/manuals/3x-ui.md @@ -180,11 +180,31 @@ 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), остальное напрямую; kit net split check | off ``` Со своим доменом панель и подписка по умолчанию открываются по IP (`https://IP/путь/`). Если нужен адрес с доменом (например, для Telegram-бота панели или ссылки, которая не зависит от IP), выберите «по домену» при установке (`--panel-on domain`) или позже: `kit net panel domain`. Путь у них секретный и по домену, как и по IP. +### Раздельная маршрутизация (`kit net split`) + +Если нужно, чтобы через VPN шли только выбранные сервисы, а всё остальное напрямую, включите: `kit net split on`. На сервере появится +файл `/etc/kit-sub/rules.yaml` со списком (по умолчанию YouTube и ChatGPT). Правила приезжают вместе с подпиской, на телефонах и компьютерах +ничего настраивать не нужно: достаточно обновить подписку в приложении. Работает в приложениях на Mihomo (Clash Verge, FlClash, Mihomo Party); +в Happ, Hiddify, v2rayN и sing-box подписка остаётся прежней, весь трафик идёт через VPN. + +``` +kit net split on # создать список и включить +nano /etc/kit-sub/rules.yaml +kit net split check # проверить файл: сколько правил применится и что отброшено +kit net split off # выключить (список сохранится в rules.yaml.off) +``` + +Формат: `GEOSITE,youtube` (готовая категория), `DOMAIN-SUFFIX,example.org`, `DOMAIN,api.example.org`, `DOMAIN-KEYWORD,слово`, `IP-CIDR,203.0.113.0/24`. +Лишнее и непонятное kit отбрасывает; если список пуст или повреждён, подписка отдаётся как обычно. Список входит в `kit backup`. +DNS при этом не течёт: имена из списка у клиента не разрешаются, а запросы про них идут по DoH через VPN; для остального сайта используется +системный DNS приложения (или адреса `direct_dns` из файла). Категории GEOSITE приложение один раз скачивает с GitHub. + Лучший сайт маскировки – ваш собственный домен (`--domain` при установке): сервер изображает ваш сайт, а сертификат и имя совпадают. Без домена установщик подбирает сайт среди соседей по подсети сервера: около 250 коротких обращений к порту 443 соседних адресов (не нужно – укажите `--sni сайт` или `--domain`). Любой протокол можно выключить командой `kit net off имя` и включить обратно `kit net on имя`. diff --git a/scripts/3x-ui.sh b/scripts/3x-ui.sh index f6b2ae5..34326b3 100755 --- a/scripts/3x-ui.sh +++ b/scripts/3x-ui.sh @@ -1695,7 +1695,7 @@ restore_main() { # файл trap "rm -rf -- '$tmp'" EXIT python3 - "$file" "$tmp" <<'PY' || die "Это не резервная копия 3X-UI KIT или она повреждена. Ничего не менял." import os, sys, tarfile -allowed = ("etc/x-ui/x-ui.db", "etc/x-ui/install-result.env", "etc/kit/kit.env", "etc/kit-sub/config.json", +allowed = ("etc/x-ui/x-ui.db", "etc/x-ui/install-result.env", "etc/kit/kit.env", "etc/kit-sub/config.json", "etc/kit-sub/rules.yaml", "etc/nginx/kit-stream.conf", "etc/nginx/conf.d/kit.conf", "var/www/kit", "root/cert/self", "root/cert/custom", "root/3x-ui.txt", "kit-backup.env") parents = {"etc", "etc/x-ui", "etc/kit", "etc/kit-sub", "etc/nginx", "etc/nginx/conf.d", "var", "var/www", "root", "root/cert"} @@ -1796,7 +1796,9 @@ PY install -m 644 "$tmp/root/cert/$c/fullchain.pem" "/root/cert/$c/fullchain.pem" install -m 600 "$tmp/root/cert/$c/privkey.pem" "/root/cert/$c/privkey.pem" done - install -d -m 700 /etc/kit /etc/kit-sub + install -d -m 700 /etc/kit + install -d -m 755 /etc/kit-sub # kit-sub (без root) читает rules.yaml + [[ -f $tmp/etc/kit-sub/rules.yaml ]] && install -m 644 "$tmp/etc/kit-sub/rules.yaml" /etc/kit-sub/rules.yaml [[ -f $tmp/etc/kit/kit.env ]] && install -m 600 "$tmp/etc/kit/kit.env" /etc/kit/kit.env [[ -f $tmp/root/3x-ui.txt ]] && install -m 600 "$tmp/root/3x-ui.txt" "$RESULT" if [[ -n $old_ip ]]; then diff --git a/scripts/kit-sub.py b/scripts/kit-sub.py index 65c6388..45faa41 100644 --- a/scripts/kit-sub.py +++ b/scripts/kit-sub.py @@ -20,6 +20,7 @@ import json import os import re import socket +import sys import ssl import threading import time @@ -53,6 +54,14 @@ PASS_HEADERS = ("content-type", "content-disposition", "profile-title", "profile with open(CONFIG, encoding="utf-8") as f: CONF = json.load(f) PATH = "/" + CONF["path"].strip("/") + "/" +# Раздельная маршрутизация (необязательно): /etc/kit-sub/rules.yaml – через VPN только перечисленное, остальное напрямую. +# Файл читается при каждом запросе подписки, перезапуск не нужен. Нет файла или он битый – подписка как обычно (всё через VPN). +RULES_FILE = os.environ.get("KIT_SUB_RULES") or "/etc/kit-sub/rules.yaml" +RULES_MAX_BYTES, RULES_MAX_COUNT = 65536, 300 +RULE_RE = re.compile(r"^(DOMAIN|DOMAIN-SUFFIX|DOMAIN-KEYWORD|GEOSITE|IP-CIDR|IP-CIDR6|GEOIP),([A-Za-z0-9._:/!@+-]{1,120})$") +DOH_RE = re.compile(r"^https://[A-Za-z0-9.-]{1,100}(:\d{1,5})?/[A-Za-z0-9._/-]{0,60}$") +DOH_DEFAULT = ["https://1.1.1.1/dns-query", "https://8.8.8.8/dns-query"] +PRIVATE_NETS = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16"] # Дополнительные пути, которые панель показывает на странице подписки (Clash и JSON): обрабатываются так же, как основной. EXTRA = ["/" + x.strip("/") + "/" for x in CONF.get("extra_paths", []) if isinstance(x, str) and re.fullmatch(r"/?[A-Za-z0-9_-]+/?", x)] @@ -193,6 +202,87 @@ def add_dns(clash_yaml): return yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False).encode() +def load_rules(report=None): + """Читает RULES_FILE: список via_vpn («ТИП,ЗНАЧЕНИЕ») и необязательный direct_dns (адреса DoH для остального). + Возвращает (правила, direct_dns) или None, если файла нет или он непригоден: тогда подписка остаётся прежней.""" + say = report or log + try: + with open(RULES_FILE, "rb") as f: + raw = f.read(RULES_MAX_BYTES + 1) + except FileNotFoundError: + return None + except OSError as e: + say(f"правила: не удалось прочитать {RULES_FILE}: {e}") + return None + if len(raw) > RULES_MAX_BYTES: + say(f"правила: файл больше {RULES_MAX_BYTES // 1024} КБ – не применяю") + return None + try: + data = yaml.safe_load(raw.decode("utf-8")) or {} + except (yaml.YAMLError, UnicodeError) as e: + say(f"правила: файл не разобран ({e}) – не применяю") + return None + items = data.get("via_vpn") if isinstance(data, dict) else None + if not isinstance(items, list) or not items: + say("правила: нет списка via_vpn – не применяю") + return None + rules, seen = [], set() + for item in items[:RULES_MAX_COUNT + 50]: + r = re.sub(r"\s*,\s*", ",", str(item).strip()) + 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))) + if not rules: + say("правила: ни одного пригодного – не применяю") + return None + direct = data.get("direct_dns") + direct = [d for d in direct if isinstance(d, str) and DOH_RE.match(d)][:4] if isinstance(direct, list) else [] + return rules, direct + + +def apply_rules(clash_yaml, loaded): + """Раздельная маршрутизация. Перечисленное идёт через VPN (первая группа прокси), остальное – напрямую. + DNS при этом не течёт: имена из списка в fake-ip режиме у клиента не разрешаются вовсе (сайт называется серверу), + запросы про них (на случай IP-подключений) – по DoH через VPN, а для остальных – системный DNS или direct_dns.""" + cfg = yaml.safe_load(clash_yaml) + if not isinstance(cfg, dict): + return clash_yaml + groups = [g.get("name") for g in cfg.get("proxy-groups") or [] if isinstance(g, dict) and g.get("name")] + proxies = [p.get("name") for p in cfg.get("proxies") or [] if isinstance(p, dict) and p.get("name")] + target = groups[0] if groups else proxies[0] if proxies else None + if target is None: + return clash_yaml + rules, direct_dns = loaded + out = [f"IP-CIDR,{n},DIRECT,no-resolve" for n in PRIVATE_NETS] + # DoH-серверы – через VPN, чтобы запросы про сайты из списка не выходили мимо него. + out += [f"IP-CIDR,{h}/32,{target},no-resolve" for h in ("1.1.1.1", "8.8.8.8")] + policy = {} + for kind, value in rules: + # Правилам по адресам не даём разрешать имена (иначе это обычный DNS-запрос мимо VPN). + out.append(f"{kind},{value},{target}" + (",no-resolve" if kind in ("IP-CIDR", "IP-CIDR6", "GEOIP") else "")) + if kind == "GEOSITE": + policy["geosite:" + value] = DOH_DEFAULT + elif kind == "DOMAIN-SUFFIX": + policy["+." + value] = DOH_DEFAULT + elif kind == "DOMAIN": + policy[value] = DOH_DEFAULT + out.append("MATCH,DIRECT") + cfg["mode"] = "rule" + cfg["rules"] = out + dns = dict(DNS_BLOCK) + dns["nameserver"] = direct_dns or ["system"] + if policy: + dns["nameserver-policy"] = policy + cfg["dns"] = dns + # Для подключений по IP (без имени) домен берётся из TLS/HTTP/QUIC – иначе правила по доменам их не увидят. + cfg["sniffer"] = {"enable": True, "override-destination": False, + "sniff": {"TLS": {"ports": [443, 8443]}, "HTTP": {"ports": [80, "8080-8880"]}, "QUIC": {"ports": [443]}}} + return yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False).encode() + + class Handler(http.server.BaseHTTPRequestHandler): server_version = "nginx" sys_version = "" @@ -281,6 +371,10 @@ class Handler(http.server.BaseHTTPRequestHandler): body = add_auto(body) if code == 200 and clash and CONF.get("dns", True): body = add_dns(body) + if code == 200 and clash: + loaded = load_rules() + if loaded: + body = apply_rules(body, loaded) except (yaml.YAMLError, UnicodeError) as e: log(f"не удалось обработать подписку: {e}") @@ -306,6 +400,12 @@ class Server(http.server.ThreadingHTTPServer): def main(): + if "--check-rules" in sys.argv: + # kit net split check: тот же разбор, что и при выдаче подписки, но с отчётом на экран. + loaded = load_rules(report=print) + if loaded: + print(f"Правил через VPN: {len(loaded[0])}; DNS для остального: {', '.join(loaded[1]) or 'системный'}") + raise SystemExit(0 if loaded else 1) cert, key = CONF.get("cert"), CONF.get("key") if cert and CREDS and os.path.exists(os.path.join(CREDS, "cert.pem")): cert, key = os.path.join(CREDS, "cert.pem"), os.path.join(CREDS, "key.pem") diff --git a/scripts/kit.sh b/scripts/kit.sh index 10db80e..4ef4d08 100644 --- a/scripts/kit.sh +++ b/scripts/kit.sh @@ -45,20 +45,32 @@ die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; } # shellcheck disable=SC1090 . "$XUI_ENV"; . "$KIT_ENV" -API="" +API=""; API_OK=no for scheme in https http; do API="$scheme://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api" - curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $XUI_API_TOKEN" "$API/server/getNewUUID" 2>/dev/null && break + if curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $XUI_API_TOKEN" "$API/server/getNewUUID" 2>/dev/null; then API_OK=yes; break; fi done +if [[ $API_OK == no ]]; then + # Токен не подошёл ни по https, ни по http. Схему выбираем по тому, говорит ли порт панели по TLS: + # иначе запрос по http к TLS-порту даёт невнятное «Unsupported HTTP version in response». + if [[ $(curl -sk -m 5 -o /dev/null -w '%{http_code}' "https://127.0.0.1:$XUI_PANEL_PORT/" 2>/dev/null || true) =~ ^[1-5] ]]; then scheme=https; else scheme=http; fi + API="$scheme://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api" +fi api() { # METHOD path [json] - local out + local out code if [[ $1 == GET ]]; then - out=$(curl -sSk -m 20 -H "Authorization: Bearer $XUI_API_TOKEN" "$API/$2") + out=$(curl -sSk -m 20 -w $'\n%{http_code}' -H "Authorization: Bearer $XUI_API_TOKEN" "$API/$2" || true) else - out=$(curl -sSk -m 20 -H "Authorization: Bearer $XUI_API_TOKEN" -H 'Content-Type: application/json' -X "$1" -d "${3:-{\}}" "$API/$2") + out=$(curl -sSk -m 20 -w $'\n%{http_code}' -H "Authorization: Bearer $XUI_API_TOKEN" -H 'Content-Type: application/json' -X "$1" -d "${3:-{\}}" "$API/$2" || true) + fi + code=${out##*$'\n'}; out=${out%$'\n'*} + if [[ $(jq -r '.success' <<<"$out" 2>/dev/null) != true ]]; then + if [[ -z $out || $out != "{"* ]]; then + die "Панель не приняла запрос (HTTP ${code:-нет ответа}). Частая причина: токен API в $XUI_ENV устарел или не подходит к этой панели. Проверьте: kit check" + fi + die "Панель ответила ошибкой: $(jq -r '.msg // .' <<<"$out" 2>/dev/null | head -c 300)" fi - [[ $(jq -r '.success' <<<"$out" 2>/dev/null) == true ]] || die "Панель ответила ошибкой: $(jq -r '.msg // .' <<<"$out" 2>/dev/null | head -c 300)" jq -c '.obj' <<<"$out" } @@ -1088,7 +1100,7 @@ cmd_fix() { # Что входит в копию: база панели (пользователи, # ключи, подключения), настройки kit и kit-sub, nginx, сайт-заглушка, свои сертификаты. # Сертификаты Let's Encrypt (на IP и на свой домен) не берём: на новом сервере он выпускается заново. -BACKUP_PATHS=(/etc/x-ui/install-result.env /etc/kit/kit.env /etc/kit-sub/config.json +BACKUP_PATHS=(/etc/x-ui/install-result.env /etc/kit/kit.env /etc/kit-sub/config.json /etc/kit-sub/rules.yaml /etc/nginx/kit-stream.conf /etc/nginx/conf.d/kit.conf /var/www/kit /root/cert/self /root/cert/custom /root/3x-ui.txt) # Вернуть проверенное ядро Xray (панель 3.9 иногда отвечает ошибкой GitHub API, хотя ядро заменено, поэтому смотрим на версию). @@ -1230,7 +1242,8 @@ dst = sqlite3.connect(sys.argv[2]) src.backup(dst) dst.close(); src.close() PY - for p in "${BACKUP_PATHS[@]}"; do if [[ -e $p ]]; then cp -a --parents "$p" "$tmp"; fi; done + # cp --parents с абсолютным путём падает («No such file»), если команду запустили не из «/» (например, из /root): копируем из корня. + for p in "${BACKUP_PATHS[@]}"; do if [[ -e $p ]]; then (cd / && cp -a --parents "${p#/}" "$tmp"); fi; done # Какой сертификат был у панели и подписки – новый сервер должен получить такой же. c=$(jq -r '.cert // empty' /etc/kit-sub/config.json 2>/dev/null || true) [[ -z $c && -f /etc/nginx/conf.d/kit.conf ]] && c=$(awk '$1 == "ssl_certificate" {sub(/;$/, "", $2); print $2; exit}' /etc/nginx/conf.d/kit.conf) @@ -1713,6 +1726,71 @@ net_dns() { # [on|off] esac } +# ---------- раздельная маршрутизация в подписке ---------- +# Необязательно: /etc/kit-sub/rules.yaml. Через VPN идёт только перечисленное, остальное (в том числе местные приложения) +# напрямую. Работает для приложений на Mihomo (Clash Verge, FlClash, Mihomo Party): правила приезжают с подпиской, на телефонах +# ничего настраивать не нужно. DNS при этом не течёт: имена из списка у клиента не разрешаются, а запросы про них идут по DoH +# через VPN (подробности – в kit-sub.py). Нет файла – подписка как обычно, весь трафик через VPN. +SPLIT_FILE=/etc/kit-sub/rules.yaml + +split_template() { + cat <<'YAML' +# Раздельная маршрутизация подписки (приложения на Mihomo: Clash Verge, FlClash, Mihomo Party). +# Через VPN идёт ТОЛЬКО перечисленное ниже, всё остальное – напрямую. +# Правка вступает в силу, когда приложение обновит подписку; перезапуск не нужен. +# Проверить файл: kit net split check. Выключить: kit net split off. +# +# Правила (по одному в строке): ТИП,ЗНАЧЕНИЕ +# GEOSITE,youtube – готовая категория сервиса (список: github.com/MetaCubeX/meta-rules-dat, geo/geosite) +# DOMAIN-SUFFIX,example.org – домен и все его поддомены +# DOMAIN,api.example.org – ровно этот домен +# DOMAIN-KEYWORD,example – домен, в котором есть слово +# IP-CIDR,203.0.113.0/24 – подсеть +# GEOSITE: приложение один раз скачивает базу категорий с GitHub. + +via_vpn: + - GEOSITE,youtube + - GEOSITE,openai + # - DOMAIN-SUFFIX,example.org + +# Необязательно: DNS для всего остального (по умолчанию – системный DNS приложения). Только адреса https://… +# direct_dns: +# - https://dns.example.net/dns-query +YAML +} + +net_split() { # [on|off|check] + local act=${1:-} n + 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) + echo "Раздельная маршрутизация включена: $n" + echo "Файл: $SPLIT_FILE (править, проверить: kit net split check, выключить: kit net split off)" + echo "Работает в приложениях на Mihomo; Xray и sing-box приложения получают подписку как раньше." + else + echo "Раздельная маршрутизация выключена: через VPN идёт весь трафик. Включить: kit net split on" + fi ;; + on) + [[ -f /usr/local/lib/kit-sub/kit_sub.py ]] || die "Нужна подписка kit-sub (обновите kit: kit update)." + if [[ -f $SPLIT_FILE ]]; then say "Уже включено: $SPLIT_FILE"; return 0; fi + 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), остальное напрямую." + echo "Список правится так: nano $SPLIT_FILE, затем kit net split check." + echo "Приложения на Mihomo подхватят при обновлении подписки." ;; + off) + [[ -f $SPLIT_FILE ]] || { say "Уже выключено."; return 0; } + mv "$SPLIT_FILE" "$SPLIT_FILE.off" + say "Выключил: подписка снова отдаёт весь трафик через VPN (список сохранён: $SPLIT_FILE.off)." ;; + check) + [[ -f $SPLIT_FILE ]] || die "Файла нет: $SPLIT_FILE (включить: kit net split on)" + python3 /usr/local/lib/kit-sub/kit_sub.py --check-rules || die "Список не применится – подписка останется без раздельной маршрутизации." ;; + *) die "kit net split [on|off|check]" ;; + esac +} + # ---------- панель и подписка: по IP или по домену ---------- # Со своим доменом (режим «всё на 443») заход по имени домена попадает к сайту-прикрытию. Панель и подписка по # умолчанию открываются по IP; `kit net panel domain` добавляет их и в блок домена (сертификат Let's Encrypt домена), @@ -2064,8 +2142,9 @@ cmd_net() { masq) hy_masq "$@" ;; dns) net_dns "$@" ;; 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 | vision on|off]" ;; + *) die "kit net [site | port имя порт | off имя | on имя | fp отпечаток | masq on|off | dns on|off | panel domain|ip | split on|off|check | vision on|off]" ;; esac } diff --git a/tools/test/rules_test.py b/tools/test/rules_test.py new file mode 100644 index 0000000..68fcca2 --- /dev/null +++ b/tools/test/rules_test.py @@ -0,0 +1,91 @@ +"""Проверки раздельной маршрутизации kit-sub: python3 tools/test/rules_test.py""" +import json, os, sys, 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) +import yaml + +BASE = yaml.safe_dump({"proxies": [{"name": "a", "type": "vless"}, {"name": "b", "type": "vless"}], + "proxy-groups": [{"name": "Авто", "type": "url-test", "proxies": ["a", "b"]}], + "rules": ["MATCH,Авто"], "dns": {"enable": False}}).encode() + + +def write(text): + with open(ks.RULES_FILE, "w") as f: + f.write(text) + + +class Rules(unittest.TestCase): + def tearDown(self): + if os.path.exists(ks.RULES_FILE): + os.remove(ks.RULES_FILE) + + def apply(self, text): + write(text) + loaded = ks.load_rules(report=lambda m: None) + self.assertIsNotNone(loaded) + return yaml.safe_load(ks.apply_rules(BASE, loaded)) + + def test_no_file(self): + self.assertIsNone(ks.load_rules()) + + def test_basic(self): + c = self.apply("via_vpn:\n - GEOSITE,youtube\n - DOMAIN-SUFFIX, example.org \n - IP-CIDR,1.2.3.0/24\n") + r = c["rules"] + self.assertEqual(r[-1], "MATCH,DIRECT") + self.assertIn("GEOSITE,youtube,Авто", r) + self.assertIn("DOMAIN-SUFFIX,example.org,Авто", r) + self.assertIn("IP-CIDR,1.2.3.0/24,Авто,no-resolve", r) # адресные правила не разрешают имена + self.assertLess(r.index("IP-CIDR,192.168.0.0/16,DIRECT,no-resolve"), r.index("GEOSITE,youtube,Авто")) + self.assertEqual(c["mode"], "rule") + + def test_dns_no_leak(self): + c = self.apply("via_vpn:\n - GEOSITE,youtube\n - DOMAIN-SUFFIX,example.org\n - DOMAIN,api.example.com\n") + d = c["dns"] + self.assertEqual(d["enhanced-mode"], "fake-ip") + self.assertEqual(d["nameserver"], ["system"]) + self.assertNotIn("fallback", d) + self.assertEqual(d["nameserver-policy"]["geosite:youtube"], ks.DOH_DEFAULT) + self.assertIn("+.example.org", d["nameserver-policy"]) + self.assertIn("api.example.com", d["nameserver-policy"]) + # DoH-серверы идут через VPN, а не напрямую + self.assertIn("IP-CIDR,1.1.1.1/32,Авто,no-resolve", c["rules"]) + self.assertFalse(c["sniffer"]["override-destination"]) + + def test_direct_dns(self): + c = self.apply("direct_dns:\n - https://dns.example.net/dns-query\n - http://evil/x\n - 'https://a b/'\nvia_vpn:\n - GEOSITE,openai\n") + self.assertEqual(c["dns"]["nameserver"], ["https://dns.example.net/dns-query"]) + + def test_bad_rules_dropped(self): + write("via_vpn:\n - MATCH,DIRECT\n - PROCESS-NAME,x\n - SCRIPT,abc\n - 'DOMAIN,a.com,DIRECT'\n - 'DOMAIN,a b'\n - GEOSITE,youtube\n - 'DOMAIN-SUFFIX,x.org\\nMATCH,Авто'\n") + loaded = ks.load_rules(report=lambda m: None) + self.assertEqual(loaded[0], [("GEOSITE", "youtube")]) + + def test_broken_files_leave_subscription_alone(self): + for text in ("", "via_vpn: 5", "via_vpn: []", "- a\n- b", ":\n - [", "via_vpn:\n - MATCH,DIRECT"): + write(text) + self.assertIsNone(ks.load_rules(report=lambda m: None), text) + with open(ks.RULES_FILE, "wb") as f: + f.write(b"via_vpn:\n" + b" - DOMAIN,a.com\n" * 6000) + self.assertIsNone(ks.load_rules(report=lambda m: None)) # слишком большой + + def test_limits_and_duplicates(self): + write("via_vpn:\n" + "".join(f" - DOMAIN,h{i}.example.org\n" for i in range(400)) + " - DOMAIN,h1.example.org\n") + rules, _ = ks.load_rules(report=lambda m: None) + self.assertEqual(len(rules), ks.RULES_MAX_COUNT) + self.assertEqual(len(set(rules)), len(rules)) + + def test_no_proxies_untouched(self): + write("via_vpn:\n - GEOSITE,youtube\n") + loaded = ks.load_rules(report=lambda m: None) + self.assertEqual(ks.apply_rules(b"proxies: []\n", loaded), b"proxies: []\n") + + +if __name__ == "__main__": + unittest.main(verbosity=1)