Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efd722aa2f | |||
|
|
b9f849e8fb | ||
|
|
2a9e5da43a | ||
| 3347bc3324 | |||
|
|
3f0b31294c | ||
|
|
2f29be7ec7 | ||
|
|
f9024705fb | ||
|
|
c47c90b484 | ||
|
|
30742ad47d | ||
|
|
01815c3895 |
@@ -1,3 +1,6 @@
|
|||||||
|
certs
|
||||||
|
test
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[codz]
|
*.py[codz]
|
||||||
|
|||||||
25
.env.example
25
.env.example
@@ -7,6 +7,31 @@ URI_PATH=/sub/ # Базовый путь URL для подписк
|
|||||||
# Должен начинаться и заканчиваться на '/'.
|
# Должен начинаться и заканчиваться на '/'.
|
||||||
# Пример: https://sub.example.com/sub/username
|
# Пример: https://sub.example.com/sub/username
|
||||||
|
|
||||||
|
# Время кеширования внешних подписок (в секундах). По умолчанию 1 час.
|
||||||
|
SUBSCRIPTION_CACHE_TTL=3600
|
||||||
|
|
||||||
|
# ГЛОБАЛЬНЫЕ МЕТАДАННЫЕ ПОДПИСКИ
|
||||||
|
# Ссылка на поддержку (отображается в клиентах)
|
||||||
|
SUPPORT_URL=https://t.me/your_support
|
||||||
|
|
||||||
|
# Ссылка на профиль / панель управления
|
||||||
|
PROFILE_WEB_PAGE_URL=https://panel.example.com
|
||||||
|
|
||||||
|
# Объявление / важное сообщение (показывается в некоторых клиентах)
|
||||||
|
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=
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
# URLS — основные ссылки и конфигурации, которые будут возвращаться пользователю
|
# URLS — основные ссылки и конфигурации, которые будут возвращаться пользователю
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ jobs:
|
|||||||
images: git.crutoboy.ru/${{ gitea.repository }}
|
images: git.crutoboy.ru/${{ gitea.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=sha
|
type=sha
|
||||||
type=raw,value=latest
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
|||||||
48
.gitea/workflows/docker-release.yml
Normal file
48
.gitea/workflows/docker-release.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: Release Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.crutoboy.ru
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.GITEATOKEN }}
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
git.crutoboy.ru/${{ gitea.repository }}
|
||||||
|
${{ secrets.DOCKERHUB_USERNAME }}/xray_sub_server
|
||||||
|
tags: |
|
||||||
|
type=ref,event=tag
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
certs
|
||||||
|
test
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[codz]
|
*.py[codz]
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 crutoboy
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
179
README.md
Normal file
179
README.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Xray Subscription Server
|
||||||
|
|
||||||
|
Лёгкий сервер подписок для Xray/V2Ray экосистемы. Позволяет объединять несколько источников конфигураций (внешние подписки + локальные ссылки) и отдавать их в формате, совместимом с популярными клиентами (v2rayN, Nekobox, Happ, Sing-box и др.).
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- Объединение внешних подписок (`https://`) и локальных ссылок
|
||||||
|
- Поддержка метаданных подписки в стиле **3x-ui**:
|
||||||
|
- `Subscription-Userinfo` (динамически подтягивается из внешних подписок)
|
||||||
|
- `Support-Url`
|
||||||
|
- `Profile-Web-Page-Url`
|
||||||
|
- `Announce`
|
||||||
|
- `Profile-Update-Interval`
|
||||||
|
- `Routing` + `Routing-Enable` (для клиента Happ)
|
||||||
|
- Умное объединение `Subscription-Userinfo` из нескольких upstream-подписок
|
||||||
|
- Кеширование внешних подписок (TTL)
|
||||||
|
- Запуск через Docker + gunicorn
|
||||||
|
- Поддержка SSL прямо в gunicorn (без reverse proxy)
|
||||||
|
- Простая конфигурация через `.env`
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### Через Docker Compose (рекомендуется)
|
||||||
|
|
||||||
|
1. Склонируй репозиторий:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/crutoboy/xray_sub_server.git
|
||||||
|
cd xray_sub_server
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Создай `.env` на основе примера:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Отредактируй `.env` — укажи свои ссылки и метаданные.
|
||||||
|
|
||||||
|
4. Настрой сертификаты (обязательно для работы по HTTPS).
|
||||||
|
|
||||||
|
Подробности ниже в разделе **SSL / Сертификаты**.
|
||||||
|
|
||||||
|
5. Запусти:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Подписка будет доступна по адресу:
|
||||||
|
```
|
||||||
|
https://your-domain:2096/sub/<username>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Основная конфигурация находится в файле `.env`.
|
||||||
|
|
||||||
|
### URLS
|
||||||
|
|
||||||
|
Параметр `URLS` — это JSON-объект, где ключ — имя пользователя, а значение — массив ссылок.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"all": [
|
||||||
|
"https://panel.example.com:2096/sub/{}",
|
||||||
|
"vless://...@server.com:443?security=reality#{}"
|
||||||
|
],
|
||||||
|
"crutoboy": [
|
||||||
|
"hysteria2://...@server.com:444#user-{}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ключ `"all"` — ссылки, которые получают **все** пользователи.
|
||||||
|
- Другие ключи — персональные ссылки для конкретного пользователя.
|
||||||
|
- На место `{}` подставляется имя пользователя из URL.
|
||||||
|
|
||||||
|
Поддерживаемые типы ссылок:
|
||||||
|
- `https://...` — внешняя подписка (сервер сам её скачает и объединит)
|
||||||
|
- `vless://`, `hysteria2://`, `trojan://`, `vmess://` и т.д. — прямые конфиги
|
||||||
|
|
||||||
|
### Глобальные метаданные подписки
|
||||||
|
|
||||||
|
Эти параметры добавляются во все подписки:
|
||||||
|
|
||||||
|
| Переменная | Описание | Пример |
|
||||||
|
|--------------------------|-----------------------------------------------|--------|
|
||||||
|
| `SUPPORT_URL` | Ссылка на поддержку | `https://t.me/your_support` |
|
||||||
|
| `PROFILE_WEB_PAGE_URL` | Ссылка на панель / профиль | `https://panel.example.com` |
|
||||||
|
| `ANNOUNCE` | Объявление (показывается в клиентах) | `Техработы 25.06 с 23:00` |
|
||||||
|
| `UPDATE_INTERVAL` | Интервал обновления подписки (в часах) | `12` |
|
||||||
|
| `SUBSCRIPTION_USERINFO` | Fallback для статистики трафика | `upload=0; download=0; total=0; expire=0` |
|
||||||
|
| `HAPP_ROUTING_LINK` | Ссылка на маршрутизацию для клиента Happ | `happ://routing/add/...` |
|
||||||
|
|
||||||
|
> **Важно:** Если среди ваших `https://` ссылок есть подписки от 3x-ui / Hiddify и т.д., то `Subscription-Userinfo` будет автоматически подтягиваться оттуда и объединяться.
|
||||||
|
|
||||||
|
## SSL / Сертификаты
|
||||||
|
|
||||||
|
Сервер по умолчанию работает по HTTP. Для продакшена рекомендуется использовать HTTPS.
|
||||||
|
|
||||||
|
В поставляемом `docker-compose.yml` настроен запуск с SSL напрямую через gunicorn. Для этого в контейнере должны быть доступны файлы сертификатов:
|
||||||
|
- `fullchain.pem`
|
||||||
|
- `privkey.pem`
|
||||||
|
|
||||||
|
### Получение сертификатов через Certbot (рекомендуется)
|
||||||
|
|
||||||
|
Самый правильный способ — не копировать сертификаты, а смонтировать их напрямую из директории Let's Encrypt.
|
||||||
|
|
||||||
|
1. Получи сертификат:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install certbot
|
||||||
|
|
||||||
|
sudo certbot certonly --standalone -d your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Отредактируй `docker-compose.yml` и замени путь к сертификатам:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
# Вместо ./certs монтируем настоящие сертификаты Let's Encrypt
|
||||||
|
- /etc/letsencrypt/live/your-domain.com:/certs:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Запусти:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
При таком подходе после автоматического продления сертификатов (`certbot renew`) ничего дополнительно делать не нужно.
|
||||||
|
|
||||||
|
### Альтернатива: Симлинк в папку certs
|
||||||
|
|
||||||
|
Если по каким-то причинам хочешь оставить структуру с `./certs`, можно сделать симлинк:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/letsencrypt/live/your-domain.com /path/to/project/certs
|
||||||
|
```
|
||||||
|
|
||||||
|
После этого можно не менять `docker-compose.yml`.
|
||||||
|
|
||||||
|
### Самоподписной сертификат (только для теста)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p certs
|
||||||
|
openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes \
|
||||||
|
-keyout certs/privkey.pem \
|
||||||
|
-out certs/fullchain.pem \
|
||||||
|
-subj "/CN=your-domain.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Через reverse proxy (альтернатива)
|
||||||
|
|
||||||
|
Если предпочитаешь вынести SSL на уровень reverse proxy — используй **Caddy**, **Nginx** или **Traefik**. В этом случае можно запускать приложение без SSL (убрав блок `command` в docker-compose).
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
Полный список переменных окружения описан в файле [`.env.example`](.env.example).
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
### Локальный запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Проект создан для личного использования и self-hosted решений.** Приветствуются пулл-реквесты и идеи по улучшению.
|
||||||
13
config.py
13
config.py
@@ -11,3 +11,16 @@ LISTEN_HOST = os.getenv('LISTEN_HOST', '0.0.0.0')
|
|||||||
LISTEN_PORT = int(os.getenv('LISTEN_PORT', '2096'))
|
LISTEN_PORT = int(os.getenv('LISTEN_PORT', '2096'))
|
||||||
URI_PATH = os.getenv('URI_PATH', '/sub/')
|
URI_PATH = os.getenv('URI_PATH', '/sub/')
|
||||||
URLS = json.loads(os.getenv('URLS', '{}'))
|
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/...
|
||||||
|
|
||||||
|
# Fallback значение Subscription-Userinfo, если ни одна внешняя подписка не вернула свои данные
|
||||||
|
SUBSCRIPTION_USERINFO = os.getenv('SUBSCRIPTION_USERINFO', '')
|
||||||
|
|||||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
xray-sub-server:
|
||||||
|
image: crutoboy/xray_sub_server:latest
|
||||||
|
container_name: xray-sub-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "2096:2096"
|
||||||
|
volumes:
|
||||||
|
# Монтируем папку с сертификатами
|
||||||
|
# Измените на свои. например,
|
||||||
|
# - /etc/letsencrypt/live/example.com:/certs:ro
|
||||||
|
- ./certs:/certs:ro
|
||||||
|
# Запуск через gunicorn с SSL
|
||||||
|
command: >
|
||||||
|
gunicorn
|
||||||
|
--workers=4
|
||||||
|
--bind=0.0.0.0:2096
|
||||||
|
--keyfile=/certs/privkey.pem
|
||||||
|
--certfile=/certs/fullchain.pem
|
||||||
|
wsgi:app
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
# Пример с явным указанием переменных (альтернатива env_file):
|
||||||
|
# environment:
|
||||||
|
# LISTEN_HOST: 0.0.0.0
|
||||||
|
# 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 файле
|
||||||
148
main.py
148
main.py
@@ -2,7 +2,9 @@ from typing import List
|
|||||||
import base64
|
import base64
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
|
from flask import make_response
|
||||||
import requests
|
import requests
|
||||||
|
from cachetools import TTLCache, cached
|
||||||
|
|
||||||
import config as c
|
import config as c
|
||||||
|
|
||||||
@@ -15,33 +17,141 @@ def index():
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
def get_subs_from_server(link: str) -> List[str]:
|
# Кеш внешних подписок с TTL (по умолчанию 1 час)
|
||||||
|
_subs_cache = TTLCache(maxsize=256, ttl=c.SUBSCRIPTION_CACHE_TTL)
|
||||||
|
@cached(_subs_cache)
|
||||||
|
def get_subs_from_server(link: str):
|
||||||
|
"""
|
||||||
|
Получает внешнюю подписку.
|
||||||
|
Возвращает кортеж: (список нод, Subscription-Userinfo из заголовка или None)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
sub_response = requests.get(link)
|
sub_response = requests.get(link, timeout=10)
|
||||||
decoded = base64.b64decode(sub_response.text).decode('utf-8')
|
userinfo = sub_response.headers.get('Subscription-Userinfo') or \
|
||||||
res = decoded.split('\n')
|
sub_response.headers.get('subscription-userinfo')
|
||||||
res = list(filter(None, res))
|
|
||||||
return res
|
decoded = base64.b64decode(sub_response.text).decode('utf-8')
|
||||||
except:
|
nodes = [line for line in decoded.split('\n') if line.strip()]
|
||||||
return []
|
|
||||||
|
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:
|
for url in urls:
|
||||||
url = url.strip().format(user)
|
url = url.strip().format(user)
|
||||||
if url.startswith('https://'):
|
if url.startswith('https://'):
|
||||||
subs = get_subs_from_server(url)
|
nodes, userinfo = get_subs_from_server(url)
|
||||||
res += subs
|
all_nodes += nodes
|
||||||
else:
|
if userinfo:
|
||||||
res.append(url)
|
userinfos.append(userinfo)
|
||||||
return res
|
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>')
|
@app.route(f'{c.URI_PATH}<user>')
|
||||||
def get_subs(user: str):
|
def get_subs(user: str):
|
||||||
urls = format_urls(c.URLS.get(user, []) + c.URLS.get('all', []), user)
|
nodes, upstream_userinfos = format_urls(
|
||||||
urls_text = '\n'.join(urls)
|
c.URLS.get('all', []) + c.URLS.get(user, []), user
|
||||||
res = base64.b64encode(bytes(urls_text, 'utf-8'))
|
)
|
||||||
return res
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ python-dotenv==1.2.2
|
|||||||
requests==2.34.2
|
requests==2.34.2
|
||||||
urllib3==2.7.0
|
urllib3==2.7.0
|
||||||
Werkzeug==3.1.8
|
Werkzeug==3.1.8
|
||||||
|
cachetools==5.5.2
|
||||||
|
|||||||
Reference in New Issue
Block a user