Self-hosted VPN reseller panel — VLESS+Reality/gRPC/XHTTP/WS-TLS + Hysteria2, Telegram bot, admin panel, one-command node install
Find a file
savsis 8343a66a14 fix: admin panel showed raw JSON error envelopes instead of the actual message
Found while adding one more input check (webhook URL scheme) and
noticing the error would render as literal {"detail":"..."} text in
the UI. Root cause is in the shared api() JS helper, not any individual
route: on a non-ok response it did `throw new Error(await res.text())`
— the raw response body, not the parsed message. FastAPI's default
HTTPException handler returns {"detail": "message"} as JSON, so every
`esc(e.message)` display in the panel (roughly 15 call sites) was
showing the whole JSON envelope, curly braces and quotes included, not
just the message inside it. Confirmed this wasn't already handled by
checking login()'s own catch block — it hardcodes a fixed string
instead of showing e.message at all, which only makes sense if e.message
was never fit to show directly.

This affects every validation message added the last several commits
(prices, HWID settings, node creation, provision-guide, hwid-limit) and
plenty from before tonight too — not something introduced by this
session, but something this session's run of new validation made worth
actually fixing rather than shipping another error message into a
broken display path.

api(): on error, try to JSON.parse the body and use .detail if it's a
string; anything that doesn't match that exact shape (plain text body,
malformed JSON, FastAPI's array-shaped 422 validation-error detail)
falls through to the original raw-text behavior unchanged, so nothing
that worked before regresses.

Also added the actual check that prompted this: webhook URL must start
with http:// or https://, rejecting things like a bare hostname or a
file:// URL (webhooks.send() never reads or forwards the response body,
so this was never a real exfiltration path, but it's an essentially
free guard against both a fat-fingered URL that would otherwise silently
never deliver anything, and the more deliberate file://-style misuse).

Verification: the api() fix is pure client-side logic with no backend
dependency, so tested directly under Node against a mocked fetch — 6
cases: the exact FastAPI {detail: string} shape extracting cleanly, a
non-JSON error body falling back unchanged, the Pydantic array-detail
422 shape not crashing the parser, malformed JSON falling back to raw
text, the 401/showLogin path completely unchanged, and the successful-
response happy path unaffected. AST-extracted admin_set_webhook_settings
out of api.py (still can't import it directly) and ran it against a
fake legal/env module — 6 cases covering both accepted schemes, both
rejected ones (ftp://, file://), a scheme-less bare hostname, and
confirming clearing the webhook with an empty string still works.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 04:25:34 +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 fix: admin panel showed raw JSON error envelopes instead of the actual message 2026-09-14 04:25:34 +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.