Self-hosted VPN reseller panel — VLESS+Reality/gRPC/XHTTP/WS-TLS + Hysteria2, Telegram bot, admin panel, one-command node install
Find a file
savsis 2b34b11391 fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing
Found by systematically walking every async function in admin.html and
checking whether it wraps its api() call in try/catch — 24 didn't. Two
of them (createManualNode, generateGuide) are the exact forms whose
backend validation this session added over the last several commits:
type a duplicate node code, a bad port, anything the new checks reject,
and the button just... does nothing. No error, no success message, the
click looks like it didn't register. The backend was correctly
rejecting bad input with a clear message (and, since two commits ago,
that message even displays cleanly instead of as raw JSON) — none of
it reached the screen because the calling function never caught the
exception to display it.

Triaged the other 22 by actual risk instead of fixing all of them:
- 12 mutating actions where a silent failure leaves the admin unsure
  whether their click did anything — grant/revoke/hold/resume a
  subscription, delete a device, set an HWID limit, create/toggle/
  delete a node, create a gift code, start 2FA setup, plus the two
  above. Fixed all 12.
- The remaining ~14 are view-population loads (loadNodes, loadGifts,
  loadDashboard, etc.) and logout. Deferred, deliberately: their most
  likely real failure mode is an expired session, which api()'s own
  401 handling already resolves by redirecting to the login screen
  before the exception even reaches the caller — the confusing "did
  it work" ambiguity that motivates this fix doesn't really apply to
  a read-only load the way it does to a deliberate action.

Two feedback shapes depending on what's nearby: functions with an
existing dedicated result <div> (createManualNode, generateGuide,
createGift) route the error there, matching how every other form in
the panel already shows its errors. Functions with no natural home for
inline text (grant/revoke/hold/resume, node toggle/delete, device
delete, HWID limit, 2FA setup) use a plain alert() — these are
infrequent, deliberate single-action clicks, not something a blocking
dialog would be disruptive for. All of them still run their normal
refresh after a failure, not just after success, so the view never
goes stale relative to what the backend actually did.

Verification: pure client-side JS, no backend involved, so tested
directly under Node with a mocked api()/alert()/refresh — representative
cases from both feedback shapes: holdSub and toggleNode (alert-based,
confirmed the real backend message reaches the alert and the refresh
still fires on both success and failure), createManualNode (result-div-
based, confirmed the error text renders and loadNodes is correctly
NOT called when creation genuinely failed), and startEnableTotp
(confirmed the early return after a failed setup call avoids a second,
more confusing crash from reading .secret off an undefined response).
Re-ran the full function-by-function try/catch audit afterward to
confirm exactly the intended 12 were fixed and list what's still
deferred, rather than assuming the diff did what I meant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +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: catch up the comparison table with v1.2.0 (hold/pause, node webhooks, custom admin path, drag-n-drop) 2026-09-14 05:58:27 +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 fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing 2026-09-14 07:35:44 +05:00
api.py fix: admin panel showed raw JSON error envelopes instead of the actual message 2026-09-14 04:25:34 +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 fix: validate node code/label/port when manually adding a node — was completely unvalidated 2026-09-14 03:25:49 +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.