Техническая заметка · панель управления RabbitHole VPNTechnical note · RabbitHole VPN control panel
Платёж, подписка и реальное состояние 3x-ui.Payment, subscription and the actual state of 3x-ui.
Разбор внутренней панели: как связать оплату в Telegram, баланс и несколько узлов так, чтобы повторное уведомление не начислило деньги дважды, а сбой 3x-ui не потерял выданный доступ.An internal control-panel case study: connecting Telegram payments, a balance and several nodes without double-crediting a repeated notification or losing provisioned access when 3x-ui fails.
На входе было три действия: принять оплату, создать клиента в 3x-ui и отправить человеку конфигурацию. Выполнить их одной транзакцией нельзя: платёжная система, PostgreSQL, Telegram и каждая панель 3x-ui отвечают независимо и иногда не отвечают вовсе.
Готовые платёжные модули не закрывали нужный сценарий: они доверяли одному входящему уведомлению, ожидали заранее заданную сумму и не учитывали применение покупки сразу на нескольких узлах. В своей будущей панели я выделил для YooMoney отдельный обработчик. Он проверяет операцию, вычисляет сумму зачисления и передаёт подтверждённый результат в общий журнал.
Пользователю нужна была понятная цепочка: выбрать услугу в Telegram, пополнить баланс удобным способом и получить одну ссылку или QR-код. Для составной услуги за этим простым интерфейсом стоят несколько клиентов на разных 3x-ui-направлениях с одинаковыми сроками и лимитами.
Пополнение здесь не привязано к цене тарифа. На баланс попадает подтверждённая сумма платежа; покупка услуги проходит отдельным списанием, поэтому остаток не теряется. Срок действия сдвигается на календарный месяц, а не на фиксированные 30 дней — иначе даты постепенно расходятся. В панели можно проверить состояние каждого направления и историю платежей, а спорную операцию при необходимости разобрать вручную.
Нагрузка здесь — несколько действий в минуту, поэтому развёртывание осталось компактным. При этом повторное уведомление не должно удваивать баланс, недоступность одной 3x-ui — оставлять половину составной подписки, а ручное изменение панели — проходить незамеченным.
Почему я реализовал свой обработчик платежей
YooMoney остаётся платёжным провайдером. Собственный компонент отвечает за прикладную часть: создаёт непрозрачную метку без Telegram ID, принимает уведомление, проверяет подпись HMAC-SHA256, тип операции, валюту и идентификатор, а затем связывает подтверждение с ожидаемой операцией пользователя.
Обычно платёж подтверждается входящим уведомлением. Если оно не дошло, пользователь может запустить проверку кнопкой; кроме того, фоновая задача раз в минуту сверяет незавершённые платежи через operation-history. Все три пути вызывают один обработчик и записывают источник подтверждения. Для входящих уведомлений ведётся отдельный журнал: адрес источника, метка, операция, результат проверки подписи и ошибка применения.
В уведомлении может прийти сумма уже после комиссии. Для карточного пополнения обработчик определяет канал по типу события и, когда возможно, по operation-details, а затем восстанавливает исходную сумму. Запланированная сумма не подменяет фактическую: в одной из ранних версий я исправил ошибку, при которой баланс мог пополняться минимум на ожидаемое значение.
Подтверждение платежа атомарно меняет транзакцию и баланс в PostgreSQL. Здесь же закрывается ожидаемое пополнение, фиксируется промокод и рассчитывается реферальное начисление. Создание клиентов 3x-ui начинается только после фиксации денег и желаемого состояния.
Повторная обработка блокируется на нескольких уровнях. Пара «провайдер — идентификатор операции» и внутренний ключ идемпотентности уникальны. Строка транзакции блокируется через FOR UPDATE; уже завершённый платёж возвращает already_processed. Обновление Telegram получает короткую аренду: после ошибки маркер освобождается для новой попытки, а завершённая обработка больше не запускается.
В первой реализации вызов 3x-ui выполнялся внутри транзакции БД. Сетевой запрос удерживал блокировки, а успешное изменение во внешней панели уже нельзя было отменить откатом PostgreSQL. При обрыве между ответом API и COMMIT панель и база расходились.
Теперь транзакция только записывает желаемые параметры узла: входящее правило, адрес и идентификатор клиента, срок, лимиты и поколение операции. После COMMIT обработчик получает задачу с пятиминутной арендой, обращается к нужному адаптеру 3x-ui и завершает её лишь тогда, когда маркер аренды и поколение всё ещё совпадают. Если желаемое состояние успело измениться, старый результат не может затереть новое.
Аренда и поколение задачи · сокращённый SQL
provision.sql
UPDATE subscription_nodes
SET provisioning_status = 'processing',
provisioning_attempts = provisioning_attempts + 1,
provisioning_lease_until = NOW() + INTERVAL '5 minutes',
provisioning_claim_token = $claim
WHERE id = $node
AND provisioning_status <> 'done'
AND (provisioning_lease_until IS NULL
OR provisioning_lease_until <= NOW())
RETURNING provisioning_generation;
Почему я выбрал Go, PostgreSQL и два процесса
Go я выбрал из-за небольшого исполняемого файла, явных таймаутов и общего кода для HTTP, Telegram и адаптеров 3x-ui. PostgreSQL одновременно служит журналом денег, источником желаемого состояния и надёжной очередью небольшой нагрузки: частичные уникальные индексы, блокировки строк, аренды и поколения здесь полезнее отдельного брокера.
Один образ запускается в двух ролях. Процесс bot отвечает за Telegram, платёжные маршруты, выдачу агрегированных подписок и быстрые попытки применения. Процесс worker сверяет сроки, автопродление, незавершённую выдачу доступа и фактическое состояние 3x-ui. PostgreSQL остаётся общей точкой согласования, а наружу через Nginx публикуется только HTTP-контур бота.
Redis и отдельный брокер здесь не понадобились: при текущей нагрузке PostgreSQL закрывает очередь с меньшим числом служб в эксплуатации. Если появятся несколько реплик, тяжёлые очереди или жёсткое время доставки, это решение придётся пересмотреть.
Контроль нескольких 3x-ui
В текущей конфигурации код знает четыре логических направления 3x-ui. У каждого свой адаптер и входящее правило, а составная услуга разворачивает несколько узлов с общим сроком. Пользователь при этом получает одну токенизированную подписку с ограниченным сроком действия, а не набор внутренних адресов панели.
API 3x-ui менялся между версиями: отличались методы добавления и обновления клиента, формат отдельных полей и параметры VLESS. Адаптер сначала читает входящее правило, находит клиента по идентификатору или адресу, пробует изменить его и только при подтверждённом отсутствии создаёт нового. Параметры ссылки строятся из фактической конфигурации, включая TLS, REALITY, WebSocket, gRPC или XHTTP.
Сбои и компромиссы из истории проекта
Перестал доверять уведомлению только по факту доставки.
Добавил секрет уведомлений Telegram, устранение повторов обновлений и более безопасный порядок привязки клиентов; позже дополнил YooMoney отдельной HMAC-проверкой и тестом.
Перевёл баланс на фактическую сумму платежа.
Убрал ожидаемое пополнение из роли нижней границы зачисления, а затем отдельно уточнил восстановление карточной суммы после комиссии.
Зафиксировал версию 3x-ui как часть контракта.
Поддержка v3.1 потребовала новых вариантов запросов. Различия оставил внутри адаптера, а не размазывал по прикладной логике.
Вынес вызовы 3x-ui из транзакций PostgreSQL.
Добавил состояния выдачи, аренду, поколение операции и повторную обработку; следующим изменением — компенсацию расхождений между БД и панелью.
Убрал зависимость запуска от порядка контейнеров.
Оба процесса теперь ожидают готовность PostgreSQL с повторными попытками, а не завершаются после первого неудачного подключения.
Что происходит при отказе
не пришло уведомление — платёж может подтвердить пользовательская кнопка или фоновая сверка;
уведомление повторилось — уникальные ключи и блокировка строки возвращают уже готовый результат без второго начисления;
3x-ui недоступен — деньги и желаемое состояние остаются в БД, задача получает задержку и повтор;
клиента удалили или изменили вручную — фоновый процесс сравнивает активные неистёкшие подписки с панелью и ставит компенсацию;
во время запроса изменился срок или лимит — старое поколение не может завершить новую задачу;
ошибка становится повторяющейся — администратор получает уведомления на первой, третьей и затем каждой десятой попытке.
Неизбежный компромисс
Общей транзакции между PostgreSQL и несколькими 3x-ui нет. Платёж фиксируется в базе сразу, а доступ может короткое время оставаться в состоянии pending; повторяемая операция затем приводит фактическую конфигурацию к записанному желаемому состоянию. Коэффициент восстановления карточной суммы 0.97 — часть текущего договора с провайдером; при изменении комиссии я пересмотрю его.
Измеримый результат
Коммерческие показатели и пользовательские данные закрыты. По текущей реализации можно назвать технические величины:
4логических направления 3x-ui в одном контуре управления
3пути подтверждения платежа сходятся в одну идемпотентную операцию
2процесса Go разделяют быстрый интерфейс и фоновое восстановление
1агрегированная ссылка и QR скрывают устройство составной подписки
Для каждого подтверждённого платежа сохраняются источник, фактическая сумма и запись аудита; для управляемого узла — желаемые параметры, число попыток, аренда, поколение и последняя ошибка. По этим данным видно текущее состояние и можно восстановить последовательность действий, которая к нему привела.
Основание материала:частный репозиторий · 52 измененияконтракты 3x-ui · журнал решений
The input was three actions: accept a payment, create a 3x-ui client and send the connection details. They cannot run as one transaction: the payment provider, PostgreSQL, Telegram and each 3x-ui panel respond independently and may not respond at all.
Off-the-shelf payment modules did not fit this workflow: they trusted a single incoming notification, expected a fixed amount and did not account for applying one purchase across several nodes. I gave YooMoney a dedicated handler inside my future control panel. It verifies the operation, calculates the credited amount and records one confirmed result in the shared ledger.
01Intentamount, product, opaque label
02Confirmationsignature, operation, actual amount
03Accessdesired state across several 3x-ui nodes
04Reconcileretries, drift and recovery
The original task and its constraints
The user-facing path had to stay simple: choose a product in Telegram, add funds conveniently and receive one link or QR code. A bundled product hides several clients on different 3x-ui targets, all sharing the same expiry and limits.
Top-ups are not tied to a product price. The confirmed payment amount is credited first; purchasing the product is a separate debit, so the remainder stays on the balance. Expiry moves forward by a calendar month rather than by 30 fixed days, avoiding drift around shorter and longer months. The panel gives an operator one place to check target state and payment history, and to investigate a disputed operation when needed.
Traffic is measured in single-digit actions per minute, so the deployment remains compact. A repeated notification still must not duplicate credit, one unavailable 3x-ui target must not leave half a bundle, and a manual panel edit must not go unnoticed.
Why I implemented the payment boundary
YooMoney remains the payment provider. The custom component owns application semantics: it creates an opaque label without a Telegram ID, receives the notification, verifies its HMAC-SHA256 signature, type, currency and operation ID, and links it to a pending user operation.
An incoming notification normally confirms the payment. If it does not arrive, the user can request a check, while a background task compares pending payments through operation-history every minute. All three paths call the same handler and store the confirmation source. Incoming notifications have a separate audit trail covering source address, label, operation, signature outcome and application error.
The amount in a notification may already have commission deducted. For card top-ups the handler uses the event type and, when available, operation-details to identify the channel and reconstruct the original amount. The expected top-up never replaces the actual payment; in an early version I fixed a path that credited at least the planned amount.
Payment confirmation updates the transaction and balance atomically in PostgreSQL. The same transaction closes the pending top-up, records a promotion use and calculates referral credit. 3x-ui provisioning starts only after money and desired state are committed.
Repeated processing is blocked at several levels. Both the provider-operation pair and the internal idempotency key are unique. The transaction row is locked with FOR UPDATE; a completed payment returns already_processed. Telegram updates use a similar short lease: a failed handler releases its claim for retry, while a completed update cannot run again.
In the first implementation the 3x-ui call ran inside a database transaction. The network request held locks, while a successful change in the external panel could not be undone by rolling PostgreSQL back. A failure between the API response and COMMIT left the panel and database out of sync.
The transaction now records only the desired node state: inbound, email, client ID, expiry, limits and operation generation. After commit, a handler claims the job with a five-minute lease, calls the selected 3x-ui adapter, and completes it only when the claim and generation still match. A stale result cannot overwrite newer intent.
Provisioning lease and generation · shortened SQL
provision.sql
UPDATE subscription_nodes
SET provisioning_status = 'processing',
provisioning_attempts = provisioning_attempts + 1,
provisioning_lease_until = NOW() + INTERVAL '5 minutes',
provisioning_claim_token = $claim
WHERE id = $node
AND provisioning_status <> 'done'
AND (provisioning_lease_until IS NULL
OR provisioning_lease_until <= NOW())
RETURNING provisioning_generation;
Why I chose Go, PostgreSQL and two processes
I chose Go for a small executable, explicit timeouts and shared code for HTTP, Telegram and 3x-ui adapters. PostgreSQL acts as the money ledger, desired-state store and durable low-volume queue: partial unique indexes, row locks, leases and generations remove the need for a separate broker here.
One image runs in two roles. bot handles Telegram, payment routes, combined subscriptions and immediate apply attempts. worker reconciles expiry, auto-renewal, unfinished provisioning and actual 3x-ui state. PostgreSQL is the shared coordination point; Nginx exposes only the bot's HTTP surface.
Redis and a separate broker were unnecessary at this load: PostgreSQL provides the queue with fewer services to operate. Several replicas, heavier queues or a strict delivery deadline would require revisiting that choice.
Controlling several 3x-ui targets
The current code knows four logical 3x-ui targets. Each has its own adapter and inbound; a bundled product stages several nodes with one expiry. The user receives one time-limited tokenised subscription instead of internal panel addresses.
The 3x-ui API changed between versions: client mutation methods, field shapes and VLESS parameters differed. The adapter reads the inbound, finds a client by ID or email, attempts a patch and creates a client only after a confirmed not-found result. Public connection parameters are built from the actual inbound configuration, including TLS, REALITY, WebSocket, gRPC or XHTTP.
Failures and compromises found in history
Stopped trusting delivery alone.
Added a Telegram webhook secret, update deduplication and safer bind ordering, then gave YooMoney an independent HMAC verifier and test.
Moved the balance to the actual payment.
Removed the planned top-up as a credit floor, then refined card gross reconstruction separately.
Moved 3x-ui calls outside PostgreSQL transactions.
Added provisioning states, leases, generations and retry processing; the next change added drift compensation.
Removed startup's dependency on container timing.
Both processes now wait and retry while PostgreSQL becomes ready instead of exiting after the first failed connection.
Failure behaviour
a missing webhook is replaced by the user check or background reconciliation;
a repeated webhook meets unique keys and a row lock, returning the completed result without another credit;
an unavailable 3x-ui leaves money and desired state intact while provisioning is delayed and retried;
a client changed or removed manually is detected by comparing active unexpired subscriptions to the panel;
a changed expiry or limit advances the generation, preventing an older job from completing the new one;
repeated failures notify administrators on the first, third and then every tenth attempt.
The unavoidable compromise
PostgreSQL and several 3x-ui panels cannot share one transaction. Payment is committed to the database immediately, while access may briefly remain pending; a repeatable operation then brings actual configuration to the recorded desired state. The 0.97 card gross-up factor is part of the current provider contract; I will review it if that fee changes.
Measurable result
Commercial figures and user data remain private. The current implementation provides the following technical figures:
4logical 3x-ui targets under one control plane
3payment confirmation paths converge on one idempotent operation
2Go processes separate the immediate interface from background recovery
1combined link and QR code hide the bundled subscription topology
Each confirmed payment stores its source, actual amount and audit record; each managed node stores desired parameters, attempt count, lease, generation and last error. These records show the current state and the sequence of actions that produced it.
Material based on:private repository · 52 changes3x-ui contracts · decision history
Демонстрация полного цикла: платёжное намерение, независимая проверка YooMoney, атомарная запись баланса и желаемого состояния, выдача доступа на нескольких 3x-ui и последующая сверка.The complete cycle: payment intent, independent YooMoney verification, atomic balance and desired-state records, provisioning across several 3x-ui targets and subsequent reconciliation.
ПродолжитьContinue
К профилю и другим проектам.Back to the profile and other work.