9 Commits

Author SHA1 Message Date
crutoboy
b9f849e8fb add readme and license 2026-05-29 14:41:00 +00:00
crutoboy
2a9e5da43a changed subscription sequence
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s
Release Docker Image / release (release) Successful in 2m28s
2026-05-29 09:51:44 +00:00
3347bc3324 Merge pull request 'dev/0.0.2' (#1) from dev/0.0.2 into main
Some checks failed
Build and Push Docker Image / build-and-push (push) Successful in 40s
Release Docker Image / release (release) Failing after 2m51s
Reviewed-on: #1
2026-05-29 09:33:47 +00:00
crutoboy
3f0b31294c add Subscription-Userinfo support 2026-05-29 09:32:39 +00:00
crutoboy
2f29be7ec7 add sub metadata support 2026-05-29 09:11:51 +00:00
crutoboy
f9024705fb add TTL caching for external subscriptions 2026-05-29 08:04:53 +00:00
crutoboy
c47c90b484 add ssl support
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 38s
2026-05-29 07:57:31 +00:00
crutoboy
30742ad47d add docker-compose.yml
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s
2026-05-29 07:29:53 +00:00
crutoboy
01815c3895 update ci/cd
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 39s
Release Docker Image / release (release) Successful in 58s
add push to docker hub
2026-05-29 07:00:55 +00:00
11 changed files with 458 additions and 20 deletions

View File

@@ -1,3 +1,6 @@
certs
test
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]

View File

@@ -7,6 +7,31 @@ URI_PATH=/sub/ # Базовый путь URL для подписк
# Должен начинаться и заканчиваться на '/'.
# Пример: 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 — основные ссылки и конфигурации, которые будут возвращаться пользователю

View File

@@ -30,7 +30,6 @@ jobs:
images: git.crutoboy.ru/${{ gitea.repository }}
tags: |
type=sha
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v6

View 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
View File

@@ -1,3 +1,6 @@
certs
test
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]

21
LICENSE Normal file
View 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
View 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 решений.** Приветствуются пулл-реквесты и идеи по улучшению.

View File

@@ -11,3 +11,16 @@ 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'))
# метаданные подписки
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
View 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
View File

@@ -2,7 +2,9 @@ from typing import List
import base64
import flask
from flask import make_response
import requests
from cachetools import TTLCache, cached
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:
sub_response = requests.get(link)
decoded = base64.b64decode(sub_response.text).decode('utf-8')
res = decoded.split('\n')
res = list(filter(None, res))
return res
except:
return []
sub_response = requests.get(link, timeout=10)
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)
res = base64.b64encode(bytes(urls_text, 'utf-8'))
return res
nodes, upstream_userinfos = format_urls(
c.URLS.get('all', []) + c.URLS.get(user, []), 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)
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__':

View File

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