diff --git a/manuals/3x-ui.md b/manuals/3x-ui.md index 41a9a09..f9074a3 100644 --- a/manuals/3x-ui.md +++ b/manuals/3x-ui.md @@ -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-туннель. diff --git a/scripts/3x-ui.sh b/scripts/3x-ui.sh index a216f5c..6e00a3a 100755 --- a/scripts/3x-ui.sh +++ b/scripts/3x-ui.sh @@ -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" ' - .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") + 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' <<<"$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 diff --git a/scripts/kit-sub.py b/scripts/kit-sub.py new file mode 100644 index 0000000..87a0eb6 --- /dev/null +++ b/scripts/kit-sub.py @@ -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 + из подписки «-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()