Extensions
/
Vpn
VPN Shop 1.1.0
Sells xray accounts from 3x-ui, PasarGuard and VIPST reseller panels: servers with account and traffic capacity, plan groups, wallet checkout, subscription link and QR, named config links, renewals, a free test account per customer, and scheduled 3x-ui database backups to a group.
You are looking at 1.1.0. The current release is 2.13.0.
Install
Install it
php bin/console ext:install Vpn --version=1.1.0
Update it later
php bin/console ext:update Vpn
Remove it
php bin/console ext:remove Vpn
Add --dry-run to any of these to see exactly which files
would change before anything is written.
Readme
VPN Shop
Sells xray accounts from 3x-ui, PasarGuard and VIPST reseller
panels, paid for out of the wallet, delivered as a subscription link or a
config, plus a QR code.
Customers use two commands — /orderVpn and /myvpn — and every screen
they see is in Persian. Admins get a فروش VPN section inside /admin.
What it does
- Servers. Each one is a panel (3x-ui, PasarGuard or VIPST) with a name,
two capacities, an on/off switch, and its own renewal rules.
- Capacity, two ways. A limit on services and a limit on *traffic
sold*, either or both. A server with nothing left to sell is hidden
from the shop rather than shown as full. See Capacity below.
- Plan groups. Plans are defined in groups, and each server sells
exactly one group. Two servers can share a group, so one price list can
cover a whole region.
- Plans. Days, traffic in gigabytes, a device limit, a price in wallet
minor units, and an optional tag (تخفیفی, موقت, …) shown before the
name. The name is generated — ۳۰ گیگ - ۳۰ روزه — rather than typed:
a hand-written name drifts from the numbers beside it the first time a
plan is adjusted.
- Ordering. Server → plan → checkout → paid from the wallet → the
account is created on the panel and the link and QR come back.
- Renewal. The same plan list at the same prices, from
/myvpn. It
can be switched off per server, and gated behind a window (under N days
left, or under N gigabytes left). Renewal ignores server capacity,
because the slot is already taken. A renewal adds its days and its
traffic to whatever is left rather than replacing them — see *Renewal
rules* below.
- Paged service list.
/myvpnshows twelve services per page with a
‹ قبلی / بعدی › row underneath. Telegram caps an inline keyboard at
100 buttons and rejects the message outright once the markup grows past
its size limit, so a customer with thirty services used to get nothing
at all. Opening one service and pressing Back returns to the page it was
picked from, not to the top of the list.
- Delivery, per server. Each server decides what a customer is handed:
the subscription link, the config URIs, a QR of either, or any mix. See
What each server delivers below.
- Account codes. Every account gets a code like
K7QMX3TD. The
customer sees it, and an admin can search by it from the panel.
- Config names. Every config link a customer is shown is renamed
from a pattern in the settings — <SERVER>-<ID> by default — rather
than carrying whatever the panel called it. See *What a config is
called* below.
- Search. From
/myvpn, once a customer has more than one service, a
🔎 button finds one by its code or by anything an addon has labelled it
with — a note, say. Always scoped to the person searching.
- Free test.
/testvpngives a customer one small account, once ever.
Status, server, time, traffic and devices are set from the admin panel.
See Test accounts below.
- Admin service management.
/admin→ فروش VPN → **🛠 مدیریت
سرویسها** takes an account code and opens that account: everything
support can see, plus edit and delete. See Managing one account below.
/myvpn deliberately has no controls beyond renewal and search — no location
change, no on/off — because those belong to a separate extension. The
[ManageMyVpn](../ManageMyVpn/README.md) extension is that one, and it adds
its buttons through the hook below.
Managing one account
فروش VPN → 🛠 مدیریت سرویسها → the code. The account opens on the same
block the customer sees, plus its owner and its panel-side name, and:
| | What it does |
| --- | --- |
| ⏳ انقضا | Sets the expiry to N days from now; 0 means no expiry |
| 📦 حجم | Sets the total allowance in gigabytes; 0 means unlimited. Usage is left alone |
| 📱 دستگاه | Sets the device limit; 0 means unlimited |
| ♻️ صفر کردن مصرف | Clears the traffic counter, changing nothing else |
| 🗑 حذف سرویس | Removes it from the panel and the bot, after a confirmation |
Each edit writes to the panel first and the row only after, so a refusal
leaves the two agreeing rather than the bot claiming something the server
never accepted. An account that had expired or run out becomes active again
the moment its limits move — otherwise the screen would keep calling it
expired after it was fixed.
Deleting is best effort on the panel and final in the bot: a server that is
gone, or a client an operator already removed by hand, must not leave a row
nobody can clear. The admin is told which of the two happened. Order history
is kept — it is the record that somebody paid.
Whatever another extension adds to an account (pausing, revoking, moving,
notes) appears above these buttons, on the same screen.
Extending an account screen
Another extension can add buttons and lines to the account screens — the
one in /myvpn and the one an admin gets from the code search — without
this extension knowing anything about it. Implement
Extensions\Vpn\Support\AccountAddon and name the class from your own
Extension:
```php
public static function vpnAccountAddons(): array
{
return [AccountControls::class];
}
```
AccountAddons finds it by looking for that method on every enabled
extension, the same way core collects commands and jobs. Addons are called
while a screen is being rendered, so they must be cheap and must not send
anything; anything one throws is logged and costs only its own buttons.
Implement AccountMatcher alongside it to make your own data findable from
the /myvpn search. The shop can only search what it stores — the account
code — so it asks each addon whether an account is the one the customer
meant. The candidates are always that customer's own accounts, so an addon
is never handed a query and a search can never widen past what its owner
already has.
Implement DeliveryAddon to put a button on the message that hands over a
service. That is a different moment from the account screen and usually
wants a different button: the screen is somewhere the customer navigated
to, so the button there leads further in, while the hand-over arrives
seconds after a payment and the useful button is whatever they want in the
next ten seconds. It may land on a photo caption, so keep it to one or two
short labels.
Handing a service over
Each of these messages carries what that moment needs and nothing else —
not expiry, not traffic, not location unless it is the point. They are
read once, standing up, and anything extra is in the way of the one thing
the customer opened the message for. What the plan bought is on the
service's own screen in /myvpn, a tap away.
| Moment | Text | Buttons | QR |
| --- | --- | --- | --- |
| Purchase | headline, service code, links | copy, plus any DeliveryAddon | yes |
| Renewal | headline, new volume, new expiry | back | no |
| Revoke / move | what happened, then the links | copy, back | yes |
A renewal is deliberately not a hand-over. Nothing about how the
customer connects has changed — same link, same configs, same client — so
there is nothing to scan and nothing to copy, and sending the link again
would invite them to re-add a service they already have. Two numbers
moved, and those are the whole message.
The other two are one message wherever it fits. With QR on, the text is
the caption of its own QR rather than a message followed by a picture
of itself, so scanning and copying happen in the same place and a purchase
costs one entry in the chat instead of three.
The copy button carries what the message handed over, which is the
server's decision rather than the button's: a server that sends the
subscription link puts the link on the clipboard, and one that sends
configs puts the first config there. It used to offer the subscription
link either way, so a config-only server copied a link its own message
never mentioned.
Two limits decide the shape, and both are Telegram's. A caption holds 1024
characters against a message's 4096, so an account whose configs push it
past that is sent as text with its QRs following — the old shape. And a
copy button carries at most 256 characters, with the whole message
refused if it is longer, so a config URI over that is left in the text to
select by hand rather than risked.
Requirements
- The wallet (core). No job worker of its own — see **Keeping figures
current** below.
chillerlan/php-qrcode, already added to the project'scomposer.json.
Run composer install after pulling. The QR is rendered to a PNG
without GD, so no image extension is needed.
Install
```bash
php bin/console ext:install Vpn
php bin/console ext:enable Vpn
```
Then, in the bot, open /admin → فروش VPN and set things up in this
order. Opening the section also puts its three screens on the reply
keyboard — 🖥 سرورها, 📦 گروههای پلن, 🛠 مدیریت سرویسها — so
they stay one tap away however far the chat has scrolled; 🏠 Admin
goes back to the rest of the panel:
1. گروههای پلن → گروه جدید — a group must exist before a server can
point at one.
2. Add plans inside that group — days, gigabytes, devices, price, and an
optional tag. No name is asked for; it is built from the first two.
3. سرورها → افزودن سرور — the flow asks for the panel type, a
display name, the panel URL, admin credentials, the inbound (or group
ids), a subscription base URL, the plan group, and the two capacities
(services, then traffic). When it finishes it immediately tests the
connection and tells you what happened.
How the panel answers
Every button that changes something replaces its screen with
loading ... ⏳ while the work happens, then replaces that with what
happened — the sentence on its own, over one بازگشت button back to
the screen the button was on:
```
♻️ تمدید برای این سرور باز شد.
[بازگشت]
```
The result is never a line prepended to a redraw of the screen. A
sentence above a screen the admin has already read is buried, and the
redraw leaves a stale claim about something that happened once sitting
on a screen they keep coming back to. Refusals use the same shape, with
room to say what to clear first.
What each panel needs
3x-ui
| Question | What to send |
| --- | --- |
| آدرس پنل | Base URL including the secret path, e.g. https://host:2053/abc123 |
| نام کاربری / رمز | A panel admin login |
| شناسهی اینباند | The numeric inbound id new clients are added to |
| آدرس پایهی اشتراک | The subscription server base, e.g. https://host:2096/sub. Optional now: without it the config links are built from the inbound instead |
The driver logs in at POST /login for a session cookie, then uses
GET /panel/api/inbounds/get/{id} (falling back to list),
POST /panel/api/inbounds/addClient, updateClient/{clientId},
{inbound}/resetClientTraffic/{email}, {inbound}/delClient/{clientId}
and GET /panel/api/inbounds/getClientTraffics/{email}. A client is
named by its email field, which is where the generated username
goes, but addressed by whichever field its protocol uses -- see the
table further down.
**A new client copies its protocol settings off the clients already on
the inbound.** 3x-ui keeps a few of these per client even though they
belong to the inbound, and they have to match or the connection is
refused:
| Protocol | Copied | Why |
| --- | --- | --- |
| vmess | security | the cipher the inbound's clients agree on — auto, chacha20-poly1305, … |
| vless | flow | an inbound built for xtls-rprx-vision refuses a client without it |
| trojan | flow | same |
Whatever the first client carrying that field uses is what a new one
gets, falling back to the panel's own default. The account's own value
wins where it has one, so a renewal — which rewrites the whole client —
never changes a cipher somebody set deliberately. The share link is
spelled with the same values rather than a hardcoded auto.
Getting this wrong is silent: the link is well-formed, the customer's app
accepts it, and it simply never connects. Shadowsocks method is not
copied — there the cipher belongs to the inbound's own settings and a
client carrying one of its own is rejected by xray.
3x-ui never returns a subscription link over the API, so the link is built
as <subscription base>/<subscription id>. Without a base URL there is
no subscription link at all, and delivery falls back to config links
built from the inbound. The subscription id starts out as the
username and only differs after an account has been revoked, which is what
stops the old link resolving; it is kept in vpn_accounts.remote_sub_id
and is what every renewal writes back, so a renewal cannot hand an account
its revoked link again.
PasarGuard
| Question | What to send |
| --- | --- |
| آدرس پنل | Base URL, e.g. https://host:8000 |
| نام کاربری / رمز | A panel admin login |
| شناسهی گروهها | Group ids the account joins, e.g. 1,2 |
| آدرس پایهی اشتراک | -, unless the panel returns a relative link |
The driver takes a bearer token from POST /api/admin/token (password
grant, cached for five minutes) and uses POST /api/user,
GET|PUT|DELETE /api/user/{username}, POST /api/user/{username}/reset
and POST /api/user/{username}/revoke_sub.
The panel returns subscription_url itself; if it comes back relative it
is prefixed with the subscription base, or the panel URL.
That URL is an opaque token and contains no username:
```
https://sub2.example.site/sub/djMsMTM4OTgsMTc5MDE4MjA1Ng.ui6j3cNuN3_9iOAG…
```
So a PasarGuard link is never derived — nothing in the account row could
rebuild it. What the panel returned is stored and handed out as-is, and a
sync writes back whatever the panel says now, which is also how a
subscription revoked on the panel itself catches up. Only 3x-ui links
are assembled, as <آدرس پایهی اشتراک>/<subscription id>, because 3x-ui
returns no subscription URL over its API at all.
A sync normally learns the link from the listing, which costs nothing
extra. Some builds leave subscription_url out of /api/users, though,
and then a listing can never correct a row — it says nothing about the
link at all. So an account whose stored link is empty, or still holds
an assembled <base>/<username> from before this was fixed, is asked for
by name through GET /api/user/{username}. That is one extra request per
broken account, once; a row already holding a real token is never asked
about, and 3x-ui and VIPST are never asked at all.
The name used is the panel's own, taken from the listing, not the one
the row derives. A client sold under an earlier naming scheme still
answers to the name it was created with — the reconciler maps that name
to the current one for its own bookkeeping and never renames anything on
the panel — so asking for the derived name would be a 404 for every one
of those customers.
Their subscription itself keeps working, because the panel serves it
under the name it has. What does not work for such a client is
anything the bot asks the panel to do to it — renew, pause, delete —
since those look it up under the derived name and will not find it. That
is by design, and the way out is to rename the client on the panel to
bot<user id>-<code>.
VIPST
VIPST is not a panel you own — it is somebody else's infrastructure sold
through a reseller API. That difference is visible in every answer below.
| Question | What to send |
| --- | --- |
| آدرس پنل | https://vipst.avalshecan.site (with or without a trailing /api/v1) |
| نام حساب | Anything, or -. It is a note for you; nothing is sent |
| کلید API | The vipst_… key from API Keys in the VIPST panel |
| شناسهی لوکیشن | The numeric location id. The step lists the ones your key can sell, with 🟢/🔴 for capacity |
| آدرس پایهی اشتراک | -. There is no subscription address |
The key is a bearer token on every call and carries your whole account, so
it goes in the password column with the same warning as a panel password:
delete the message after adding the server.
The driver uses POST /clients, GET|PATCH|DELETE /clients/{uid},
POST /clients/{uid}/toggle, POST /clients/{uid}/reset-traffic,
GET /clients/{uid}/link, GET /clients, GET /locations and
GET /auth/me.
A client is addressed by uid, not by name. On the other two panels
the generated username is the primary key. VIPST issues its own uid
and treats the name as a free-text remark, so the uid is what lands in
vpn_accounts.remote_uuid and what every later call uses. The remark
still carries bot<user id>-<code>, which is what the reconciler matches
on and what a human sees in the VIPST panel.
If a row ever loses its uid — the one case is a client created on VIPST
whose row then failed to save — the driver finds it again with
GET /clients?q=<remark>, matching the remark exactly and
case-sensitively. That is what lets the shop take back a client it
created for a sale it could not record.
You sell a location, not a server. VIPST picks a healthy server with
spare capacity and fails over if the first refuses, and it deliberately
never says which one it chose — there is no server name, address or
inbound id in any response. So there is nothing to build a subscription
URL from and none is asked for: the config URI the API hands back is
delivered instead, which is why a new VIPST server defaults to sending
کانفیگ + QR کانفیگ rather than a subscription link.
**Two conversions happen at the boundary, and both round in the
customer's favour.** VIPST takes whole days and whole gigabytes where
this extension works in absolute timestamps and bytes, so expiry is
ceil of the days remaining and traffic is ceil of the gigabytes.
Rounding the other way would take minutes off a renewal or shave the last
fraction of a gigabyte off an allowance somebody paid for.
Two things this API cannot do, said out loud because neither errors:
- No device limit. There is no hwid or device field, so a plan
promising three devices is unenforceable here. The number is never
sent, and the server screen in the admin panel says so.
- No revoke. Nothing rotates a client's credentials. The nearest
route, GET /clients/{uid}/link?refresh=true, rebuilds the URI from
the server's current configuration and issues nothing new — whoever
holds the old config still has access. Offering that as a revoke would
be a lie told to a customer who just asked to lock somebody out, so
revoke() refuses with a message saying the service has to be deleted
and recreated instead.
Listing is scoped to the server's location, because one reseller key sees
every client it owns across every location and a Botex server row is one
location. Without the filter, a reconciliation pass over one server would
see the clients of another server row as strangers and offer to delete
them. Clients VIPST has marked deleted are skipped: it keeps their
history,
Releases
| Version | Published | Size | Files |
|---|---|---|---|
| 2.13.0 current | 4 Nov 2026 | 232.6 KB | 111 |
| 2.12.0 | 12 Oct 2026 | 217.9 KB | 102 |
| 2.11.0 | 12 Oct 2026 | 202.5 KB | 94 |
| 2.10.0 | 2 Oct 2026 | 195.6 KB | 92 |
| 2.9.3 | 23 Sep 2026 | 183.8 KB | 88 |
| 2.9.2 | 23 Sep 2026 | 183.6 KB | 88 |
| 2.9.1 | 23 Sep 2026 | 182.8 KB | 88 |
| 2.8.2 | 23 Sep 2026 | 178.9 KB | 88 |
| 2.8.1 | 21 Sep 2026 | 177.9 KB | 88 |
| 2.8.0 | 21 Sep 2026 | 177.3 KB | 88 |
| 2.7.1 | 21 Sep 2026 | 168.7 KB | 86 |
| 2.6.0 | 21 Sep 2026 | 157.5 KB | 82 |
| 2.5.0 | 20 Sep 2026 | 152.4 KB | 81 |
| 2.4.0 | 20 Sep 2026 | 149.5 KB | 81 |
| 2.2.0 | 15 Sep 2026 | 134.7 KB | 80 |
| 2.1.0 | 14 Sep 2026 | 134 KB | 80 |
| 2.0.0 | 14 Sep 2026 | 132.7 KB | 80 |
| 1.7.1 | 14 Sep 2026 | 130.5 KB | 80 |
| 1.7.0 | 14 Sep 2026 | 129.9 KB | 80 |
| 1.6.0 | 14 Sep 2026 | 127.4 KB | 80 |
| 1.5.0 | 13 Sep 2026 | 123.1 KB | 79 |
| 1.3.0 | 13 Sep 2026 | 122.8 KB | 80 |
| 1.1.0 | 10 Sep 2026 | 121.1 KB | 80 |