Files
3X-UI_autoVPS/scripts/kit-sub.py
T
itsnotkubrick 0ce96f08e1 Подписка с учётом приложения: 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;
обновление поверх существующей установки на сервере в России.
2026-09-27 18:13:46 +03:00

195 lines
7.8 KiB
Python

#!/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()