add Subscription-Userinfo support

This commit is contained in:
crutoboy
2026-05-29 09:32:39 +00:00
parent 2f29be7ec7
commit 3f0b31294c
3 changed files with 103 additions and 22 deletions

View File

@@ -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=

View File

@@ -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', '')

117
main.py
View File

@@ -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}<user>')
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)