kit net split: раздельная маршрутизация в Clash-подписке (rules.yaml, без утечек DNS), в копию и восстановление; kit backup работает не только из /; понятное сообщение при отказе панели
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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 имя`.
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
+88
-9
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user