From f9024705fb6f40b99c6063e1dae22f4800174022 Mon Sep 17 00:00:00 2001 From: crutoboy Date: Fri, 29 May 2026 08:04:53 +0000 Subject: [PATCH 1/3] add TTL caching for external subscriptions --- .env.example | 3 +++ config.py | 3 +++ docker-compose.yml | 1 + main.py | 15 +++++++++++++-- requirements.txt | 1 + 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index eaa53b2..797fca3 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ URI_PATH=/sub/ # Базовый путь URL для подписк # Должен начинаться и заканчиваться на '/'. # Пример: https://sub.example.com/sub/username +# Время кеширования внешних подписок (в секундах). По умолчанию 1 час. +SUBSCRIPTION_CACHE_TTL=3600 + # ===================================================================== # URLS — основные ссылки и конфигурации, которые будут возвращаться пользователю diff --git a/config.py b/config.py index 44c8c02..75df1ec 100644 --- a/config.py +++ b/config.py @@ -11,3 +11,6 @@ LISTEN_HOST = os.getenv('LISTEN_HOST', '0.0.0.0') LISTEN_PORT = int(os.getenv('LISTEN_PORT', '2096')) URI_PATH = os.getenv('URI_PATH', '/sub/') URLS = json.loads(os.getenv('URLS', '{}')) + +# TTL кеша внешних подписок в секундах (по умолчанию 1 час) +SUBSCRIPTION_CACHE_TTL = int(os.getenv('SUBSCRIPTION_CACHE_TTL', '3600')) diff --git a/docker-compose.yml b/docker-compose.yml index e94d147..2ee5053 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,4 +27,5 @@ services: # LISTEN_HOST: 0.0.0.0 # LISTEN_PORT: 2096 # URI_PATH: /sub/ + # SUBSCRIPTION_CACHE_TTL: 3600 # URLS: '{"all": [...]}' # большой JSON лучше хранить в .env файле diff --git a/main.py b/main.py index 21aed1e..846929d 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,7 @@ import base64 import flask import requests +from cachetools import TTLCache, cached import config as c @@ -15,14 +16,24 @@ def index(): ... +# Кеш внешних подписок с TTL (по умолчанию 1 час) +_subs_cache = TTLCache(maxsize=256, ttl=c.SUBSCRIPTION_CACHE_TTL) +@cached(_subs_cache) def get_subs_from_server(link: str) -> List[str]: + """ + Получает и декодирует внешнюю подписку по HTTPS ссылке. + Результат кешируется на SUBSCRIPTION_CACHE_TTL секунд. + """ try: - sub_response = requests.get(link) + sub_response = requests.get(link, timeout=10) decoded = base64.b64decode(sub_response.text).decode('utf-8') res = decoded.split('\n') res = list(filter(None, res)) return res - except: + except Exception: + # В случае ошибки не кешируем результат (попробуем снова при следующем запросе) + # Для этого очищаем кеш для данного ключа + _subs_cache.pop(link, None) return [] def format_urls(urls: List[str], user: str) -> List[str]: diff --git a/requirements.txt b/requirements.txt index 212654a..13db792 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ python-dotenv==1.2.2 requests==2.34.2 urllib3==2.7.0 Werkzeug==3.1.8 +cachetools==5.5.2 -- 2.49.1 From 2f29be7ec78474ddb18063fe3cc061f1c7286dac Mon Sep 17 00:00:00 2001 From: crutoboy Date: Fri, 29 May 2026 09:11:51 +0000 Subject: [PATCH 2/3] add sub metadata support --- .dockerignore | 1 + .env.example | 17 +++++++++++++++++ .gitignore | 1 + config.py | 7 +++++++ docker-compose.yml | 5 +++++ main.py | 30 ++++++++++++++++++++++++++++-- 6 files changed, 59 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index e2795ae..764a037 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ certs +test # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/.env.example b/.env.example index 797fca3..848147b 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,23 @@ URI_PATH=/sub/ # Базовый путь URL для подписк # Время кеширования внешних подписок (в секундах). По умолчанию 1 час. SUBSCRIPTION_CACHE_TTL=3600 +# ГЛОБАЛЬНЫЕ МЕТАДАННЫЕ ПОДПИСКИ +# Ссылка на поддержку (отображается в клиентах) +SUPPORT_URL=https://t.me/your_support + +# Ссылка на профиль / панель управления +PROFILE_WEB_PAGE_URL=https://panel.example.com + +# Объявление / важное сообщение (показывается в некоторых клиентах) +ANNOUNCE=Это объявление + +# Как часто клиенты должны обновлять подписку (в часах) +UPDATE_INTERVAL=12 + +# Ссылка на импорт маршрутизации для клиента Happ (happ://routing/add/...) +# Скопируй полную ссылку из Happ или сгенерируй самостоятельно +HAPP_ROUTING_LINK=happ://routing/add/eyJibG9ja2lwIjpbXSwiYmxvY2tzaXRlcyI6W10sImRpcmVjdGlwIjpbIjEwLjAuMC4wLzgiLCIxNzIuMTYuMC4wLzEyIiwiMTkyLjE2OC4wLjAvMTYiLCIxNjkuMjU0LjAuMC8xNiIsIjIyNC4wLjAuMC80IiwiMjU1LjI1NS4yNTUuMjU1IiwiZ2VvaXA6cnUiXSwiZGlyZWN0c2l0ZXMiOlsiZ2Vvc2l0ZTpjYXRlZ29yeS1ydSIsIioubG9jYWwiXSwiZG5zaG9zdHMiOnsiY2xvdWRmbGFyZS1kbnMuY29tIjoiMS4xLjEuMSIsImRucy5nb29nbGUiOiI4LjguOC44In0sImRvbWFpbnN0cmF0ZWd5IjoiSVBJZk5vbk1hdGNoIiwiZG9tZXN0aWNkbnNkb21haW4iOiJodHRwczovL2Rucy5nb29nbGUvZG5zLXF1ZXJ5IiwiZG9tZXN0aWNkbnNpcCI6IjguOC44LjgiLCJkb21lc3RpY2Ruc3R5cGUiOiJEb0giLCJmYWtlZG5zIjpmYWxzZSwiZ2VvaXB1cmwiOiJodHRwczovL2dpdGh1Yi5jb20vTG95YWxzb2xkaWVyL3YycmF5LXJ1bGVzLWRhdGEvcmVsZWFzZXMvbGF0ZXN0L2Rvd25sb2FkL2dlb2lwLmRhdCIsImdlb3NpdGV1cmwiOiJodHRwczovL2dpdGh1Yi5jb20vTG95YWxzb2xkaWVyL3YycmF5LXJ1bGVzLWRhdGEvcmVsZWFzZXMvbGF0ZXN0L2Rvd25sb2FkL2dlb3NpdGUuZGF0IiwiZ2xvYmFscHJveHkiOnRydWUsIm5hbWUiOiJydSwgbG9jYWwiLCJwcm94eWlwIjpbXSwicHJveHlzaXRlcyI6W10sInJlbW90ZWRuc2RvbWFpbiI6Imh0dHBzOi8vY2xvdWRmbGFyZS1kbnMuY29tL2Rucy1xdWVyeSIsInJlbW90ZWRuc2lwIjoiMS4xLjEuMSIsInJlbW90ZWRuc3R5cGUiOiJEb0giLCJyb3V0ZW9yZGVyIjoiYmxvY2stZGlyZWN0LXByb3h5In0= + # ===================================================================== # URLS — основные ссылки и конфигурации, которые будут возвращаться пользователю diff --git a/.gitignore b/.gitignore index e2795ae..764a037 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ certs +test # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/config.py b/config.py index 75df1ec..ebfaa11 100644 --- a/config.py +++ b/config.py @@ -14,3 +14,10 @@ URLS = json.loads(os.getenv('URLS', '{}')) # TTL кеша внешних подписок в секундах (по умолчанию 1 час) SUBSCRIPTION_CACHE_TTL = int(os.getenv('SUBSCRIPTION_CACHE_TTL', '3600')) + +# метаданные подписки +SUPPORT_URL = os.getenv('SUPPORT_URL', '') +PROFILE_WEB_PAGE_URL = os.getenv('PROFILE_WEB_PAGE_URL', '') +ANNOUNCE = os.getenv('ANNOUNCE', '') +UPDATE_INTERVAL = int(os.getenv('UPDATE_INTERVAL', '12')) # в часах +HAPP_ROUTING_LINK = os.getenv('HAPP_ROUTING_LINK', '') # полный happ://routing/add/... diff --git a/docker-compose.yml b/docker-compose.yml index 2ee5053..81a19bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,4 +28,9 @@ services: # LISTEN_PORT: 2096 # URI_PATH: /sub/ # SUBSCRIPTION_CACHE_TTL: 3600 + # SUPPORT_URL: https://t.me/your_support + # PROFILE_WEB_PAGE_URL: https://panel.example.com + # ANNOUNCE: Это объявление + # UPDATE_INTERVAL: 12 + # HAPP_ROUTING_LINK: happ://routing/add/... # URLS: '{"all": [...]}' # большой JSON лучше хранить в .env файле diff --git a/main.py b/main.py index 846929d..fde1906 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ from typing import List import base64 import flask +from flask import make_response import requests from cachetools import TTLCache, cached @@ -51,8 +52,33 @@ def format_urls(urls: List[str], user: str) -> List[str]: def get_subs(user: str): urls = format_urls(c.URLS.get(user, []) + c.URLS.get('all', []), user) urls_text = '\n'.join(urls) - res = base64.b64encode(bytes(urls_text, 'utf-8')) - return res + + encoded = base64.b64encode(bytes(urls_text, 'utf-8')) + + # Создаём ответ и добавляем заголовки + resp = make_response(encoded) + resp.headers['Content-Type'] = 'text/plain; charset=utf-8' + + if c.UPDATE_INTERVAL: + resp.headers['Profile-Update-Interval'] = str(c.UPDATE_INTERVAL) + + if c.SUPPORT_URL: + resp.headers['Support-Url'] = c.SUPPORT_URL + + if c.PROFILE_WEB_PAGE_URL: + resp.headers['Profile-Web-Page-Url'] = c.PROFILE_WEB_PAGE_URL + + if c.ANNOUNCE: + announce_bytes = base64.b64encode(bytes(c.ANNOUNCE, "utf-8")) + announce_encode = announce_bytes.decode('ascii') + resp.headers['Announce'] = f'base64:{announce_encode}' + + if c.HAPP_ROUTING_LINK: + resp.headers['Routing'] = c.HAPP_ROUTING_LINK + resp.headers['Routing-Enable'] = 'true' + + return resp + if __name__ == '__main__': -- 2.49.1 From 3f0b31294cffec0cf419e741c3103d5bd74cb98e Mon Sep 17 00:00:00 2001 From: crutoboy Date: Fri, 29 May 2026 09:32:39 +0000 Subject: [PATCH 3/3] add Subscription-Userinfo support --- .env.example | 5 +++ config.py | 3 ++ main.py | 117 +++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 848147b..df7c0f5 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,11 @@ ANNOUNCE=Это объявление # Как часто клиенты должны обновлять подписку (в часах) UPDATE_INTERVAL=12 +# Информация об использовании трафика по умолчанию +# Используются при отсутствии настоящих данных +# Формат: upload=XXX; download=YYY; total=ZZZ; expire=UNIX_TIMESTAMP +SUBSCRIPTION_USERINFO=upload=0; download=0; total=0; expire=0 + # Ссылка на импорт маршрутизации для клиента Happ (happ://routing/add/...) # Скопируй полную ссылку из Happ или сгенерируй самостоятельно HAPP_ROUTING_LINK=happ://routing/add/eyJibG9ja2lwIjpbXSwiYmxvY2tzaXRlcyI6W10sImRpcmVjdGlwIjpbIjEwLjAuMC4wLzgiLCIxNzIuMTYuMC4wLzEyIiwiMTkyLjE2OC4wLjAvMTYiLCIxNjkuMjU0LjAuMC8xNiIsIjIyNC4wLjAuMC80IiwiMjU1LjI1NS4yNTUuMjU1IiwiZ2VvaXA6cnUiXSwiZGlyZWN0c2l0ZXMiOlsiZ2Vvc2l0ZTpjYXRlZ29yeS1ydSIsIioubG9jYWwiXSwiZG5zaG9zdHMiOnsiY2xvdWRmbGFyZS1kbnMuY29tIjoiMS4xLjEuMSIsImRucy5nb29nbGUiOiI4LjguOC44In0sImRvbWFpbnN0cmF0ZWd5IjoiSVBJZk5vbk1hdGNoIiwiZG9tZXN0aWNkbnNkb21haW4iOiJodHRwczovL2Rucy5nb29nbGUvZG5zLXF1ZXJ5IiwiZG9tZXN0aWNkbnNpcCI6IjguOC44LjgiLCJkb21lc3RpY2Ruc3R5cGUiOiJEb0giLCJmYWtlZG5zIjpmYWxzZSwiZ2VvaXB1cmwiOiJodHRwczovL2dpdGh1Yi5jb20vTG95YWxzb2xkaWVyL3YycmF5LXJ1bGVzLWRhdGEvcmVsZWFzZXMvbGF0ZXN0L2Rvd25sb2FkL2dlb2lwLmRhdCIsImdlb3NpdGV1cmwiOiJodHRwczovL2dpdGh1Yi5jb20vTG95YWxzb2xkaWVyL3YycmF5LXJ1bGVzLWRhdGEvcmVsZWFzZXMvbGF0ZXN0L2Rvd25sb2FkL2dlb3NpdGUuZGF0IiwiZ2xvYmFscHJveHkiOnRydWUsIm5hbWUiOiJydSwgbG9jYWwiLCJwcm94eWlwIjpbXSwicHJveHlzaXRlcyI6W10sInJlbW90ZWRuc2RvbWFpbiI6Imh0dHBzOi8vY2xvdWRmbGFyZS1kbnMuY29tL2Rucy1xdWVyeSIsInJlbW90ZWRuc2lwIjoiMS4xLjEuMSIsInJlbW90ZWRuc3R5cGUiOiJEb0giLCJyb3V0ZW9yZGVyIjoiYmxvY2stZGlyZWN0LXByb3h5In0= diff --git a/config.py b/config.py index ebfaa11..8b71dbf 100644 --- a/config.py +++ b/config.py @@ -21,3 +21,6 @@ PROFILE_WEB_PAGE_URL = os.getenv('PROFILE_WEB_PAGE_URL', '') ANNOUNCE = os.getenv('ANNOUNCE', '') UPDATE_INTERVAL = int(os.getenv('UPDATE_INTERVAL', '12')) # в часах HAPP_ROUTING_LINK = os.getenv('HAPP_ROUTING_LINK', '') # полный happ://routing/add/... + +# Fallback значение Subscription-Userinfo, если ни одна внешняя подписка не вернула свои данные +SUBSCRIPTION_USERINFO = os.getenv('SUBSCRIPTION_USERINFO', '') diff --git a/main.py b/main.py index fde1906..017884b 100644 --- a/main.py +++ b/main.py @@ -20,45 +20,118 @@ def index(): # Кеш внешних подписок с TTL (по умолчанию 1 час) _subs_cache = TTLCache(maxsize=256, ttl=c.SUBSCRIPTION_CACHE_TTL) @cached(_subs_cache) -def get_subs_from_server(link: str) -> List[str]: +def get_subs_from_server(link: str): """ - Получает и декодирует внешнюю подписку по HTTPS ссылке. - Результат кешируется на SUBSCRIPTION_CACHE_TTL секунд. + Получает внешнюю подписку. + Возвращает кортеж: (список нод, Subscription-Userinfo из заголовка или None) """ try: sub_response = requests.get(link, timeout=10) - decoded = base64.b64decode(sub_response.text).decode('utf-8') - res = decoded.split('\n') - res = list(filter(None, res)) - return res - except Exception: - # В случае ошибки не кешируем результат (попробуем снова при следующем запросе) - # Для этого очищаем кеш для данного ключа - _subs_cache.pop(link, None) - return [] + userinfo = sub_response.headers.get('Subscription-Userinfo') or \ + sub_response.headers.get('subscription-userinfo') + + decoded = base64.b64decode(sub_response.text).decode('utf-8') + nodes = [line for line in decoded.split('\n') if line.strip()] + + return nodes, userinfo + except Exception: + # При ошибке не кешируем и пробуем заново в следующий раз + _subs_cache.pop(link, None) + return [], None + +def format_urls(urls: List[str], user: str): + """ + Возвращает (список всех нод, список найденных Subscription-Userinfo из внешних подписок) + """ + all_nodes = [] + userinfos = [] -def format_urls(urls: List[str], user: str) -> List[str]: - res = [] for url in urls: url = url.strip().format(user) if url.startswith('https://'): - subs = get_subs_from_server(url) - res += subs - else: - res.append(url) - return res + nodes, userinfo = get_subs_from_server(url) + all_nodes += nodes + if userinfo: + userinfos.append(userinfo) + else: + all_nodes.append(url) + + return all_nodes, userinfos + +def _parse_userinfo(s: str) -> dict: + """Парсит строку вида 'upload=123; download=456; total=789; expire=1234567890'""" + data = {} + for part in s.split(';'): + part = part.strip() + if '=' in part: + k, v = part.split('=', 1) + try: + data[k.strip().lower()] = int(v.strip()) + except ValueError: + pass + return data + + +def _merge_userinfo(infos: list[str]) -> str | None: + """Объединяет несколько Subscription-Userinfo (суммирует трафик, берёт минимальные лимиты).""" + if not infos: + return None + + total_upload = 0 + total_download = 0 + min_total = None + min_expire = None + + for info in infos: + parsed = _parse_userinfo(info) + total_upload += parsed.get('upload', 0) + total_download += parsed.get('download', 0) + + t = parsed.get('total') + if t is not None and t > 0: + min_total = t if min_total is None else min(min_total, t) + + e = parsed.get('expire') + if e is not None and e > 0: + min_expire = e if min_expire is None else min(min_expire, e) + + parts = [ + f"upload={total_upload}", + f"download={total_download}", + ] + + if min_total is not None: + parts.append(f"total={min_total}") + else: + parts.append("total=0") + + if min_expire is not None: + parts.append(f"expire={min_expire}") + else: + parts.append("expire=0") + + return '; '.join(parts) + @app.route(f'{c.URI_PATH}') def get_subs(user: str): - urls = format_urls(c.URLS.get(user, []) + c.URLS.get('all', []), user) - urls_text = '\n'.join(urls) + nodes, upstream_userinfos = format_urls( + c.URLS.get(user, []) + c.URLS.get('all', []), user + ) + urls_text = '\n'.join(nodes) encoded = base64.b64encode(bytes(urls_text, 'utf-8')) - # Создаём ответ и добавляем заголовки resp = make_response(encoded) resp.headers['Content-Type'] = 'text/plain; charset=utf-8' + # === Динамический Userinfo из внешних подписок === + dynamic_userinfo = _merge_userinfo(upstream_userinfos) + if dynamic_userinfo: + resp.headers['Subscription-Userinfo'] = dynamic_userinfo + elif c.SUBSCRIPTION_USERINFO: # fallback на статическое значение, если есть + resp.headers['Subscription-Userinfo'] = c.SUBSCRIPTION_USERINFO + if c.UPDATE_INTERVAL: resp.headers['Profile-Update-Interval'] = str(c.UPDATE_INTERVAL) -- 2.49.1