Подписка с учётом приложения: kit-sub перед подпиской 3X-UI
- scripts/kit-sub.py — посредник на публичном порту подписки (HTTPS), 3X-UI только на 127.0.0.1 - Clash/Mihomo (Clash Verge, FlClash…) получают конфиг 3X-UI плюс AmneziaWG - Happ, v2rayN, Karing, Hiddify — ссылки без AmneziaWG, которого они не умеют - expire=0 убирается из Subscription-Userinfo — больше нет «01.01.1970» - Сертификат перечитывается после продления Let's Encrypt, TLS в потоках, тайм-ауты - Установщик ставит kit-sub сам (systemd, ограничения безопасности); адрес подписки не меняется Проверено: Mihomo по итоговому Clash-конфигу — все 10 прокси, включая оба AmneziaWG; обновление поверх существующей установки на сервере в России.
This commit is contained in:
+12
-6
@@ -15,8 +15,8 @@
|
||||
- подбирает сайт для маскировки REALITY — с TLS 1.3 и HTTP/2;
|
||||
- создаёт подключения на всех протоколах из таблицы ниже, генерирует ключи
|
||||
и обфускацию AmneziaWG;
|
||||
- включает единую подписку: Clash Verge и FlClash получают готовый конфиг Mihomo,
|
||||
остальные приложения — список ссылок;
|
||||
- включает единую подписку с учётом приложения: Clash Verge и FlClash получают готовый
|
||||
конфиг Mihomo с AmneziaWG, остальные приложения — ссылки только на то, что они умеют;
|
||||
- открывает в ufw только нужные порты.
|
||||
|
||||
## Протоколы
|
||||
@@ -77,10 +77,9 @@ bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/Reality_Hyster
|
||||
| Android | Hiddify, Happ, v2rayNG, FlClash |
|
||||
| Windows, macOS, Linux | Hiddify, v2rayN, Clash Verge |
|
||||
|
||||
**AmneziaWG идёт отдельной подпиской** — её адрес заканчивается на `-awg`. Добавьте её в Clash Verge
|
||||
или FlClash, а в AmneziaVPN импортируйте ссылки `vpn://` из `/root/3x-ui.txt`. В общую подписку
|
||||
AmneziaWG не попадает: Happ, v2rayN, Karing и Hiddify его не поддерживают.
|
||||
Для Telegram — ссылка `tg://` оттуда же.
|
||||
Подписка смотрит, какое приложение её запрашивает: Clash Verge и FlClash получают AmneziaWG,
|
||||
а Happ, v2rayN, Karing и Hiddify — нет, потому что не умеют его. Для AmneziaVPN импортируйте
|
||||
ссылки `vpn://` из `/root/3x-ui.txt`, для Telegram — ссылку `tg://` оттуда же.
|
||||
|
||||
## Добавить друга
|
||||
|
||||
@@ -97,6 +96,13 @@ AmneziaWG не попадает: Happ, v2rayN, Karing и Hiddify его не п
|
||||
| `--sni www.samsung.com` | свой сайт для маскировки REALITY |
|
||||
| `--no-ufw` | не трогать файрвол |
|
||||
|
||||
## Как устроена подписка
|
||||
|
||||
Наружу смотрит небольшой сервис [kit-sub](../scripts/kit-sub.py) (Python, служба `kit-sub`),
|
||||
а подписка 3X-UI работает только внутри сервера. kit-sub узнаёт приложение по User-Agent,
|
||||
добавляет AmneziaWG в конфиг для Clash-приложений, убирает из заголовка «срок 0», из-за которого
|
||||
приложения показывали 01.01.1970, и сам подхватывает продлённый сертификат.
|
||||
|
||||
> [!NOTE]
|
||||
> Если порт 80 занят, сертификат Let's Encrypt не получить. Тогда скрипт сделает свой
|
||||
> сертификат и впишет его отпечаток в ссылки, а панель будет доступна через SSH-туннель.
|
||||
|
||||
+65
-11
@@ -287,7 +287,6 @@ main() {
|
||||
echo "Пароль: $XUI_PASSWORD"
|
||||
echo
|
||||
[[ $TRUSTED == yes ]] && { echo "Подписка ($NAME) — все протоколы одной ссылкой:"; echo "$SUB_URL"; echo; }
|
||||
[[ $TRUSTED == yes && -n $AWG_LINKS ]] && { echo "AmneziaWG — для AmneziaVPN, Clash Verge, FlClash:"; echo "$SUB_URL-awg"; echo; }
|
||||
echo "Отдельные подключения ($NAME):"
|
||||
echo "$links"
|
||||
} >"$RESULT"
|
||||
@@ -309,10 +308,7 @@ main() {
|
||||
qrencode -t ANSIUTF8 -m 1 "$SUB_URL" || true
|
||||
if [[ -n $AWG_LINKS ]]; then
|
||||
echo
|
||||
echo "AmneziaWG — отдельная подписка для Clash Verge и FlClash; в AmneziaVPN импортируйте"
|
||||
echo "ссылки vpn:// из $RESULT:"
|
||||
echo
|
||||
echo "$SUB_URL-awg"
|
||||
echo "AmneziaWG приходит по подписке в Clash Verge и FlClash; для AmneziaVPN — ссылки vpn:// в $RESULT."
|
||||
fi
|
||||
else
|
||||
echo "Без сертификата подписка недоступна снаружи — вот ссылки по одной:"
|
||||
@@ -541,25 +537,83 @@ proto_mtproto() {
|
||||
# ---------- подписка ----------
|
||||
|
||||
setup_subscription() {
|
||||
local all
|
||||
local all upd
|
||||
all=$(api POST setting/all '{}')
|
||||
SUB_PORT=$(jq -r '.subPort // 2096' <<<"$all")
|
||||
SUB_PATH=$(jq -r '.subPath // "/sub/"' <<<"$all")
|
||||
if [[ $SUB_PATH == /sub/ || -z $SUB_PATH ]]; then SUB_PATH="/$(rand_str 12 | tr 'A-Z' 'a-z')/"; fi
|
||||
local upd
|
||||
upd=$(jq -c --arg path "$SUB_PATH" --arg c "$CERT" --arg k "$KEY" --arg ssl "$TRUSTED" --arg title "3X-UI + Hysteria2 Kit" '
|
||||
if [[ $TRUSTED == yes ]]; then
|
||||
# Наружу смотрит kit-sub (подписка с учётом приложения), 3X-UI — только на 127.0.0.1.
|
||||
SUB_PORT=2096; SUB_INTERNAL=2097
|
||||
upd=$(jq -c --arg path "$SUB_PATH" --argjson ip "$SUB_INTERNAL" --arg title "3X-UI + Hysteria2 Kit" '
|
||||
.subEnable = true | .subPath = $path | .subTitle = $title | .subListen = "127.0.0.1" | .subPort = $ip
|
||||
| .subCertFile = "" | .subKeyFile = ""
|
||||
| .subClashEnable = true | .subClashAutoDetect = true | .subJsonEnable = true | .subJsonAutoDetect = true' <<<"$all")
|
||||
else
|
||||
SUB_PORT=$(jq -r '.subPort // 2096' <<<"$all")
|
||||
upd=$(jq -c --arg path "$SUB_PATH" --arg title "3X-UI + Hysteria2 Kit" '
|
||||
.subEnable = true | .subPath = $path | .subTitle = $title
|
||||
| .subClashEnable = true | .subClashAutoDetect = true | .subJsonEnable = true | .subJsonAutoDetect = true
|
||||
| if $ssl == "yes" then .subCertFile = $c | .subKeyFile = $k | .subListen = "" else . end' <<<"$all")
|
||||
| .subClashEnable = true | .subClashAutoDetect = true | .subJsonEnable = true | .subJsonAutoDetect = true' <<<"$all")
|
||||
fi
|
||||
if [[ $upd != "$all" ]]; then
|
||||
api POST setting/update "$upd" >/dev/null
|
||||
systemctl restart x-ui
|
||||
wait_panel
|
||||
fi
|
||||
[[ $TRUSTED == yes ]] && install_kit_sub
|
||||
if [[ $TRUSTED == yes ]]; then SUB_URL="https://$HOST:$SUB_PORT$SUB_PATH$SUBID"; else SUB_URL="http://127.0.0.1:$SUB_PORT$SUB_PATH$SUBID"; fi
|
||||
SUB_FETCH="$(if [[ $TRUSTED == yes ]]; then echo https; else echo http; fi)://$HOST:$SUB_PORT$SUB_PATH$SUBID"
|
||||
}
|
||||
|
||||
KIT_SUB_URL="https://raw.githubusercontent.com/itsnotkubrick/Reality_Hysteria2/main/scripts/kit-sub.py"
|
||||
|
||||
install_kit_sub() {
|
||||
say "Ставлю подписку с учётом приложения (kit-sub)"
|
||||
apt-get install -y -qq python3 python3-yaml >/dev/null
|
||||
install -d -m 755 /usr/local/lib/kit-sub /etc/kit-sub
|
||||
local src=${KIT_SUB_SRC:-}
|
||||
if [[ -z $src ]]; then
|
||||
local d; d=$(dirname "${BASH_SOURCE[0]}")
|
||||
[[ -f $d/kit-sub.py && ${BASH_SOURCE[0]} != /dev/fd/* ]] && src=$d/kit-sub.py
|
||||
fi
|
||||
if [[ -n $src ]]; then install -m 644 "$src" /usr/local/lib/kit-sub/kit_sub.py
|
||||
else curl -fsSL --retry 3 -o /usr/local/lib/kit-sub/kit_sub.py "$KIT_SUB_URL"; fi
|
||||
python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" /usr/local/lib/kit-sub/kit_sub.py || die "kit-sub скачался повреждённым"
|
||||
jq -n --arg path "$SUB_PATH" --argjson port "$SUB_PORT" --arg up "http://127.0.0.1:$SUB_INTERNAL" \
|
||||
--arg cert "$CERT" --arg key "$KEY" --arg host "$HOST" \
|
||||
'{listen: "0.0.0.0", port: $port, path: $path, upstream: $up, cert: $cert, key: $key, host: $host}' >/etc/kit-sub/config.json
|
||||
chmod 600 /etc/kit-sub/config.json
|
||||
cat >/etc/systemd/system/kit-sub.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=kit-sub: подписка с учётом приложения (3X-UI + Hysteria2 Kit)
|
||||
After=network-online.target x-ui.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /usr/local/lib/kit-sub/kit_sub.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
PrivateTmp=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
MemoryMax=64M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable kit-sub >/dev/null 2>&1
|
||||
systemctl restart kit-sub
|
||||
local i
|
||||
for i in $(seq 1 20); do port_busy "$SUB_PORT" tcp && return 0; sleep 1; done
|
||||
journalctl -u kit-sub -n 20 --no-pager >&2 || true
|
||||
die "kit-sub не запустился — лог выше."
|
||||
}
|
||||
|
||||
# Ссылки пользователя — из его же подписки (её собирает сама 3X-UI).
|
||||
sub_links() {
|
||||
local raw i
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Подписка с учётом приложения — посредник перед подпиской 3X-UI.
|
||||
|
||||
https://github.com/itsnotkubrick/Reality_Hysteria2
|
||||
|
||||
Слушает публичный адрес подписки (HTTPS) и ходит в подписку 3X-UI на 127.0.0.1:
|
||||
* Clash / Mihomo (Clash Verge, FlClash, Mihomo Party…) — конфиг 3X-UI плюс AmneziaWG
|
||||
из подписки «<id>-awg»: Mihomo умеет AmneziaWG, а остальные приложения нет;
|
||||
* остальные приложения и браузер — ответ 3X-UI как есть (ссылки или страница);
|
||||
* заголовок Subscription-Userinfo: expire=0 («бессрочно») убирается — иначе
|
||||
приложения показывают срок «01.01.1970».
|
||||
|
||||
Настройки — /etc/kit-sub/config.json. Сертификат перечитывается сам после продления.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import yaml
|
||||
|
||||
CONFIG = os.environ.get("KIT_SUB_CONFIG", "/etc/kit-sub/config.json")
|
||||
CLASH_UA = re.compile(r"clash|mihomo|flclash|stash|nyanpasu|meta", re.I)
|
||||
SUB_ID = re.compile(r"^[A-Za-z0-9_.@-]{1,64}$")
|
||||
PASS_HEADERS = ("content-type", "content-disposition", "profile-title", "profile-update-interval",
|
||||
"profile-web-page-url", "subscription-userinfo", "support-url", "cache-control")
|
||||
|
||||
with open(CONFIG, encoding="utf-8") as f:
|
||||
CONF = json.load(f)
|
||||
PATH = "/" + CONF["path"].strip("/") + "/"
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def upstream(sub_id, ua, host, accept):
|
||||
"""GET к подписке 3X-UI. Возвращает (код, заголовки, тело) или (None, {}, b"")."""
|
||||
req = urllib.request.Request(CONF["upstream"].rstrip("/") + PATH + sub_id, headers={
|
||||
"User-Agent": ua, "Host": host, "Accept": accept or "*/*"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return r.status, {k.lower(): v for k, v in r.getheaders()}, r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read()
|
||||
except (urllib.error.URLError, OSError, socket.timeout) as e:
|
||||
log(f"upstream недоступен: {e}")
|
||||
return None, {}, b""
|
||||
|
||||
|
||||
def fix_userinfo(value):
|
||||
# «expire=0» значит «бессрочно», но приложения рисуют 01.01.1970 — убираем.
|
||||
parts = [p.strip() for p in value.split(";") if p.strip() and p.strip() != "expire=0"]
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def merge_awg(main_yaml, awg_yaml):
|
||||
"""Добавляет прокси AmneziaWG в Clash-конфиг и во все группы, где перечислены прокси."""
|
||||
main = yaml.safe_load(main_yaml)
|
||||
awg = yaml.safe_load(awg_yaml)
|
||||
if not isinstance(main, dict) or not isinstance(awg, dict):
|
||||
return main_yaml
|
||||
extra = [p for p in (awg.get("proxies") or []) if isinstance(p, dict) and p.get("name")]
|
||||
if not extra:
|
||||
return main_yaml
|
||||
names = {p.get("name") for p in main.get("proxies") or []}
|
||||
for p in extra:
|
||||
base, n = p["name"], 2
|
||||
while p["name"] in names:
|
||||
p["name"] = f"{base} {n}"
|
||||
n += 1
|
||||
names.add(p["name"])
|
||||
main.setdefault("proxies", []).extend(extra)
|
||||
added = [p["name"] for p in extra]
|
||||
for g in main.get("proxy-groups") or []:
|
||||
lst = g.get("proxies")
|
||||
if isinstance(lst, list) and any(x in names for x in lst):
|
||||
pos = lst.index("DIRECT") if "DIRECT" in lst else len(lst)
|
||||
g["proxies"] = lst[:pos] + added + lst[pos:]
|
||||
return yaml.safe_dump(main, allow_unicode=True, sort_keys=False).encode()
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
server_version = "nginx"
|
||||
sys_version = ""
|
||||
timeout = 20 # зависшие соединения не держим
|
||||
|
||||
def setup(self):
|
||||
# TLS-рукопожатие — в потоке запроса, а не в общем цикле приёма соединений.
|
||||
self.request.settimeout(self.timeout)
|
||||
self.request = self.server.ssl_ctx.wrap_socket(self.request, server_side=True)
|
||||
super().setup()
|
||||
|
||||
def handle(self):
|
||||
try:
|
||||
super().handle()
|
||||
except (ssl.SSLError, ConnectionError, socket.timeout, OSError):
|
||||
pass
|
||||
|
||||
def log_message(self, fmt, *args): # без IP клиентов в логах
|
||||
pass
|
||||
|
||||
def send_plain(self, code, text=""):
|
||||
body = text.encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_HEAD(self):
|
||||
self.do_GET()
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
if not path.startswith(PATH):
|
||||
return self.send_plain(404, "404 page not found")
|
||||
sub_id = path[len(PATH):]
|
||||
if not SUB_ID.match(sub_id):
|
||||
return self.send_plain(404, "404 page not found")
|
||||
ua = self.headers.get("User-Agent", "")
|
||||
host = self.headers.get("Host", CONF.get("host", ""))
|
||||
accept = self.headers.get("Accept", "")
|
||||
code, headers, body = upstream(sub_id, ua, host, accept)
|
||||
if code is None:
|
||||
return self.send_plain(502, "subscription backend is unavailable")
|
||||
|
||||
clash = bool(CLASH_UA.search(ua)) and "yaml" in headers.get("content-type", "")
|
||||
if code == 200 and clash and not sub_id.endswith(("-awg", "-tg")):
|
||||
acode, _, abody = upstream(sub_id + "-awg", ua, host, accept)
|
||||
if acode == 200 and abody:
|
||||
try:
|
||||
body = merge_awg(body, abody)
|
||||
except yaml.YAMLError as e:
|
||||
log(f"не удалось добавить AmneziaWG: {e}")
|
||||
|
||||
self.send_response(code)
|
||||
for k in PASS_HEADERS:
|
||||
if k in headers:
|
||||
v = fix_userinfo(headers[k]) if k == "subscription-userinfo" else headers[k]
|
||||
if v:
|
||||
self.send_header(k.title(), v)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
class Server(http.server.ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
ssl_ctx = None
|
||||
|
||||
def handle_error(self, request, client_address): # обрывы TLS от сканеров — не ошибка
|
||||
pass
|
||||
address_family = socket.AF_INET6 if ":" in CONF.get("listen", "") else socket.AF_INET
|
||||
|
||||
|
||||
def main():
|
||||
cert, key = CONF["cert"], CONF["key"]
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.load_cert_chain(cert, key)
|
||||
stamp = [os.path.getmtime(cert)]
|
||||
|
||||
def reload_cert():
|
||||
# Let's Encrypt на IP живёт 6 дней — после продления берём новый сертификат без перезапуска.
|
||||
while True:
|
||||
time.sleep(600)
|
||||
try:
|
||||
m = os.path.getmtime(cert)
|
||||
if m != stamp[0]:
|
||||
ctx.load_cert_chain(cert, key)
|
||||
stamp[0] = m
|
||||
log("сертификат обновлён")
|
||||
except (OSError, ssl.SSLError) as e:
|
||||
log(f"не удалось перечитать сертификат: {e}")
|
||||
|
||||
threading.Thread(target=reload_cert, daemon=True).start()
|
||||
srv = Server((CONF.get("listen", "0.0.0.0"), int(CONF["port"])), Handler)
|
||||
srv.ssl_ctx = ctx
|
||||
log(f"kit-sub слушает {CONF.get('listen', '0.0.0.0')}:{CONF['port']}{PATH}")
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user