Self-hosted VPN reseller panel — VLESS+Reality/gRPC/XHTTP/WS-TLS + Hysteria2, Telegram bot, admin panel, one-command node install
Find a file
savsis bbbab9998e 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
.github/workflows feat: optional custom admin login path — matches a Remnawave-listed security measure Marzban doesn't have 2026-09-14 01:58:45 +05:00
landing site: add multi-admin, 2FA, rate-limit rows to the landing comparison table 2026-09-13 20:45:12 +05:00
site feat: custom brand name everywhere + a working client-facing site out of the box 2026-09-13 23:52:54 +05:00
systemd perf: multi-worker uvicorn + fix N+1 on the hottest path in the app 2026-09-12 16:13:44 +05:00
.env.example feat: optional custom admin login path — matches a Remnawave-listed security measure Marzban doesn't have 2026-09-14 01:58:45 +05:00
.gitignore core: env-driven config, sqlite schema, xray/node management, subscription links 2026-09-10 17:45:36 +05:00
admin.html feat: outbound webhooks for node lifecycle — closes the "users + nodes" gap from the comparison 2026-09-14 02:56:25 +05:00
api.py feat: outbound webhooks for node lifecycle — closes the "users + nodes" gap from the comparison 2026-09-14 02:56:25 +05:00
backup.py fix: prune old backup safety-copies and expired admin sessions instead of letting them pile up forever 2026-09-12 15:08:34 +05:00
bot.py feat: custom brand name everywhere + a working client-facing site out of the box 2026-09-13 23:52:54 +05:00
config.py feat: optional custom admin login path — matches a Remnawave-listed security measure Marzban doesn't have 2026-09-14 01:58:45 +05:00
db.py feat: pause/resume a subscription without losing paid time (Remnawave-style hold) 2026-09-14 00:28:20 +05:00
install.sh feat: custom brand name everywhere + a working client-facing site out of the box 2026-09-13 23:52:54 +05:00
legal.py feat: custom brand name everywhere + a working client-facing site out of the box 2026-09-13 23:52:54 +05:00
LICENSE docs: readme, MIT license, CI workflow 2026-09-10 17:45:43 +05:00
links.py perf: multi-worker uvicorn + fix N+1 on the hottest path in the app 2026-09-12 16:13:44 +05:00
mbs perf: multi-worker uvicorn + fix N+1 on the hottest path in the app 2026-09-12 16:13:44 +05:00
nodeprov.py feat: validate xray config before every restart, local + managed nodes 2026-09-12 09:34:52 +05:00
payments.py feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart 2026-09-13 23:27:06 +05:00
README.md feat: outbound webhooks for node lifecycle — closes the "users + nodes" gap from the comparison 2026-09-14 02:56:25 +05:00
requirements.txt feat: backup & restore built into the admin panel 2026-09-12 10:23:38 +05:00
settings.py feat: custom brand name everywhere + a working client-facing site out of the box 2026-09-13 23:52:54 +05:00
totp.py feat: TOTP two-factor auth for admin login 2026-09-12 15:43:52 +05:00
webhooks.py feat: outbound webhooks for payment/subscription events 2026-09-13 21:15:13 +05:00
xray_manager.py feat: validate xray config before every restart, local + managed nodes 2026-09-12 09:34:52 +05:00

MBS Panel

CI License: MIT Release Python Xray-core

Самостоятельная VPN-панель на VLESS+Reality (+ gRPC/XHTTP/WS-TLS транспорты) и Hysteria2. Телеграм-бот для выдачи подписок, веб-сайт с личным кабинетом, и админ-панель для управления нодами, юзерами и трафиком — всё в одном репозитории, без сторонних панелей типа x-ui или Marzban под капотом.

Сделано by savsis. Изначально писалось под конкретный проект (шеринг VPN среди своих), но получилось достаточно универсально, чтобы выложить как есть.

Лендинг с фичами и честным сравнением с Remnawave/Marzban: mbs.savsis.xyz

Что внутри

  • Бот (aiogram 3) — выдача подписок по кнопкам, гифт-коды, привязка тарифов (7 дней / месяц / 3 месяца / полгода / год), автоматическое отключение по истечении подписки (не раз в полчаса, а раз в 90 секунд — важно, чтобы просрочка реально обрывала доступ, а не продолжала работать).
  • 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-код.
  • Свой путь входа — страницу логина можно увести с дефолтного /admin на любой другой (ADMIN_PATH в .env), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще.
  • Пауза подписки — временно отключить доступ без потери оплаченных дней (Marzban это умеет, Remnawave — нет): при возобновлении срок сдвигается ровно на длительность паузы.
  • Исходящие вебхуки — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса.
  • Поиск и фильтр по подпискам — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице.

Архитектура

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 в БД):

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)

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 <(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 и список 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) появится админ-меню.
  • На 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 <(curl -Ls https://panel.example.com/install/ТОКЕН.sh)

Вставляешь на чистый сервер (тот же Ubuntu 22/24 или Debian 11/12) — нода сама всё ставит и регистрируется. Если включал WS+TLS — скрипт выпустит для неё отдельный Let's Encrypt сертификат (нужна ещё одна A-запись, см. таблицу выше). Если включал Hysteria2 — поднимет отдельный процесс на UDP.

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 и/или Platega — оба требуют реального ИП/самозанятости и проходят собственную проверку (реквизиты, сайт с офертой). Панель это не автоматизирует.
  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-скрипта ноды.

Авторы:

github.com/devsavsis github.com/welfizx

Лицензия

MIT — см. LICENSE.