mbs-panel/README.md

224 lines
23 KiB
Markdown
Raw Normal View History

# MBS Panel
[![CI](https://github.com/devsavsis/mbs-panel/actions/workflows/ci.yml/badge.svg)](https://github.com/devsavsis/mbs-panel/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Release](https://img.shields.io/github/v/release/devsavsis/mbs-panel?include_prereleases)](https://github.com/devsavsis/mbs-panel/releases)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
[![Xray-core](https://img.shields.io/badge/xray--core-latest-red)](https://github.com/XTLS/Xray-core)
Самостоятельная VPN-панель на VLESS+Reality (+ gRPC/XHTTP/WS-TLS транспорты) и Hysteria2. Телеграм-бот для выдачи подписок, веб-сайт с личным кабинетом, и админ-панель для управления нодами, юзерами и трафиком — всё в одном репозитории, без сторонних панелей типа x-ui или Marzban под капотом.
Сделано by savsis. Изначально писалось под конкретный проект (шеринг VPN среди своих), но получилось достаточно универсально, чтобы выложить как есть.
Лендинг с фичами и честным сравнением с Remnawave/Marzban: **[mbs.savsis.xyz](https://mbs.savsis.xyz)**
## Что внутри
- **Бот** (aiogram 3) — выдача подписок по кнопкам, гифт-коды, привязка тарифов (7 дней / месяц / 3 месяца / полгода / год), автоматическое отключение по истечении подписки (не раз в полчаса, а раз в 90 секунд — важно, чтобы просрочка реально обрывала доступ, а не продолжала работать).
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
- **API + сайт** (FastAPI) — страница подписки со ссылкой `happ://` и QR-кодом, готовый клиентский лендинг + личный кабинет (живут прямо в панели, подставляют название и реальные тарифы сами, ничего отдельно хостить не надо).
- **Своё название бренда** — панель, бот, сайт, страница подписки, оферта/политика показывают одно и то же настраиваемое название вместо дефолтного «MBS Panel», меняется в один клик из Настроек, применяется сразу.
- **Админ-панель** (чистый HTML/CSS/JS, без фреймворков и сборки) — дашборд, полная карточка юзера (история подписок, ручная выдача, устройства), подписки, гифт-коды, ноды (полное редактирование, не только вкл/выкл), трафик по Stats API самого Xray со сбросом счётчика по клику.
- **Мультинодовость** — добавляешь новую ноду в панели, получаешь одну команду `bash <(curl ...)`, вставляешь на чистый сервер — нода сама ставит Xray, генерит ключи, регистрируется в панели. Как у Remnawave/3x-ui, только свой велосипед.
- **Протоколы на выбор при добавлении ноды**: VLESS TCP+Reality, VLESS gRPC+Reality, VLESS XHTTP+Reality, VLESS WS+TLS (с реальным Let's Encrypt сертификатом), Hysteria2 (QUIC, отдельный процесс, obfs).
- **Лимит устройств (HWID)** — как у Remnawave, опционально: ограничение числа устройств на подписку через `x-hwid` заголовок.
- **Приём оплаты** — ЮKassa и Platega из коробки, опционально; без них бот просто бесплатно выдаёт по кнопке.
- **CLI `mbs`** — управление панелью прямо с сервера: пароль, статус, рестарт, логи, обновление.
- **Backup & Restore прямо в админке** — скачал архив (база + `.env`) одной кнопкой, восстановил загрузкой файла. Ни у Remnawave, ни у Marzban такого нет из коробки, только community-скрипты.
- **Мультиадминство + 2FA** — отдельные логины вместо одного пароля на всех, опциональная TOTP-двухфакторка (любой Google Authenticator/Authy) поверх пароля.
- **Проверка конфига Xray перед рестартом** — `xray run -test` плюс проверка что серты реально читаемы юзером, под которым крутится Xray, до того как что-то применится и уронит сервис.
- **Drag-n-drop ноды** — порядок нод в списке настраивается мышкой, как у Remnawave.
- **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код.
feat: optional custom admin login path — matches a Remnawave-listed security measure Marzban doesn't have Pulled a fresh copy of docs.rw's own Remnawave-vs-Marzban comparison table (not working from memory of an earlier read) to check what's still genuinely different after tonight's run of fixes — most rows already match or beat both panels (multi-admin, 2FA, HWID limits, backup/restore, host sorting, config validation, node autonomy, on-hold status as of a few commits ago). One concrete, bounded, unclaimed row: "Security measures in documentation" lists CF zero trust / custom path / Telegram OAuth / 2FA for Remnawave, nothing for Marzban. We already had 2FA and rate-limiting; custom path was the missing, actually implementable piece — everything else in that row is deployment guidance, not panel code. New ADMIN_PATH env var (config.py, defaults to "admin" — every existing install keeps working exactly as before with zero action needed). The page-serving route moves to whatever path is configured; root() on PANEL_DOMAIN only falls through to serving admin.html when ADMIN_PATH is still the default, otherwise it shows the same branded landing page every other domain gets — so a scanner or a human guessing "/admin" finds nothing once this is set, not even a redirect that confirms something lives there. Deliberately scoped to ONLY the page route. /admin/api/* stays fixed — it's already behind real cookie+session auth (verified this while auditing: every mutating admin route either calls require_admin() or the equivalent _require_current_admin(), checked programmatically via ast rather than trusting my memory of having added the check everywhere — found nothing actually missing, which is itself worth knowing, not just assumed). Moving the API namespace too would be a much bigger, riskier rewrite of every @app decorator in the file for no real security gain over what auth already provides. Deliberately NOT exposed in the Settings UI, unlike almost everything else made live-editable tonight. This one genuinely needs a process restart to take effect (FastAPI resolves routes at import time, not per-request), and a typo saved through the UI followed by a restart is a real self-lockout risk with no web-based way back — same tier as PANEL_DOMAIN/SUB_DOMAIN, which are also .env-only for the same reason. .env + SSH is the correct blast radius for a setting that can lock you out. Verification: config.py's normalization (strip slashes, empty/lone- slash/repeated-slash input all falling back to "admin" rather than accidentally producing a route at bare "/") tested directly — 8 cases. AST-extracted the updated root() out of api.py (still can't import the module locally) and exercised its actual branching with a mocked FileResponse/legal/request — confirmed the default case is byte-for- byte the old behavior and the custom-path case stops serving admin.html on PANEL_DOMAIN's root. Added a dedicated CI step that does what only a real FastAPI import can prove: with ADMIN_PATH set, /xyz123secret is a registered route, plain /admin is NOT (not just supplemented — actually gone), and /admin/api/login is untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:58:45 +05:00
- **Свой путь входа** — страницу логина можно увести с дефолтного `/admin` на любой другой (`ADMIN_PATH` в `.env`), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще.
feat: outbound webhooks for node lifecycle — closes the "users + nodes" gap from the comparison Last remaining actionable row from the docs.rw comparison table pulled two commits ago: "Webhook event support — Users + nodes (Remnawave) / Users only (Marzban)". Every webhook we send is subscription/payment events — user-side only, same as Marzban, even after last commit's revoke/hold/resume additions. Zero node events. node.added on creation, node.deleted on deletion (captures the node's label before it's gone, since delete_node doesn't return the row), node.enabled/node.disabled on the PATCH route — but only when the enabled field actually changes value, not on every save. Editing just the label, or PATCHing enabled to the same value it already had, correctly fires nothing — checked this specifically since a naive "enabled is in the request body" check would have spammed an event on every harmless edit of an already-enabled node. Verification: same two-part approach as the subscription lifecycle webhooks. AST-extracted the actual admin_update_node() body out of api.py (still can't import it directly) and ran it against a fake db/webhooks module — 5 cases: enabling, disabling, a same-value no-op save, and an unrelated-field-only edit, confirming the webhook fires exactly when and only when it should. Then a real local HTTP server for all four event types through the actual webhooks.send(), receiver-side HMAC recomputed independently from its own copy of the secret and compared against X-Signature, not trusted from the sender. README's feature list had also fallen behind the last three commits (webhooks, hold/pause, subscription search never got a bullet) — caught up all three while I was in there, not just the one this commit adds. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 02:56:25 +05:00
- **Пауза подписки** — временно отключить доступ без потери оплаченных дней (Marzban это умеет, Remnawave — нет): при возобновлении срок сдвигается ровно на длительность паузы.
- **Исходящие вебхуки** — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса.
- **Поиск и фильтр по подпискам** — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице.
## Архитектура
```mermaid
flowchart LR
tg["Telegram юзер"] -->|кнопки| bot["bot.py (aiogram)"]
browser["Браузер / Happ-клиент"] -->|"/sub/{token}"| api["api.py (FastAPI)"]
admin["Админ в браузере"] -->|"/admin/api/*"| api
bot --> db[("SQLite (db.py)")]
api --> db
api -->|локально| xray["Xray на этой же ноде"]
api -->|SSH, paramiko| remote["Управляемые ноды"]
subgraph node1 [Управляемая нода]
remote --> xray2["Xray"]
end
```
Панель и первая VPN-нода живут на одном сервере. Дополнительные ноды подключаются по SSH (management-ключ, генерится сам при первом добавлении ноды) — панель не устанавливает на себя ничего от ноды, только управляет клиентами через SSH-команды и Xray Stats API.
### Связь панели с нодой
Два разных пути в зависимости от типа ноды (`node.kind` в БД):
```mermaid
flowchart TB
api["api.py / bot.py"] --> disp{"node.kind?"}
disp -->|local| xm["xray_manager.py"]
xm -->|"правит /usr/local/etc/xray/config.json напрямую + xray api statsquery"| xrayLocal["Xray на этой же машине"]
disp -->|managed| np["nodeprov.py"]
np -->|"SSH, ключ /root/.ssh/mbs_nodes_ed25519"| xrayRemote["Xray на удалённой ноде"]
subgraph ssh ["По SSH (nodeprov.py)"]
direction TB
f1["remote_add_client / remote_remove_client — правят config.json на ноде"]
f2["remote_query_stats — xray api statsquery"]
f3["remote_reset_stats — statsquery -reset"]
f4["remote_node_status — /proc/loadavg, /proc/meminfo, systemctl"]
end
np -.-> ssh
```
Публичный (`.pub`) ключ отдаётся самим install-скриптом ноды при первом запуске (`curl .../mgmt-pubkey.txt >> authorized_keys`) — панель никогда не просит пароль от новой ноды, только добавляет туда свой ключ через сам же install-скрипт, который админ запускает руками на новом сервере.
### Поток оплаты (когда `PAYMENTS_ENABLED=true`)
```mermaid
sequenceDiagram
participant U as Юзер (бот)
participant B as bot.py
participant P as ЮKassa/Platega
participant A as api.py
participant X as Xray
U->>B: выбрал тариф
B->>P: создать платёж
P-->>B: ссылка на оплату
B-->>U: кнопка "Оплатить"
U->>P: оплатил
P->>A: webhook (подпись проверяется)
A->>X: добавить клиента
A->>U: уведомление в Telegram
```
Панель и первая VPN-нода живут на одном сервере. На 443 порту нельзя одновременно держать и настоящий HTTPS (для сайта/подписки), и замаскированный под HTTPS VLESS+Reality трафик — поэтому перед Xray стоит `nginx stream` модуль с `ssl_preread`, который смотрит SNI входящего TLS-соединения и роутит: домены панели/подписки идут на nginx-бэкенд, всё остальное (в том числе Reality-трафик с левым SNI) — на Xray.
## Установка — одна команда
Нужен чистый сервер на **Ubuntu 22.04/24.04** или **Debian 11/12**, root-доступ и три поднятых DNS A-записи (см. таблицу ниже).
```bash
bash <(curl -Ls https://mbs.savsis.xyz/install.sh)
```
(или напрямую с GitHub, если так удобнее: `git clone https://github.com/devsavsis/mbs-panel.git && cd mbs-panel && sudo bash install.sh` — скрипт один и тот же, `mbs.savsis.xyz` просто зеркало с автосинком)
Скрипт спросит домен панели, домен подписки, токен бота от [@BotFather](https://t.me/BotFather) и список Telegram ID админов — и дальше всё сам: ставит зависимости, Xray, nginx, выпускает сертификаты Let's Encrypt, генерирует Reality-ключи, поднимает systemd-сервисы, настраивает firewall (ufw) и fail2ban. В конце покажет пароль от админки и ссылку на панель.
### DNS-записи
Подними их **до** запуска `install.sh` — иначе Let's Encrypt не сможет выпустить сертификаты.
| Запись | Тип | Куда указывает | Зачем |
|---|---|---|---|
| `panel.example.com` | A | IP сервера панели | Админка (веб-интерфейс) |
| `sub.example.com` | A | IP сервера панели | Страница подписки + личный кабинет |
| `de1.example.com` | A | IP сервера панели | Адрес, на который подключаются VPN-клиенты (первая нода) |
Для каждой **дополнительной** ноды (добавляется позже через панель) — ещё одна A-запись на IP той ноды, например `de2.example.com`, `nl1.example.com` и т.д. Панель сама подскажет, какую запись нужно создать, и выдаст готовую команду установки, когда жмёшь «Добавить ноду».
Если Cloudflare — держи эти записи **DNS only** (серое облако), не проксируй: и Reality-трафику, и Let's Encrypt нужен прямой доступ до сервера.
### После установки
- Зайди на `https://panel.example.com`, залогинься паролем из вывода скрипта.
- Смени пароль в любой момент: `mbs pass новый_пароль` (без аргумента — сгенерит случайный).
- В боте у себя (Telegram ID из ADMIN_IDS) появится админ-меню.
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
- На `https://sub.example.com` уже живёт готовый клиентский сайт (лендинг + личный кабинет) с подставленным названием и реальными тарифами — ничего отдельно разворачивать не нужно. Название меняется в Настройки → «Название» в панели, применяется сразу везде (сайт, бот, страница подписки, оферта/политика).
В конце установки `install.sh` шлёт один пинг на `stats.api.savsis.xyz` (только название ОС) — просто счётчик "сколько раз панель установили", никаких доменов/токенов/паролей туда не уходит, IP не сохраняется. Отключить: `MBS_SKIP_STATS=1 sudo bash install.sh`.
## CLI `mbs`
Ставится автоматически в `/usr/local/bin/mbs`.
```
mbs pass [пароль] сменить пароль админ-панели (без аргумента — случайный)
mbs status статус bot / api / xray / nginx
mbs restart перезапустить bot + api
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
mbs domain текущий домен панели
mbs update обновить код с GitHub и перезапустить
```
`mbs update` тянет `git pull` (только fast-forward — если на сервере что-то правили руками, честно откажется и не полезет мержить), ставит зависимости, **проверяет, что новый код вообще компилируется**, и только потом перезапускает. Если после рестарта `mbs-bot`/`mbs-api` не поднялись — сам откатывает на предыдущий коммит и поднимает его. `.env` и база (`mbs.db`) не в гите — их не тронет ни при каком раскладе.
## Добавление ноды
В панели: Ноды → Добавить ноду → выбираешь страну, протоколы (Reality-транспорты всегда включены, WS+TLS и Hysteria2 — опционально) → получаешь команду вида:
```bash
bash <(curl -Ls https://panel.example.com/install/ТОКЕН.sh)
```
Вставляешь на чистый сервер (тот же Ubuntu 22/24 или Debian 11/12) — нода сама всё ставит и регистрируется. Если включал WS+TLS — скрипт выпустит для неё отдельный Let's Encrypt сертификат (нужна ещё одна A-запись, см. таблицу выше). Если включал Hysteria2 — поднимет отдельный процесс на UDP.
```mermaid
sequenceDiagram
participant Admin as Админ
participant Panel as Панель
participant Node as Новая нода
Admin->>Panel: Ноды → Добавить ноду
Panel->>Panel: генерит Reality-ключи, токен
Panel-->>Admin: команда bash <(curl ...ТОКЕН.sh)
Admin->>Node: вставляет команду по SSH
Node->>Node: ставит Xray, настраивает transports
Node->>Panel: POST /nodes/register/ТОКЕН
Panel->>Panel: нода активна, доступна в боте
```
## Приём оплаты
По умолчанию бот выдаёт подписки бесплатно по кнопке — платежи выключены (`PAYMENTS_ENABLED=false`). Чтобы продавать доступ:
1. Заведи аккаунт в [ЮKassa](https://yookassa.ru) и/или [Platega](https://platega.io) — оба требуют реального ИП/самозанятости и проходят собственную проверку (реквизиты, сайт с офертой). Панель это не автоматизирует.
2. В `.env`: `PAYMENTS_ENABLED=true`, цены на тарифы (`PRICE_7D`, `PRICE_1M` и т.д. в рублях), и креды провайдера(ов) — `YOOKASSA_SHOP_ID`/`YOOKASSA_SECRET_KEY` и/или `PLATEGA_MERCHANT_ID`/`PLATEGA_SECRET`, включив соответствующий `*_ENABLED`.
3. В личном кабинете провайдера пропиши webhook на `https://<PANEL_DOMAIN>/payments/webhook/yookassa` и/или `.../payments/webhook/platega`.
4. `mbs restart` — бот начнёт показывать способ оплаты вместо мгновенной выдачи, подписка активируется автоматически по вебхуку с уведомлением в Telegram.
Заполни реальными данными `site/offer.html` (публичная оферта) и `site/privacy.html` (политика конфиденциальности) перед подачей заявки в ЮKassa — они нужны для их проверки, шаблоны уже на сайте (`/offer.html`, `/privacy.html`), но с плейсхолдерами вместо твоих реквизитов.
## Лимит устройств (HWID)
Как в Remnawave — ограничение, сколько разных устройств может использовать одну подписку. Работает не через сам VPN-протокол (Xray физически не видит "железо" клиента), а на уровне выдачи самой подписки: современные клиенты (Happ, v2rayTun и т.д.) при запросе `/sub/{token}` шлют заголовок `x-hwid` — уникальный ID устройства. Панель запоминает первые N увиденных hwid на юзера; при попытке добавить N+1-е устройство — отказ (404 + заголовок `x-hwid-max-devices-reached`).
Выключено по умолчанию (`HWID_LIMIT_ENABLED=false`) — клиенты, которые не шлют `x-hwid`, при включённом лимите вообще не получат подписку, так что включай только если знаешь, что твои пользователи сидят на приложениях с поддержкой этого заголовка. `HWID_FALLBACK_LIMIT` — лимит по умолчанию для всех, в админке (Подписки → кнопка «Устройства» у юзера) можно посмотреть/удалить привязанные устройства и задать индивидуальный лимит.
## Стабильность и производительность
- `install.sh` ретраит все сетевые шаги (apt, git, pip, certbot, установка Xray) — 3 попытки с паузой, типичная причина падения свежих VPS-установок это не баги, а моргнувшая сеть/зеркало пакетов.
- SQLite в WAL-режиме + `synchronous=NORMAL` (стандартная безопасная связка для этого режима) — меньше блокировок между ботом и апи, которые пишут в одну базу параллельно.
- Индексы на всех горячих путях (`subscriptions(active, expires_at)` — дергается ботом каждые 90 секунд на проверку истечения; `subscriptions(tg_id)`, `devices(tg_id)`, `payments(status)`) — на паре сотен юзеров разницы не увидишь, но на тысячах — увидишь.
- CI гоняет не только синтаксис, а реальный импорт `api.py`/`bot.py` целиком плюс smoke-тесты платёжной и HWID-логики на живом Ubuntu — ловит баги при импорте/вайринге модулей до того, как они попадут на прод.
## Частые проблемы при установке
- **Certbot не может выпустить сертификат** — почти всегда значит DNS A-записи ещё не проехали (обнови и подожди 5-10 минут) или порт 80 занят чем-то ещё (`ss -ltnp | grep :80`).
- **Xray не стартует после установки** — `mbs logs xray`, почти всегда конфликт порта 443 с чем-то другим, что уже там висит (другая панель, старый nginx на 443 напрямую).
- **Панель недоступна, но сервисы `active`** — проверь `nginx -t` и `systemctl status nginx`, скорее всего SNI-роутер (`/etc/nginx/stream.d/mbs.conf`) не подхватился — `nginx -s reload` после любых правок туда вручную.
## Разработка / вклад
Код простой — без сборки фронта, без ORM, без лишних абстракций. `admin.html` — один файл, vanilla JS. Питон-часть — обычные функции, SQLite напрямую.
PR и issues welcome. CI на каждый пуш гоняет compile-check по питону, синтаксис-проверку шелл-скриптов и smoke-тест генерации install-скрипта ноды.
2026-09-10 22:38:49 +05:00
## Авторы:
github.com/devsavsis
github.com/welfizx
## Лицензия
MIT — см. [LICENSE](LICENSE).