Откат 28a7917: формат sing-box для Karing и Hiddify не применяется клиентами (Karing игнорирует правила, Hiddify не показывает серверы)
This commit is contained in:
+1
-1
@@ -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` – и через 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), спасибо. Karing и Hiddify получают те же правила в конфиге sing-box – я выучил ещё один диалект. У v2rayN и приложений с обычным списком ссылок подписка прежняя.
|
||||
* Раздельная маршрутизация: `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`.
|
||||
|
||||
+2
-3
@@ -197,13 +197,12 @@ kit net split on # через VPN только список (по ум
|
||||
| Clash Verge, FlClash, Mihomo Party (ядро Mihomo) | из подписки, сразу (режим «Rule», не «Global») |
|
||||
| Happ | заголовком подписки (профиль «3X-UI KIT» включается сам) |
|
||||
| Приложения, которые берут JSON-подписку Xray (адрес «JSON» со страницы подписки) | из конфига подписки |
|
||||
| Karing, Hiddify (ядро sing-box) | из подписки: им приходит конфиг sing-box с теми же правилами (без XHTTP и AmneziaWG – их ядро sing-box не умеет); категории скачиваются через VPN |
|
||||
| v2rayN, v2rayNG и другие с обычным списком ссылок, sing-box | не получают: подписка прежняя, весь трафик через VPN; раздельную маршрутизацию там настраивают в самом приложении |
|
||||
| v2rayN, v2rayNG и другие с обычным списком ссылок, Karing, Hiddify, sing-box | не получают: подписка прежняя, весь трафик через VPN; раздельную маршрутизацию там настраивают в самом приложении |
|
||||
|
||||
```
|
||||
kit net split on # создать список и включить
|
||||
nano /etc/kit-sub/rules.yaml
|
||||
kit net split check # проверить файл: сколько правил применится, что отброшено и есть ли категории для Mihomo и sing-box
|
||||
kit net split check # проверить файл: сколько правил применится и что отброшено
|
||||
kit net split apply # после правки списка: обновить профили Happ и Xray в панели (Mihomo берёт файл сам)
|
||||
kit net split off # выключить (список сохранится в rules.yaml.off)
|
||||
```
|
||||
|
||||
@@ -6,7 +6,6 @@ https://github.com/itsnotkubrick/3X-UI_KIT
|
||||
Слушает публичный адрес подписки (HTTPS) и ходит в подписку 3X-UI на 127.0.0.1:
|
||||
* Clash / Mihomo (Clash Verge, FlClash, Mihomo Party…) – конфиг 3X-UI плюс AmneziaWG
|
||||
из подписки «<id>-awg»: Mihomo умеет AmneziaWG, а остальные приложения нет;
|
||||
* Karing и Hiddify при включённой раздельной маршрутизации (rules.yaml) – конфиг sing-box с правилами;
|
||||
* остальные приложения и браузер – ответ 3X-UI как есть (ссылки или страница);
|
||||
* заголовок Subscription-Userinfo: expire=0 («бессрочно») убирается – иначе
|
||||
приложения показывают срок «01.01.1970».
|
||||
@@ -15,7 +14,6 @@ https://github.com/itsnotkubrick/3X-UI_KIT
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import http.client
|
||||
import http.server
|
||||
import ipaddress
|
||||
@@ -28,7 +26,6 @@ import ssl
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import yaml
|
||||
@@ -42,9 +39,6 @@ CLASH_UA = re.compile(r"clash|mihomo|flclash|stash|nyanpasu|meta", re.I)
|
||||
# AmneziaWG добавляем только приложениям на ядре Mihomo. Karing, Hiddify и другие на sing-box
|
||||
# тоже могут просить формат Clash (Karing так и делает), но AmneziaWG не умеют.
|
||||
NO_AWG_UA = re.compile(r"karing|hiddify|nekobox|sing-?box|husi|stash|shadowrocket|v2box|streisand|happ|loon|surge|quantumult", re.I)
|
||||
# Karing и Hiddify (ядро sing-box) при включённой раздельной маршрутизации получают конфиг sing-box с правилами.
|
||||
# Karing в User-Agent перечисляет и clash, и mihomo – поэтому эта проверка идёт раньше проверки на Clash.
|
||||
SINGBOX_UA = re.compile(r"karing|hiddify", re.I)
|
||||
SUB_ID = re.compile(r"^[A-Za-z0-9_.@-]{1,64}$")
|
||||
# Страница подписки для браузера (React от 3X-UI) подгружает скрипты и стили из «<путь>/assets/…»: пропускаем только такие файлы.
|
||||
ASSET = re.compile(r"^assets/[A-Za-z0-9_.-]{1,128}\.(js|css|woff2?|svg|png|ico|map)$")
|
||||
@@ -341,256 +335,6 @@ def apply_rules(clash_yaml, loaded):
|
||||
return yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False).encode()
|
||||
|
||||
|
||||
# --- Конфиг sing-box для Karing и Hiddify (формат sing-box 1.12 и новее) ---
|
||||
# Источник – обычный список ссылок той же подписки (запрашивается у 3X-UI с нейтральным User-Agent), правила – из load_rules().
|
||||
# Всё собирается словарями и выдаётся через json.dumps: значения из ссылок и rules.yaml в JSON не склеиваются строками.
|
||||
# Не попадают: XHTTP, WireGuard, AmneziaWG, MTProto (sing-box их не умеет или им нужен отдельный формат), а также
|
||||
# подключения с закреплённым отпечатком или без проверки сертификата (sing-box не умеет закреплять – не ослабляем проверку).
|
||||
LINKS_UA = "kit-sub"
|
||||
SB_PROXY, SB_AUTO, SB_DIRECT = "Прокси", "Авто", "direct"
|
||||
SRS_URL = "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/{}/{}.srs"
|
||||
SRS_NAME = re.compile(r"^[a-z0-9][a-z0-9_.!@-]{0,79}$") # без «/», «:», «%» и «..»: адрес загрузки не уходит из папки
|
||||
SB_HOST = re.compile(r"^[A-Za-z0-9.:-]{1,253}$")
|
||||
SB_FP = ("chrome", "firefox", "edge", "safari", "360", "qq", "ios", "android", "random", "randomized")
|
||||
SS_METHODS = ("aes-128-gcm", "aes-256-gcm", "chacha20-poly1305", "chacha20-ietf-poly1305", "xchacha20-poly1305",
|
||||
"xchacha20-ietf-poly1305", "2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305")
|
||||
|
||||
|
||||
def srs_ok(name):
|
||||
return bool(SRS_NAME.match(name)) and ".." not in name
|
||||
|
||||
|
||||
def _b64(s):
|
||||
s = re.sub(r"\s+", "", s).replace("-", "+").replace("_", "/")
|
||||
return base64.b64decode(s + "=" * (-len(s) % 4), validate=True).decode("utf-8")
|
||||
|
||||
|
||||
def _host_port(u):
|
||||
host, port = u.hostname or "", u.port or 443 # u.port сам бросает ValueError на мусор
|
||||
if not SB_HOST.match(host) or not 1 <= port <= 65535:
|
||||
raise ValueError("адрес")
|
||||
return {"server": host, "server_port": port}
|
||||
|
||||
|
||||
def _first(q, *keys):
|
||||
return next((q[k][0] for k in keys if q.get(k) and q[k][0]), "")
|
||||
|
||||
|
||||
def _truthy(v):
|
||||
return v in ("1", "true", "True")
|
||||
|
||||
|
||||
def _sb_tls(q, server, default="none"):
|
||||
sec = (_first(q, "security") or default).lower()
|
||||
if sec == "none":
|
||||
return None
|
||||
if sec not in ("tls", "reality") or _first(q, "pcs", "pinSHA256") or _truthy(_first(q, "allowInsecure", "insecure")):
|
||||
raise ValueError("tls")
|
||||
t = {"enabled": True, "server_name": _first(q, "sni", "peer") or _first(q, "host") or server}
|
||||
alpn = [a.strip() for a in _first(q, "alpn").split(",") if a.strip()]
|
||||
if alpn:
|
||||
t["alpn"] = alpn
|
||||
fp = _first(q, "fp") or ("chrome" if sec == "reality" else "")
|
||||
if fp: # отпечаток, которого sing-box не знает, сломал бы весь конфиг
|
||||
t["utls"] = {"enabled": True, "fingerprint": fp if fp in SB_FP else "chrome"}
|
||||
if sec == "reality":
|
||||
if not _first(q, "pbk"):
|
||||
raise ValueError("pbk")
|
||||
t["reality"] = {"enabled": True, "public_key": _first(q, "pbk")}
|
||||
if _first(q, "sid"):
|
||||
t["reality"]["short_id"] = _first(q, "sid")
|
||||
return t
|
||||
|
||||
|
||||
def _sb_transport(q):
|
||||
net = (_first(q, "type") or "tcp").lower()
|
||||
path, host = _first(q, "path") or "/", _first(q, "host")
|
||||
if net in ("tcp", "raw"):
|
||||
if _first(q, "headerType") == "http":
|
||||
return {"type": "http", "path": path, **({"host": [host]} if host else {})}
|
||||
return None
|
||||
if net == "ws":
|
||||
return {"type": "ws", "path": path, **({"headers": {"Host": host}} if host else {})}
|
||||
if net == "httpupgrade":
|
||||
return {"type": "httpupgrade", "path": path, **({"host": host} if host else {})}
|
||||
if net == "grpc":
|
||||
sn = _first(q, "serviceName", "path")
|
||||
return {"type": "grpc", **({"service_name": sn} if sn else {})}
|
||||
raise ValueError("транспорт " + net) # xhttp и прочее sing-box не умеет
|
||||
|
||||
|
||||
def _sb_vmess(link):
|
||||
j = json.loads(_b64(link[len("vmess://"):].split("#")[0]))
|
||||
if not isinstance(j, dict) or not j.get("id") or not j.get("add"):
|
||||
raise ValueError("vmess")
|
||||
net = str(j.get("net") or "tcp")
|
||||
q = {"type": [net], "security": [str(j.get("tls") or "none")]}
|
||||
for k, src in (("host", "host"), ("sni", "sni"), ("alpn", "alpn"), ("fp", "fp"), ("pbk", "pbk"), ("sid", "sid")):
|
||||
if j.get(src):
|
||||
q[k] = [str(j[src])]
|
||||
if j.get("path"):
|
||||
q["serviceName" if net == "grpc" else "path"] = [str(j["path"])]
|
||||
if j.get("type") and j["type"] != "none" and net != "grpc":
|
||||
q["headerType"] = [str(j["type"])]
|
||||
u = urllib.parse.urlsplit("vmess://x@" + ("[%s]" % j["add"] if ":" in str(j["add"]) else str(j["add"])) + ":" + str(j.get("port")))
|
||||
o = {"type": "vmess", **_host_port(u), "uuid": str(j["id"]), "security": str(j.get("scy") or "auto"),
|
||||
"alter_id": int(j.get("aid") or 0)}
|
||||
return str(j.get("ps") or ""), o, q
|
||||
|
||||
|
||||
def sb_outbound(link):
|
||||
"""Одна ссылка → (имя, outbound sing-box). Неподдерживаемое – ValueError."""
|
||||
scheme = link.split("://", 1)[0].lower()
|
||||
if scheme == "vmess":
|
||||
name, o, q = _sb_vmess(link)
|
||||
else:
|
||||
u = urllib.parse.urlsplit(link)
|
||||
q = urllib.parse.parse_qs(u.query)
|
||||
name = urllib.parse.unquote(u.fragment)
|
||||
user = urllib.parse.unquote(u.username or "")
|
||||
if scheme == "vless":
|
||||
if not user or _first(q, "encryption") not in ("", "none"):
|
||||
raise ValueError("vless")
|
||||
o = {"type": "vless", **_host_port(u), "uuid": user}
|
||||
if _first(q, "flow"):
|
||||
o["flow"] = _first(q, "flow")
|
||||
elif scheme == "trojan":
|
||||
if not user:
|
||||
raise ValueError("trojan")
|
||||
o = {"type": "trojan", **_host_port(u), "password": user}
|
||||
q.setdefault("security", ["tls"])
|
||||
elif scheme == "ss":
|
||||
if _first(q, "plugin"):
|
||||
raise ValueError("ss plugin")
|
||||
info = user if ":" in user else _b64(user)
|
||||
method, _, pw = info.partition(":")
|
||||
if method.lower() not in SS_METHODS or not pw:
|
||||
raise ValueError("ss")
|
||||
return name, {"type": "shadowsocks", **_host_port(u), "method": method.lower(), "password": pw}
|
||||
elif scheme in ("hy2", "hysteria2", "tuic"):
|
||||
if re.search(r":\d+[-,]", u.netloc) or _first(q, "pinSHA256") or _truthy(_first(q, "insecure", "allow_insecure")):
|
||||
raise ValueError(scheme)
|
||||
pw = urllib.parse.unquote(u.password or "")
|
||||
t = {"enabled": True, "server_name": _first(q, "sni") or u.hostname or ""}
|
||||
alpn = [a.strip() for a in _first(q, "alpn").split(",") if a.strip()]
|
||||
if scheme == "tuic":
|
||||
if not user or not pw:
|
||||
raise ValueError("tuic")
|
||||
t["alpn"] = alpn or ["h3"]
|
||||
return name, {"type": "tuic", **_host_port(u), "uuid": user, "password": pw, "tls": t,
|
||||
"congestion_control": _first(q, "congestion_control") or "bbr",
|
||||
"udp_relay_mode": _first(q, "udp_relay_mode") or "native"}
|
||||
auth = user + (":" + pw if pw else "")
|
||||
if not auth:
|
||||
raise ValueError("hy2")
|
||||
if alpn:
|
||||
t["alpn"] = alpn
|
||||
o = {"type": "hysteria2", **_host_port(u), "password": auth, "tls": t}
|
||||
obfs = _first(q, "obfs")
|
||||
if obfs:
|
||||
if obfs != "salamander":
|
||||
raise ValueError("obfs")
|
||||
o["obfs"] = {"type": "salamander", "password": _first(q, "obfs-password")}
|
||||
return name, o
|
||||
else:
|
||||
raise ValueError(scheme) # vpn:// (AmneziaWG), tg://, wireguard:// и прочее
|
||||
tls = _sb_tls(q, o["server"])
|
||||
if tls:
|
||||
o["tls"] = tls
|
||||
tr = _sb_transport(q)
|
||||
if tr:
|
||||
o["transport"] = tr
|
||||
return name, o
|
||||
|
||||
|
||||
def singbox_config(links_body, loaded):
|
||||
"""Список ссылок 3X-UI + правила → конфиг sing-box (bytes) или None, если не из чего собрать (тогда подписка прежняя).
|
||||
Через VPN – только список, остальное напрямую (final: direct). DNS без утечек: имена из списка разрешаются по DoH
|
||||
через VPN (detour), остальные – системным DNS или direct_dns; DoH-серверы 1.1.1.1 и 8.8.8.8 и сами идут через VPN.
|
||||
Категории GEOSITE/GEOIP – готовые наборы .srs с фиксированного адреса, скачиваются тоже через VPN (download_detour)."""
|
||||
if len(links_body) > JSON_FIX_MAX:
|
||||
return None
|
||||
text = links_body.decode("utf-8", "replace").strip()
|
||||
if "://" not in text:
|
||||
try:
|
||||
text = _b64(text)
|
||||
except (ValueError, UnicodeError):
|
||||
return None
|
||||
outs, tags = [], {SB_PROXY, SB_AUTO, SB_DIRECT}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or "://" not in line:
|
||||
continue
|
||||
try:
|
||||
name, o = sb_outbound(line)
|
||||
except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error, RecursionError):
|
||||
continue
|
||||
base = re.sub(r"[\x00-\x1f\x7f]", "", name).strip()[:64] or o["type"]
|
||||
tag, n = base, 2
|
||||
while tag in tags:
|
||||
tag, n = f"{base} {n}", n + 1
|
||||
tags.add(tag)
|
||||
outs.append({"tag": tag, **o})
|
||||
if not outs:
|
||||
return None
|
||||
names = [o["tag"] for o in outs]
|
||||
rules, direct_dns = loaded
|
||||
|
||||
def pick(kind): # без повторов, домены – строчными
|
||||
out = []
|
||||
for k, v in rules:
|
||||
v = v.lower() if k != "IP-CIDR" and k != "IP-CIDR6" else v
|
||||
if k == kind and v not in out:
|
||||
out.append(v)
|
||||
return out
|
||||
|
||||
sets = [(kind, v) for kind in ("geosite", "geoip") for v in pick(kind.upper()) if srs_ok(v)]
|
||||
dom = {"domain": pick("DOMAIN"), "domain_suffix": pick("DOMAIN-SUFFIX"), "domain_keyword": pick("DOMAIN-KEYWORD")}
|
||||
# Каждое правило – с непустым условием: пустое правило в sing-box совпадает со всем.
|
||||
by_name = [r for r in ({k: v for k, v in dom.items() if v},
|
||||
{"rule_set": [f"geosite-{v}" for k, v in sets if k == "geosite"]}) if any(r.values())]
|
||||
by_ip = [r for r in ({"ip_cidr": pick("IP-CIDR") + pick("IP-CIDR6")},
|
||||
{"rule_set": [f"geoip-{v}" for k, v in sets if k == "geoip"]}) if any(r.values())]
|
||||
if not by_name and not by_ip:
|
||||
return None
|
||||
dns_servers = [{"type": "https", "tag": "dns-proxy", "server": "1.1.1.1", "detour": SB_PROXY},
|
||||
{"type": "local", "tag": "dns-local"},
|
||||
# Имена самих серверов (если сервер задан доменом) – по DoH напрямую, как в подписке Mihomo.
|
||||
{"type": "https", "tag": "dns-bootstrap", "server": "1.1.1.1"}]
|
||||
final_dns = "dns-local"
|
||||
if direct_dns:
|
||||
d = urllib.parse.urlsplit(direct_dns[0])
|
||||
srv = {"type": "https", "tag": "dns-direct", "server": d.hostname, "path": d.path or "/dns-query"}
|
||||
if d.port:
|
||||
srv["server_port"] = d.port
|
||||
try:
|
||||
ipaddress.ip_address(d.hostname)
|
||||
except ValueError:
|
||||
srv["domain_resolver"] = "dns-bootstrap"
|
||||
dns_servers.append(srv)
|
||||
final_dns = "dns-direct"
|
||||
dns_rules = [dict(r, server="dns-proxy") for r in by_name]
|
||||
route_rules = [{"action": "sniff"}, {"protocol": "dns", "action": "hijack-dns"},
|
||||
{"ip_is_private": True, "outbound": SB_DIRECT},
|
||||
{"ip_cidr": ["1.1.1.1/32", "8.8.8.8/32"], "outbound": SB_PROXY}] + [dict(r, outbound=SB_PROXY) for r in by_name + by_ip]
|
||||
auto = [SB_AUTO] if len(outs) > 1 else []
|
||||
cfg = {
|
||||
"log": {"level": "warn"},
|
||||
"dns": {"servers": dns_servers, "rules": dns_rules, "final": final_dns, "strategy": "ipv4_only"},
|
||||
"outbounds": [{"type": "selector", "tag": SB_PROXY, "outbounds": auto + names, "default": (auto + names)[0]}]
|
||||
+ ([{"type": "urltest", "tag": SB_AUTO, "outbounds": names, "url": "https://www.gstatic.com/generate_204",
|
||||
"interval": "5m", "tolerance": 100}] if auto else [])
|
||||
+ outs + [{"type": "direct", "tag": SB_DIRECT}],
|
||||
"route": {"rules": route_rules,
|
||||
"rule_set": [{"type": "remote", "tag": f"{k}-{v}", "format": "binary", "url": SRS_URL.format(k, v),
|
||||
"download_detour": SB_PROXY} for k, v in sets],
|
||||
"final": SB_DIRECT, "auto_detect_interface": True, "default_domain_resolver": "dns-bootstrap"},
|
||||
}
|
||||
if not cfg["route"]["rule_set"]:
|
||||
del cfg["route"]["rule_set"]
|
||||
return json.dumps(cfg, ensure_ascii=False, indent=1).encode()
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
server_version = "nginx"
|
||||
sys_version = ""
|
||||
@@ -657,22 +401,6 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
ua = self.headers.get("User-Agent", "")
|
||||
host = CONF.get("link_host") or self.headers.get("Host", CONF.get("host", ""))
|
||||
accept = self.headers.get("Accept", "")
|
||||
if prefix == PATH and SUB_ID.match(sub_id) and not sub_id.endswith(("-awg", "-tg")) and SINGBOX_UA.search(ua[:512]):
|
||||
# Karing и Hiddify: при включённой раздельной маршрутизации – конфиг sing-box с правилами. User-Agent приложения
|
||||
# нужен только для выбора формата: панели уходит нейтральный, в ответ он не попадает. Не вышло – подписка прежняя.
|
||||
loaded = load_rules()
|
||||
if loaded:
|
||||
scode, sheaders, sbody = upstream(sub_id, LINKS_UA, host, "*/*", prefix)
|
||||
try:
|
||||
out = singbox_config(sbody, loaded) if scode == 200 else None
|
||||
except (ValueError, TypeError, KeyError, UnicodeError) as e:
|
||||
log(f"конфиг sing-box не собран: {e}")
|
||||
out = None
|
||||
if out:
|
||||
log(f"{ua[:80]!r} → sing-box+rules")
|
||||
sheaders = {k: v for k, v in sheaders.items() if k not in ("content-type", "content-disposition")}
|
||||
sheaders["content-type"] = "application/json; charset=utf-8"
|
||||
return self.reply(200, sheaders, out)
|
||||
code, headers, body = upstream(sub_id, ua, host, accept, prefix)
|
||||
if code is None:
|
||||
return self.send_html_error(502)
|
||||
@@ -707,9 +435,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
body = apply_rules(body, loaded)
|
||||
except (yaml.YAMLError, UnicodeError) as e:
|
||||
log(f"не удалось обработать подписку: {e}")
|
||||
self.reply(code, headers, body)
|
||||
|
||||
def reply(self, code, headers, body):
|
||||
self.send_response(code)
|
||||
for k in PASS_HEADERS:
|
||||
if k in headers:
|
||||
@@ -781,26 +507,6 @@ def unknown_geo(kind, names):
|
||||
return [n for n in names if b"\x0a" + bytes([len(n)]) + n.upper().encode() not in data]
|
||||
|
||||
|
||||
def unknown_srs(kind, names):
|
||||
"""То же для Karing и Hiddify: у каждой категории должен быть готовый набор .srs (иначе sing-box не запустит конфиг).
|
||||
Имя с недопустимыми символами в их конфиг не попадает – о нём тоже сообщаем. Нет доступа к GitHub – не проверяем."""
|
||||
bad = []
|
||||
for n in names:
|
||||
if not srs_ok(n.lower()):
|
||||
bad.append(n)
|
||||
continue
|
||||
try:
|
||||
req = urllib.request.Request(SRS_URL.format(kind, n.lower()), method="HEAD")
|
||||
urllib.request.urlopen(req, timeout=15).close()
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
bad.append(n)
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
print(f"Наборы {kind.upper()} для Karing и Hiddify не проверил: GitHub недоступен ({e}).")
|
||||
break
|
||||
return bad
|
||||
|
||||
|
||||
def main():
|
||||
if "--routing-payload" in sys.argv:
|
||||
# kit net split: профиль для панели (для Happ и Xray); пусто и код 1, если правил нет.
|
||||
@@ -815,8 +521,6 @@ def main():
|
||||
if loaded:
|
||||
print(f"Правил через VPN: {len(loaded[0])}; DNS для остального: {', '.join(loaded[1]) or 'системный'}")
|
||||
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 not bad: # по базе всё есть – сверяем ещё наборы sing-box (Karing и Hiddify)
|
||||
bad = [f"{k} {n}" for k in ("geosite", "geoip") for n in unknown_srs(k, [v for kk, v in loaded[0] if kk == k.upper()])]
|
||||
if bad:
|
||||
print("Нет такой категории: " + ", ".join(bad) + ". Приложение не сможет запустить конфиг – исправьте или удалите эти строки.")
|
||||
raise SystemExit(2)
|
||||
|
||||
+4
-4
@@ -1746,14 +1746,14 @@ net_dns() { # [on|off]
|
||||
|
||||
# ---------- раздельная маршрутизация в подписке ----------
|
||||
# Необязательно: /etc/kit-sub/rules.yaml. Через VPN идёт только перечисленное, остальное (в том числе местные приложения)
|
||||
# напрямую. Работает для приложений на Mihomo (Clash Verge, FlClash, Mihomo Party), Karing и Hiddify (конфиг sing-box): правила приезжают с подпиской, на телефонах
|
||||
# напрямую. Работает для приложений на 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; Karing и Hiddify).
|
||||
# Раздельная маршрутизация подписки (приложения на Mihomo: Clash Verge, FlClash, Mihomo Party).
|
||||
# Через VPN идёт ТОЛЬКО перечисленное ниже (сервисы, которым VPN нужен), всё остальное – напрямую.
|
||||
# Правка вступает в силу, когда приложение обновит подписку; перезапуск не нужен.
|
||||
# Проверить файл: kit net split check. Выключить: kit net split off.
|
||||
@@ -1890,7 +1890,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 и JSON-подписка Xray – из панели (после правки файла: kit net split apply)."
|
||||
echo "Mihomo-приложения берут список из подписки сами; Happ и JSON-подписка Xray – из панели (после правки файла: kit net split apply)."
|
||||
else
|
||||
echo "Раздельная маршрутизация выключена: через VPN идёт весь трафик. Включить: kit net split on"
|
||||
fi ;;
|
||||
@@ -1913,7 +1913,7 @@ net_split() { # [on|off|check]
|
||||
[[ -f $SPLIT_FILE ]] || die "Файла нет: $SPLIT_FILE (включить: kit net split on)"
|
||||
python3 /usr/local/lib/kit-sub/kit_sub.py --check-rules && return 0
|
||||
case $? in
|
||||
2) die "Исправьте список: с неизвестной категорией приложения на Mihomo, Karing и Hiddify не смогут запустить конфиг." ;;
|
||||
2) die "Исправьте список: с неизвестной категорией приложения на Mihomo не смогут запустить конфиг." ;;
|
||||
*) die "Список не применится – подписка останется без раздельной маршрутизации." ;;
|
||||
esac ;;
|
||||
apply)
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
"""Конфиг sing-box для Karing и Hiddify в kit-sub: python3 tools/test/singbox_test.py
|
||||
Если рядом есть sing-box (переменная SING_BOX=путь к бинарнику), выданный конфиг проверяется ещё и `sing-box check`."""
|
||||
import base64, json, os, subprocess, tempfile, threading, unittest, urllib.request, 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
|
||||
|
||||
PBK = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA"
|
||||
U = "0b9f6a8e-1111-4222-8333-444455556666"
|
||||
VMESS = "vmess://" + base64.b64encode(json.dumps({"v": "2", "ps": "VMess WS-admin", "add": "203.0.113.10", "port": "443", "id": U,
|
||||
"aid": "0", "scy": "auto", "net": "ws", "path": "/vm", "host": "", "tls": "tls",
|
||||
"sni": "203.0.113.10", "fp": "chrome"}).encode()).decode()
|
||||
LINKS = "\n".join([
|
||||
f"vless://{U}@203.0.113.10:443?type=tcp&security=reality&pbk=AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA&fp=chrome&sni=www.example.com&sid=ab12&flow=xtls-rprx-vision#VLESS%20REALITY-admin",
|
||||
f"vless://{U}@203.0.113.10:443?type=xhttp&security=reality&pbk=AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA&sni=www.example.com&path=%2Fx#XHTTP-admin",
|
||||
f"vless://{U}@203.0.113.10:443?type=ws&security=tls&path=%2Fws&sni=203.0.113.10&fp=firefox&alpn=http%2F1.1#VLESS%20WS-admin",
|
||||
f"trojan://pass%40word@203.0.113.10:443?type=grpc&serviceName=tg&security=tls&sni=203.0.113.10#Trojan%20gRPC-admin",
|
||||
VMESS,
|
||||
"ss://" + base64.urlsafe_b64encode(b"2022-blake3-aes-256-gcm:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=:ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=").decode().rstrip("=") + "@203.0.113.10:8388#SS-admin",
|
||||
"hysteria2://hypass@203.0.113.10:443?sni=203.0.113.10&alpn=h3#Hysteria2-admin",
|
||||
f"tuic://{U}:tpass@203.0.113.10:8444?sni=203.0.113.10&congestion_control=bbr&alpn=h3#TUIC-admin",
|
||||
"vpn://AAAA#AmneziaWG", "tg://proxy?server=203.0.113.10&port=443&secret=ee00",
|
||||
"wireguard://PRIV@203.0.113.10:51820?publickey=PUB&address=10.0.0.2%2F32#WG",
|
||||
f"vless://{U}@203.0.113.10:443?type=ws&security=tls&pcs=ABCD#Pinned", # закреплённый отпечаток – sing-box не умеет, пропускаем
|
||||
f"vless://{U}@203.0.113.10:443?type=ws&security=tls&allowInsecure=1#Insecure", # без проверки сертификата – пропускаем
|
||||
])
|
||||
RULES = ("via_vpn:\n - GEOSITE,youtube\n - GEOSITE,category-ai-!cn\n - DOMAIN-SUFFIX,Example.ORG\n - DOMAIN,api.example.com\n"
|
||||
" - DOMAIN-KEYWORD,video\n - GEOIP,telegram\n - IP-CIDR,203.0.113.0/24\n - IP-CIDR6,2001:db8::/32\n")
|
||||
|
||||
|
||||
def write(text):
|
||||
with open(ks.RULES_FILE, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def loaded(text=RULES):
|
||||
write(text)
|
||||
return ks.load_rules(report=lambda m: None)
|
||||
|
||||
|
||||
def build(links=LINKS, rules=RULES):
|
||||
out = ks.singbox_config(links.encode(), loaded(rules))
|
||||
return None if out is None else json.loads(out)
|
||||
|
||||
|
||||
def sing_box_check(cfg):
|
||||
sb = os.environ.get("SING_BOX")
|
||||
if not sb:
|
||||
return
|
||||
p = os.path.join(tmp, "sb.json")
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False)
|
||||
r = subprocess.run([sb, "check", "-c", p], capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stderr
|
||||
|
||||
|
||||
class Convert(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
if os.path.exists(ks.RULES_FILE):
|
||||
os.remove(ks.RULES_FILE)
|
||||
|
||||
def test_outbounds(self):
|
||||
c = build()
|
||||
o = {x["tag"]: x for x in c["outbounds"]}
|
||||
self.assertEqual(o["VLESS REALITY-admin"]["tls"]["reality"], {"enabled": True, "public_key": PBK, "short_id": "ab12"})
|
||||
self.assertEqual(o["VLESS REALITY-admin"]["flow"], "xtls-rprx-vision")
|
||||
self.assertEqual(o["VLESS WS-admin"]["transport"], {"type": "ws", "path": "/ws"})
|
||||
self.assertEqual(o["VLESS WS-admin"]["tls"]["utls"]["fingerprint"], "firefox")
|
||||
self.assertEqual(o["Trojan gRPC-admin"]["password"], "pass@word")
|
||||
self.assertEqual(o["Trojan gRPC-admin"]["transport"], {"type": "grpc", "service_name": "tg"})
|
||||
self.assertEqual(o["VMess WS-admin"]["transport"]["path"], "/vm")
|
||||
self.assertEqual(o["SS-admin"]["password"], "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=:ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=")
|
||||
self.assertEqual(o["Hysteria2-admin"]["tls"]["alpn"], ["h3"])
|
||||
self.assertEqual(o["TUIC-admin"]["password"], "tpass")
|
||||
for skipped in ("XHTTP-admin", "AmneziaWG", "WG", "Pinned", "Insecure"):
|
||||
self.assertNotIn(skipped, o)
|
||||
self.assertNotIn("endpoints", c)
|
||||
for x in c["outbounds"]: # ослабленной проверки сертификата нет нигде
|
||||
self.assertNotIn("insecure", x.get("tls", {}))
|
||||
sing_box_check(c)
|
||||
|
||||
def test_route_and_dns(self):
|
||||
c = build()
|
||||
r, d = c["route"], c["dns"]
|
||||
self.assertEqual(r["final"], "direct")
|
||||
self.assertEqual(r["rules"][:2], [{"action": "sniff"}, {"protocol": "dns", "action": "hijack-dns"}])
|
||||
self.assertIn({"ip_is_private": True, "outbound": "direct"}, r["rules"])
|
||||
self.assertIn({"ip_cidr": ["1.1.1.1/32", "8.8.8.8/32"], "outbound": "Прокси"}, r["rules"]) # DoH – через VPN
|
||||
self.assertIn({"domain": ["api.example.com"], "domain_suffix": ["example.org"], "domain_keyword": ["video"],
|
||||
"outbound": "Прокси"}, r["rules"])
|
||||
self.assertIn({"rule_set": ["geosite-youtube", "geosite-category-ai-!cn"], "outbound": "Прокси"}, r["rules"])
|
||||
self.assertIn({"ip_cidr": ["203.0.113.0/24", "2001:db8::/32"], "outbound": "Прокси"}, r["rules"])
|
||||
self.assertIn({"rule_set": ["geoip-telegram"], "outbound": "Прокси"}, r["rules"])
|
||||
# Ни одного правила без условия (в sing-box оно совпало бы со всем)
|
||||
for rule in r["rules"][2:] + d["rules"]:
|
||||
self.assertTrue(any(k not in ("outbound", "server", "action") for k in rule), rule)
|
||||
# DNS: список – только по DoH через VPN, остальное – системный DNS
|
||||
srv = {s["tag"]: s for s in d["servers"]}
|
||||
self.assertEqual(srv["dns-proxy"], {"type": "https", "tag": "dns-proxy", "server": "1.1.1.1", "detour": "Прокси"})
|
||||
self.assertEqual(d["final"], "dns-local")
|
||||
self.assertEqual(srv["dns-local"]["type"], "local")
|
||||
self.assertTrue(all(x["server"] == "dns-proxy" for x in d["rules"]))
|
||||
self.assertEqual(len(d["rules"]), 2) # имена и категории GEOSITE; адресные правила DNS не касаются
|
||||
sing_box_check(c)
|
||||
|
||||
def test_rule_sets_fixed_url_via_proxy(self):
|
||||
c = build()
|
||||
for rs in c["route"]["rule_set"]:
|
||||
kind, name = rs["tag"].split("-", 1)
|
||||
self.assertEqual(rs["url"], f"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/{kind}/{name}.srs")
|
||||
self.assertEqual(rs["download_detour"], "Прокси")
|
||||
self.assertEqual(len({rs["tag"] for rs in c["route"]["rule_set"]}), len(c["route"]["rule_set"]))
|
||||
|
||||
def test_bad_category_names_never_reach_url(self):
|
||||
# Что если: в категории «../» или адрес – путь загрузки не должен уйти из папки meta-rules-dat
|
||||
c = build(rules="via_vpn:\n - GEOSITE,../../../evil/repo/main/x\n - GEOSITE,a:b\n - GEOSITE,..x\n - GEOSITE,YouTube\n - GEOSITE,youtube\n")
|
||||
self.assertEqual([rs["tag"] for rs in c["route"]["rule_set"]], ["geosite-youtube"])
|
||||
for name in ("../x", "a/b", "a:b", "..", "x..y", "a%2f", "", "-x", "a" * 81):
|
||||
self.assertFalse(ks.srs_ok(name), name)
|
||||
for name in ("youtube", "category-ai-!cn", "google@cn", "category-ads-all"):
|
||||
self.assertTrue(ks.srs_ok(name), name)
|
||||
|
||||
def test_only_unusable_rules(self):
|
||||
self.assertIsNone(build(rules="via_vpn:\n - GEOSITE,a:b\n")) # нечего применять – подписка прежняя
|
||||
|
||||
def test_direct_dns(self):
|
||||
c = build(rules="direct_dns:\n - https://dns.example.net:8443/q\nvia_vpn:\n - GEOSITE,youtube\n")
|
||||
srv = {s["tag"]: s for s in c["dns"]["servers"]}
|
||||
self.assertEqual(srv["dns-direct"], {"type": "https", "tag": "dns-direct", "server": "dns.example.net", "path": "/q",
|
||||
"server_port": 8443, "domain_resolver": "dns-bootstrap"})
|
||||
self.assertEqual(c["dns"]["final"], "dns-direct")
|
||||
c = build(rules="direct_dns:\n - https://9.9.9.9/dns-query\nvia_vpn:\n - DOMAIN,a.example.com\n")
|
||||
self.assertNotIn("domain_resolver", {s["tag"]: s for s in c["dns"]["servers"]}["dns-direct"])
|
||||
sing_box_check(c)
|
||||
|
||||
def test_names_quotes_unicode_duplicates(self):
|
||||
links = "\n".join([f'vless://{U}@203.0.113.10:443?security=reality&pbk={PBK}#{n}' for n in
|
||||
("%22%7D%2C%7B%22x", "Привет", "Привет", "Прокси", "direct", "%0Aa%0Db", "")])
|
||||
c = build(links=links, rules='via_vpn:\n - \'DOMAIN,a.example.com"\'\n - DOMAIN-SUFFIX,пример.рф\n - DOMAIN,b.example.com\n')
|
||||
tags = [o["tag"] for o in c["outbounds"]]
|
||||
self.assertEqual(len(tags), len(set(tags)))
|
||||
self.assertIn('"},{"x', tags)
|
||||
self.assertIn("Привет 2", tags)
|
||||
self.assertIn("Прокси 2", tags)
|
||||
self.assertIn("direct 2", tags)
|
||||
self.assertIn("ab", tags)
|
||||
self.assertIn("vless", tags)
|
||||
self.assertEqual(c["route"]["rules"][4], {"domain": ["b.example.com"], "outbound": "Прокси"}) # кавычка и юникод отброшены правилом
|
||||
sing_box_check(c)
|
||||
|
||||
def test_single_proxy_no_auto(self):
|
||||
c = build(links=LINKS.splitlines()[0])
|
||||
self.assertEqual([o["tag"] for o in c["outbounds"]], ["Прокси", "VLESS REALITY-admin", "direct"])
|
||||
sing_box_check(c)
|
||||
|
||||
def test_nothing_usable(self):
|
||||
for body in (b"", b"!!!not base64!!!", b"vpn://AAAA\ntg://proxy?server=x", b"\xff\xfe", b"<html>panel</html>",
|
||||
b"a" * (ks.JSON_FIX_MAX + 1)):
|
||||
self.assertIsNone(ks.singbox_config(body, loaded()), body[:20])
|
||||
|
||||
def test_base64_body(self):
|
||||
c = json.loads(ks.singbox_config(base64.b64encode(LINKS.encode()), loaded()))
|
||||
self.assertIn("VLESS REALITY-admin", [o["tag"] for o in c["outbounds"]])
|
||||
|
||||
def test_broken_links_skipped(self):
|
||||
for bad in (f"vless://@203.0.113.10:443?security=reality&pbk=" + PBK + "#x", f"vless://{U}@203.0.113.10:99999?security=none#x",
|
||||
f"vless://{U}@203.0.113.10:443?security=reality#nopbk", f"vless://{U}@bad_host!:443#x",
|
||||
f"vless://{U}@203.0.113.10:443?encryption=mlkem768#x", "vmess://notjson", "vmess://" + base64.b64encode(b"[1]").decode(),
|
||||
"ss://bm9wZQ@203.0.113.10:8388#x", "hysteria2://p@203.0.113.10:443-500#x", "tuic://u@203.0.113.10:1#x",
|
||||
f"vless://{U}@203.0.113.10:443?security=xtls#x"):
|
||||
self.assertIsNone(ks.singbox_config(bad.encode(), loaded()), bad)
|
||||
|
||||
|
||||
class Agents(unittest.TestCase):
|
||||
def test_singbox_ua(self):
|
||||
for ua in ("Karing/1.2.25.2802 platform/ios;mihomo/1.19.28;clash-verge;FLClash;mihomo.party/", "karing", "Hiddify/2.0",
|
||||
"HiddifyNext/4.1.0 (android) like ClashMeta v2ray sing-box", "HiddifyNext/2.0", "clash.meta karing"):
|
||||
self.assertTrue(ks.SINGBOX_UA.search(ua[:512]), ua)
|
||||
for ua in ("", "sing-box/1.12", "SFI/1.12", "FlClash/0.8", "clash-verge/v2", "Happ/1.0", "v2rayN/7.0", "Mozilla/5.0",
|
||||
"a" * 600 + "karing"): # очень длинный: смотрим только начало
|
||||
self.assertFalse(ks.SINGBOX_UA.search(ua[:512]), ua[:40])
|
||||
|
||||
|
||||
CLASH = yaml.safe_dump({"proxies": [{"name": "a", "type": "vless", "server": "203.0.113.10", "port": 443}],
|
||||
"proxy-groups": [{"name": "g", "type": "select", "proxies": ["a"]}], "rules": ["MATCH,g"]}).encode()
|
||||
|
||||
|
||||
class Http(unittest.TestCase):
|
||||
"""Через настоящий обработчик, upstream подменён заглушкой: какой формат и с каким User-Agent уходит к панели."""
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
self.orig = ks.upstream
|
||||
|
||||
def fake(sub_id, ua, host, accept, prefix=None):
|
||||
self.calls.append((sub_id, ua, prefix))
|
||||
if ua == ks.LINKS_UA:
|
||||
return 200, {"content-type": "text/plain; charset=utf-8", "subscription-userinfo": "upload=1; expire=0",
|
||||
"profile-title": "3X-UI KIT", "content-disposition": "attachment"}, LINKS.encode()
|
||||
if ks.CLASH_UA.search(ua):
|
||||
return 200, {"content-type": "application/yaml"}, CLASH
|
||||
return 200, {"content-type": "text/plain; charset=utf-8"}, b"vless://a@b:1#x\n"
|
||||
ks.upstream = fake
|
||||
self.srv = ks.Server(("127.0.0.1", 0), ks.Handler)
|
||||
threading.Thread(target=self.srv.serve_forever, daemon=True).start()
|
||||
|
||||
def tearDown(self):
|
||||
self.srv.shutdown(); self.srv.server_close(); ks.upstream = self.orig
|
||||
if os.path.exists(ks.RULES_FILE):
|
||||
os.remove(ks.RULES_FILE)
|
||||
|
||||
def get(self, ua, path=None):
|
||||
req = urllib.request.Request("http://127.0.0.1:%d%s" % (self.srv.server_address[1], path or ks.PATH + "abc123"),
|
||||
headers={"User-Agent": ua})
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return r.status, {k.lower(): v for k, v in r.getheaders()}, r.read()
|
||||
|
||||
def test_karing_gets_singbox_with_rules(self):
|
||||
write(RULES)
|
||||
ua = 'Karing/1.2 clash"}{"x'
|
||||
code, h, body = self.get(ua)
|
||||
self.assertEqual(code, 200)
|
||||
self.assertTrue(h["content-type"].startswith("application/json"))
|
||||
self.assertNotIn("content-disposition", h)
|
||||
self.assertEqual(h["subscription-userinfo"], "upload=1")
|
||||
self.assertEqual(h["profile-title"], "3X-UI KIT")
|
||||
c = json.loads(body)
|
||||
self.assertEqual(c["route"]["final"], "direct")
|
||||
self.assertNotIn(b"Karing", body) # User-Agent в ответ не попадает
|
||||
self.assertEqual(self.calls, [("abc123", ks.LINKS_UA, ks.PATH)]) # панели – нейтральный User-Agent
|
||||
sing_box_check(c)
|
||||
|
||||
def test_hiddify_gets_singbox(self):
|
||||
write(RULES)
|
||||
_, h, body = self.get("HiddifyNext/4.1.0 (android) like ClashMeta v2ray sing-box")
|
||||
self.assertIn("outbounds", json.loads(body))
|
||||
|
||||
def test_split_off_unchanged(self):
|
||||
# Что если: kit net split off – Karing снова получает прежнюю подписку (Clash без AmneziaWG, без правил)
|
||||
_, h, body = self.get("Karing/1.2 clash")
|
||||
self.assertIn("yaml", h["content-type"])
|
||||
self.assertEqual(yaml.safe_load(body)["rules"], ["MATCH,g"])
|
||||
self.assertEqual([c[1] for c in self.calls], ["Karing/1.2 clash"])
|
||||
|
||||
def test_broken_rules_unchanged(self):
|
||||
write("via_vpn: [")
|
||||
_, h, _ = self.get("Karing/1.2 clash")
|
||||
self.assertIn("yaml", h["content-type"])
|
||||
|
||||
def test_upstream_fails_fallback(self):
|
||||
write(RULES)
|
||||
fake = ks.upstream
|
||||
ks.upstream = lambda sub_id, ua, *a: (500, {}, b"") if ua == ks.LINKS_UA else fake(sub_id, ua, *a)
|
||||
_, h, _ = self.get("Karing/1.2 clash")
|
||||
self.assertIn("yaml", h["content-type"])
|
||||
|
||||
def test_other_apps_untouched(self):
|
||||
# Регрессия: Mihomo, Happ, v2rayN, браузер – один запрос к панели с их User-Agent, без sing-box
|
||||
write(RULES)
|
||||
for ua in ("FlClash/0.8", "clash-verge/v2", "Happ/1.0", "v2rayN/7.0", "Mozilla/5.0", "sing-box/1.12", ""):
|
||||
self.calls.clear()
|
||||
_, h, body = self.get(ua)
|
||||
self.assertEqual(self.calls[0][1], ua, ua) # Mihomo-приложения ещё подмешивают «-awg» – как раньше
|
||||
self.assertNotIn(ks.LINKS_UA, [c[1] for c in self.calls], ua)
|
||||
self.assertNotIn("application/json", h["content-type"], ua)
|
||||
|
||||
def test_mihomo_still_gets_rules(self):
|
||||
write(RULES)
|
||||
_, _, body = self.get("FlClash/0.8")
|
||||
self.assertEqual(yaml.safe_load(body)["rules"][-1], "MATCH,DIRECT")
|
||||
|
||||
def test_awg_and_extra_paths_untouched(self):
|
||||
write(RULES)
|
||||
for path in (ks.PATH + "abc123-awg", ks.PATH + "abc123-tg"):
|
||||
self.calls.clear()
|
||||
self.get("Karing/1.2 clash", path)
|
||||
self.assertEqual([c[1] for c in self.calls], ["Karing/1.2 clash"], path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=1)
|
||||
Reference in New Issue
Block a user