Compare commits

...

34 commits
v1.1.0 ... main

Author SHA1 Message Date
82f83eede7 traffic limits, promo codes, trial period, expiry reminders, node alerts in the bot and api
All checks were successful
CI / build (push) Successful in 49s
2026-10-05 14:47:11 +05:00
ebdea51221 install and update from lab.savsis.xyz first, daily autoupdate timer, mbs autoupdate command
All checks were successful
CI / build (push) Successful in 49s
2026-10-05 14:23:05 +05:00
181bf69778 ci: rerun
All checks were successful
CI / build (push) Successful in 49s
2026-10-05 14:21:24 +05:00
990bdc403a ci: run the same checks on lab.savsis.xyz actions
Some checks failed
CI / build (push) Failing after 40s
2026-10-05 14:19:37 +05:00
6372d649da fix: node installer refuses a non-empty server before touching it and reports active only after xray stays up
The install script now checks for a foreign proxy (xray directory, docker marzban/xray) and busy ports
before adding the management key or writing anything, and tells the panel the node is active only after
xray has stayed up with its port listening for three checks in a row.
2026-10-04 04:48:39 +05:00
4791c5ca2b chore: point repo links, badges and the installer at savsisbtw/mbs-panel 2026-10-04 03:51:22 +05:00
a701d55e3b docs: chains, users, audit log, mbs update/backup/mirror in the readme, chains row in the landing comparison 2026-10-04 03:51:17 +05:00
e3bd279407 ci: smoke tests for chains and mbs update, separate node code in the backup round-trip step 2026-10-04 03:51:17 +05:00
264991593a feat: mbs update takes a mirror url, backs up first and survives manual edits
Manual edits on the server are stashed (and saved as a patch) instead of aborting the update,
a full backup is taken before every update, mbs mirror remembers a custom source, unsafe urls
are refused, rollback restores the stashed edits. Covered by tests/test_mbs_update.sh.
2026-10-04 03:51:16 +05:00
2c5ec8b06c feat: admin panel redesign, chain builder, users page, audit log, command palette
New visual system with accent presets, toasts and custom confirms. Chain builder: hold the
left button on Client and drag the wire through servers to Internet (max two servers,
latency warning with measured RTT). Users page lists everyone incl. people without
subscriptions, with search and grant by telegram id. Live node ping, Ctrl+K palette.
2026-10-04 03:51:16 +05:00
da6200ae4a feat: server chains (client -> A -> B -> internet), audit log, users list, csv export
Chains are real Xray hops: a chain-<code> inbound + outbound + routing rule on the
entry node and a relay-<code> client on the exit node, reconciled by sync_node with
config test, port check, rollback on a failed restart and a per-node lock.
Admin API gets chains CRUD/probe/check, node latency, audit log (middleware, no request
bodies), users list with subscription counters and a csv export that neutralises formulas.
2026-10-04 03:51:16 +05:00
6546d5bed1 feat: two-sided referral program
Each user gets a short ref_code (backfilled lazily for pre-existing
accounts too) and a shareable t.me/<bot>?start=ref_<code> link, new
"Пригласить друга" menu item shows it plus how many referrals actually
converted and any bonus days waiting to be applied.

Reward fires once, on the referred user's first subscription of any
kind (free, gift, or paid) — not on signup, so an unconverted click
never pays out. Both sides get REFERRAL_BONUS_DAYS (config.py/.env,
default 3): the referrer's day count comes from settings.py's live-read
pattern, same as prices/HWID, so it's tunable without a restart even
before a panel UI exists for it. Bonus extends an active subscription
directly if the recipient has one, otherwise accumulates in
bonus_days_pending and gets folded into whichever subscription they
create next (redeemed automatically inside create_subscription, one
choke point regardless of which of bot.py's several call sites created
it — free trial, gift code, paid, or admin grant).

Guards: no self-referral, referrer must exist, first-touch attribution
only (a second ?start=ref_ link never overwrites it), and only takes
for genuinely new accounts (no existing subscriptions) — attaching a
referrer to an already-active user was never the intent.

Tested two ways, matching this repo's usual db.py-can-be-imported-
standalone / bot.py-needs-a-workaround split: 16 checks against a real
isolated sqlite db for the db.py logic (attribution, both reward paths,
double-reward guard, pending-bonus fold-in), then 9 more through an
actual `import bot` — aiogram/fastapi now have Python 3.14 wheels so
this imported for real rather than needing AST-extraction, modulo one
old blocker (xray_manager still imports the Unix-only fcntl for its
file lock) worked around with a tiny fake fcntl module in sys.modules,
same spirit as the fcntl shim already used elsewhere in this project's
history. Real start_deeplink and cb_referral calls, get_me() mocked to
avoid a live Telegram API call.

Not done: admin-panel UI toggle for REFERRAL_ENABLED/REFERRAL_BONUS_DAYS
(currently .env-only, like several other business tunables were before
they got a settings-page treatment) and a docs-tab writeup — happy to
add both if wanted, scoped this pass to the mechanic itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 22:29:15 +05:00
695771c79a fix: node-provisioning status poll could run forever, silently or stuck on "waiting"
Follow-on from the last commit's error-handling sweep — one more spot
that calls api() without a try/catch, but a different shape of problem
than the others: this one's a setInterval, not a one-shot action, so a
thrown/rejected promise inside it doesn't stop anything — the interval
just keeps firing every 4s regardless, forever, with each failure only
visible as an unhandled rejection in devtools. And even on the success
path there was no upper bound at all: if the node never actually comes
online (the admin closes the terminal before finishing the install
command, say), "Ожидаю установки…" just sits there indefinitely with
no way to know if it's still trying or has effectively given up.

Now: a consecutive-error counter that gives up after 5 straight
failures with a visible message pointing at the manual "Проверить"
button, and an overall 150-attempt cap (10 minutes at the existing 4s
interval) that stops polling and says so if the node genuinely never
reports active. A single transient failure doesn't trip either — the
error counter resets on any successful check, so one blip in an
otherwise-working poll doesn't cut it short.

Verification: extracted the poll callback's logic (can't spin up a real
setInterval usefully in a one-shot Node script) and drove it by calling
it directly in sequence, which is what setInterval does under the hood
anyway. 6 cases: quick success, a transient error that self-heals by
the next tick, 5 consecutive failures giving up with the right message
at exactly attempt 5, the 150-attempt timeout firing when status never
goes active, and confirming no further attempts happen at all once
either give-up path triggers — not just that the message stops
updating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:09:36 +05:00
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
02ff43095c site: catch up the comparison table with v1.2.0 (hold/pause, node webhooks, custom admin path, drag-n-drop)
The table hadn't been touched since it first went up — missed host
sorting entirely (had the feature, never listed it) and everything
shipped tonight. Same sourcing discipline as before: only claiming a
Remnawave/Marzban row when it traces back to their own docs.rw
comparison table, not guessing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 05:58:27 +05:00
591b1ba692 chore: bump version to v1.2.0 in the panel UI
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 05:25:19 +05:00
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
59f67b8e4e fix: two more unguarded int() calls on admin input, one with a real reproducible crash path
Continued last commit's input-validation sweep — grepped every
`int(body...)` / `int(...get(...))` in api.py rather than stopping at
the one I'd already found. Two more:

1. admin_set_hwid_limit (per-user device-limit override) — bare
   `int(limit) if limit else None`. The devices-limit input in the user
   card is a plain text field, so typing anything non-numeric threw an
   unhandled TypeError/ValueError straight through the route. Also
   traced the `if limit else None` truthiness check specifically
   because of the historical bug already on record in memory for this
   exact field (hwid_limit=0 silently getting replaced by the fallback
   because 0 is falsy) — wrote the new check as `raw_limit in (None, "")`
   instead of a bare truthiness test so 0 keeps working as "block this
   user entirely," verified with its own test case, not just assumed
   from remembering the old bug.

2. admin_provision_guide (the node-add "Гайд по установке" flow) — both
   `port` and `hysteria_port` had the same bare int(). Traced this one
   to an actually-reachable crash, not just a theoretical gap: the
   fields are type="text" (not type="number"), the JS does
   parseInt(value || "443") — type garbage over the pre-filled "443"
   and parseInt returns NaN, which JSON.stringify silently serializes
   as null. The route's dict then has "port": null — a key that EXISTS,
   so body.get("port", 443)'s default never kicks in — and int(None)
   throws TypeError, uncaught. Reproduced this exact null-not-missing
   shape in the test rather than just "some invalid input," since that's
   the actual failure mode a user hits by editing the field, not a
   contrived one.

Same pattern as admin_create_node's port fix last commit for
consistency: try/except converting to a friendly 400, then a 1-65535
range check. Kept it as a second inline try/except rather than
extracting a shared helper — the four now-similar blocks aren't
identical enough (different valid ranges, one skips silently when its
key is absent entirely, this one treats an absent key as "restore the
default") to be worth the risk of reshaping already-shipped, tested
code this late for a stylistic win.

Verification: AST-extracted both updated route bodies out of api.py
(still can't import it directly) and drove each through a fake
db/nodeprov module. admin_provision_guide: 7 cases, including
reproducing the literal null-after-JSON shape for both port fields (not
a generic "bad input" test), the missing-key-defaults-to-443 path for
both, and confirming hysteria_port is never even touched when
include_hysteria2 is false. admin_set_hwid_limit: 8 cases — numeric
string coercion, explicit 0 preserved, three different ways of clearing
the override (null/empty-string/absent-key) all landing on the same
result, and the three rejection branches (non-numeric, negative,
over-1000).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 03:55:37 +05:00
f37b8a5018 fix: validate node code/label/port when manually adding a node — was completely unvalidated
New lens this pass: read every admin-mutating route for input
validation, not just auth (already audited that separately). Found
POST /admin/api/nodes taking `code` straight from the request body with
zero checks — reachable for real from admin.html's "Вручную" add-node
tab (nm-code is a free-text field), not just a theoretical API-only
path. code is the table's PRIMARY KEY and gets embedded directly into
every /admin/api/nodes/{code}/... URL afterward.

Concretely: an empty code, one containing a slash, or one that
collides with an existing node would all previously either succeed
into a node the UI can no longer address by its own generated URLs, or
crash with a raw unhandled sqlite3.IntegrityError / ValueError instead
of a real error message. None of this needed a live server to
reproduce — it's pure input handling.

Traced kind="external" nodes first before touching anything near
them, since add_client_to_node/remove_client_from_node only branch on
"local"/"managed" with no external case — worth being sure that's the
intentional "this node's clients are managed outside the panel, we
just reference a fixed shared_uuid" design (confirmed via links.py's
own use of shared_uuid) and not an actual bug before writing validation
around it.

NODE_CODE_RE (same style as the existing HWID_RE): letters/digits/-/_,
1-32 chars — covers "de1", the "n"+hex(4) auto-generated codes, and any
reasonable manual name, rejects anything that would break URL routing
or silently create an unreachable node. Non-empty label. Port coerced
and range-checked (1-65535) instead of a bare int() that throws on
garbage input. Duplicate code now raises a ValueError from
db.create_node (pre-checked via get_node(), same pattern create_admin
already uses for duplicate usernames — not a bolted-on try/except
IntegrityError) which the route turns into a real 400.

Deliberately scoped to creation only — code isn't in admin_update_node's
editable set, so there's no separate update-path gap to also close.

Verification: db.create_node's duplicate guard tested directly against
a real sqlite db (fresh code succeeds, immediate duplicate attempt
raises and leaves the original untouched, a second distinct code still
works). NODE_CODE_RE run through 12 cases — valid codes including the
real auto-generated shape, and the specific invalid ones that matter
(slash, space, unicode, empty, over-length, exactly-at-the-length-
limit). AST-extracted the whole updated admin_create_node() route (still
can't import api.py) and drove it through a fake db/webhooks/
HTTPException with 9 cases covering every rejection branch, the happy
path (including that node.added still fires with the right payload),
and the duplicate-code path specifically, confirming the ValueError
from db.py correctly surfaces as an HTTP 400 rather than an unhandled
exception.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 03:25:49 +05:00
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
b8c4949201 feat: search and status filter on the Подписки table
Another line off the fresh docs.rw comparison from last commit: "User
Management Filters — Extended selection (Remnawave) vs Minimal options
(Marzban)". The subscriptions table had none at all — no search, no
status filter, just the raw list with a server-side limit=200. Fine
with a handful of test subscriptions, useless once a real business has
a few hundred customers and support needs to find one person's row.

Pure client-side: the full list was already fetched in one call
(/admin/api/subscriptions), so filtering it in the browser needs no new
backend route and can't regress anything server-side. Refactored
loadSubscriptions() to keep the fetched list in allSubs and render
through a separate renderFilteredSubs(), which the existing
revoke/hold/resume refresh calls now go through too — so the search box
and status filter stay applied after an action instead of resetting the
view. Search matches username, tg_id, node label, and plan label as one
lowercased substring check. Status filter (active / on hold / expired-
revoked / all) reuses the exact three-way split statusBadge() already
draws, via a new subStatus() helper — same custom .dd dropdown as
everywhere else in the panel, not a native <select>.

Verification: extracted the actual subStatus()/renderFilteredSubs()
filter predicate out of admin.html — not a reimplementation, diffed it
against the file to confirm byte-for-byte match — and ran it under Node
against four mock subscriptions covering all three statuses, including
one with a null username (the real shape for gift-redeemed subs with no
Telegram username set) to make sure the search doesn't throw on that.
13 checks: plain search, case-insensitivity, tg_id/node/plan matching,
no-match, each status filter alone, and two combined search+status
cases. node --check on the full extracted script, div-tag balance on
the whole file, both clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 02:27:40 +05:00
670579fccd feat: optional custom admin login path — matches a Remnawave-listed security measure Marzban doesn't have
Pulled a fresh copy of docs.rw's own Remnawave-vs-Marzban comparison
table (not working from memory of an earlier read) to check what's
still genuinely different after tonight's run of fixes — most rows
already match or beat both panels (multi-admin, 2FA, HWID limits,
backup/restore, host sorting, config validation, node autonomy, on-hold
status as of a few commits ago). One concrete, bounded, unclaimed row:
"Security measures in documentation" lists CF zero trust / custom path
/ Telegram OAuth / 2FA for Remnawave, nothing for Marzban. We already
had 2FA and rate-limiting; custom path was the missing, actually
implementable piece — everything else in that row is deployment
guidance, not panel code.

New ADMIN_PATH env var (config.py, defaults to "admin" — every existing
install keeps working exactly as before with zero action needed). The
page-serving route moves to whatever path is configured; root() on
PANEL_DOMAIN only falls through to serving admin.html when ADMIN_PATH
is still the default, otherwise it shows the same branded landing page
every other domain gets — so a scanner or a human guessing "/admin"
finds nothing once this is set, not even a redirect that confirms
something lives there.

Deliberately scoped to ONLY the page route. /admin/api/* stays fixed —
it's already behind real cookie+session auth (verified this while
auditing: every mutating admin route either calls require_admin() or
the equivalent _require_current_admin(), checked programmatically via
ast rather than trusting my memory of having added the check everywhere
— found nothing actually missing, which is itself worth knowing, not
just assumed). Moving the API namespace too would be a much bigger,
riskier rewrite of every @app decorator in the file for no real security
gain over what auth already provides.

Deliberately NOT exposed in the Settings UI, unlike almost everything
else made live-editable tonight. This one genuinely needs a process
restart to take effect (FastAPI resolves routes at import time, not
per-request), and a typo saved through the UI followed by a restart
is a real self-lockout risk with no web-based way back — same tier as
PANEL_DOMAIN/SUB_DOMAIN, which are also .env-only for the same reason.
.env + SSH is the correct blast radius for a setting that can lock you
out.

Verification: config.py's normalization (strip slashes, empty/lone-
slash/repeated-slash input all falling back to "admin" rather than
accidentally producing a route at bare "/") tested directly — 8 cases.
AST-extracted the updated root() out of api.py (still can't import the
module locally) and exercised its actual branching with a mocked
FileResponse/legal/request — confirmed the default case is byte-for-
byte the old behavior and the custom-path case stops serving admin.html
on PANEL_DOMAIN's root. Added a dedicated CI step that does what only a
real FastAPI import can prove: with ADMIN_PATH set, /xyz123secret is a
registered route, plain /admin is NOT (not just supplemented — actually
gone), and /admin/api/login is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:58:45 +05:00
f6e4e52b65 feat: outbound webhooks for revoke/hold/resume — only grant and payment fired before
Found while re-reading the subscription lifecycle routes: payment.paid
and subscription.granted_by_admin fire a webhook, but revoke (which has
existed the whole night) and the two new hold/resume routes did not.
Inconsistent for anyone actually wiring this into a CRM/support tool —
they'd see a subscription get granted but never find out it was later
paused, resumed, or cut off entirely, since only the "gains access"
side of the lifecycle was ever reported outward.

Three new events, same shape and delivery as the existing ones:
subscription.revoked, subscription.held, subscription.resumed. Added
right where the DB/xray state change already happens in each route, so
they're conditioned on the action actually succeeding (a hold attempt
on an already-held/expired subscription 400s before ever reaching the
webhooks.send call).

Verification: webhooks.py itself is unchanged — this only adds new call
sites with new event-name strings, so re-verified the exact thing the
original webhook feature proved: stood up a real local HTTP server,
fired all three new events through the actual webhooks.send(), and had
the receiver independently recompute the HMAC from its own copy of the
secret and compare against the X-Signature header it actually got, for
all three — not just trusting that the sender computed something.
Checked the JSON envelope and data payload match what each route sends
byte for byte. api.py itself still can't be imported locally, same wall
as always; the new lines were checked by reading the subscription-row
shape they pull from (tg_id/node/plan are all real columns already
confirmed present in every prior test this session) plus the standard
py_compile + pyflakes pass, clean across the whole repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:26:26 +05:00
71c98c5032 test: verify backup/restore actually undoes branding, live settings and held subscriptions together
Not a new feature — a checkpoint after three commits in a row (plan
pricing/HWID live-settings, branding + client site, subscription hold)
that all touched adjacent ground, none of which had been exercised
against backup/restore since. backup.py itself needed zero changes —
it already does a full sqlite-level snapshot plus a raw .env copy, so
by design any new column or .env key is automatically covered without
per-feature updates. That's exactly the kind of claim worth actually
proving instead of assuming, especially for a "restore my database"
feature — if it silently missed something, the admin would only find
out when they needed it most.

Verified end to end against a real isolated sqlite db (not mocked):
set a custom brand name, a price override, HWID settings, and held one
of two subscriptions — snapshot — mutated all of that further (new
brand name, new price, HWID back off, resumed the held subscription,
granted a third one) — restored from the snapshot — confirmed every
single value reverted to exactly what it was at snapshot time,
including held_at surviving the round-trip (the held subscription
comes back held, not silently resumed) and the newer third subscription
being gone. 23 checks, all passing on the first run — backup/restore's
"it's a full snapshot, not a selective export" design held up exactly
as intended.

Added this as a permanent CI step (not just a local script) so future
changes to any of these three features get caught if they ever break
this interaction — ran the step's exact extracted content locally
before committing, same as every other CI addition tonight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:57:56 +05:00
906b1f5842 feat: pause/resume a subscription without losing paid time (Remnawave-style hold)
From the original night's low-priority backlog item ("user on hold
status") — the only lever admin had for cutting a customer's access was
Revoke, which is permanent: the subscription's remaining days are just
gone, and restoring access means manually granting a brand-new one and
eyeballing how many days to give back. No way to say "block this for a
few days, then give the exact remaining time back."

db.py: new held_at column on subscriptions (same ALTER-TABLE migration
pattern as every other column added this week). hold_subscription()
sets it, guarded to only fire on a subscription that's currently active,
not already held, not expired — returns False instead of silently
no-opping so the caller can tell holding didn't happen. resume_subscription()
shifts expires_at forward by exactly how long it was held (now - held_at)
and clears held_at, so a subscription paused for 3 days comes back with
3 days added, not 3 days lost.

The part that actually mattered for correctness: list_active_subscriptions()
now also requires held_at IS NULL. This function is what xray_manager's
periodic sync (every 90s) uses to decide which clients belong in Xray's
config — without this exclusion, holding a subscription would look like
it worked for about 90 seconds and then the next sync would silently
re-add the client, since the row still has active=1 and a future
expires_at. Found this by actually tracing sync_from_db()/sync_all()
before writing the hold logic, not after debugging a live failure.

api.py: POST .../hold and .../resume routes, mirroring the existing
revoke route (fetch the sub, touch the node's xray client immediately
rather than waiting for the next periodic sync, same as revoke already
does). _days_left() now takes the whole subscription row instead of just
expires_at, so it can use held_at as the reference point instead of "now"
for a held subscription — otherwise the admin UI would show the days
counter silently ticking down while the customer isn't even able to use
the service.

admin.html: Пауза/Возобновить buttons next to Отозвать in both the main
Подписки table and the per-user card, a "на паузе" badge, and a doc-block
explaining the hold-vs-revoke distinction. Also fixed a latent race while
touching this code: the old inline revoke handler in the user card fired
openUserCard() immediately alongside revokeSub() without waiting for it,
so the card could refresh before the revoke's own API call had finished;
switched to .then() so hold/resume/revoke all correctly wait for the
action before refreshing the card.

Verification: db.py has no fastapi/aiogram dependency so this was fully
testable locally, unlike most of tonight's api.py/bot.py-touching work.
16 checks against a real isolated sqlite db: hold/resume round-trip,
the exclude-from-active-list behavior the xray sync depends on, the
exact hours-shift math (simulated a 5h hold by rewriting held_at
directly, verified the resumed expires_at landed within 6 minutes of
the expected shift), and edge cases — double-hold, double-resume,
holding an expired or already-revoked subscription, nonexistent uuid.
AST-extracted the updated _days_left() out of api.py (still can't
import the module directly) and ran it against hand-built held/active
subscription dicts. Added the same hold/resume sequence to the existing
CI "TOTP/backup/reorder" step and ran that step's exact full script
locally end to end before committing — all six of its sections pass
together, not just the new one in isolation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
7cc50973f6 feat: custom brand name everywhere + a working client-facing site out of the box
User ask, paraphrased: install it, get help wiring up payments, and
immediately have a ready site under your own name — not "MBS Panel"
plastered everywhere and a bunch of manual follow-up.

Two things were actually broken/missing, found by tracing every surface
a real customer or the operator would see:

1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription
   page, admin panel splash/title/sidebar, legal pages, 2FA issuer,
   install.sh) with zero way to change it short of editing source.
   New BRAND_NAME config value (config.py default "MBS Panel", so this
   is 100% backward compatible for existing installs) wired through
   everywhere via the same live-settings pattern from the last commit
   (settings.get_brand_name(), no restart needed anywhere it's used).
   New Настройки → «Название» section in the admin panel to change it.

2. site/index.html and site/cabinet.html — a fully-built landing page +
   personal-cabinet template, already in the repo — were never actually
   served by anything. Not mounted by FastAPI, not deployed by
   install.sh, not linked from anywhere. Pure dead weight: a repo that
   looked like it shipped a client site but didn't. Now legal.py gets a
   render_site_page() (same {{TOKEN}} substitution + HTML-escaping as
   the existing offer/privacy renderer, new tokens: BRAND_NAME,
   SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded
   landing page on any host that isn't PANEL_DOMAIN (in practice:
   SUB_DOMAIN, which nginx already routes to this backend — zero
   install.sh/nginx/certbot changes needed, so this is live on every
   existing install without an upgrade step beyond `mbs update`).
   GET /cabinet.html serves the cabinet. Landing page's pricing section
   now fetches real, live prices from a new public GET /api/plans
   instead of showing static duration labels with no numbers.

Also fixed along the way, same staleness-bug class as the payments/HWID
fix last commit, found by grepping for every remaining frozen `from
config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen
constants in api.py (mbs-api never restarts itself). Concretely this
meant: changing the bot via Настройки → Telegram-бот would leave
_tg_send_message (payment-received notifications) silently trying the
OLD token, admin_get_bot_settings showing the OLD username right after
a successful save, and gift-code links pointing at the OLD bot — all
until a manual mbs restart, same shape as the Platega-secret bug fixed
last commit. Added settings.bot_credentials(), wired it through every
call site (hoisted out of loops where relevant, same N+1 discipline as
always), removed the now-stale "выполни mbs restart" copy from the bot
settings hint.

legal.py's own BOT_USERNAME import was frozen too (used by the /offer
and /privacy {{BOT_USERNAME}} token) — switched to reading it live
in-module (no settings.py import from legal.py, would've been circular
since settings.py already imports legal.py for the env reader).

install.sh: new interactive prompt for the brand name (default "MBS
Panel", so hitting enter reproduces today's behavior exactly), written
to .env, echoed in the final summary along with the now-live site URL.

Verification: same story as always — api.py/bot.py still can't import
locally (no pydantic-core wheel for Python 3.14 on this machine).
py_compile + pyflakes clean across the whole repo. Real runtime test
against an isolated .env fixture: brand name and bot-credential live
reads (no reimport), render_site_page() token substitution correctness
on the actual site/index.html and site/cabinet.html files including an
XSS check (brand name containing <script> comes out HTML-escaped), and
a regression check that adding the BRAND_NAME token to the existing
legal.render() didn't break offer.html/privacy.html. Extracted
SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't
import the module, but can pull the string constants) and ran the real
.format() calls against them to catch any brace-escaping mistake in the
new {brand_name} placeholder — CSS braces in those templates are
already double-escaped for .format(), easy to get wrong. Extracted and
node --check'd admin.html's whole inline script, div-tag-balance check
on the full file. install.sh's new prompt+heredoc snippet run standalone
with piped stdin (both a brand name with spaces and an empty/default
input), round-tripped the resulting .env back through the real
env-parsing logic. Extended the existing CI "app wiring" step (which
does import api/bot for real on Linux) with branding assertions calling
the actual route functions directly (api.root(), api.public_plans(),
api.public_branding()) — ran every part of that step's new logic that
doesn't need api.py locally first, to catch what's catchable before
trusting the rest to CI once the account's abuse-review lifts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
7d140711fd feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.

New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).

api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.

New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.

admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.

Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
1747d63539 perf: two more N+1s in bot.py, found by scanning the whole codebase for the pattern
Same shape as the admin-endpoint and build_subscription_text fixes
from earlier tonight, just two spots that scan hadn't covered:
cb_mysub (per-node db.get_node() in the loop building the 'my
subscriptions' bot message - the more user-facing of the two, fires
on every tap of that button) and reconcile_pending_payments (same
pattern in the 90s background reconciler, lower-impact since it only
reaches the lookup for payments that just turned paid, but same fix
either way for consistency).

Ran a small script over every db.py call site in api.py/bot.py/
links.py to confirm these were the only two still inside a loop —
everything else already resolved to a single-row lookup outside any
loop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:10:12 +05:00
6d94b36c31 docs: fill in the panel's own reference docs for everything shipped tonight
The Документация tab (the panel's built-in ops reference, no GitHub
trip needed) still only covered the pre-tonight feature set —
multi-admin, backup/restore, the payments wizard, webhooks, node
reordering, and the xray config pre-flight check were all live but
undocumented there. Added two new doc-blocks (Бэкапы, Платежи и
вебхуки) and extended the existing Пароль/Ноды blocks rather than
duplicating structure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:42:33 +05:00
4b86406041 feat: outbound webhooks for payment/subscription events
Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.

New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.

Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.

Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
24a1b26df2 site: add multi-admin, 2FA, rate-limit rows to the landing comparison table
These shipped after the landing page was first built, so the table
was already behind. Kept every claim honest against the same source
(docs.rw) as before rather than just marking everything a win —
Remnawave's docs do claim some 2FA options (passkeys/OAuth), that's
noted rather than hidden; multi-admin and rate-limiting are genuine
differentiators (Remnawave has neither, Marzban's multi-admin is
still WIP per their own docs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 20:45:12 +05:00
dcfcf8f650 feat: Platega credentials section in the Payments wizard, for parity with YooKassa
The setup wizard added last iteration only covered YooKassa, leaving
Platega (the second provider this panel has always supported) still
.env-only despite everything else in the payments flow treating both
providers symmetrically. Same GET/POST shape as the YooKassa settings
routes. Platega has no documented lightweight credentials-check
endpoint like YooKassa's /v3/me, so this one honestly says so in the
UI instead of saving through a fabricated validation call — it saves
directly and a bad key will surface on the first real payment attempt
instead of at save time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:50:54 +05:00
6311532c45 feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.

Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).

YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.

Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.

Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
3bd38103c3 ci: cover tonight's features — TOTP, backup/restore, node reorder, multi-admin, rate-limit
Same inline-assert smoke-test pattern the rest of ci.yml already
uses, not a new pytest dependency — matches the existing style. Covers
exactly what was manually verified ad-hoc while building each feature
tonight, now codified so it doesn't regress silently: RFC 4226 TOTP
vectors, node reorder + its validation, a real backup-then-mutate-
then-restore round trip, multi-admin create/delete-last-refusal, and
rate-limit counter accumulation/clearing.

Verified by extracting the exact embedded script and running it
locally end-to-end before committing — CI itself is still not
triggering runs on this account (separate, already-reported GitHub-side
issue, see memory), so this was the only way to actually confirm it
passes rather than hoping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 17:37:57 +05:00
31 changed files with 6413 additions and 478 deletions

View file

@ -1,5 +1,9 @@
# Copy this to .env and fill in real values. Never commit .env.
# Shown to clients everywhere: site, bot, subscription page, offer/privacy, panel
# login. Also editable live from Настройки in the admin panel, no restart needed.
BRAND_NAME=MBS Panel
# From @BotFather
BOT_TOKEN=123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
BOT_USERNAME=YourBot_robot
@ -17,6 +21,15 @@ PANEL_DOMAIN=panel.example.com
SUB_DOMAIN=sub.example.com
SITE_DOMAIN=example.com
# Optional: move the admin login off the well-known /admin path (e.g. to a
# random string) so it doesn't show up to anyone scanning for /admin,
# /login etc. Leave unset for the default. This is on top of the existing
# rate-limiting and 2FA, not instead of them. Requires `mbs restart` to
# take effect (it's a route, not a setting the running process can pick up
# live) — write it down somewhere before you restart, there's no UI for
# this on purpose, only .env + SSH can get you back in if you forget it.
ADMIN_PATH=admin
# Reality identity for the local node (this same box). Generate with:
# /usr/local/bin/xray x25519
# XRAY_PUBLIC_KEY is the "Password (PublicKey)" line; keep the matching
@ -34,6 +47,8 @@ DE1_ADDRESS=de1.example.com
# Payments — off by default, bot keeps handing out free subscriptions on button press.
# Flip to true only once at least one provider below is configured and its webhook is live.
# All of this (toggle, prices, provider keys) is also editable live from the admin panel
# (Платежи tab) after first boot — no need to hand-edit this file or restart afterwards.
PAYMENTS_ENABLED=false
# Prices in RUB per plan (whole numbers). Only used when PAYMENTS_ENABLED=true.
@ -58,6 +73,6 @@ PLATEGA_SECRET=
# Device limit (HWID) — off by default. Requires the VPN client app to send an
# x-hwid header on subscription fetch (Happ/v2rayTun-class apps do this); clients
# that don't send it get refused once enabled, so only flip this on if your users'
# apps actually support it.
# apps actually support it. Also editable live from Настройки in the admin panel.
HWID_LIMIT_ENABLED=false
HWID_FALLBACK_LIMIT=3

602
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,602 @@
name: CI
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Compile check all Python files
run: python -m compileall -q .
- name: Shell syntax check
run: |
bash -n install.sh
bash -n mbs
bash -n tests/test_mbs_update.sh
- name: Smoke test mbs update and mirror (manual edits on the server, url mirror, unsafe urls, rollback)
run: bash tests/test_mbs_update.sh
- name: Test traffic limits, promo codes, trial and reminders
run: python tests/test_features.py
- name: Smoke test install-script rendering
env:
BOT_TOKEN: "x"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
run: |
python - << 'PYEOF'
import json
import nodeprov
transports = nodeprov.build_transports("fi2.example.com", 443, "www.microsoft.com", "PUBKEY", include_ws=True)
node = {
"provision_token": "TESTTOKEN",
"address": "fi2.example.com",
"sni": "www.microsoft.com",
"private_key": "PRIVKEY",
"transports_json": json.dumps(transports),
"hysteria_enabled": 1,
"hysteria_port": 443,
"hysteria_password": "hypass",
"hysteria_obfs_password": "obfspass",
}
script = nodeprov.render_install_script(node)
assert "PRIVKEY" in script
assert "PREFLIGHT_FAIL" in script and "443 2053 2087" in script, "node install must refuse a non-empty server before touching it"
assert script.index("PREFLIGHT_FAIL") < script.index("authorized_keys"), "preflight must run before the management key is added"
assert "OK=$((OK+1))" in script and "STATUS=failed" in script, "node must report active only after xray stays up"
assert len(script) > 500
print("node install script rendered OK,", len(script), "bytes")
PYEOF
- name: Smoke test app wiring + payments + HWID logic
env:
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
PAYMENTS_ENABLED: "true"
YOOKASSA_ENABLED: "true"
YOOKASSA_SHOP_ID: "123"
YOOKASSA_SECRET_KEY: "xxx"
PLATEGA_ENABLED: "true"
PLATEGA_MERCHANT_ID: "abc"
PLATEGA_SECRET: "yyy"
HWID_LIMIT_ENABLED: "true"
run: |
python - << 'PYEOF'
import hashlib
import hmac
import api
import bot
import payments
import db
assert set(payments.available_providers()) == {"yookassa", "platega"}
good_sig = hmac.new(b"yyy", b'{"a":1}', hashlib.sha256).hexdigest()
assert payments.verify_platega_signature(b'{"a":1}', good_sig)
assert not payments.verify_platega_signature(b'{"a":1}', "wrong")
db.init_db()
db.create_payment("pid1", 1, "de1", "1m", "yookassa", 399)
assert db.mark_payment_paid("pid1")["status"] == "paid"
assert db.mark_payment_paid("pid1") is None
db.get_or_create_user(1, "tester")
db.add_device(1, "hwid-aaaaaaaaaa", "android", "Pixel", "ua")
assert db.count_devices(1) == 1
assert db.get_device(1, "hwid-aaaaaaaaaa") is not None
print("app wiring + payments + HWID logic OK")
import legal
import settings
assert settings.get_brand_name() == "MBS Panel"
legal.update_env_var("BRAND_NAME", "CI Test Brand")
assert settings.get_brand_name() == "CI Test Brand"
index_html = legal.render_site_page("index.html")
assert "CI Test Brand" in index_html
assert "MBS Panel" not in index_html
assert "example.com" not in index_html
assert "YourBot_robot" not in index_html
assert "{{" not in index_html and "}}" not in index_html
cabinet_html = legal.render_site_page("cabinet.html")
assert "CI Test Brand" in cabinet_html
assert "{{" not in cabinet_html and "}}" not in cabinet_html
fake_request = type("FakeRequest", (), {"headers": {}})()
root_resp = api.root(fake_request)
assert "CI Test Brand" in root_resp
plans_resp = api.public_plans()
assert plans_resp["plans"][0]["code"] == "7d"
branding_resp = api.public_branding()
assert branding_resp["brand_name"] == "CI Test Brand"
print("branding: site templates + public routes render live, no restart OK")
PYEOF
- name: Smoke test TOTP, backup/restore, node reorder, multi-admin, rate-limit
env:
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
run: |
python - << 'PYEOF'
import base64
import db
import totp
raw_key = b"12345678901234567890"
secret = base64.b32encode(raw_key).decode("ascii").rstrip("=")
expected = ["755224","287082","359152","969429","338314","254676","287922","162583","399871","520489"]
for counter, exp in enumerate(expected):
assert totp._hotp(secret, counter) == exp, f"RFC 4226 vector failed at counter={counter}"
print("TOTP: all 10 RFC 4226 test vectors pass")
db.init_db()
db.create_node("n1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
db.create_node("n2", "Node Two", "managed", "2.2.2.2", 443, "pub2", "sid2", "sni2", "xtls-rprx-vision")
order = [n["code"] for n in db.list_nodes()]
assert order == ["de1", "n1", "n2"], order
db.reorder_nodes(["n2", "de1", "n1"])
assert [n["code"] for n in db.list_nodes()] == ["n2", "de1", "n1"]
try:
db.reorder_nodes(["n2", "de1"])
assert False, "should reject incomplete reorder list"
except ValueError:
pass
print("node reorder OK")
import backup
data = backup.create_backup()
db.create_node("n3", "Node Three", "managed", "3.3.3.3", 443, "pub3", "sid3", "sni3", "xtls-rprx-vision")
assert len(db.list_nodes()) == 4
backup.restore_backup(data)
assert len(db.list_nodes()) == 3, "restore should have reverted the extra node"
print("backup/restore round-trip OK")
admin = db.verify_admin_login("admin", "ci-test-password-not-real")
assert admin is not None
second = db.create_admin("second", "another-strong-password")
assert len(db.list_admins()) == 2
try:
db.delete_admin(admin["id"])
db.delete_admin(second["id"])
assert False, "should refuse deleting the last admin"
except ValueError:
pass
print("multi-admin OK")
ip = "203.0.113.9"
for _ in range(10):
db.record_login_attempt(ip, "password")
assert db.count_recent_login_attempts(ip, "password", minutes=15) >= 10
db.clear_login_attempts(ip, "password")
assert db.count_recent_login_attempts(ip, "password", minutes=15) == 0
print("rate-limit counters OK")
import datetime as dt
hold_sub = db.create_subscription(999, "n1", 30, "1m", source="bot")
original_expires = dt.datetime.fromisoformat(hold_sub["expires_at"])
assert db.hold_subscription(hold_sub["uuid"])
assert db.hold_subscription(hold_sub["uuid"]) is False
assert len(db.list_active_subscriptions(tg_id=999)) == 0, "held sub must not count as active"
with db.get_conn() as conn:
simulated = (dt.datetime.utcnow() - dt.timedelta(hours=5)).isoformat()
conn.execute("UPDATE subscriptions SET held_at=? WHERE uuid=?", (simulated, hold_sub["uuid"]))
resumed = db.resume_subscription(hold_sub["uuid"])
assert resumed["held_at"] is None
shift_hours = (dt.datetime.fromisoformat(resumed["expires_at"]) - original_expires).total_seconds() / 3600
assert 4.9 <= shift_hours <= 5.1, f"expected ~5h shift, got {shift_hours}"
assert len(db.list_active_subscriptions(tg_id=999)) == 1, "resumed sub must count as active again"
assert db.resume_subscription(hold_sub["uuid"]) is None
print("subscription hold/resume OK")
print("all v1.1.0 feature smoke tests passed")
PYEOF
- name: Smoke test live settings (.env-backed plans/toggles/HWID/credentials, no restart)
env:
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
run: |
python - << 'PYEOF'
import hashlib
import hmac
with open(".env", "a", encoding="utf-8") as f:
f.write("PLATEGA_SECRET=old_secret\n")
f.write("PLATEGA_ENABLED=true\n")
f.write("PLATEGA_MERCHANT_ID=m1\n")
import legal
import settings
import payments
plans = settings.get_plans_by_code()
assert plans["1m"]["price"] > 0, "default price should come from config before any .env override"
settings.set_plan_prices({"1m": 4242})
assert settings.get_plans_by_code()["1m"]["price"] == 4242, "price edit should apply live, no reimport"
assert settings.get_plans_by_code()["7d"]["price"] != 4242, "unrelated plan must stay untouched"
assert settings.get_hwid_settings()["enabled"] is False
legal.update_env_var("HWID_LIMIT_ENABLED", "true")
legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
hwid = settings.get_hwid_settings()
assert hwid["enabled"] is True and hwid["fallback_limit"] == 9, "HWID settings should apply live"
body = b'{"transactionId":"t1","status":"CONFIRMED"}'
sig_old = hmac.new(b"old_secret", body, hashlib.sha256).hexdigest()
assert payments.verify_platega_signature(body, sig_old), "signature must verify against the current secret"
legal.update_env_var("PLATEGA_SECRET", "rotated_secret")
assert not payments.verify_platega_signature(body, sig_old), "OLD signature must be rejected right after rotation, same process, no restart"
sig_new = hmac.new(b"rotated_secret", body, hashlib.sha256).hexdigest()
assert payments.verify_platega_signature(body, sig_new), "NEW signature must verify immediately after rotation, same process, no restart"
for _ in range(5):
legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
with open(".env", encoding="utf-8") as f:
lines = [l for l in f.readlines() if l.startswith("HWID_FALLBACK_LIMIT=")]
assert len(lines) == 1, "repeated writes to the same key must not duplicate .env lines"
print("live settings: prices/HWID/credential-rotation all apply with zero reimport OK")
PYEOF
- name: Smoke test backup/restore round-trip covers branding + live settings + held subscriptions
env:
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
run: |
python - << 'PYEOF'
import backup
import db
import legal
import settings
db.init_db()
db.create_node("bk1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
sub_a = db.create_subscription(111, "bk1", 30, "1m", source="bot")
sub_b = db.create_subscription(222, "bk1", 30, "1m", source="bot")
legal.update_env_var("BRAND_NAME", "SnapshotBrand")
settings.set_plan_prices({"1m": 555})
legal.update_env_var("HWID_LIMIT_ENABLED", "true")
legal.update_env_var("HWID_FALLBACK_LIMIT", "4")
assert db.hold_subscription(sub_a["uuid"])
assert settings.get_brand_name() == "SnapshotBrand"
assert settings.get_plans_by_code()["1m"]["price"] == 555
assert settings.get_hwid_settings() == {"enabled": True, "fallback_limit": 4}
assert len(db.list_active_subscriptions(tg_id=111)) == 0, "held sub excluded pre-backup"
assert len(db.list_active_subscriptions(tg_id=222)) == 1
snapshot = backup.create_backup()
legal.update_env_var("BRAND_NAME", "MutatedAfterBackup")
settings.set_plan_prices({"1m": 999})
legal.update_env_var("HWID_LIMIT_ENABLED", "false")
assert db.resume_subscription(sub_a["uuid"])["held_at"] is None
sub_c = db.create_subscription(333, "bk1", 30, "1m", source="bot")
assert settings.get_brand_name() == "MutatedAfterBackup"
assert len(db.list_active_subscriptions(tg_id=111)) == 1
result = backup.restore_backup(snapshot)
assert result["restored_env"] is True
assert settings.get_brand_name() == "SnapshotBrand", "brand must revert to snapshot value"
assert settings.get_plans_by_code()["1m"]["price"] == 555, "price override must revert"
assert settings.get_hwid_settings() == {"enabled": True, "fallback_limit": 4}, "hwid settings must revert"
restored_sub_a = db.get_subscription(sub_a["uuid"])
assert restored_sub_a["held_at"] is not None, "held_at must round-trip through backup/restore"
assert len(db.list_active_subscriptions(tg_id=111)) == 0, "sub_a held again after restore"
assert len(db.list_active_subscriptions(tg_id=222)) == 1, "sub_b untouched"
assert db.get_subscription(sub_c["uuid"]) is None, "sub_c created after backup point must be gone"
print("backup/restore correctly round-trips branding, live settings and held_at together OK")
PYEOF
- name: Smoke test custom ADMIN_PATH actually moves the login page, not just adds a copy
env:
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
ADMIN_PATH: "xyz123secret"
run: |
python - << 'PYEOF'
import api
paths = {r.path for r in api.app.routes}
assert "/xyz123secret" in paths, "custom ADMIN_PATH must be registered as a route"
assert "/admin" not in paths, "the default /admin page route must be GONE once a custom path is set, not just supplemented"
assert "/admin/api/login" in paths, "the API namespace must stay fixed regardless of ADMIN_PATH"
fake_request = type("FakeRequest", (), {"headers": {"host": "panel.test"}})()
root_response = api.root(fake_request)
assert isinstance(root_response, str), \
f"root() on PANEL_DOMAIN must return the rendered site page (a string), not admin.html, once ADMIN_PATH is customized — got {type(root_response)}"
assert "admin.html" not in root_response
print("custom ADMIN_PATH: old /admin route gone, new path registered, root() no longer leaks the panel OK")
PYEOF
- name: Smoke test server chains (xray config generation, relay clients, subscription entries, audit log, old-db migration)
env:
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
run: |
python - << 'PYEOF'
import base64
import json
import urllib.parse
import chains
import db
import links
import nodeprov
import xray_manager
transports = nodeprov.build_transports("a.example.com", 443, "www.microsoft.com", "PUBA", include_ws=False)
entry_cfg = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
exit_cfg = json.loads(nodeprov._build_config_json(
nodeprov.build_transports("b.example.com", 443, "www.microsoft.com", "PUBB"), "PRIVB", "b.example.com"))
wanted = {"uuid-1": "uuid-1", "uuid-2": "uuid-2"}
chain = {"code": "cabc12", "port": 10443, "short_id": "1234567890abcdef", "exit_node": "chb", "relay_uuid": "relay-uuid-1"}
exit_nodes = {"chb": {
"address": "b.example.com", "port": 443, "sni": "www.microsoft.com",
"public_key": "PUBB", "short_id": "ffff", "kind": "managed", "shared_uuid": None,
}}
base_before = json.dumps([ib for ib in entry_cfg["inbounds"] if ib["tag"] in chains.BASE_TAGS], sort_keys=True)
changed, problems = chains.sync_config(entry_cfg, wanted, {}, [chain], exit_nodes)
assert changed and not problems, problems
tags = [ib["tag"] for ib in entry_cfg["inbounds"]]
assert "chain-cabc12" in tags, tags
ci = chains.find_inbound(entry_cfg, "chain-cabc12")
assert ci["port"] == 10443
assert ci["streamSettings"]["realitySettings"]["shortIds"] == ["1234567890abcdef"]
assert ci["streamSettings"]["realitySettings"]["privateKey"] == "PRIVA"
assert [c["id"] for c in ci["settings"]["clients"]] == ["uuid-1", "uuid-2"]
assert all(c["flow"] == "xtls-rprx-vision" for c in ci["settings"]["clients"])
out = [o for o in entry_cfg["outbounds"] if o["tag"] == "chain-cabc12-out"]
assert len(out) == 1
vn = out[0]["settings"]["vnext"][0]
assert vn["address"] == "b.example.com" and vn["port"] == 443 and vn["users"][0]["id"] == "relay-uuid-1"
assert out[0]["streamSettings"]["realitySettings"]["publicKey"] == "PUBB"
rules = [r for r in entry_cfg["routing"]["rules"] if r.get("outboundTag") == "chain-cabc12-out"]
assert len(rules) == 1 and rules[0]["inboundTag"] == ["chain-cabc12"]
assert entry_cfg["routing"]["rules"][0]["outboundTag"] == "api"
assert entry_cfg["outbounds"][0]["tag"] == "direct", "default outbound must stay first"
print("entry config: chain inbound/outbound/rule built OK")
changed2, problems2 = chains.sync_config(entry_cfg, wanted, {}, [chain], exit_nodes)
assert not changed2 and not problems2, "second pass must be a no-op"
print("idempotent OK")
wanted3 = {"uuid-2": "uuid-2", "uuid-3": "uuid-3"}
changed3, _ = chains.sync_config(entry_cfg, wanted3, {}, [chain], exit_nodes)
assert changed3
ci = chains.find_inbound(entry_cfg, "chain-cabc12")
assert [c["id"] for c in ci["settings"]["clients"]] == ["uuid-2", "uuid-3"]
for tag in ("vless-tcp-reality", "vless-grpc-reality", "vless-xhttp-reality"):
assert [c["id"] for c in chains.find_inbound(entry_cfg, tag)["settings"]["clients"]] == ["uuid-2", "uuid-3"]
print("clients follow the active set on every inbound incl. chain OK")
changed4, _ = chains.sync_config(entry_cfg, {"uuid-9": "uuid-9"}, {}, [], exit_nodes, apply_chains=False)
assert changed4
assert chains.find_inbound(entry_cfg, "chain-cabc12") is not None, "apply_chains=False must not drop chains"
assert [c["id"] for c in chains.find_inbound(entry_cfg, "chain-cabc12")["settings"]["clients"]] == ["uuid-9"]
print("clients-only fallback keeps existing chains and still syncs their clients OK")
chains.sync_config(entry_cfg, wanted, {}, [], exit_nodes)
assert chains.find_inbound(entry_cfg, "chain-cabc12") is None
assert not [o for o in entry_cfg["outbounds"] if o["tag"].startswith("chain-")]
assert not [r for r in entry_cfg["routing"]["rules"] if str(r.get("outboundTag", "")).startswith("chain-")]
base_after = json.dumps([ib for ib in entry_cfg["inbounds"] if ib["tag"] in chains.BASE_TAGS], sort_keys=True)
assert json.loads(base_after) != [] and len(json.loads(base_after)) == len(json.loads(base_before))
print("removing the chain cleans inbound/outbound/rule OK")
changed5, p5 = chains.sync_config(exit_cfg, wanted, {"relay-uuid-1": "relay-cabc12"}, [], {})
assert changed5 and not p5
tcp_ids = [c["id"] for c in chains.find_inbound(exit_cfg, "vless-tcp-reality")["settings"]["clients"]]
grpc_ids = [c["id"] for c in chains.find_inbound(exit_cfg, "vless-grpc-reality")["settings"]["clients"]]
assert "relay-uuid-1" in tcp_ids and "relay-uuid-1" not in grpc_ids
relay_entry = [c for c in chains.find_inbound(exit_cfg, "vless-tcp-reality")["settings"]["clients"] if c["id"] == "relay-uuid-1"][0]
assert relay_entry["flow"] == "xtls-rprx-vision" and relay_entry["email"] == "relay-cabc12"
changed6, _ = chains.sync_config(exit_cfg, wanted, {"relay-uuid-1": "relay-cabc12"}, [], {})
assert not changed6
print("exit node keeps the relay client only on the TCP inbound and survives sync OK")
busy_cfg = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
usable, skipped = chains.split_busy_chains(busy_cfg, [chain], {10443})
assert usable == [] and len(skipped) == 1
usable2, skipped2 = chains.split_busy_chains(busy_cfg, [chain], set())
assert usable2 == [chain] and skipped2 == []
chains.sync_config(busy_cfg, wanted, {}, [chain], exit_nodes)
usable3, skipped3 = chains.split_busy_chains(busy_cfg, [chain], {10443})
assert usable3 == [chain], "an already-applied chain is not a new port, busy check must ignore it"
print("busy port handling OK")
ext_nodes = {"chb": dict(exit_nodes["chb"], kind="external", shared_uuid="shared-1")}
ext_chain = dict(chain, relay_uuid=None)
cfg_e = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
ch, pr = chains.sync_config(cfg_e, wanted, {}, [ext_chain], ext_nodes)
assert ch and not pr
assert [o for o in cfg_e["outbounds"] if o["tag"] == "chain-cabc12-out"][0]["settings"]["vnext"][0]["users"][0]["id"] == "shared-1"
no_key = dict(ext_nodes["chb"], shared_uuid=None)
cfg_f = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
ch, pr = chains.sync_config(cfg_f, wanted, {}, [ext_chain], {"chb": no_key})
assert pr and chains.find_inbound(cfg_f, "chain-cabc12") is None
print("external exit uses shared uuid, missing key is reported OK")
assert chains.latency_level(10) == "low" and chains.latency_level(80) == "medium" and chains.latency_level(300) == "high"
assert chains.latency_level(None) == "unknown"
assert chains.median_ms([-1, -1]) is None and chains.median_ms([30, 10, -1]) == 30
print("latency helpers OK")
db.init_db()
db.create_node("cha", "🇫🇮 Финляндия", "managed", "fi.example.com", 443, "PUBFI", "sidfi", "www.microsoft.com", "xtls-rprx-vision")
db.create_node("chb", "🇳🇱 Нидерланды", "managed", "nl.example.com", 443, "PUBNL", "sidnl", "www.microsoft.com", "xtls-rprx-vision")
db.create_node("chx", "Внешняя", "external", "ex.example.com", 443, "PUBEX", "sidex", "www.microsoft.com", "xtls-rprx-vision", shared_uuid="shared-ex")
c1 = db.create_chain("Финка → Голландия", "cha", "chb", "relay-1")
assert c1["port"] == 10443 and len(c1["short_id"]) == 16 and c1["code"].startswith("c")
c2 = db.create_chain("Финка → Внешняя", "cha", "chx", None)
assert c2["port"] == 10444
try:
db.create_chain("dup", "cha", "chb", "x")
assert False
except ValueError:
pass
try:
db.delete_node("chb")
assert False, "node used in chain must not be deletable"
except ValueError as e:
assert "chain" in str(e)
assert [c["code"] for c in db.list_chains()] == [c1["code"], c2["code"]]
assert len(db.list_chains(enabled_only=True)) == 2
db.update_chain(c2["code"], enabled=0)
assert len(db.list_chains(enabled_only=True)) == 1
assert db.stats()["chains"] == 1
print("db chains CRUD, port allocation, node-delete guard OK")
sub = db.create_subscription(500, "cha", 30, "1m", source="bot")
text = base64.b64decode(links.build_subscription_text([sub])).decode()
lines = text.split("\n")
chain_lines = [l for l in lines if ":10443?" in l]
assert len(chain_lines) == 1, lines
assert "10444" not in text, "disabled chain must not leak into the subscription"
parsed = urllib.parse.urlparse(chain_lines[0])
assert parsed.hostname == "fi.example.com" and parsed.port == 10443
qs = urllib.parse.parse_qs(parsed.query)
assert qs["sid"] == [c1["short_id"]] and qs["pbk"] == ["PUBFI"] and qs["flow"] == ["xtls-rprx-vision"]
assert urllib.parse.unquote(parsed.fragment) == "🇫🇮 Финляндия → 🇳🇱 Нидерланды"
assert parsed.username == sub["uuid"]
print("subscription text carries the chain entry for the entry node's subscribers OK")
other = db.create_subscription(501, "chb", 30, "1m", source="bot")
text2 = base64.b64decode(links.build_subscription_text([other])).decode()
assert ":10443?" not in text2, "subscribers of the exit node must not get the entry node's chain"
print("chain is only offered to entry-node subscribers OK")
db.update_node("cha", enabled=0)
text3 = base64.b64decode(links.build_subscription_text([sub])).decode()
assert text3.strip() == ""
db.update_node("cha", enabled=1)
db.update_chain(c2["code"], enabled=1)
node_n1 = db.get_node("cha")
w, relay, entry_chains, exit_n = xray_manager.desired_state(node_n1)
assert sub["uuid"] in w and [c["code"] for c in entry_chains] == [c1["code"], c2["code"]] and relay == {}
node_n2 = db.get_node("chb")
w2, relay2, entry2, exit2 = xray_manager.desired_state(node_n2)
assert relay2 == {"relay-1": chains.relay_email(c1["code"])} and entry2 == []
node_ex = db.get_node("chx")
w3, relay3, entry3, exit3 = xray_manager.desired_state(node_ex)
assert relay3 == {}
db.update_node("chb", enabled=0)
w4, relay4, entry4, exit4 = xray_manager.desired_state(node_n1)
assert [c["code"] for c in entry4] == [c2["code"]], "chain whose exit is disabled must drop out"
print("desired_state: entry/relay/disabled-node logic OK")
db.add_audit("admin", "node.add", "/admin/api/nodes", "1.2.3.4")
db.add_audit(None, "login.failed", "", "5.6.7.8")
rows = db.list_audit(10)
assert rows[0]["action"] == "login.failed" and rows[1]["admin"] == "admin"
print("audit log OK")
with db.get_conn() as conn:
conn.execute("DROP TABLE chains")
conn.execute("DROP TABLE audit_log")
db.init_db()
assert db.list_chains() == [] and db.list_audit() == []
print("init_db recreates chain/audit tables on an old database OK")
print("chains: all smoke tests passed")
PYEOF

View file

@ -24,6 +24,13 @@ jobs:
run: |
bash -n install.sh
bash -n mbs
bash -n tests/test_mbs_update.sh
- name: Smoke test mbs update and mirror (manual edits on the server, url mirror, unsafe urls, rollback)
run: bash tests/test_mbs_update.sh
- name: Test traffic limits, promo codes, trial and reminders
run: python tests/test_features.py
- name: Smoke test install-script rendering
env:
@ -57,6 +64,9 @@ jobs:
}
script = nodeprov.render_install_script(node)
assert "PRIVKEY" in script
assert "PREFLIGHT_FAIL" in script and "443 2053 2087" in script, "node install must refuse a non-empty server before touching it"
assert script.index("PREFLIGHT_FAIL") < script.index("authorized_keys"), "preflight must run before the management key is added"
assert "OK=$((OK+1))" in script and "STATUS=failed" in script, "node must report active only after xray stays up"
assert len(script) > 500
print("node install script rendered OK,", len(script), "bytes")
PYEOF
@ -109,4 +119,484 @@ jobs:
assert db.get_device(1, "hwid-aaaaaaaaaa") is not None
print("app wiring + payments + HWID logic OK")
import legal
import settings
assert settings.get_brand_name() == "MBS Panel"
legal.update_env_var("BRAND_NAME", "CI Test Brand")
assert settings.get_brand_name() == "CI Test Brand"
index_html = legal.render_site_page("index.html")
assert "CI Test Brand" in index_html
assert "MBS Panel" not in index_html
assert "example.com" not in index_html
assert "YourBot_robot" not in index_html
assert "{{" not in index_html and "}}" not in index_html
cabinet_html = legal.render_site_page("cabinet.html")
assert "CI Test Brand" in cabinet_html
assert "{{" not in cabinet_html and "}}" not in cabinet_html
fake_request = type("FakeRequest", (), {"headers": {}})()
root_resp = api.root(fake_request)
assert "CI Test Brand" in root_resp
plans_resp = api.public_plans()
assert plans_resp["plans"][0]["code"] == "7d"
branding_resp = api.public_branding()
assert branding_resp["brand_name"] == "CI Test Brand"
print("branding: site templates + public routes render live, no restart OK")
PYEOF
- name: Smoke test TOTP, backup/restore, node reorder, multi-admin, rate-limit
env:
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
run: |
python - << 'PYEOF'
import base64
import db
import totp
raw_key = b"12345678901234567890"
secret = base64.b32encode(raw_key).decode("ascii").rstrip("=")
expected = ["755224","287082","359152","969429","338314","254676","287922","162583","399871","520489"]
for counter, exp in enumerate(expected):
assert totp._hotp(secret, counter) == exp, f"RFC 4226 vector failed at counter={counter}"
print("TOTP: all 10 RFC 4226 test vectors pass")
db.init_db()
db.create_node("n1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
db.create_node("n2", "Node Two", "managed", "2.2.2.2", 443, "pub2", "sid2", "sni2", "xtls-rprx-vision")
order = [n["code"] for n in db.list_nodes()]
assert order == ["de1", "n1", "n2"], order
db.reorder_nodes(["n2", "de1", "n1"])
assert [n["code"] for n in db.list_nodes()] == ["n2", "de1", "n1"]
try:
db.reorder_nodes(["n2", "de1"])
assert False, "should reject incomplete reorder list"
except ValueError:
pass
print("node reorder OK")
import backup
data = backup.create_backup()
db.create_node("n3", "Node Three", "managed", "3.3.3.3", 443, "pub3", "sid3", "sni3", "xtls-rprx-vision")
assert len(db.list_nodes()) == 4
backup.restore_backup(data)
assert len(db.list_nodes()) == 3, "restore should have reverted the extra node"
print("backup/restore round-trip OK")
admin = db.verify_admin_login("admin", "ci-test-password-not-real")
assert admin is not None
second = db.create_admin("second", "another-strong-password")
assert len(db.list_admins()) == 2
try:
db.delete_admin(admin["id"])
db.delete_admin(second["id"])
assert False, "should refuse deleting the last admin"
except ValueError:
pass
print("multi-admin OK")
ip = "203.0.113.9"
for _ in range(10):
db.record_login_attempt(ip, "password")
assert db.count_recent_login_attempts(ip, "password", minutes=15) >= 10
db.clear_login_attempts(ip, "password")
assert db.count_recent_login_attempts(ip, "password", minutes=15) == 0
print("rate-limit counters OK")
import datetime as dt
hold_sub = db.create_subscription(999, "n1", 30, "1m", source="bot")
original_expires = dt.datetime.fromisoformat(hold_sub["expires_at"])
assert db.hold_subscription(hold_sub["uuid"])
assert db.hold_subscription(hold_sub["uuid"]) is False
assert len(db.list_active_subscriptions(tg_id=999)) == 0, "held sub must not count as active"
with db.get_conn() as conn:
simulated = (dt.datetime.utcnow() - dt.timedelta(hours=5)).isoformat()
conn.execute("UPDATE subscriptions SET held_at=? WHERE uuid=?", (simulated, hold_sub["uuid"]))
resumed = db.resume_subscription(hold_sub["uuid"])
assert resumed["held_at"] is None
shift_hours = (dt.datetime.fromisoformat(resumed["expires_at"]) - original_expires).total_seconds() / 3600
assert 4.9 <= shift_hours <= 5.1, f"expected ~5h shift, got {shift_hours}"
assert len(db.list_active_subscriptions(tg_id=999)) == 1, "resumed sub must count as active again"
assert db.resume_subscription(hold_sub["uuid"]) is None
print("subscription hold/resume OK")
print("all v1.1.0 feature smoke tests passed")
PYEOF
- name: Smoke test live settings (.env-backed plans/toggles/HWID/credentials, no restart)
env:
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
run: |
python - << 'PYEOF'
import hashlib
import hmac
with open(".env", "a", encoding="utf-8") as f:
f.write("PLATEGA_SECRET=old_secret\n")
f.write("PLATEGA_ENABLED=true\n")
f.write("PLATEGA_MERCHANT_ID=m1\n")
import legal
import settings
import payments
plans = settings.get_plans_by_code()
assert plans["1m"]["price"] > 0, "default price should come from config before any .env override"
settings.set_plan_prices({"1m": 4242})
assert settings.get_plans_by_code()["1m"]["price"] == 4242, "price edit should apply live, no reimport"
assert settings.get_plans_by_code()["7d"]["price"] != 4242, "unrelated plan must stay untouched"
assert settings.get_hwid_settings()["enabled"] is False
legal.update_env_var("HWID_LIMIT_ENABLED", "true")
legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
hwid = settings.get_hwid_settings()
assert hwid["enabled"] is True and hwid["fallback_limit"] == 9, "HWID settings should apply live"
body = b'{"transactionId":"t1","status":"CONFIRMED"}'
sig_old = hmac.new(b"old_secret", body, hashlib.sha256).hexdigest()
assert payments.verify_platega_signature(body, sig_old), "signature must verify against the current secret"
legal.update_env_var("PLATEGA_SECRET", "rotated_secret")
assert not payments.verify_platega_signature(body, sig_old), "OLD signature must be rejected right after rotation, same process, no restart"
sig_new = hmac.new(b"rotated_secret", body, hashlib.sha256).hexdigest()
assert payments.verify_platega_signature(body, sig_new), "NEW signature must verify immediately after rotation, same process, no restart"
for _ in range(5):
legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
with open(".env", encoding="utf-8") as f:
lines = [l for l in f.readlines() if l.startswith("HWID_FALLBACK_LIMIT=")]
assert len(lines) == 1, "repeated writes to the same key must not duplicate .env lines"
print("live settings: prices/HWID/credential-rotation all apply with zero reimport OK")
PYEOF
- name: Smoke test backup/restore round-trip covers branding + live settings + held subscriptions
env:
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
run: |
python - << 'PYEOF'
import backup
import db
import legal
import settings
db.init_db()
db.create_node("bk1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
sub_a = db.create_subscription(111, "bk1", 30, "1m", source="bot")
sub_b = db.create_subscription(222, "bk1", 30, "1m", source="bot")
legal.update_env_var("BRAND_NAME", "SnapshotBrand")
settings.set_plan_prices({"1m": 555})
legal.update_env_var("HWID_LIMIT_ENABLED", "true")
legal.update_env_var("HWID_FALLBACK_LIMIT", "4")
assert db.hold_subscription(sub_a["uuid"])
assert settings.get_brand_name() == "SnapshotBrand"
assert settings.get_plans_by_code()["1m"]["price"] == 555
assert settings.get_hwid_settings() == {"enabled": True, "fallback_limit": 4}
assert len(db.list_active_subscriptions(tg_id=111)) == 0, "held sub excluded pre-backup"
assert len(db.list_active_subscriptions(tg_id=222)) == 1
snapshot = backup.create_backup()
legal.update_env_var("BRAND_NAME", "MutatedAfterBackup")
settings.set_plan_prices({"1m": 999})
legal.update_env_var("HWID_LIMIT_ENABLED", "false")
assert db.resume_subscription(sub_a["uuid"])["held_at"] is None
sub_c = db.create_subscription(333, "bk1", 30, "1m", source="bot")
assert settings.get_brand_name() == "MutatedAfterBackup"
assert len(db.list_active_subscriptions(tg_id=111)) == 1
result = backup.restore_backup(snapshot)
assert result["restored_env"] is True
assert settings.get_brand_name() == "SnapshotBrand", "brand must revert to snapshot value"
assert settings.get_plans_by_code()["1m"]["price"] == 555, "price override must revert"
assert settings.get_hwid_settings() == {"enabled": True, "fallback_limit": 4}, "hwid settings must revert"
restored_sub_a = db.get_subscription(sub_a["uuid"])
assert restored_sub_a["held_at"] is not None, "held_at must round-trip through backup/restore"
assert len(db.list_active_subscriptions(tg_id=111)) == 0, "sub_a held again after restore"
assert len(db.list_active_subscriptions(tg_id=222)) == 1, "sub_b untouched"
assert db.get_subscription(sub_c["uuid"]) is None, "sub_c created after backup point must be gone"
print("backup/restore correctly round-trips branding, live settings and held_at together OK")
PYEOF
- name: Smoke test custom ADMIN_PATH actually moves the login page, not just adds a copy
env:
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
ADMIN_PATH: "xyz123secret"
run: |
python - << 'PYEOF'
import api
paths = {r.path for r in api.app.routes}
assert "/xyz123secret" in paths, "custom ADMIN_PATH must be registered as a route"
assert "/admin" not in paths, "the default /admin page route must be GONE once a custom path is set, not just supplemented"
assert "/admin/api/login" in paths, "the API namespace must stay fixed regardless of ADMIN_PATH"
fake_request = type("FakeRequest", (), {"headers": {"host": "panel.test"}})()
root_response = api.root(fake_request)
assert isinstance(root_response, str), \
f"root() on PANEL_DOMAIN must return the rendered site page (a string), not admin.html, once ADMIN_PATH is customized — got {type(root_response)}"
assert "admin.html" not in root_response
print("custom ADMIN_PATH: old /admin route gone, new path registered, root() no longer leaks the panel OK")
PYEOF
- name: Smoke test server chains (xray config generation, relay clients, subscription entries, audit log, old-db migration)
env:
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
BOT_USERNAME: "x"
ADMIN_IDS: "1"
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
PANEL_DOMAIN: "panel.test"
SUB_DOMAIN: "sub.test"
SITE_DOMAIN: "test"
XRAY_PUBLIC_KEY: "x"
XRAY_SHORT_ID_TCP: "x"
XRAY_SHORT_ID_GRPC: "x"
XRAY_SHORT_ID_XHTTP: "x"
run: |
python - << 'PYEOF'
import base64
import json
import urllib.parse
import chains
import db
import links
import nodeprov
import xray_manager
transports = nodeprov.build_transports("a.example.com", 443, "www.microsoft.com", "PUBA", include_ws=False)
entry_cfg = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
exit_cfg = json.loads(nodeprov._build_config_json(
nodeprov.build_transports("b.example.com", 443, "www.microsoft.com", "PUBB"), "PRIVB", "b.example.com"))
wanted = {"uuid-1": "uuid-1", "uuid-2": "uuid-2"}
chain = {"code": "cabc12", "port": 10443, "short_id": "1234567890abcdef", "exit_node": "chb", "relay_uuid": "relay-uuid-1"}
exit_nodes = {"chb": {
"address": "b.example.com", "port": 443, "sni": "www.microsoft.com",
"public_key": "PUBB", "short_id": "ffff", "kind": "managed", "shared_uuid": None,
}}
base_before = json.dumps([ib for ib in entry_cfg["inbounds"] if ib["tag"] in chains.BASE_TAGS], sort_keys=True)
changed, problems = chains.sync_config(entry_cfg, wanted, {}, [chain], exit_nodes)
assert changed and not problems, problems
tags = [ib["tag"] for ib in entry_cfg["inbounds"]]
assert "chain-cabc12" in tags, tags
ci = chains.find_inbound(entry_cfg, "chain-cabc12")
assert ci["port"] == 10443
assert ci["streamSettings"]["realitySettings"]["shortIds"] == ["1234567890abcdef"]
assert ci["streamSettings"]["realitySettings"]["privateKey"] == "PRIVA"
assert [c["id"] for c in ci["settings"]["clients"]] == ["uuid-1", "uuid-2"]
assert all(c["flow"] == "xtls-rprx-vision" for c in ci["settings"]["clients"])
out = [o for o in entry_cfg["outbounds"] if o["tag"] == "chain-cabc12-out"]
assert len(out) == 1
vn = out[0]["settings"]["vnext"][0]
assert vn["address"] == "b.example.com" and vn["port"] == 443 and vn["users"][0]["id"] == "relay-uuid-1"
assert out[0]["streamSettings"]["realitySettings"]["publicKey"] == "PUBB"
rules = [r for r in entry_cfg["routing"]["rules"] if r.get("outboundTag") == "chain-cabc12-out"]
assert len(rules) == 1 and rules[0]["inboundTag"] == ["chain-cabc12"]
assert entry_cfg["routing"]["rules"][0]["outboundTag"] == "api"
assert entry_cfg["outbounds"][0]["tag"] == "direct", "default outbound must stay first"
print("entry config: chain inbound/outbound/rule built OK")
changed2, problems2 = chains.sync_config(entry_cfg, wanted, {}, [chain], exit_nodes)
assert not changed2 and not problems2, "second pass must be a no-op"
print("idempotent OK")
wanted3 = {"uuid-2": "uuid-2", "uuid-3": "uuid-3"}
changed3, _ = chains.sync_config(entry_cfg, wanted3, {}, [chain], exit_nodes)
assert changed3
ci = chains.find_inbound(entry_cfg, "chain-cabc12")
assert [c["id"] for c in ci["settings"]["clients"]] == ["uuid-2", "uuid-3"]
for tag in ("vless-tcp-reality", "vless-grpc-reality", "vless-xhttp-reality"):
assert [c["id"] for c in chains.find_inbound(entry_cfg, tag)["settings"]["clients"]] == ["uuid-2", "uuid-3"]
print("clients follow the active set on every inbound incl. chain OK")
changed4, _ = chains.sync_config(entry_cfg, {"uuid-9": "uuid-9"}, {}, [], exit_nodes, apply_chains=False)
assert changed4
assert chains.find_inbound(entry_cfg, "chain-cabc12") is not None, "apply_chains=False must not drop chains"
assert [c["id"] for c in chains.find_inbound(entry_cfg, "chain-cabc12")["settings"]["clients"]] == ["uuid-9"]
print("clients-only fallback keeps existing chains and still syncs their clients OK")
chains.sync_config(entry_cfg, wanted, {}, [], exit_nodes)
assert chains.find_inbound(entry_cfg, "chain-cabc12") is None
assert not [o for o in entry_cfg["outbounds"] if o["tag"].startswith("chain-")]
assert not [r for r in entry_cfg["routing"]["rules"] if str(r.get("outboundTag", "")).startswith("chain-")]
base_after = json.dumps([ib for ib in entry_cfg["inbounds"] if ib["tag"] in chains.BASE_TAGS], sort_keys=True)
assert json.loads(base_after) != [] and len(json.loads(base_after)) == len(json.loads(base_before))
print("removing the chain cleans inbound/outbound/rule OK")
changed5, p5 = chains.sync_config(exit_cfg, wanted, {"relay-uuid-1": "relay-cabc12"}, [], {})
assert changed5 and not p5
tcp_ids = [c["id"] for c in chains.find_inbound(exit_cfg, "vless-tcp-reality")["settings"]["clients"]]
grpc_ids = [c["id"] for c in chains.find_inbound(exit_cfg, "vless-grpc-reality")["settings"]["clients"]]
assert "relay-uuid-1" in tcp_ids and "relay-uuid-1" not in grpc_ids
relay_entry = [c for c in chains.find_inbound(exit_cfg, "vless-tcp-reality")["settings"]["clients"] if c["id"] == "relay-uuid-1"][0]
assert relay_entry["flow"] == "xtls-rprx-vision" and relay_entry["email"] == "relay-cabc12"
changed6, _ = chains.sync_config(exit_cfg, wanted, {"relay-uuid-1": "relay-cabc12"}, [], {})
assert not changed6
print("exit node keeps the relay client only on the TCP inbound and survives sync OK")
busy_cfg = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
usable, skipped = chains.split_busy_chains(busy_cfg, [chain], {10443})
assert usable == [] and len(skipped) == 1
usable2, skipped2 = chains.split_busy_chains(busy_cfg, [chain], set())
assert usable2 == [chain] and skipped2 == []
chains.sync_config(busy_cfg, wanted, {}, [chain], exit_nodes)
usable3, skipped3 = chains.split_busy_chains(busy_cfg, [chain], {10443})
assert usable3 == [chain], "an already-applied chain is not a new port, busy check must ignore it"
print("busy port handling OK")
ext_nodes = {"chb": dict(exit_nodes["chb"], kind="external", shared_uuid="shared-1")}
ext_chain = dict(chain, relay_uuid=None)
cfg_e = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
ch, pr = chains.sync_config(cfg_e, wanted, {}, [ext_chain], ext_nodes)
assert ch and not pr
assert [o for o in cfg_e["outbounds"] if o["tag"] == "chain-cabc12-out"][0]["settings"]["vnext"][0]["users"][0]["id"] == "shared-1"
no_key = dict(ext_nodes["chb"], shared_uuid=None)
cfg_f = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
ch, pr = chains.sync_config(cfg_f, wanted, {}, [ext_chain], {"chb": no_key})
assert pr and chains.find_inbound(cfg_f, "chain-cabc12") is None
print("external exit uses shared uuid, missing key is reported OK")
assert chains.latency_level(10) == "low" and chains.latency_level(80) == "medium" and chains.latency_level(300) == "high"
assert chains.latency_level(None) == "unknown"
assert chains.median_ms([-1, -1]) is None and chains.median_ms([30, 10, -1]) == 30
print("latency helpers OK")
db.init_db()
db.create_node("cha", "🇫🇮 Финляндия", "managed", "fi.example.com", 443, "PUBFI", "sidfi", "www.microsoft.com", "xtls-rprx-vision")
db.create_node("chb", "🇳🇱 Нидерланды", "managed", "nl.example.com", 443, "PUBNL", "sidnl", "www.microsoft.com", "xtls-rprx-vision")
db.create_node("chx", "Внешняя", "external", "ex.example.com", 443, "PUBEX", "sidex", "www.microsoft.com", "xtls-rprx-vision", shared_uuid="shared-ex")
c1 = db.create_chain("Финка → Голландия", "cha", "chb", "relay-1")
assert c1["port"] == 10443 and len(c1["short_id"]) == 16 and c1["code"].startswith("c")
c2 = db.create_chain("Финка → Внешняя", "cha", "chx", None)
assert c2["port"] == 10444
try:
db.create_chain("dup", "cha", "chb", "x")
assert False
except ValueError:
pass
try:
db.delete_node("chb")
assert False, "node used in chain must not be deletable"
except ValueError as e:
assert "chain" in str(e)
assert [c["code"] for c in db.list_chains()] == [c1["code"], c2["code"]]
assert len(db.list_chains(enabled_only=True)) == 2
db.update_chain(c2["code"], enabled=0)
assert len(db.list_chains(enabled_only=True)) == 1
assert db.stats()["chains"] == 1
print("db chains CRUD, port allocation, node-delete guard OK")
sub = db.create_subscription(500, "cha", 30, "1m", source="bot")
text = base64.b64decode(links.build_subscription_text([sub])).decode()
lines = text.split("\n")
chain_lines = [l for l in lines if ":10443?" in l]
assert len(chain_lines) == 1, lines
assert "10444" not in text, "disabled chain must not leak into the subscription"
parsed = urllib.parse.urlparse(chain_lines[0])
assert parsed.hostname == "fi.example.com" and parsed.port == 10443
qs = urllib.parse.parse_qs(parsed.query)
assert qs["sid"] == [c1["short_id"]] and qs["pbk"] == ["PUBFI"] and qs["flow"] == ["xtls-rprx-vision"]
assert urllib.parse.unquote(parsed.fragment) == "🇫🇮 Финляндия → 🇳🇱 Нидерланды"
assert parsed.username == sub["uuid"]
print("subscription text carries the chain entry for the entry node's subscribers OK")
other = db.create_subscription(501, "chb", 30, "1m", source="bot")
text2 = base64.b64decode(links.build_subscription_text([other])).decode()
assert ":10443?" not in text2, "subscribers of the exit node must not get the entry node's chain"
print("chain is only offered to entry-node subscribers OK")
db.update_node("cha", enabled=0)
text3 = base64.b64decode(links.build_subscription_text([sub])).decode()
assert text3.strip() == ""
db.update_node("cha", enabled=1)
db.update_chain(c2["code"], enabled=1)
node_n1 = db.get_node("cha")
w, relay, entry_chains, exit_n = xray_manager.desired_state(node_n1)
assert sub["uuid"] in w and [c["code"] for c in entry_chains] == [c1["code"], c2["code"]] and relay == {}
node_n2 = db.get_node("chb")
w2, relay2, entry2, exit2 = xray_manager.desired_state(node_n2)
assert relay2 == {"relay-1": chains.relay_email(c1["code"])} and entry2 == []
node_ex = db.get_node("chx")
w3, relay3, entry3, exit3 = xray_manager.desired_state(node_ex)
assert relay3 == {}
db.update_node("chb", enabled=0)
w4, relay4, entry4, exit4 = xray_manager.desired_state(node_n1)
assert [c["code"] for c in entry4] == [c2["code"]], "chain whose exit is disabled must drop out"
print("desired_state: entry/relay/disabled-node logic OK")
db.add_audit("admin", "node.add", "/admin/api/nodes", "1.2.3.4")
db.add_audit(None, "login.failed", "", "5.6.7.8")
rows = db.list_audit(10)
assert rows[0]["action"] == "login.failed" and rows[1]["admin"] == "admin"
print("audit log OK")
with db.get_conn() as conn:
conn.execute("DROP TABLE chains")
conn.execute("DROP TABLE audit_log")
db.init_db()
assert db.list_chains() == [] and db.list_audit() == []
print("init_db recreates chain/audit tables on an old database OK")
print("chains: all smoke tests passed")
PYEOF

2
.gitignore vendored
View file

@ -7,3 +7,5 @@ __pycache__/
*.pyc
venv/
.claude/
.update_mirror
local-changes/

View file

@ -1,8 +1,8 @@
# MBS Panel
[![CI](https://github.com/devsavsis/mbs-panel/actions/workflows/ci.yml/badge.svg)](https://github.com/devsavsis/mbs-panel/actions/workflows/ci.yml)
[![CI](https://lab.savsis.xyz/savsisbtw/mbs-panel/actions/workflows/ci.yml/badge.svg)](https://lab.savsis.xyz/savsisbtw/mbs-panel/actions)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Release](https://img.shields.io/github/v/release/devsavsis/mbs-panel?include_prereleases)](https://github.com/devsavsis/mbs-panel/releases)
[![Release](https://img.shields.io/github/v/release/savsisbtw/mbs-panel?include_prereleases)](https://lab.savsis.xyz/savsisbtw/mbs-panel/releases)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
[![Xray-core](https://img.shields.io/badge/xray--core-latest-red)](https://github.com/XTLS/Xray-core)
@ -15,7 +15,8 @@
## Что внутри
- **Бот** (aiogram 3) — выдача подписок по кнопкам, гифт-коды, привязка тарифов (7 дней / месяц / 3 месяца / полгода / год), автоматическое отключение по истечении подписки (не раз в полчаса, а раз в 90 секунд — важно, чтобы просрочка реально обрывала доступ, а не продолжала работать).
- **API + сайт** (FastAPI) — страница подписки со ссылкой `happ://` и QR-кодом, личный кабинет, JSON API для сайта.
- **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).
@ -27,6 +28,14 @@
- **Проверка конфига 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/ноде/тарифу и по статусу, прямо в таблице. Плюс экспорт всех подписок в CSV одной кнопкой.
- **Цепочки серверов (v1.6)** — `клиент → нода A → нода B → интернет`: собираются в админке мышкой, зажал ЛКМ на клиенте и протянул провод через серверы к интернету. Больше двух серверов нельзя специально, на двух панель предупреждает про задержку и сама меряет RTT между нодами. Подробности ниже в разделе «Цепочки серверов».
- **Юзеры и выдача подписок (v1.6)** — отдельная страница со всеми, кто уже есть в системе, даже если подписок у них ни разу не было (в «Подписках» таких не видно): поиск по юзернейму и Telegram ID, счётчики активных подписок и устройств, кнопка «Выдать подписку». Можно выдать и по Telegram ID тому, кого в базе ещё нет, подписка дождётся, пока он зайдёт в бота.
- **Журнал действий (v1.6)** — кто из админов и когда менял ноды, цепочки, подписки, настройки, качал бэкап и входил (включая неудачные входы с IP). Пароли и ключи в журнал не попадают, только факт действия.
- **Пинг нод и палитра команд (v1.6)** — живая задержка от панели до каждой ноды на дашборде и в списке нод; `Ctrl K` открывает поиск по страницам, нодам, цепочкам и действиям. Акцентный цвет панели меняется кружками сверху.
## Архитектура
@ -103,10 +112,10 @@ sequenceDiagram
Нужен чистый сервер на **Ubuntu 22.04/24.04** или **Debian 11/12**, root-доступ и три поднятых DNS A-записи (см. таблицу ниже).
```bash
bash <(curl -Ls https://mbs.savsis.xyz/install.sh)
bash <(curl -Ls https://lab.savsis.xyz/savsisbtw/mbs-panel/raw/branch/main/install.sh)
```
(или напрямую с GitHub, если так удобнее: `git clone https://github.com/devsavsis/mbs-panel.git && cd mbs-panel && sudo bash install.sh` — скрипт один и тот же, `mbs.savsis.xyz` просто зеркало с автосинком)
(или так: `git clone https://lab.savsis.xyz/savsisbtw/mbs-panel.git && cd mbs-panel && sudo bash install.sh`. Основной репозиторий лежит на lab.savsis.xyz, GitHub и api.savsis.xyz остаются зеркалами на случай, если lab недоступен, установщик и `mbs update` сами переключаются на них)
Скрипт спросит домен панели, домен подписки, токен бота от [@BotFather](https://t.me/BotFather) и список Telegram ID админов — и дальше всё сам: ставит зависимости, Xray, nginx, выпускает сертификаты Let's Encrypt, генерирует Reality-ключи, поднимает systemd-сервисы, настраивает firewall (ufw) и fail2ban. В конце покажет пароль от админки и ссылку на панель.
@ -129,6 +138,7 @@ bash <(curl -Ls https://mbs.savsis.xyz/install.sh)
- Зайди на `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`.
@ -142,10 +152,18 @@ mbs status статус bot / api / xray / nginx
mbs restart перезапустить bot + api
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
mbs domain текущий домен панели
mbs update обновить код с GitHub и перезапустить
mbs backup полная копия панели в /root/mbs-backups (база, .env, твои правки, конфиг Xray)
mbs update [ссылка] обновить код и перезапустить; со ссылкой на git-зеркало берёт обновление оттуда
mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, его mbs update проверяет первым
```
`mbs update` тянет `git pull` (только fast-forward — если на сервере что-то правили руками, честно откажется и не полезет мержить), ставит зависимости, **проверяет, что новый код вообще компилируется**, и только потом перезапускает. Если после рестарта `mbs-bot`/`mbs-api` не поднялись — сам откатывает на предыдущий коммит и поднимает его. `.env` и база (`mbs.db`) не в гите — их не тронет ни при каком раскладе.
`mbs update` тянет обновление (только fast-forward, чужую историю на сервере не мержит), ставит зависимости, **проверяет, что новый код вообще компилируется**, и только потом перезапускает. Если после рестарта `mbs-bot`/`mbs-api` не поднялись — сам откатывает на предыдущий коммит и поднимает его. `.env` и база (`mbs.db`) не в гите — их не тронет ни при каком раскладе.
Перед каждым обновлением `mbs update` сам снимает полную копию в `/root/mbs-backups/` (консистентный снапшот базы через backup API SQLite, `.env`, код вместе с твоими ручными правками, конфиг Xray, без `venv`), хранит 5 последних, права `600`. Не получилось сделать копию (нет места на диске), обновление даже не начнётся. То же вручную: `mbs backup`. Вернуть всё как было: `tar xzf /root/mbs-backups/mbs-before-update-<время>.tar.gz -C /` и `mbs restart`.
Если на сервере правили файлы руками (бывает, `bot.py`/`config.py`/`db.py` под себя), обновление больше на этом не падает: правки откладываются в `git stash` и сохраняются патчем в `local-changes/local-changes-<время>.patch`, потом подтягивается новая версия. Вернуть своё поверх новой: `git stash pop` (может быть конфликт, если новая версия правила те же строки, тогда смотри патч). Если новый код не прошёл проверку или сервисы не поднялись, откат на старый коммит возвращает и твои правки.
Источники по порядку: своё зеркало (если задано через `mbs mirror`), потом `lab.savsis.xyz`, потом `api.savsis.xyz`, потом GitHub. Установщик включает автообновление: каждый день около 04:00 сервер сам делает `mbs update` (перед ним всегда резервная копия). Выключить: `mbs autoupdate off`, включить обратно: `mbs autoupdate on`. Появилось новое зеркало или GitHub недоступен, а ссылка на репо есть: `mbs update https://example.com/путь/mbs-panel.git` возьмёт обновление именно оттуда, один раз. Чтобы всегда обновляться с него: `mbs mirror https://example.com/путь/mbs-panel.git` (убрать: `mbs mirror off`). Принимаются только `https://`, `http://`, `ssh://` и `git@хост:путь`, всё остальное (в том числе `file://` и хитрые транспорты типа `ext::`) отбрасывается, ветка берётся `main`.
## Добавление ноды
@ -172,6 +190,22 @@ sequenceDiagram
Panel->>Panel: нода активна, доступна в боте
```
## Цепочки серверов
Обычное подключение это `клиент → нода → интернет`. Цепочка добавляет второй прыжок: `клиент → нода A → нода B → интернет`. Сайты видят IP ноды B, а клиент коннектится к A. Пригождается, когда вход хочется держать в одном регионе (ближе, не режут), а выход нужен в другой стране. Больше двух серверов не даёт специально: каждый лишний прыжок это задержка, а скорость упирается в самое слабое звено.
Собирается в админке: Цепочки → зажимаешь ЛКМ на «Клиенте», тянешь провод через серверы из пула и отпускаешь на «Интернете». Пока ведёшь, сервер под курсором цепляется после короткой задержки (чтоб не хватать всё подряд по пути). Можно и без перетаскивания, просто кликами по серверам и по «Интернету», `Esc` сбрасывает. Один сервер это обычное подключение, оно и так есть у каждой ноды, а вот два уже цепочка: панель сразу показывает предупреждение про высокую задержку и замеряет реальный RTT между нодами (TCP-коннект с входной ноды до выходной).
Что реально происходит под капотом:
- на входной ноде появляется отдельный Xray-inbound `chain-<код>` (тот же Reality-ключ что у ноды, но свой порт из 10443–10999 и свой shortId), outbound `chain-<код>-out` до выходной ноды и routing-правило «всё из этого inbound уходит в этот outbound»;
- на выходной ноде заводится служебный клиент `relay-<код>`, под ним входная нода и ходит на выход (только на TCP+Reality inbound, на обоих прыжках `xtls-rprx-vision`);
- порт открывается в ufw сам, конфиг прогоняется через `xray run -test`, после рестарта проверяется что Xray реально поднялся, если нет, конфиг откатывается;
- подписчикам входной ноды в подписку добавляется ещё одна ссылка «A → B», подписчикам выходной цепочку не выдаём;
- любая проблема с цепочкой не блокирует обычную синхронизацию клиентов, они применятся в любом случае.
Ограничения: входом может быть только локальная или управляемая нода (панель правит её конфиг), выходом ещё и внешняя нода с общим UUID. Ноду, которая сидит в цепочке, удалить нельзя, сначала удали цепочку. И важный момент про Reality: SNI-маскировка (`dest`) не должна быть сайтом с пост-квантовым обменом ключами (например `www.microsoft.com`), на таком Reality не заводится вообще, ни в цепочке, ни без неё, проверено руками. Дефолтный `www.wildberries.ru` подходит.
## Приём оплаты
По умолчанию бот выдаёт подписки бесплатно по кнопке — платежи выключены (`PAYMENTS_ENABLED=false`). Чтобы продавать доступ:
@ -209,7 +243,7 @@ sequenceDiagram
PR и issues welcome. CI на каждый пуш гоняет compile-check по питону, синтаксис-проверку шелл-скриптов и smoke-тест генерации install-скрипта ноды.
## Авторы:
github.com/devsavsis
github.com/savsisbtw
github.com/welfizx
## Лицензия

2350
admin.html

File diff suppressed because it is too large Load diff

816
api.py

File diff suppressed because it is too large Load diff

View file

@ -123,5 +123,8 @@ def restore_backup(data: bytes) -> dict:
os.chmod(tmp_db_path, 0o600)
os.replace(tmp_db_path, DB_PATH)
import db
db.init_db()
_prune_old_safety_copies()
return {"restored_env": restored_env, "safety_copy": safety_copy}

308
bot.py
View file

@ -2,16 +2,19 @@ import asyncio
import logging
from aiogram import Bot, Dispatcher, F
from aiogram.filters import CommandStart, CommandObject
from aiogram.filters import Command, CommandStart, CommandObject
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
import chains
import db
import links
import features
import payments
import settings
import webhooks
import xray_manager
from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN, PAYMENTS_ENABLED
from config import BOT_TOKEN, ADMIN_IDS, SUB_DOMAIN, SITE_DOMAIN
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("mbs-bot")
@ -29,9 +32,14 @@ def is_admin(tg_id: int) -> bool:
def main_menu_kb(tg_id: int) -> InlineKeyboardMarkup:
rows = [
rows = []
if settings.get_features()["trial_enabled"] and db.trial_available(tg_id):
rows.append([InlineKeyboardButton(text="Попробовать бесплатно", callback_data="trial:start")])
rows += [
[InlineKeyboardButton(text="Получить VPN", callback_data="menu:get")],
[InlineKeyboardButton(text="Моя подписка", callback_data="menu:mysub")],
[InlineKeyboardButton(text="Промокод", callback_data="menu:promo")],
[InlineKeyboardButton(text="Пригласить друга", callback_data="menu:referral")],
[InlineKeyboardButton(text="О сервисе", callback_data="menu:about")],
]
if is_admin(tg_id):
@ -39,6 +47,14 @@ def main_menu_kb(tg_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=rows)
async def get_bot_username() -> str:
global _bot_username
if _bot_username is None:
me = await bot.get_me()
_bot_username = me.username
return _bot_username
def nodes_kb(prefix: str) -> InlineKeyboardMarkup:
rows = []
for n in db.list_nodes(enabled_only=True):
@ -47,10 +63,16 @@ def nodes_kb(prefix: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=rows)
def plans_kb(prefix: str, node_code: str) -> InlineKeyboardMarkup:
def plans_kb(prefix: str, node_code: str, tg_id: int | None = None) -> InlineKeyboardMarkup:
payments_enabled = settings.get_payment_settings()["payments_enabled"]
promo = db.get_pending_promo(tg_id) if tg_id else None
rows = []
for p in PLANS:
label = f"{p['label']} — {p['price']} ₽" if PAYMENTS_ENABLED and p["price"] > 0 else p["label"]
for p in settings.get_plans():
if payments_enabled and p["price"] > 0:
final = db.discounted_price(p["price"], promo)
label = f"{p['label']} — {final} ₽" if final == p["price"] else f"{p['label']} — {final} ₽ (было {p['price']})"
else:
label = p["label"]
rows.append([InlineKeyboardButton(text=label, callback_data=f"{prefix}:{node_code}:{p['code']}")])
rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:get")])
return InlineKeyboardMarkup(inline_keyboard=rows)
@ -70,13 +92,14 @@ def connect_kb(token: str, extra_rows: list[list[InlineKeyboardButton]] | None =
return InlineKeyboardMarkup(inline_keyboard=rows)
ABOUT_TEXT = (
"<b>MBS Panel</b>\n\n"
"Быстрый и незаметный доступ без границ. Протокол VLESS+Reality "
"маскируется под обычный HTTPS-трафик, ничем не палится.\n\n"
f"{DIVIDER}\n"
f"Сайт: {SITE_DOMAIN}"
)
def about_text() -> str:
return (
f"<b>{settings.get_brand_name()}</b>\n\n"
"Быстрый и незаметный доступ без границ. Протокол VLESS+Reality "
"маскируется под обычный HTTPS-трафик, ничем не палится.\n\n"
f"{DIVIDER}\n"
f"Сайт: {SITE_DOMAIN}"
)
async def send_main_menu(message: Message):
@ -87,6 +110,12 @@ async def send_main_menu(message: Message):
async def start_deeplink(message: Message, command: CommandObject):
user = db.get_or_create_user(message.from_user.id, message.from_user.username)
payload = command.args or ""
if payload.startswith("ref_") or payload.startswith("ref-"):
ref_code = payload[4:]
referrer = db.get_user_by_ref_code(ref_code)
if referrer and settings.get_referral_settings()["enabled"]:
db.set_referred_by(message.from_user.id, referrer["tg_id"])
return await send_main_menu(message)
if payload.startswith("gift_") or payload.startswith("gift-"):
code = payload[5:]
gift, err = db.redeem_gift_code(code, message.from_user.id)
@ -96,7 +125,7 @@ async def start_deeplink(message: Message, command: CommandObject):
if err == "already_used":
await message.answer("Этот код уже был использован.")
return await send_main_menu(message)
plan = PLANS_BY_CODE.get(gift["plan"])
plan = settings.get_plans_by_code().get(gift["plan"])
gift_node = db.get_node(gift["node"])
if not plan or not gift_node:
await message.answer("Этот подарок больше недоступен.")
@ -119,7 +148,7 @@ async def start_deeplink(message: Message, command: CommandObject):
async def start_plain(message: Message):
db.get_or_create_user(message.from_user.id, message.from_user.username)
await message.answer(
"Привет! Это бот MBS Panel.\nВыбери действие ниже.",
f"Привет! Это бот {settings.get_brand_name()}.\nВыбери действие ниже.",
)
await send_main_menu(message)
@ -133,7 +162,33 @@ async def cb_menu_main(cb: CallbackQuery):
@dp.callback_query(F.data == "menu:about")
async def cb_about(cb: CallbackQuery):
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
await cb.message.edit_text(ABOUT_TEXT, reply_markup=kb)
await cb.message.edit_text(about_text(), reply_markup=kb)
await cb.answer()
@dp.callback_query(F.data == "menu:referral")
async def cb_referral(cb: CallbackQuery):
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
ref_settings = settings.get_referral_settings()
if not ref_settings["enabled"]:
await cb.message.edit_text("Реферальная программа сейчас отключена.", reply_markup=kb)
return await cb.answer()
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
stats = db.referral_stats(cb.from_user.id)
username = await get_bot_username()
link = f"https://t.me/{username}?start=ref_{user['ref_code']}"
days = ref_settings["bonus_days"]
text = (
f"<b>Пригласи друга</b>\n\n"
f"За каждого друга, который активирует подписку по твоей ссылке, "
f"вы <b>оба</b> получаете +{days} дн. к подписке.\n\n"
f"{DIVIDER}\n"
f"Твоя ссылка:\n<code>{link}</code>\n\n"
f"Приглашено: {stats['referred_count']}\n"
)
if stats["bonus_days_pending"]:
text += f"Накоплено бонусных дней (зачислятся при следующей подписке): {stats['bonus_days_pending']}\n"
await cb.message.edit_text(text, reply_markup=kb)
await cb.answer()
@ -146,7 +201,7 @@ async def cb_get(cb: CallbackQuery):
@dp.callback_query(F.data.startswith("node:"))
async def cb_node(cb: CallbackQuery):
node_code = cb.data.split(":")[1]
await cb.message.edit_text("Выбери срок:", reply_markup=plans_kb("plan", node_code))
await cb.message.edit_text("Выбери срок:", reply_markup=plans_kb("plan", node_code, cb.from_user.id))
await cb.answer()
@ -161,18 +216,27 @@ def providers_kb(node_code: str, plan_code: str) -> InlineKeyboardMarkup:
@dp.callback_query(F.data.startswith("plan:"))
async def cb_plan(cb: CallbackQuery):
_, node_code, plan_code = cb.data.split(":")
plan = PLANS_BY_CODE[plan_code]
plan = settings.get_plans_by_code()[plan_code]
db.get_or_create_user(cb.from_user.id, cb.from_user.username)
if PAYMENTS_ENABLED and plan["price"] > 0 and payments.available_providers():
promo = db.get_pending_promo(cb.from_user.id)
final_price = db.discounted_price(plan["price"], promo)
if settings.get_payment_settings()["payments_enabled"] and final_price > 0 and payments.available_providers():
price_line = f"{final_price} ₽" if final_price == plan["price"] else f"{final_price} ₽ (скидка по промокоду {promo['code']})"
await cb.message.edit_text(
f"<b>{plan['label']}</b> — {plan['price']} ₽\n\nВыбери способ оплаты:",
f"<b>{plan['label']}</b> — {price_line}\n\nВыбери способ оплаты:",
reply_markup=providers_kb(node_code, plan_code),
)
return await cb.answer()
if promo and settings.get_payment_settings()["payments_enabled"] and plan["price"] > 0 and final_price == 0:
db.consume_promo(promo["code"], cb.from_user.id)
db.set_promo_pending(cb.from_user.id, None)
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
sub = db.create_subscription(cb.from_user.id, node_code, plan["days"], plan_code, source="bot")
sub = db.create_subscription(
cb.from_user.id, node_code, plan["days"], plan_code, source="bot",
traffic_limit=settings.default_traffic_limit_bytes(),
)
node_row = db.get_node(node_code)
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
kb = connect_kb(user["token"], extra_rows=[
@ -193,13 +257,18 @@ async def cb_plan(cb: CallbackQuery):
@dp.callback_query(F.data.startswith("pay:"))
async def cb_pay(cb: CallbackQuery):
_, provider, node_code, plan_code = cb.data.split(":")
plan = PLANS_BY_CODE[plan_code]
plan = settings.get_plans_by_code()[plan_code]
node_row = db.get_node(node_code)
payment_id = payments.new_payment_id()
db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, plan["price"])
promo = db.get_pending_promo(cb.from_user.id)
final_price = db.discounted_price(plan["price"], promo)
db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, final_price)
if promo and final_price != plan["price"]:
db.set_payment_promo(payment_id, promo["code"], plan["price"])
db.set_promo_pending(cb.from_user.id, None)
try:
external_id, pay_url = payments.create_payment_link(
provider, payment_id, plan["price"], f"MBS Panel — {node_row['label']}, {plan['label']}",
provider, payment_id, final_price, f"{settings.get_brand_name()} — {node_row['label']}, {plan['label']}",
)
except Exception:
log.exception("payment creation failed")
@ -211,7 +280,7 @@ async def cb_pay(cb: CallbackQuery):
[InlineKeyboardButton(text="Назад", callback_data=f"plan:{node_code}:{plan_code}")],
])
await cb.message.edit_text(
f"Счёт на {plan['price']} ₽ создан.\nПосле оплаты подписка выдастся автоматически.",
f"Счёт на {final_price} ₽ создан.\nПосле оплаты подписка выдастся автоматически.",
reply_markup=kb,
)
await cb.answer()
@ -226,11 +295,13 @@ async def cb_mysub(cb: CallbackQuery):
await cb.message.edit_text("У тебя пока нет активных подписок.", reply_markup=kb)
return await cb.answer()
lines = ["<b>Твои подписки</b>\n"]
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
plans_by_code = settings.get_plans_by_code()
for s in subs:
plan = PLANS_BY_CODE.get(s["plan"], {}).get("label", s["plan"])
node_info = db.get_node(s["node"])
plan = plans_by_code.get(s["plan"], {}).get("label", s["plan"])
node_info = nodes_by_code.get(s["node"])
node = node_info["label"] if node_info else s["node"]
lines.append(f"{node} — {plan}, до {s['expires_at'][:10]}")
lines.append(f"{node} — {plan}, до {s['expires_at'][:10]}\nТрафик: {features.traffic_text(s)}")
lines.append(f"\n{DIVIDER}\nСсылка-подписка:\n<code>{sub_url_for(user['token'])}</code>")
kb = connect_kb(user["token"], extra_rows=[[InlineKeyboardButton(text="В меню", callback_data="menu:main")]])
await cb.message.edit_text("\n".join(lines), reply_markup=kb)
@ -282,7 +353,7 @@ async def cb_admin_giftmake(cb: CallbackQuery):
me = await bot.get_me()
_bot_username = me.username
link = f"https://t.me/{_bot_username}?start=gift_{code}"
plan = PLANS_BY_CODE[plan_code]
plan = settings.get_plans_by_code()[plan_code]
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В админку", callback_data="menu:admin")]])
await cb.message.edit_text(
f"Гифт-ссылка готова ({db.get_node(node_code)['label']}, {plan['label']}):\n\n"
@ -322,9 +393,145 @@ async def cb_admin_sync(cb: CallbackQuery):
await cb.answer()
async def reconcile_pending_payments():
if not PAYMENTS_ENABLED:
@dp.callback_query(F.data == "trial:start")
async def cb_trial(cb: CallbackQuery):
feats = settings.get_features()
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
if not feats["trial_enabled"] or not db.trial_available(cb.from_user.id):
return await cb.answer("Пробный период недоступен", show_alert=True)
node_row = features.pick_trial_node()
if not node_row:
return await cb.answer("Сейчас нет доступных серверов", show_alert=True)
if not db.claim_trial(cb.from_user.id):
return await cb.answer("Пробный период уже использован", show_alert=True)
limit = feats["trial_traffic_gb"] * settings.GB if feats["trial_traffic_gb"] > 0 else None
sub = db.create_subscription(
cb.from_user.id, node_row["code"], feats["trial_days"], "trial", source="trial", traffic_limit=limit,
)
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
traffic_line = f"\nТрафик: до {feats['trial_traffic_gb']} ГБ" if limit else ""
kb = connect_kb(user["token"], extra_rows=[[InlineKeyboardButton(text="В меню", callback_data="menu:main")]])
await cb.message.edit_text(
f"<b>Пробный период активен</b>\n\n"
f"Сервер: {node_row['label']}\n"
f"Срок: до {sub['expires_at'][:10]}{traffic_line}\n\n"
f"{DIVIDER}\n"
f"Ссылка-подписка:\n<code>{sub_url_for(user['token'])}</code>",
reply_markup=kb,
)
await cb.answer("Пробный период выдан")
await asyncio.to_thread(webhooks.send, "subscription.trial", {
"tg_id": cb.from_user.id, "node": node_row["code"], "subscription_uuid": sub["uuid"],
"expires_at": sub["expires_at"],
})
PROMO_ERRORS = {
"not_found": "Такого промокода нет.",
"expired": "Срок действия промокода закончился.",
"exhausted": "Этот промокод уже использован максимальное число раз.",
"already_used": "Ты уже использовал этот промокод.",
}
async def apply_promo_code(message: Message, raw_code: str):
code = (raw_code or "").strip()
if not code:
return await message.answer("Напиши промокод так: /promo КОД")
db.get_or_create_user(message.from_user.id, message.from_user.username)
promo, err = db.validate_promo(code, message.from_user.id)
if err:
return await message.answer(PROMO_ERRORS.get(err, "Промокод не подошёл."))
if promo["kind"] == "days":
redeemed, err = db.redeem_days_promo(code, message.from_user.id)
if err:
return await message.answer(PROMO_ERRORS.get(err, "Промокод не подошёл."))
return await message.answer(f"Промокод принят: +{promo['value']} дн. к подписке.")
db.set_promo_pending(message.from_user.id, promo["code"])
what = f"{promo['value']}%" if promo["kind"] == "percent" else f"{promo['value']} ₽"
await message.answer(f"Промокод принят: скидка {what}. Она применится на следующей оплате, выбери срок в меню.")
@dp.message(Command("promo"))
async def cmd_promo(message: Message, command: CommandObject):
await apply_promo_code(message, command.args or "")
@dp.callback_query(F.data == "menu:promo")
async def cb_promo_hint(cb: CallbackQuery):
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
await cb.message.edit_text("Отправь команду с кодом, например:\n<code>/promo КОД</code>", reply_markup=kb)
await cb.answer()
async def notify_limit_reached(subs: list):
for sub in subs:
try:
await bot.send_message(
sub["tg_id"],
"<b>Лимит трафика исчерпан</b>\n\nДоступ приостановлен. Продли подписку или напиши в поддержку, "
"чтобы получить ещё трафик.",
)
except Exception:
log.exception("failed to notify about traffic limit")
await asyncio.to_thread(webhooks.send, "subscription.limit_reached", {
"tg_id": sub["tg_id"], "subscription_uuid": sub["uuid"], "node": sub["node"],
"limit": sub["traffic_limit"], "used": sub["traffic_used"],
})
async def send_expiry_reminders():
if not settings.get_features()["reminders_enabled"]:
return
for sub, kind, stage in features.reminders_due():
left = "3 дня" if stage == "3d" else "сутки"
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Продлить", callback_data="menu:get")]])
try:
await bot.send_message(
sub["tg_id"],
f"<b>Подписка скоро закончится</b>\n\nДо конца осталось меньше чем {left} "
f"(до {sub['expires_at'][:10]}). Продли заранее, чтобы доступ не прерывался.",
reply_markup=kb,
)
except Exception:
log.exception("failed to send expiry reminder")
features.mark_stage_sent(sub["uuid"], sub["expires_at"])
_node_state: dict = {}
async def check_nodes_and_alert():
if not settings.get_features()["node_alerts_enabled"]:
return
for node in db.list_nodes(enabled_only=True):
if node["kind"] == "local" or node["status"] != "active" or not node.get("address"):
continue
samples = await asyncio.to_thread(chains.tcp_connect_ms, node["address"], node["port"], 2, 3.0)
alive = chains.median_ms(samples) is not None
previous = _node_state.get(node["code"])
_node_state[node["code"]] = alive
if previous is None or previous == alive:
continue
text = (
f"Нода «{node['label']}» ({node['address']}) снова доступна."
if alive else f"Нода «{node['label']}» ({node['address']}) не отвечает."
)
for admin_id in ADMIN_IDS:
try:
await bot.send_message(admin_id, text)
except Exception:
log.exception("failed to send node alert")
await asyncio.to_thread(webhooks.send, "node.up" if alive else "node.down", {
"node": node["code"], "label": node["label"], "address": node["address"],
})
async def reconcile_pending_payments():
if not settings.get_payment_settings()["payments_enabled"]:
return
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
plans_by_code = settings.get_plans_by_code()
for payment in db.list_payments():
if payment["status"] != "pending" or not payment.get("external_id"):
continue
@ -333,14 +540,17 @@ async def reconcile_pending_payments():
except Exception:
continue
if status in payments.PAID_STATUSES:
plan = PLANS_BY_CODE.get(payment["plan"])
node_row = db.get_node(payment["node"])
plan = plans_by_code.get(payment["plan"])
node_row = nodes_by_code.get(payment["node"])
if not plan or not node_row:
continue
granted = db.mark_payment_paid(payment["id"])
if not granted:
continue
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
sub = db.create_subscription(
payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment",
traffic_limit=settings.default_traffic_limit_bytes(),
)
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
user = db.get_or_create_user(payment["tg_id"], None)
try:
@ -353,16 +563,44 @@ async def reconcile_pending_payments():
)
except Exception:
log.exception("failed to notify user about payment")
await asyncio.to_thread(webhooks.send, "payment.paid", {
"tg_id": payment["tg_id"],
"amount": payment["amount"],
"provider": payment["provider"],
"node": payment["node"],
"plan": payment["plan"],
"subscription_uuid": sub["uuid"],
"expires_at": sub["expires_at"],
})
elif status in payments.FAILED_STATUSES:
db.mark_payment_failed(payment["id"])
async def periodic_sync():
tick = 0
while True:
if tick % 3 == 0:
try:
exceeded = await asyncio.to_thread(features.update_traffic_and_find_exceeded)
if exceeded:
await notify_limit_reached(exceeded)
except Exception:
log.exception("traffic accounting failed")
try:
await asyncio.to_thread(xray_manager.sync_all)
except Exception:
log.exception("periodic sync failed")
if tick % 20 == 0:
try:
await send_expiry_reminders()
except Exception:
log.exception("expiry reminders failed")
if tick % 2 == 0:
try:
await check_nodes_and_alert()
except Exception:
log.exception("node alerts failed")
tick += 1
try:
await reconcile_pending_payments()
except Exception:

233
chains.py Normal file
View file

@ -0,0 +1,233 @@
import copy
import json
import re
import socket
import time
BASE_TAGS = ("vless-tcp-reality", "vless-grpc-reality", "vless-xhttp-reality", "vless-ws-tls")
TCP_TAG = "vless-tcp-reality"
VISION = "xtls-rprx-vision"
CHAIN_PREFIX = "chain-"
RELAY_EMAIL_PREFIX = "relay-"
MAX_SERVERS = 2
PORT_MIN = 10443
PORT_MAX = 10999
CODE_RE = re.compile(r"^[a-z0-9]{1,16}$")
HOST_RE = re.compile(r"^[A-Za-z0-9.-]{1,253}$")
CHAIN_KINDS_ENTRY = ("local", "managed")
CHAIN_KINDS_EXIT = ("local", "managed", "external")
class ChainConfigError(Exception):
pass
def inbound_tag(code):
return CHAIN_PREFIX + code
def outbound_tag(code):
return CHAIN_PREFIX + code + "-out"
def relay_email(code):
return RELAY_EMAIL_PREFIX + code
def is_chain_inbound_tag(tag):
return bool(tag) and tag.startswith(CHAIN_PREFIX) and not tag.endswith("-out")
def is_user_tag(tag):
return tag in BASE_TAGS or is_chain_inbound_tag(tag)
def flow_for_tag(tag):
if tag == TCP_TAG or is_chain_inbound_tag(tag):
return VISION
return None
def sync_clients(clients, wanted, flow):
kept = []
seen = set()
for c in clients:
cid = c.get("id")
if cid in wanted and cid not in seen:
kept.append(c)
seen.add(cid)
for cid in wanted:
if cid in seen:
continue
entry = {"id": cid, "email": wanted[cid]}
if flow:
entry["flow"] = flow
kept.append(entry)
return kept
def find_inbound(cfg, tag):
for ib in cfg.get("inbounds", []):
if ib.get("tag") == tag:
return ib
return None
def build_chain_inbound(template, chain, wanted, old_clients):
reality = (template.get("streamSettings") or {}).get("realitySettings")
if not reality:
raise ChainConfigError("у входной ноды нет TCP+Reality inbound — цепочку строить не из чего")
ib = copy.deepcopy(template)
ib["tag"] = inbound_tag(chain["code"])
ib["port"] = chain["port"]
ib["streamSettings"]["realitySettings"]["shortIds"] = [chain["short_id"]]
ib["settings"]["clients"] = sync_clients(old_clients, wanted, VISION)
return ib
def build_chain_outbound(chain, exit_node, relay_uuid):
return {
"tag": outbound_tag(chain["code"]),
"protocol": "vless",
"settings": {
"vnext": [{
"address": exit_node["address"],
"port": int(exit_node["port"]),
"users": [{"id": relay_uuid, "encryption": "none", "flow": VISION}],
}],
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"serverName": exit_node["sni"],
"fingerprint": "chrome",
"publicKey": exit_node["public_key"],
"shortId": exit_node["short_id"],
"spiderX": "",
},
},
}
def build_chain_rule(chain):
return {
"type": "field",
"inboundTag": [inbound_tag(chain["code"])],
"outboundTag": outbound_tag(chain["code"]),
}
def relay_for_chain(chain, exit_node):
if exit_node["kind"] == "external":
return exit_node.get("shared_uuid")
return chain.get("relay_uuid")
def split_busy_chains(cfg, entry_chains, busy_ports):
new_ports = set(new_ports_needed(cfg, entry_chains))
usable = []
problems = []
for chain in entry_chains:
if chain["port"] in new_ports and chain["port"] in busy_ports:
problems.append(f"{chain['code']}: порт {chain['port']} уже занят другим процессом, цепочка не применена")
continue
usable.append(chain)
return usable, problems
def sync_config(cfg, wanted, relay_wanted, entry_chains, exit_nodes, apply_chains=True):
before = json.dumps(cfg, sort_keys=True)
problems = []
template = find_inbound(cfg, TCP_TAG)
for ib in cfg["inbounds"]:
tag = ib.get("tag")
if not is_user_tag(tag):
continue
want = dict(wanted)
if tag == TCP_TAG:
want.update(relay_wanted)
ib["settings"]["clients"] = sync_clients(ib["settings"]["clients"], want, flow_for_tag(tag))
if not apply_chains:
changed = json.dumps(cfg, sort_keys=True) != before
return changed, problems
old_chain_inbounds = {}
for ib in cfg["inbounds"]:
if is_chain_inbound_tag(ib.get("tag")):
old_chain_inbounds[ib["tag"]] = ib
kept_inbounds = [ib for ib in cfg["inbounds"] if not is_chain_inbound_tag(ib.get("tag"))]
kept_outbounds = [ob for ob in cfg.get("outbounds", []) if not (ob.get("tag") or "").startswith(CHAIN_PREFIX)]
routing = cfg.setdefault("routing", {})
kept_rules = [r for r in routing.get("rules", []) if not (r.get("outboundTag") or "").startswith(CHAIN_PREFIX)]
for chain in entry_chains:
exit_node = exit_nodes.get(chain["exit_node"])
if template is None:
problems.append(f"{chain['code']}: нет TCP+Reality inbound на входной ноде")
continue
if not exit_node:
problems.append(f"{chain['code']}: выходная нода не найдена")
continue
relay_uuid = relay_for_chain(chain, exit_node)
if not relay_uuid:
problems.append(f"{chain['code']}: у выходной ноды нет ключа для цепочки")
continue
old = old_chain_inbounds.get(inbound_tag(chain["code"]))
old_clients = old["settings"]["clients"] if old else []
kept_inbounds.append(build_chain_inbound(template, chain, wanted, old_clients))
kept_outbounds.append(build_chain_outbound(chain, exit_node, relay_uuid))
kept_rules.append(build_chain_rule(chain))
cfg["inbounds"] = kept_inbounds
cfg["outbounds"] = kept_outbounds
routing["rules"] = kept_rules
changed = json.dumps(cfg, sort_keys=True) != before
return changed, problems
def new_ports_needed(cfg, entry_chains):
existing = set()
for ib in cfg.get("inbounds", []):
if is_chain_inbound_tag(ib.get("tag")):
existing.add(ib["tag"])
ports = []
for chain in entry_chains:
if inbound_tag(chain["code"]) not in existing:
ports.append(chain["port"])
return ports
def tcp_connect_ms(host, port, samples=3, timeout=3.0):
results = []
for _ in range(samples):
start = time.perf_counter()
try:
with socket.create_connection((host, int(port)), timeout=timeout):
pass
results.append(round((time.perf_counter() - start) * 1000))
except OSError:
results.append(-1)
return results
def median_ms(samples):
good = sorted(s for s in samples if s >= 0)
if not good:
return None
return good[len(good) // 2]
def latency_level(rtt_ms):
if rtt_ms is None:
return "unknown"
if rtt_ms < 40:
return "low"
if rtt_ms < 120:
return "medium"
return "high"

View file

@ -38,6 +38,8 @@ if ADMIN_PANEL_PASSWORD in ("change-me", "changeme", "admin", "password") or len
PANEL_DOMAIN = env("PANEL_DOMAIN", required=True)
SUB_DOMAIN = env("SUB_DOMAIN", required=True)
SITE_DOMAIN = env("SITE_DOMAIN", required=True)
BRAND_NAME = env("BRAND_NAME", "MBS Panel")
ADMIN_PATH = env("ADMIN_PATH", "admin").strip("/") or "admin"
DB_PATH = os.path.join(BASE_DIR, "mbs.db")
XRAY_CONFIG_PATH = "/usr/local/etc/xray/config.json"
@ -118,3 +120,6 @@ PLATEGA_SECRET = env("PLATEGA_SECRET", "")
HWID_LIMIT_ENABLED = env("HWID_LIMIT_ENABLED", "false").lower() == "true"
HWID_FALLBACK_LIMIT = int(env("HWID_FALLBACK_LIMIT", "3"))
REFERRAL_ENABLED = env("REFERRAL_ENABLED", "true").lower() == "true"
REFERRAL_BONUS_DAYS = int(env("REFERRAL_BONUS_DAYS", "3"))

549
db.py
View file

@ -6,6 +6,7 @@ import secrets
import datetime
import contextlib
import chains as chainsmod
from config import DB_PATH
SCHEMA = """
@ -113,6 +114,56 @@ CREATE TABLE IF NOT EXISTS nodes (
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS chains (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
entry_node TEXT NOT NULL,
exit_node TEXT NOT NULL,
port INTEGER NOT NULL,
short_id TEXT NOT NULL,
relay_uuid TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
admin TEXT,
action TEXT NOT NULL,
detail TEXT,
ip TEXT
);
CREATE TABLE IF NOT EXISTS promo_codes (
code TEXT PRIMARY KEY,
kind TEXT NOT NULL,
value INTEGER NOT NULL,
max_uses INTEGER,
used_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS promo_uses (
code TEXT NOT NULL,
tg_id INTEGER NOT NULL,
used_at TEXT NOT NULL,
PRIMARY KEY (code, tg_id)
);
CREATE TABLE IF NOT EXISTS sub_notices (
sub_uuid TEXT NOT NULL,
kind TEXT NOT NULL,
sent_at TEXT NOT NULL,
PRIMARY KEY (sub_uuid, kind)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_chains_pair ON chains (entry_node, exit_node);
CREATE UNIQUE INDEX IF NOT EXISTS idx_chains_entry_port ON chains (entry_node, port);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log (ts);
CREATE INDEX IF NOT EXISTS idx_subs_active_expires ON subscriptions (active, expires_at);
CREATE INDEX IF NOT EXISTS idx_subs_tg_id ON subscriptions (tg_id);
CREATE INDEX IF NOT EXISTS idx_subs_node ON subscriptions (node);
@ -133,6 +184,12 @@ _NEW_NODE_COLUMNS = {
_NEW_USER_COLUMNS = {
"hwid_limit": "INTEGER",
"ref_code": "TEXT",
"referred_by": "INTEGER",
"referral_rewarded": "INTEGER NOT NULL DEFAULT 0",
"bonus_days_pending": "INTEGER NOT NULL DEFAULT 0",
"trial_used": "INTEGER NOT NULL DEFAULT 0",
"promo_pending": "TEXT",
}
_NEW_ADMIN_SESSION_COLUMNS = {
@ -143,6 +200,19 @@ _NEW_ADMIN_COLUMNS = {
"totp_secret": "TEXT",
}
_NEW_SUBSCRIPTION_COLUMNS = {
"held_at": "TEXT",
"traffic_limit": "INTEGER",
"traffic_used": "INTEGER NOT NULL DEFAULT 0",
"traffic_last_raw": "INTEGER NOT NULL DEFAULT 0",
"limit_hit_at": "TEXT",
}
_NEW_PAYMENT_COLUMNS = {
"promo_code": "TEXT",
"original_amount": "INTEGER",
}
def _migrate():
with get_conn() as conn:
@ -155,6 +225,7 @@ def _migrate():
for name, decl in _NEW_USER_COLUMNS.items():
if name not in ucols:
conn.execute(f"ALTER TABLE users ADD COLUMN {name} {decl}")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ref_code ON users(ref_code)")
scols = {r["name"] for r in conn.execute("PRAGMA table_info(admin_sessions)").fetchall()}
for name, decl in _NEW_ADMIN_SESSION_COLUMNS.items():
if name not in scols:
@ -163,6 +234,14 @@ def _migrate():
for name, decl in _NEW_ADMIN_COLUMNS.items():
if name not in acols:
conn.execute(f"ALTER TABLE admins ADD COLUMN {name} {decl}")
subcols = {r["name"] for r in conn.execute("PRAGMA table_info(subscriptions)").fetchall()}
for name, decl in _NEW_SUBSCRIPTION_COLUMNS.items():
if name not in subcols:
conn.execute(f"ALTER TABLE subscriptions ADD COLUMN {name} {decl}")
pcols = {r["name"] for r in conn.execute("PRAGMA table_info(payments)").fetchall()}
for name, decl in _NEW_PAYMENT_COLUMNS.items():
if name not in pcols:
conn.execute(f"ALTER TABLE payments ADD COLUMN {name} {decl}")
if needs_sort_order_backfill:
rows = conn.execute(
"SELECT code FROM nodes ORDER BY (code='de1') DESC, created_at ASC"
@ -279,6 +358,8 @@ def reorder_nodes(codes: list):
def create_node(code, label, kind, address, port, public_key, short_id, sni, flow, shared_uuid=None):
if get_node(code):
raise ValueError("node with this code already exists")
with get_conn() as conn:
next_order = _next_sort_order(conn)
conn.execute(
@ -340,48 +421,216 @@ def delete_node(code: str):
).fetchone()["c"]
if active:
raise ValueError(f"node has {active} active subscriptions, revoke them first")
in_chains = conn.execute(
"SELECT COUNT(*) c FROM chains WHERE entry_node=? OR exit_node=?", (code, code)
).fetchone()["c"]
if in_chains:
raise ValueError(f"node is used in {in_chains} chain(s), delete them first")
conn.execute("DELETE FROM nodes WHERE code=?", (code,))
def list_chains(enabled_only: bool = False):
q = "SELECT * FROM chains"
if enabled_only:
q += " WHERE enabled=1"
q += " ORDER BY sort_order ASC, created_at ASC"
with get_conn() as conn:
rows = conn.execute(q).fetchall()
return [dict(r) for r in rows]
def get_chain(code: str):
with get_conn() as conn:
row = conn.execute("SELECT * FROM chains WHERE code=?", (code,)).fetchone()
return dict(row) if row else None
def create_chain(label: str, entry_node: str, exit_node: str, relay_uuid: str | None):
with get_conn() as conn:
dup = conn.execute(
"SELECT 1 FROM chains WHERE entry_node=? AND exit_node=?", (entry_node, exit_node)
).fetchone()
if dup:
raise ValueError("такая цепочка уже есть")
used = {r["port"] for r in conn.execute(
"SELECT port FROM chains WHERE entry_node=?", (entry_node,)
).fetchall()}
port = None
for candidate in range(chainsmod.PORT_MIN, chainsmod.PORT_MAX + 1):
if candidate not in used:
port = candidate
break
if port is None:
raise ValueError("закончились свободные порты под цепочки на этой ноде")
row = conn.execute("SELECT MAX(sort_order) m FROM chains").fetchone()
next_order = (row["m"] or 0) + 1
code = "c" + secrets.token_hex(3)
conn.execute(
"INSERT INTO chains (code, label, entry_node, exit_node, port, short_id, relay_uuid, enabled, sort_order, created_at) "
"VALUES (?,?,?,?,?,?,?,1,?,?)",
(code, label, entry_node, exit_node, port, secrets.token_hex(8), relay_uuid, next_order, now_iso()),
)
return get_chain(code)
def update_chain(code: str, **fields):
if not fields:
return get_chain(code)
cols = ", ".join(f"{k}=?" for k in fields)
with get_conn() as conn:
conn.execute(f"UPDATE chains SET {cols} WHERE code=?", (*fields.values(), code))
return get_chain(code)
def delete_chain(code: str):
with get_conn() as conn:
conn.execute("DELETE FROM chains WHERE code=?", (code,))
def add_audit(admin: str | None, action: str, detail: str = "", ip: str | None = None):
with get_conn() as conn:
conn.execute(
"INSERT INTO audit_log (ts, admin, action, detail, ip) VALUES (?,?,?,?,?)",
(now_iso(), admin, action, detail[:500], ip),
)
conn.execute("DELETE FROM audit_log WHERE id <= (SELECT MAX(id) FROM audit_log) - 5000")
def list_audit(limit: int = 100):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM audit_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
return [dict(r) for r in rows]
def _generate_ref_code(conn) -> str:
for _ in range(20):
code = secrets.token_hex(4)
if not conn.execute("SELECT 1 FROM users WHERE ref_code=?", (code,)).fetchone():
return code
raise RuntimeError("could not generate a unique ref_code")
def get_or_create_user(tg_id: int, username: str | None):
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
if row:
if username and row["username"] != username:
conn.execute("UPDATE users SET username=? WHERE tg_id=?", (username, tg_id))
if not row["ref_code"]:
conn.execute(
"UPDATE users SET ref_code=? WHERE tg_id=?", (_generate_ref_code(conn), tg_id)
)
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
return dict(row)
token = secrets.token_hex(16)
ref_code = _generate_ref_code(conn)
conn.execute(
"INSERT INTO users (tg_id, token, username, created_at) VALUES (?,?,?,?)",
(tg_id, token, username, now_iso()),
"INSERT INTO users (tg_id, token, username, ref_code, created_at) VALUES (?,?,?,?,?)",
(tg_id, token, username, ref_code, now_iso()),
)
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
return dict(row)
def get_user_by_ref_code(ref_code: str):
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE ref_code=?", (ref_code,)).fetchone()
return dict(row) if row else None
def set_referred_by(tg_id: int, referrer_tg_id: int) -> bool:
"""First-touch attribution: only takes effect for a brand-new account
(no subscriptions yet) that isn't already attributed, and never to self."""
if tg_id == referrer_tg_id:
return False
with get_conn() as conn:
row = conn.execute("SELECT referred_by FROM users WHERE tg_id=?", (tg_id,)).fetchone()
if not row or row["referred_by"] is not None:
return False
has_sub = conn.execute("SELECT 1 FROM subscriptions WHERE tg_id=?", (tg_id,)).fetchone()
if has_sub:
return False
referrer = conn.execute("SELECT 1 FROM users WHERE tg_id=?", (referrer_tg_id,)).fetchone()
if not referrer:
return False
conn.execute("UPDATE users SET referred_by=? WHERE tg_id=?", (referrer_tg_id, tg_id))
return True
def _apply_bonus_days(conn, tg_id: int, days: int):
if days <= 0:
return
row = conn.execute(
"SELECT uuid, expires_at FROM subscriptions WHERE tg_id=? AND active=1 AND held_at IS NULL "
"ORDER BY expires_at DESC LIMIT 1",
(tg_id,),
).fetchone()
if row:
new_expires = datetime.datetime.fromisoformat(row["expires_at"]) + datetime.timedelta(days=days)
conn.execute("UPDATE subscriptions SET expires_at=? WHERE uuid=?", (new_expires.isoformat(), row["uuid"]))
else:
conn.execute(
"UPDATE users SET bonus_days_pending = COALESCE(bonus_days_pending, 0) + ? WHERE tg_id=?",
(days, tg_id),
)
def credit_bonus_days(tg_id: int, days: int):
with get_conn() as conn:
_apply_bonus_days(conn, tg_id, days)
def referral_stats(tg_id: int) -> dict:
with get_conn() as conn:
user = conn.execute("SELECT ref_code, bonus_days_pending FROM users WHERE tg_id=?", (tg_id,)).fetchone()
count = conn.execute(
"SELECT COUNT(*) c FROM users WHERE referred_by=? AND referral_rewarded=1", (tg_id,)
).fetchone()["c"]
return {
"ref_code": user["ref_code"] if user else None,
"bonus_days_pending": user["bonus_days_pending"] if user else 0,
"referred_count": count,
}
def get_user_by_token(token: str):
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE token=?", (token,)).fetchone()
return dict(row) if row else None
def create_subscription(tg_id: int, node: str, plan_days: int, plan_code: str, source: str = "bot", client_uuid: str | None = None):
def create_subscription(tg_id: int, node: str, plan_days: int, plan_code: str, source: str = "bot", client_uuid: str | None = None, traffic_limit: int | None = None):
import uuid as uuidlib
import config
cid = client_uuid or str(uuidlib.uuid4())
created = datetime.datetime.utcnow()
expires = created + datetime.timedelta(days=plan_days)
with get_conn() as conn:
is_first = conn.execute("SELECT 1 FROM subscriptions WHERE tg_id=?", (tg_id,)).fetchone() is None
urow = conn.execute(
"SELECT referred_by, referral_rewarded, bonus_days_pending FROM users WHERE tg_id=?", (tg_id,)
).fetchone()
pending = urow["bonus_days_pending"] if urow else 0
if pending:
expires += datetime.timedelta(days=pending)
conn.execute("UPDATE users SET bonus_days_pending=0 WHERE tg_id=?", (tg_id,))
conn.execute(
"INSERT INTO subscriptions (uuid, tg_id, node, plan, created_at, expires_at, active, source) "
"VALUES (?,?,?,?,?,?,1,?)",
(cid, tg_id, node, plan_code, created.isoformat(), expires.isoformat(), source),
"INSERT INTO subscriptions (uuid, tg_id, node, plan, created_at, expires_at, active, source, traffic_limit) "
"VALUES (?,?,?,?,?,?,1,?,?)",
(cid, tg_id, node, plan_code, created.isoformat(), expires.isoformat(), source, traffic_limit),
)
return {"uuid": cid, "tg_id": tg_id, "node": node, "plan": plan_code, "expires_at": expires.isoformat()}
if is_first and urow and urow["referred_by"] and not urow["referral_rewarded"] and config.REFERRAL_ENABLED:
conn.execute("UPDATE users SET referral_rewarded=1 WHERE tg_id=?", (tg_id,))
bonus = config.REFERRAL_BONUS_DAYS
_apply_bonus_days(conn, tg_id, bonus)
_apply_bonus_days(conn, urow["referred_by"], bonus)
expires_final = conn.execute("SELECT expires_at FROM subscriptions WHERE uuid=?", (cid,)).fetchone()["expires_at"]
return {"uuid": cid, "tg_id": tg_id, "node": node, "plan": plan_code, "expires_at": expires_final}
def list_active_subscriptions(tg_id: int | None = None, node: str | None = None):
q = "SELECT * FROM subscriptions WHERE active=1 AND expires_at > ?"
q = "SELECT * FROM subscriptions WHERE active=1 AND held_at IS NULL AND expires_at > ?"
params = [now_iso()]
if tg_id is not None:
q += " AND tg_id=?"
@ -597,6 +846,34 @@ def revoke_subscription(client_uuid: str):
conn.execute("UPDATE subscriptions SET active=0 WHERE uuid=?", (client_uuid,))
def hold_subscription(client_uuid: str) -> bool:
with get_conn() as conn:
cur = conn.execute(
"UPDATE subscriptions SET held_at=? WHERE uuid=? AND active=1 AND held_at IS NULL AND expires_at > ?",
(now_iso(), client_uuid, now_iso()),
)
return cur.rowcount > 0
def resume_subscription(client_uuid: str):
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM subscriptions WHERE uuid=? AND held_at IS NOT NULL", (client_uuid,)
).fetchone()
if not row:
return None
held_at = datetime.datetime.fromisoformat(row["held_at"])
shift = datetime.datetime.utcnow() - held_at
new_expires = (datetime.datetime.fromisoformat(row["expires_at"]) + shift).isoformat()
cur = conn.execute(
"UPDATE subscriptions SET expires_at=?, held_at=NULL WHERE uuid=? AND held_at IS NOT NULL",
(new_expires, client_uuid),
)
if cur.rowcount == 0:
return None
return get_subscription(client_uuid)
def get_subscription(client_uuid: str):
with get_conn() as conn:
row = conn.execute(
@ -607,6 +884,32 @@ def get_subscription(client_uuid: str):
return dict(row) if row else None
def list_users(q: str = "", limit: int = 200):
limit = max(1, min(int(limit), 1000))
q = (q or "").strip()
where = ""
params = [now_iso(), now_iso()]
if q:
if q.lstrip("-").isdigit():
where = "WHERE u.tg_id = ? OR instr(lower(COALESCE(u.username, '')), ?) > 0"
params += [int(q), q.lower()]
else:
where = "WHERE instr(lower(COALESCE(u.username, '')), ?) > 0"
params.append(q.lower().lstrip("@"))
params.append(limit)
query = (
"SELECT u.tg_id, u.username, u.created_at, "
"(SELECT COUNT(*) FROM subscriptions s WHERE s.tg_id=u.tg_id) AS subs_total, "
"(SELECT COUNT(*) FROM subscriptions s WHERE s.tg_id=u.tg_id AND s.active=1 AND s.held_at IS NULL AND s.expires_at > ?) AS subs_active, "
"(SELECT MAX(s.expires_at) FROM subscriptions s WHERE s.tg_id=u.tg_id AND s.active=1 AND s.held_at IS NULL AND s.expires_at > ?) AS active_until, "
"(SELECT COUNT(*) FROM devices d WHERE d.tg_id=u.tg_id) AS devices "
"FROM users u " + where + " ORDER BY u.created_at DESC LIMIT ?"
)
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
return [dict(r) for r in rows]
def get_user(tg_id: int):
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
@ -638,12 +941,16 @@ def stats():
total_subs = conn.execute("SELECT COUNT(*) c FROM subscriptions").fetchone()["c"]
gifts_created = conn.execute("SELECT COUNT(*) c FROM gift_codes").fetchone()["c"]
gifts_used = conn.execute("SELECT COUNT(*) c FROM gift_codes WHERE used_by IS NOT NULL").fetchone()["c"]
nodes_n = conn.execute("SELECT COUNT(*) c FROM nodes WHERE enabled=1").fetchone()["c"]
chains_n = conn.execute("SELECT COUNT(*) c FROM chains WHERE enabled=1").fetchone()["c"]
return {
"users": users_n,
"active_subscriptions": active_n,
"total_subscriptions": total_subs,
"gifts_created": gifts_created,
"gifts_used": gifts_used,
"nodes": nodes_n,
"chains": chains_n,
}
@ -680,6 +987,9 @@ def mark_payment_paid(payment_id: str):
)
if cur.rowcount == 0:
return None
row = conn.execute("SELECT tg_id, promo_code FROM payments WHERE id=?", (payment_id,)).fetchone()
if row and row["promo_code"]:
_consume_promo(conn, row["promo_code"], row["tg_id"])
return get_payment(payment_id)
@ -754,3 +1064,226 @@ def delete_device(device_id: int):
def set_user_hwid_limit(tg_id: int, limit: int | None):
with get_conn() as conn:
conn.execute("UPDATE users SET hwid_limit=? WHERE tg_id=?", (limit, tg_id))
def add_traffic_sample(client_uuid: str, raw_total: int) -> int:
with get_conn() as conn:
row = conn.execute(
"SELECT traffic_used, traffic_last_raw FROM subscriptions WHERE uuid=?", (client_uuid,)
).fetchone()
if not row:
return 0
last = row["traffic_last_raw"]
delta = raw_total - last if raw_total >= last else raw_total
used = row["traffic_used"] + max(delta, 0)
conn.execute(
"UPDATE subscriptions SET traffic_used=?, traffic_last_raw=? WHERE uuid=?",
(used, raw_total, client_uuid),
)
return used
def set_traffic_limit(client_uuid: str, limit_bytes: int | None):
with get_conn() as conn:
conn.execute("UPDATE subscriptions SET traffic_limit=? WHERE uuid=?", (limit_bytes, client_uuid))
conn.execute(
"UPDATE subscriptions SET active=1, limit_hit_at=NULL "
"WHERE uuid=? AND limit_hit_at IS NOT NULL AND expires_at > ? "
"AND (traffic_limit IS NULL OR traffic_limit <= 0 OR traffic_used < traffic_limit)",
(client_uuid, now_iso()),
)
def list_over_limit():
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM subscriptions WHERE active=1 AND traffic_limit IS NOT NULL AND traffic_limit > 0 "
"AND traffic_used >= traffic_limit"
).fetchall()
return [dict(r) for r in rows]
def mark_limit_hit(client_uuid: str):
with get_conn() as conn:
conn.execute(
"UPDATE subscriptions SET active=0, limit_hit_at=? WHERE uuid=? AND active=1",
(now_iso(), client_uuid),
)
def reset_traffic_counter(client_uuid: str) -> bool:
with get_conn() as conn:
conn.execute(
"UPDATE subscriptions SET traffic_used=0, traffic_last_raw=0 WHERE uuid=?", (client_uuid,)
)
cur = conn.execute(
"UPDATE subscriptions SET active=1, limit_hit_at=NULL "
"WHERE uuid=? AND limit_hit_at IS NOT NULL AND expires_at > ?",
(client_uuid, now_iso()),
)
return cur.rowcount > 0
def list_subscriptions_expiring(within_hours: int):
now = datetime.datetime.utcnow()
until = (now + datetime.timedelta(hours=within_hours)).isoformat()
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM subscriptions WHERE active=1 AND held_at IS NULL AND expires_at > ? AND expires_at <= ?",
(now.isoformat(), until),
).fetchall()
return [dict(r) for r in rows]
def notice_already_sent(sub_uuid: str, kind: str) -> bool:
with get_conn() as conn:
return conn.execute(
"SELECT 1 FROM sub_notices WHERE sub_uuid=? AND kind=?", (sub_uuid, kind)
).fetchone() is not None
def mark_notice_sent(sub_uuid: str, kind: str):
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO sub_notices (sub_uuid, kind, sent_at) VALUES (?,?,?)",
(sub_uuid, kind, now_iso()),
)
def claim_trial(tg_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("UPDATE users SET trial_used=1 WHERE tg_id=? AND trial_used=0", (tg_id,))
return cur.rowcount > 0
def trial_available(tg_id: int) -> bool:
with get_conn() as conn:
row = conn.execute("SELECT trial_used FROM users WHERE tg_id=?", (tg_id,)).fetchone()
if not row or row["trial_used"]:
return False
has_sub = conn.execute("SELECT 1 FROM subscriptions WHERE tg_id=?", (tg_id,)).fetchone()
return has_sub is None
PROMO_KINDS = ("percent", "fixed", "days")
def create_promo(code: str, kind: str, value: int, max_uses: int | None = None, expires_at: str | None = None):
code = code.strip().upper()
if not code or len(code) > 40 or not all(c.isalnum() or c in "-_" for c in code):
raise ValueError("код: только буквы, цифры, - и _, до 40 символов")
if kind not in PROMO_KINDS:
raise ValueError("тип промокода: percent, fixed или days")
value = int(value)
if value <= 0 or (kind == "percent" and value > 100):
raise ValueError("значение должно быть больше нуля, для процентов не больше 100")
if max_uses is not None and int(max_uses) <= 0:
max_uses = None
with get_conn() as conn:
if conn.execute("SELECT 1 FROM promo_codes WHERE code=?", (code,)).fetchone():
raise ValueError("такой промокод уже есть")
conn.execute(
"INSERT INTO promo_codes (code, kind, value, max_uses, expires_at, created_at) VALUES (?,?,?,?,?,?)",
(code, kind, value, max_uses, expires_at or None, now_iso()),
)
return get_promo(code)
def get_promo(code: str):
with get_conn() as conn:
row = conn.execute("SELECT * FROM promo_codes WHERE code=?", ((code or "").strip().upper(),)).fetchone()
return dict(row) if row else None
def list_promos():
with get_conn() as conn:
rows = conn.execute("SELECT * FROM promo_codes ORDER BY created_at DESC").fetchall()
return [dict(r) for r in rows]
def set_promo_active(code: str, active: bool):
with get_conn() as conn:
conn.execute("UPDATE promo_codes SET active=? WHERE code=?", (1 if active else 0, code.strip().upper()))
def delete_promo(code: str):
with get_conn() as conn:
conn.execute("DELETE FROM promo_codes WHERE code=?", (code.strip().upper(),))
def validate_promo(code: str, tg_id: int):
promo = get_promo(code)
if not promo or not promo["active"]:
return None, "not_found"
if promo["expires_at"] and promo["expires_at"] <= now_iso():
return None, "expired"
if promo["max_uses"] is not None and promo["used_count"] >= promo["max_uses"]:
return None, "exhausted"
with get_conn() as conn:
used = conn.execute(
"SELECT 1 FROM promo_uses WHERE code=? AND tg_id=?", (promo["code"], tg_id)
).fetchone()
if used:
return None, "already_used"
return promo, None
def discounted_price(price: int, promo: dict | None) -> int:
if not promo or promo["kind"] == "days":
return price
if promo["kind"] == "percent":
return max(price - price * promo["value"] // 100, 0)
return max(price - promo["value"], 0)
def _consume_promo(conn, code: str, tg_id: int) -> bool:
cur = conn.execute(
"INSERT OR IGNORE INTO promo_uses (code, tg_id, used_at) VALUES (?,?,?)", (code, tg_id, now_iso())
)
if cur.rowcount == 0:
return False
conn.execute("UPDATE promo_codes SET used_count=used_count+1 WHERE code=?", (code,))
return True
def redeem_days_promo(code: str, tg_id: int):
promo, err = validate_promo(code, tg_id)
if err:
return None, err
if promo["kind"] != "days":
return None, "not_days"
with get_conn() as conn:
if not _consume_promo(conn, promo["code"], tg_id):
return None, "already_used"
_apply_bonus_days(conn, tg_id, promo["value"])
return promo, None
def set_promo_pending(tg_id: int, code: str | None):
with get_conn() as conn:
conn.execute("UPDATE users SET promo_pending=? WHERE tg_id=?", (code, tg_id))
def get_pending_promo(tg_id: int):
with get_conn() as conn:
row = conn.execute("SELECT promo_pending FROM users WHERE tg_id=?", (tg_id,)).fetchone()
if not row or not row["promo_pending"]:
return None
promo, err = validate_promo(row["promo_pending"], tg_id)
if err:
set_promo_pending(tg_id, None)
return None
return promo
def set_payment_promo(payment_id: str, promo_code: str | None, original_amount: int | None):
with get_conn() as conn:
conn.execute(
"UPDATE payments SET promo_code=?, original_amount=? WHERE id=?",
(promo_code, original_amount, payment_id),
)
def consume_promo(code: str, tg_id: int) -> bool:
with get_conn() as conn:
return _consume_promo(conn, code, tg_id)

94
features.py Normal file
View file

@ -0,0 +1,94 @@
import db
import nodeprov
import settings
import xray_manager
from settings import GB
REMINDER_STAGES = (("3d", 72), ("1d", 24))
def format_bytes(n: int) -> str:
v = float(n)
for unit in ["Б", "КБ", "МБ", "ГБ", "ТБ"]:
if v < 1024 or unit == "ТБ":
return f"{int(v)} {unit}" if unit == "Б" else f"{v:.1f} {unit}"
v /= 1024
return f"{v:.1f} ТБ"
def collect_all_stats() -> dict:
all_stats = dict(xray_manager.query_stats())
for node in db.list_nodes():
if node["kind"] != "managed" or node["status"] != "active":
continue
try:
remote = nodeprov.remote_query_stats(node)
except Exception:
remote = {}
for key, value in remote.items():
if key in all_stats:
all_stats[key] = {
"up": all_stats[key]["up"] + value["up"],
"down": all_stats[key]["down"] + value["down"],
}
else:
all_stats[key] = value
return all_stats
def update_traffic_and_find_exceeded() -> list:
stats = collect_all_stats()
if not stats:
return []
for sub in db.list_active_subscriptions():
row = stats.get(sub["uuid"])
if row:
db.add_traffic_sample(sub["uuid"], row["up"] + row["down"])
exceeded = db.list_over_limit()
for sub in exceeded:
db.mark_limit_hit(sub["uuid"])
return exceeded
def reminders_due() -> list:
due = []
for stage, hours in REMINDER_STAGES:
for sub in db.list_subscriptions_expiring(hours):
kind = f"{stage}:{sub['expires_at'][:10]}"
if db.notice_already_sent(sub["uuid"], kind):
continue
due.append((sub, kind, stage))
seen = set()
result = []
for sub, kind, stage in sorted(due, key=lambda x: x[2]):
if sub["uuid"] in seen:
continue
seen.add(sub["uuid"])
result.append((sub, kind, stage))
return result
def mark_stage_sent(sub_uuid: str, expires_at: str):
for stage, _ in REMINDER_STAGES:
db.mark_notice_sent(sub_uuid, f"{stage}:{expires_at[:10]}")
def pick_trial_node():
features = settings.get_features()
wanted = features["trial_node"]
if wanted:
node = db.get_node(wanted)
if node and node["enabled"] and node["status"] == "active":
return node
for node in db.list_nodes(enabled_only=True):
if node["status"] == "active":
return node
return None
def traffic_text(sub: dict) -> str:
used = sub.get("traffic_used") or 0
limit = sub.get("traffic_limit") or 0
if limit > 0:
return f"{format_bytes(used)} из {format_bytes(limit)}"
return f"{format_bytes(used)}, без лимита"

View file

@ -2,8 +2,9 @@
set -e
set -o pipefail
LAB_URL="https://lab.savsis.xyz/savsisbtw/mbs-panel.git"
MIRROR_URL="https://api.savsis.xyz/git/mbs-panel.git/"
REPO_URL="https://github.com/devsavsis/mbs-panel.git"
REPO_URL="https://github.com/savsisbtw/mbs-panel.git"
APP_DIR="/opt/mbs-panel"
WEBROOT="/var/www/certbot"
@ -47,6 +48,7 @@ case "$ID" in
*) echo "тестировалось на Ubuntu 22/24 и Debian 11/12, но пробуем всё равно на $PRETTY_NAME" ;;
esac
BRAND_NAME=$(ask "Название твоего сервиса (видят клиенты — сайт/бот/подписка)" "MBS Panel")
PANEL_DOMAIN=$(ask "Домен панели (админка)" "")
SUB_DOMAIN=$(ask "Домен подписок" "")
SITE_DOMAIN=$(ask "Домен сайта (для CORS и ссылок в боте)" "$PANEL_DOMAIN")
@ -83,11 +85,17 @@ echo "клонируем репозиторий в $APP_DIR..."
if [ -d "$APP_DIR/.git" ]; then
retry git -C "$APP_DIR" pull --quiet
else
if ! git clone --quiet "$MIRROR_URL" "$APP_DIR" 2>/dev/null; then
echo "зеркало недоступно, клонирую напрямую с GitHub..."
retry git clone --quiet "$REPO_URL" "$APP_DIR"
git -C "$APP_DIR" remote set-url origin "$MIRROR_URL"
if ! git clone --quiet "$LAB_URL" "$APP_DIR" 2>/dev/null; then
echo "lab.savsis.xyz недоступен, пробую зеркало..."
rm -rf "$APP_DIR"
if ! git clone --quiet "$MIRROR_URL" "$APP_DIR" 2>/dev/null; then
echo "зеркало недоступно, клонирую напрямую с GitHub..."
rm -rf "$APP_DIR"
retry git clone --quiet "$REPO_URL" "$APP_DIR"
fi
git -C "$APP_DIR" remote set-url origin "$LAB_URL"
fi
git -C "$APP_DIR" remote add mirror "$MIRROR_URL" 2>/dev/null || true
git -C "$APP_DIR" remote add github "$REPO_URL" 2>/dev/null || true
fi
@ -105,6 +113,7 @@ XRAY_SHORT_ID_GRPC=$(openssl rand -hex 8)
XRAY_SHORT_ID_XHTTP=$(openssl rand -hex 8)
cat > "$APP_DIR/.env" << ENVEOF
BRAND_NAME=$BRAND_NAME
BOT_TOKEN=$BOT_TOKEN
BOT_USERNAME=$BOT_USERNAME
ADMIN_IDS=$ADMIN_IDS
@ -384,6 +393,8 @@ else API_WORKERS=$CPU_COUNT
fi
cp "$APP_DIR/systemd/mbs-bot.service" /etc/systemd/system/mbs-bot.service
sed "s/__WORKERS__/$API_WORKERS/" "$APP_DIR/systemd/mbs-api.service" > /etc/systemd/system/mbs-api.service
cp "$APP_DIR/systemd/mbs-autoupdate.service" /etc/systemd/system/mbs-autoupdate.service
cp "$APP_DIR/systemd/mbs-autoupdate.timer" /etc/systemd/system/mbs-autoupdate.timer
systemctl daemon-reload
echo "ставим CLI mbs..."
@ -405,6 +416,7 @@ systemctl reload nginx
systemctl enable --now xray
systemctl enable --now mbs-bot
systemctl enable --now mbs-api
systemctl enable --now mbs-autoupdate.timer
sleep 2
@ -418,6 +430,7 @@ echo "== готово =="
echo "Панель: https://$PANEL_DOMAIN"
echo "Пароль: $ADMIN_PANEL_PASSWORD (сменить: mbs pass)"
echo "Подписки: https://$SUB_DOMAIN"
echo "Сайт: https://$SUB_DOMAIN (готовый лендинг, название/тарифы уже подставлены — правь site/index.html под себя, если нужно)"
echo "Нода: $DE1_ADDRESS"
echo
echo "статус сервисов:"

View file

@ -155,20 +155,20 @@
<a href="#features">Возможности</a>
<a href="#comparison">Сравнение</a>
<a href="#install">Установка</a>
<a href="https://github.com/devsavsis/mbs-panel" target="_blank">GitHub</a>
<a href="https://github.com/savsisbtw/mbs-panel" target="_blank">GitHub</a>
</nav>
</header>
<section class="hero" style="padding-bottom:0">
<div class="badges reveal">
<img src="https://img.shields.io/github/actions/workflow/status/devsavsis/mbs-panel/ci.yml?label=CI&style=flat-square&color=7c6cf0" alt="CI">
<img src="https://img.shields.io/github/license/devsavsis/mbs-panel?style=flat-square&color=7c6cf0" alt="License">
<img src="https://img.shields.io/github/stars/devsavsis/mbs-panel?style=flat-square&color=7c6cf0" alt="Stars">
<img src="https://img.shields.io/github/actions/workflow/status/savsisbtw/mbs-panel/ci.yml?label=CI&style=flat-square&color=7c6cf0" alt="CI">
<img src="https://img.shields.io/github/license/savsisbtw/mbs-panel?style=flat-square&color=7c6cf0" alt="License">
<img src="https://img.shields.io/github/stars/savsisbtw/mbs-panel?style=flat-square&color=7c6cf0" alt="Stars">
</div>
<h1 class="reveal">VPN-панель, которую<br>можно <span class="accent">понять за вечер</span></h1>
<p class="reveal">Xray-core, Reality, Hysteria2, Telegram-бот и платежи — в одном небольшом репозитории на Python. Без Docker, без чужой закрытой панели под капотом, без разбора чужого фреймворка перед первым коммитом.</p>
<div class="hero-ctas reveal">
<a class="btn" href="https://github.com/devsavsis/mbs-panel" target="_blank">Смотреть на GitHub</a>
<a class="btn" href="https://github.com/savsisbtw/mbs-panel" target="_blank">Смотреть на GitHub</a>
<a class="btn ghost" href="#install">Установка</a>
</div>
<div class="install reveal">
@ -230,6 +230,14 @@
<tr><td>Установка</td><td class="us">1 bash-команда</td><td>Docker Compose</td><td>bash-скрипт / Docker</td></tr>
<tr><td>Backup & Restore в самой панели</td><td class="us yes">✓</td><td class="no">community tools</td><td class="no">community tools</td></tr>
<tr><td>Xray config pre-flight проверка</td><td class="us yes">✓</td><td class="yes">full-featured</td><td class="no">только JSON-синтаксис</td></tr>
<tr><td>Мультиадминство (раздельные логины)</td><td class="us yes">✓</td><td class="no">—</td><td class="no">в разработке</td></tr>
<tr><td>2FA на вход в админку</td><td class="us yes">✓ TOTP</td><td>есть (passkeys/OAuth)</td><td class="no">—</td></tr>
<tr><td>Rate-limit на вход/2FA-код</td><td class="us yes">✓</td><td class="no">не документировано</td><td class="no">не документировано</td></tr>
<tr><td>Свой путь входа в админку</td><td class="us yes">✓</td><td class="yes">заявлено</td><td class="no">—</td></tr>
<tr><td>Цепочки серверов (клиент → A → B → интернет)</td><td class="us yes">✓ мышкой, с замером задержки</td><td>руками в конфиге Xray</td><td>руками в конфиге Xray</td></tr>
<tr><td>Сортировка нод мышкой</td><td class="us yes">✓</td><td class="yes">Web UI</td><td class="no">только через конфиг Xray</td></tr>
<tr><td>Пауза подписки без потери оплаченных дней</td><td class="us yes">✓</td><td class="no">—</td><td class="yes">есть</td></tr>
<tr><td>Исходящие вебхуки (пользователи + ноды)</td><td class="us yes">✓</td><td class="yes">✓</td><td class="no">только пользователи</td></tr>
<tr><td>Лицензия</td><td class="us">MIT</td><td>AGPL-3.0</td><td>AGPL-3.0</td></tr>
</tbody>
</table>
@ -289,16 +297,16 @@
<h2>Открытый исходник, MIT</h2>
<p class="section-sub">Изначально писалось под конкретный проект — получилось достаточно универсально, чтобы выложить как есть. Issues и PR приветствуются.</p>
<div class="cta-row">
<a class="btn" href="https://github.com/devsavsis/mbs-panel" target="_blank">github.com/devsavsis/mbs-panel</a>
<a class="btn ghost" href="https://github.com/devsavsis/mbs-panel#readme" target="_blank">Читать README</a>
<a class="btn" href="https://github.com/savsisbtw/mbs-panel" target="_blank">github.com/savsisbtw/mbs-panel</a>
<a class="btn ghost" href="https://github.com/savsisbtw/mbs-panel#readme" target="_blank">Читать README</a>
</div>
</div>
</div>
<footer>
<div class="wrap">
<div>MBS Panel — made by <a href="https://github.com/devsavsis" target="_blank">savsis</a></div>
<div><a href="https://github.com/devsavsis/mbs-panel/blob/main/LICENSE" target="_blank">MIT License</a></div>
<div>MBS Panel — made by <a href="https://github.com/savsisbtw" target="_blank">savsis</a></div>
<div><a href="https://github.com/savsisbtw/mbs-panel/blob/main/LICENSE" target="_blank">MIT License</a></div>
</div>
</footer>

108
legal.py Normal file
View file

@ -0,0 +1,108 @@
import html
import os
import config
ENV_PATH = os.path.join(config.BASE_DIR, ".env")
SITE_DIR = os.path.join(config.BASE_DIR, "site")
FIELD_KEYS = ["LEGAL_NAME", "LEGAL_INN", "REFUND_HOURS", "SUPPORT_CONTACT", "SUPPORT_EMAIL", "OFFER_EFFECTIVE_DATE"]
def read_env_vars(keys: list) -> dict:
result = {key: None for key in keys}
if not os.path.exists(ENV_PATH):
return result
wanted = set(keys)
with open(ENV_PATH, encoding="utf-8") as f:
for line in f:
line = line.strip()
if "=" not in line or line.startswith("#"):
continue
key, _, value = line.partition("=")
if key in wanted and result[key] is None:
result[key] = value
return result
def read_env_var(key: str, default: str = "") -> str:
value = read_env_vars([key])[key]
return default if value is None else value
def update_env_var(key: str, value: str):
lines = []
if os.path.exists(ENV_PATH):
with open(ENV_PATH, encoding="utf-8") as f:
lines = f.readlines()
found = False
for i, line in enumerate(lines):
if line.strip().startswith(f"{key}="):
lines[i] = f"{key}={value}\n"
found = True
break
if not found:
lines.append(f"{key}={value}\n")
with open(ENV_PATH, "w", encoding="utf-8") as f:
f.writelines(lines)
def get_settings() -> dict:
raw = read_env_vars(FIELD_KEYS)
return {key: (raw[key] or "") for key in FIELD_KEYS}
def _fallback(label: str) -> str:
return f'<span class="fill">{html.escape(label)}</span>'
def _field(value: str, fallback_label: str) -> str:
return html.escape(value) if value else _fallback(fallback_label)
def live_bot_username() -> str:
raw = read_env_var("BOT_USERNAME", "")
return raw.strip() if raw.strip() else config.BOT_USERNAME
def live_brand_name() -> str:
raw = read_env_var("BRAND_NAME", "")
return raw.strip() if raw.strip() else config.BRAND_NAME
def render(template_name: str) -> str:
path = os.path.join(SITE_DIR, template_name)
with open(path, encoding="utf-8") as f:
content = f.read()
s = get_settings()
bot_username = live_bot_username()
replacements = {
"EFFECTIVE_DATE": _field(s["OFFER_EFFECTIVE_DATE"], "дата не указана"),
"LEGAL_NAME": _field(s["LEGAL_NAME"], "название/ФИО не указано"),
"INN": _field(s["LEGAL_INN"], "ИНН не указан"),
"BOT_USERNAME": _field(f"@{bot_username}" if bot_username else "", "бот не указан"),
"REFUND_HOURS": html.escape(s["REFUND_HOURS"]) if s["REFUND_HOURS"] else "24",
"SUPPORT_CONTACT": _field(s["SUPPORT_CONTACT"], "контакт не указан"),
"SUPPORT_EMAIL": _field(s["SUPPORT_EMAIL"], "email не указан"),
"BRAND_NAME": html.escape(live_brand_name()),
}
for token, value in replacements.items():
content = content.replace("{{" + token + "}}", value)
return content
def render_site_page(template_name: str) -> str:
path = os.path.join(SITE_DIR, template_name)
with open(path, encoding="utf-8") as f:
content = f.read()
replacements = {
"BRAND_NAME": html.escape(live_brand_name()),
"SITE_DOMAIN": html.escape(config.SITE_DOMAIN),
"SUB_DOMAIN": html.escape(config.SUB_DOMAIN),
"BOT_USERNAME": html.escape(live_bot_username()),
}
for token, value in replacements.items():
content = content.replace("{{" + token + "}}", value)
return content

View file

@ -89,6 +89,17 @@ def vless_uris_for_node(client_uuid: str, node: dict, base_name: str) -> list[st
)]
def chain_remark(entry_node: dict, exit_node: dict) -> str:
return f"{display_name(entry_node['label'])} → {display_name(exit_node['label'])}"
def chain_uri(client_uuid: str, entry_node: dict, chain: dict, remark: str) -> str:
return _tcp_reality_uri(
client_uuid, entry_node["address"], chain["port"], entry_node["public_key"],
chain["short_id"], entry_node["sni"], "xtls-rprx-vision", remark,
)
def build_subscription_text(subs: list[dict]) -> str:
import db
@ -99,6 +110,10 @@ def build_subscription_text(subs: list[dict]) -> str:
best_by_node[s["node"]] = s
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
chains_by_entry = {}
for chain in db.list_chains(enabled_only=True):
chains_by_entry.setdefault(chain["entry_node"], []).append(chain)
lines = []
for node_code, s in best_by_node.items():
node = nodes_by_code.get(node_code)
@ -109,5 +124,10 @@ def build_subscription_text(subs: list[dict]) -> str:
hy = hysteria_uri_for_node(node, base_name)
if hy:
lines.append(hy)
for chain in chains_by_entry.get(node_code, []):
exit_node = nodes_by_code.get(chain["exit_node"])
if not node["enabled"] or not exit_node or not exit_node["enabled"]:
continue
lines.append(chain_uri(s["uuid"], node, chain, chain_remark(node, exit_node)))
raw = "\n".join(lines)
return base64.b64encode(raw.encode()).decode()

221
mbs
View file

@ -13,7 +13,11 @@ mbs — управление MBS Panel
mbs restart перезапустить всё (bot, api, xray, reload nginx)
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
mbs domain показать текущий домен панели
mbs update обновить код (зеркало api.savsis.xyz, потом GitHub) и перезапустить (не трогает .env и базу)
mbs backup полная копия панели (база, .env, ручные правки, конфиг Xray) в /root/mbs-backups
mbs update [ссылка] обновить код и перезапустить (не трогает .env и базу, перед этим сам делает копию). Без ссылки: своё зеркало -> lab.savsis.xyz -> api.savsis.xyz -> GitHub.
Со ссылкой на git-репозиторий (зеркало) — берёт обновление оттуда, один раз
mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, которое mbs update проверяет первым
mbs autoupdate [on|off] включить / выключить ежедневное автообновление (04:00, перед ним всегда копия), без аргумента — показать состояние
EOF
}
@ -55,34 +59,175 @@ cmd_domain() {
grep "^PANEL_DOMAIN=" "$ENV_FILE"
}
cmd_update() {
cd "$APP_DIR"
echo "проверяю обновления..."
local remote="origin"
if ! git fetch --quiet origin main 2>/dev/null; then
if git remote | grep -q '^github$'; then
echo "зеркало недоступно, пробую github..."
if ! git fetch --quiet github main 2>/dev/null; then
echo "не удалось получить обновления ни с зеркала, ни с github"
MIRROR_FILE="$APP_DIR/.update_mirror"
BACKUP_DIR="${MBS_BACKUP_DIR:-/root/mbs-backups}"
XRAY_CONFIG="/usr/local/etc/xray/config.json"
BACKUP_FILE=""
UPDATE_STASHED=0
snapshot_backup() {
local stamp dbsnap file
local -a targs=(--exclude=venv --exclude=__pycache__)
stamp=$(date +%Y%m%d-%H%M%S)
file="$BACKUP_DIR/mbs-$1-$stamp.tar.gz"
dbsnap="$APP_DIR/.mbs.db.snapshot"
mkdir -p "$BACKUP_DIR" || return 1
chmod 700 "$BACKUP_DIR"
rm -f "$dbsnap"
if [ -f "$APP_DIR/mbs.db" ] && [ -x "$APP_DIR/venv/bin/python" ] && \
"$APP_DIR/venv/bin/python" -c "import sqlite3,sys; s=sqlite3.connect(sys.argv[1]); d=sqlite3.connect(sys.argv[2]); s.backup(d); d.close(); s.close()" "$APP_DIR/mbs.db" "$dbsnap" 2>/dev/null; then
targs+=(--exclude=mbs.db --exclude=mbs.db-wal --exclude=mbs.db-shm "--transform=s#\.mbs\.db\.snapshot#mbs.db#")
fi
if [ -f "$XRAY_CONFIG" ]; then
tar czf "$file" "${targs[@]}" "$APP_DIR" "$XRAY_CONFIG" 2>/dev/null
else
tar czf "$file" "${targs[@]}" "$APP_DIR" 2>/dev/null
fi
local rc=$?
rm -f "$dbsnap"
if [ $rc -ne 0 ] || [ ! -s "$file" ]; then
rm -f "$file"
return 1
fi
chmod 600 "$file"
ls -1t "$BACKUP_DIR"/mbs-*.tar.gz 2>/dev/null | tail -n +6 | while read -r old; do rm -f "$old"; done
BACKUP_FILE="$file"
return 0
}
cmd_backup() {
if snapshot_backup manual; then
echo "копия сохранена: $BACKUP_FILE"
echo "внутри: база (консистентный снапшот), .env, код с твоими правками, конфиг Xray. Хранится 5 последних."
else
echo "не удалось сделать копию в $BACKUP_DIR (место на диске?)"
return 1
fi
}
valid_url() {
case "$1" in
https://*|http://*|ssh://*|git@*:*) ;;
*) return 1 ;;
esac
case "$1" in
*[[:space:]]*) return 1 ;;
esac
return 0
}
fetch_url() {
git -c protocol.ext.allow=never fetch --quiet -- "$1" main 2>/dev/null
}
restore_stash() {
if [ "$UPDATE_STASHED" = "1" ]; then
UPDATE_STASHED=0
if git stash pop --quiet 2>/dev/null; then
echo "ручные правки вернул на место"
else
echo "ручные правки остались в git stash (git stash list)"
fi
fi
}
cmd_mirror() {
local url="$1"
case "$url" in
"")
if [ -f "$MIRROR_FILE" ]; then
echo "своё зеркало для обновлений: $(head -n 1 "$MIRROR_FILE")"
else
echo "своё зеркало не задано — mbs update берёт api.savsis.xyz, потом GitHub"
fi
;;
off|clear)
rm -f "$MIRROR_FILE"
echo "своё зеркало убрано"
;;
*)
if ! valid_url "$url"; then
echo "это не похоже на ссылку на git-репозиторий (нужна https://..., ssh://... или git@хост:путь)"
return 1
fi
remote="github"
printf '%s\n' "$url" > "$MIRROR_FILE"
echo "зеркало запомнил: $url — mbs update теперь проверяет его первым"
;;
esac
}
cmd_update() {
local arg_url="$1" target="" saved="" before="" stamp="" patch=""
cd "$APP_DIR"
echo "проверяю обновления..."
if [ -n "$arg_url" ]; then
if ! valid_url "$arg_url"; then
echo "это не похоже на ссылку на git-репозиторий (нужна https://..., ssh://... или git@хост:путь)"
return 1
fi
if ! fetch_url "$arg_url"; then
echo "не удалось получить обновления по ссылке: $arg_url"
return 1
fi
target=$(git rev-parse FETCH_HEAD)
echo "источник: $arg_url"
else
if [ -f "$MIRROR_FILE" ]; then
saved=$(head -n 1 "$MIRROR_FILE" | tr -d '[:space:]')
fi
if [ -n "$saved" ] && valid_url "$saved" && fetch_url "$saved"; then
target=$(git rev-parse FETCH_HEAD)
echo "источник: своё зеркало $saved"
elif git fetch --quiet origin main 2>/dev/null; then
target=$(git rev-parse origin/main)
elif git remote | grep -q '^mirror$' && git fetch --quiet mirror main 2>/dev/null; then
echo "lab.savsis.xyz недоступен, взял с зеркала..."
target=$(git rev-parse mirror/main)
elif git remote | grep -q '^github$' && git fetch --quiet github main 2>/dev/null; then
echo "зеркало недоступно, взял с github..."
target=$(git rev-parse github/main)
else
echo "не удалось получить обновления"
echo "не удалось получить обновления ни с зеркала, ни с github"
return 1
fi
fi
local before after
before=$(git rev-parse HEAD)
after=$(git rev-parse "$remote/main")
if [ "$before" = "$after" ]; then
if [ "$before" = "$target" ]; then
echo "уже последняя версия ($before)."
return 0
fi
if git merge-base --is-ancestor "$target" "$before" 2>/dev/null; then
echo "на сервере версия новее, чем в источнике ($before) — ничего не делаю."
return 0
fi
echo "текущая: $before"
echo "новая: $after"
if ! git merge --ff-only "$remote/main" --quiet; then
echo "не вышло быстро обновиться (похоже, файлы правились вручную на сервере) — разберись руками: git status"
echo "новая: $target"
if ! snapshot_backup before-update; then
echo "не вышло сделать резервную копию в $BACKUP_DIR — обновление не начинаю, чтобы ничего не потерять (место на диске?)"
return 1
fi
echo "резервная копия перед обновлением: $BACKUP_FILE"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
stamp=$(date +%Y%m%d-%H%M%S)
mkdir -p "$APP_DIR/local-changes"
patch="$APP_DIR/local-changes/local-changes-$stamp.patch"
git diff HEAD > "$patch"
if GIT_AUTHOR_NAME=mbs GIT_AUTHOR_EMAIL=mbs@localhost GIT_COMMITTER_NAME=mbs GIT_COMMITTER_EMAIL=mbs@localhost git stash push --quiet -m "mbs-update-$stamp"; then
UPDATE_STASHED=1
echo "на сервере были ручные правки — убрал в сторону, ничего не потеряно:"
echo " патч: $patch"
echo " stash: git stash list (вернуть обратно: git stash pop)"
else
echo "не вышло спрятать ручные правки, остановился — разберись руками: git status"
return 1
fi
fi
if ! git merge --ff-only "$target" --quiet 2>/dev/null; then
echo "не вышло быстро обновиться (на сервере есть свои коммиты, которых нет в источнике) — разберись руками: git log"
restore_stash
return 1
fi
echo "обновляю зависимости..."
@ -92,6 +237,7 @@ cmd_update() {
echo "новый код не проходит проверку, откатываюсь на $before..."
git reset --hard "$before" --quiet
venv/bin/pip install --quiet -r requirements.txt
restore_stash
return 1
fi
echo "обновляю сам CLI..."
@ -115,22 +261,59 @@ cmd_update() {
sleep 2
if systemctl is-active --quiet mbs-bot && systemctl is-active --quiet mbs-api; then
echo "обновлено: $before -> $(git rev-parse --short HEAD)"
if [ "$UPDATE_STASHED" = "1" ]; then
echo "твои ручные правки лежат в git stash и в $patch — если они нужны, посмотри git stash show -p"
fi
echo "если что-то пошло не так: копия $BACKUP_FILE (распаковать: tar xzf файл -C /)"
if [ -n "$arg_url" ]; then
echo "чтобы всегда обновляться с этого зеркала: mbs mirror $arg_url"
fi
else
echo "сервисы не поднялись после обновления, откатываюсь на $before..."
git reset --hard "$before" --quiet
venv/bin/pip install --quiet -r requirements.txt
systemctl restart mbs-bot mbs-api
restore_stash
echo "откачено обратно на $before"
return 1
fi
}
cmd_autoupdate() {
case "$1" in
on)
systemctl enable --now mbs-autoupdate.timer
echo "автообновление включено: каждый день около 04:00, перед обновлением делается копия"
;;
off)
systemctl disable --now mbs-autoupdate.timer
echo "автообновление выключено"
;;
"")
if systemctl is-enabled --quiet mbs-autoupdate.timer 2>/dev/null; then
echo "автообновление включено"
systemctl list-timers mbs-autoupdate.timer --no-pager 2>/dev/null | head -n 2
else
echo "автообновление выключено (mbs autoupdate on — включить)"
fi
;;
*) echo "mbs autoupdate [on|off]"; exit 1 ;;
esac
}
main() {
case "$1" in
pass) cmd_pass "$2" ;;
status) cmd_status ;;
restart) cmd_restart ;;
logs) cmd_logs "$2" ;;
domain) cmd_domain ;;
update) cmd_update ;;
update) cmd_update "$2" ;;
mirror) cmd_mirror "$2" ;;
backup) cmd_backup ;;
autoupdate) cmd_autoupdate "$2" ;;
*) usage ;;
esac
}
main "$@"; exit $?

View file

@ -1,3 +1,6 @@
import contextlib
import fcntl
import hashlib
import json
import secrets
import socket
@ -5,12 +8,12 @@ import subprocess
import paramiko
import chains
from config import PANEL_DOMAIN
MGMT_KEY_PATH = "/root/.ssh/mbs_nodes_ed25519"
MGMT_KNOWN_HOSTS_PATH = "/root/.ssh/mbs_nodes_known_hosts"
LOCAL_TAGS = {"vless-tcp-reality", "vless-grpc-reality", "vless-xhttp-reality", "vless-ws-tls"}
TAG_FLOW = {"vless-tcp-reality": "xtls-rprx-vision"}
REMOTE_CONFIG_PATH = "/usr/local/etc/xray/config.json"
ONE_COMMAND_TEMPLATE = "bash <(curl -Ls https://{panel}/install/{token}.sh)"
@ -68,6 +71,28 @@ set -e
echo "== MBS Panel node install =="
export DEBIAN_FRONTEND=noninteractive
PREFLIGHT_FAIL=0
if ! {{ [ -f /usr/local/etc/xray/config.json ] && grep -q mbs-grpc /usr/local/etc/xray/config.json; }}; then
if [ -d /usr/local/bin/xray ]; then
echo "СТОП: /usr/local/bin/xray это каталог, на сервере уже стоит чужой прокси (Marzban-node и подобное)"
PREFLIGHT_FAIL=1
fi
if command -v docker >/dev/null 2>&1 && docker ps --format '{{{{.Names}}}}' 2>/dev/null | grep -qiE 'marzban|xray|remnawave|v2ray|hysteria'; then
echo "СТОП: в Docker на этом сервере уже крутится прокси"
PREFLIGHT_FAIL=1
fi
for p in {ports}; do
if ss -ltn 2>/dev/null | awk '{{print $4}}' | grep -qE "[:.]$p$"; then
echo "СТОП: порт $p уже занят другим процессом"
PREFLIGHT_FAIL=1
fi
done
fi
if [ "$PREFLIGHT_FAIL" = "1" ]; then
echo "Нужен чистый сервер. Ничего не установлено и не изменено, ключ панели не добавлен."
exit 1
fi
mkdir -p /root/.ssh
chmod 700 /root/.ssh
curl -Ls https://{panel}/mgmt-pubkey.txt >> /root/.ssh/authorized_keys
@ -88,9 +113,19 @@ XRAYCFG
command -v ufw >/dev/null 2>&1 && {{ ufw allow 22/tcp || true; {ufw_rules} }}
systemctl enable xray >/dev/null 2>&1 || true
systemctl restart xray
sleep 1
STATUS=$(systemctl is-active xray)
OK=0
for i in 1 2 3 4 5 6 7 8; do
sleep 1
if systemctl is-active --quiet xray && ss -ltn 2>/dev/null | awk '{{print $4}}' | grep -qE "[:.]{first_port}$"; then
OK=$((OK+1))
else
OK=0
fi
if [ "$OK" -ge 3 ]; then break; fi
done
if [ "$OK" -ge 3 ]; then STATUS=active; else STATUS=failed; fi
echo "xray status: $STATUS"
if [ "$STATUS" != "active" ]; then journalctl -u xray -n 15 --no-pager 2>/dev/null || true; fi
{hysteria_block}
MY_IP=$(curl -s https://api.ipify.org || echo unknown)
@ -231,9 +266,11 @@ def render_install_script(node: dict) -> str:
sni=node["sni"],
)
ports = " ".join(str(t["port"]) for t in transports)
return SELF_INSTALL_SCRIPT.format(
panel=PANEL_DOMAIN, token=node["provision_token"], config_json=config_json,
certbot_block=certbot_block, hysteria_block=hysteria_block, ufw_rules=ufw_rules,
ports=ports, first_port=transports[0]["port"],
)
@ -254,26 +291,45 @@ class RemoteConfigError(Exception):
pass
def _remote_edit_clients(node: dict, mutate_fn):
def _busy_ports(client) -> set:
_, stdout, _ = client.exec_command("ss -ltnH 2>/dev/null | awk '{print $4}'", timeout=10)
busy = set()
for line in stdout.read().decode(errors="replace").splitlines():
tail = line.rsplit(":", 1)[-1]
if tail.isdigit():
busy.add(int(tail))
return busy
@contextlib.contextmanager
def _node_lock(address: str):
key = hashlib.sha1(address.encode()).hexdigest()[:12]
with open(f"/tmp/mbs-node-{key}.lock", "w") as lock_file:
fcntl.flock(lock_file, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
def _remote_edit_config(node: dict, mutate_fn):
with _node_lock(node["address"]):
return _remote_edit_config_locked(node, mutate_fn)
def _remote_edit_config_locked(node: dict, mutate_fn):
client = _mgmt_connect(node["address"])
try:
sftp = client.open_sftp()
with sftp.open("/usr/local/etc/xray/config.json") as f:
with sftp.open(REMOTE_CONFIG_PATH) as f:
cfg = json.loads(f.read().decode())
changed = False
for ib in cfg["inbounds"]:
if ib.get("tag") not in LOCAL_TAGS:
continue
clients = ib["settings"]["clients"]
new_clients = mutate_fn(clients, ib["tag"])
if new_clients is not None:
ib["settings"]["clients"] = new_clients
changed = True
if not changed:
result = mutate_fn(cfg, client)
if not result["changed"]:
sftp.close()
return
return result
data = json.dumps(cfg, indent=2).encode()
tmp_path = "/usr/local/etc/xray/config.json.validate.tmp"
tmp_path = REMOTE_CONFIG_PATH + ".validate.tmp"
prev_path = REMOTE_CONFIG_PATH + ".mbs-prev"
with sftp.open(tmp_path, "wb") as f:
f.write(data)
_, stdout, stderr = client.exec_command(f"/usr/local/bin/xray run -test -format=json -config {tmp_path}", timeout=15)
@ -283,23 +339,43 @@ def _remote_edit_clients(node: dict, mutate_fn):
client.exec_command(f"rm -f {tmp_path}")
sftp.close()
raise RemoteConfigError(f"config test failed on {node['address']}: {test_out}")
client.exec_command(f"mv {tmp_path} /usr/local/etc/xray/config.json")[1].channel.recv_exit_status()
client.exec_command(f"cp -p {REMOTE_CONFIG_PATH} {prev_path}")[1].channel.recv_exit_status()
client.exec_command(f"mv {tmp_path} {REMOTE_CONFIG_PATH}")[1].channel.recv_exit_status()
sftp.close()
_, stdout, stderr = client.exec_command("systemctl restart xray", timeout=20)
_, stdout, stderr = client.exec_command("systemctl restart xray && sleep 1 && systemctl is-active xray", timeout=30)
restart_exit = stdout.channel.recv_exit_status()
if restart_exit != 0:
err = stderr.read().decode(errors="replace").strip()
raise RemoteConfigError(f"xray restart failed on {node['address']}: {err}")
client.exec_command(f"cp -p {prev_path} {REMOTE_CONFIG_PATH} && systemctl restart xray")[1].channel.recv_exit_status()
raise RemoteConfigError(f"xray не поднялся на {node['address']}, конфиг откатили назад: {err}")
for port in result.get("new_ports") or []:
client.exec_command(f"command -v ufw >/dev/null 2>&1 && ufw allow {int(port)}/tcp || true")[1].channel.recv_exit_status()
return result
finally:
client.close()
def _remote_edit_clients(node: dict, mutate_fn):
def mutate(cfg, client):
changed = False
for ib in cfg["inbounds"]:
tag = ib.get("tag")
if not chains.is_user_tag(tag):
continue
new_clients = mutate_fn(ib["settings"]["clients"], tag)
if new_clients is not None:
ib["settings"]["clients"] = new_clients
changed = True
return {"changed": changed}
_remote_edit_config(node, mutate)
def remote_add_client(node: dict, client_uuid: str, email: str):
def mutate(clients, tag):
if any(c["id"] == client_uuid for c in clients):
return None
entry = {"id": client_uuid, "email": email}
flow = TAG_FLOW.get(tag)
flow = chains.flow_for_tag(tag)
if flow:
entry["flow"] = flow
clients.append(entry)
@ -314,24 +390,49 @@ def remote_remove_client(node: dict, client_uuid: str):
_remote_edit_clients(node, mutate)
def remote_sync(node: dict, active_subs: list[dict]):
active_by_id = {s["uuid"]: s for s in active_subs}
def remote_reconcile(node: dict, wanted: dict, relay_wanted: dict, entry_chains: list, exit_nodes: dict, apply_chains: bool = True):
def mutate(cfg, client):
usable = entry_chains
skipped = []
if apply_chains:
usable, skipped = chains.split_busy_chains(cfg, entry_chains, _busy_ports(client))
new_ports = chains.new_ports_needed(cfg, usable) if apply_chains else []
changed, problems = chains.sync_config(cfg, wanted, relay_wanted, usable, exit_nodes, apply_chains=apply_chains)
return {"changed": changed, "new_ports": new_ports, "problems": skipped + problems}
return _remote_edit_config(node, mutate)
def mutate(clients, tag):
current_ids = {c["id"] for c in clients}
if current_ids == set(active_by_id.keys()):
return None
new_clients = [c for c in clients if c["id"] in active_by_id]
existing_ids = {c["id"] for c in new_clients}
flow = TAG_FLOW.get(tag)
for cid in active_by_id:
if cid not in existing_ids:
entry = {"id": cid, "email": cid}
if flow:
entry["flow"] = flow
new_clients.append(entry)
return new_clients
_remote_edit_clients(node, mutate)
PROBE_SCRIPT = """for i in 1 2 3; do
s=$(date +%s%N)
if timeout 3 bash -c 'exec 3<>/dev/tcp/{host}/{port}' 2>/dev/null; then
e=$(date +%s%N)
echo $(( (e - s) / 1000000 ))
else
echo -1
fi
done
"""
def remote_probe(node: dict, host: str, port: int) -> list:
if not chains.HOST_RE.match(host or ""):
raise ValueError("bad host")
port = int(port)
client = _mgmt_connect(node["address"])
try:
stdin, stdout, _ = client.exec_command("bash -s", timeout=30)
stdin.write(PROBE_SCRIPT.format(host=host, port=port))
stdin.channel.shutdown_write()
out = stdout.read().decode(errors="replace")
finally:
client.close()
samples = []
for line in out.split():
try:
samples.append(int(line))
except ValueError:
continue
return samples
def remote_query_stats(node: dict) -> dict:

View file

@ -5,20 +5,18 @@ import json
import secrets
import urllib.request
from config import (
PANEL_DOMAIN,
YOOKASSA_ENABLED, YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY,
PLATEGA_ENABLED, PLATEGA_MERCHANT_ID, PLATEGA_SECRET,
)
from config import PANEL_DOMAIN
import settings
PROVIDER_NAMES = {"yookassa": "ЮKassa", "platega": "Platega"}
def available_providers() -> list[str]:
enabled = settings.get_payment_settings()
providers = []
if YOOKASSA_ENABLED:
if enabled["yookassa_enabled"]:
providers.append("yookassa")
if PLATEGA_ENABLED:
if enabled["platega_enabled"]:
providers.append("platega")
return providers
@ -41,7 +39,8 @@ def _get_json(url: str, headers: dict, timeout: int = 15) -> dict:
def create_yookassa_payment(payment_id: str, amount_rub: int, description: str) -> str:
auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode()
shop_id, secret_key = settings.yookassa_credentials()
auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode()
data = _post_json(
"https://api.yookassa.ru/v3/payments",
{
@ -62,12 +61,18 @@ def create_yookassa_payment(payment_id: str, amount_rub: int, description: str)
return external_id, pay_url
def validate_yookassa_credentials(shop_id: str, secret_key: str) -> dict:
auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode()
return _get_json("https://api.yookassa.ru/v3/me", {"Authorization": f"Basic {auth}"})
def verify_yookassa_notification(body: dict) -> bool:
return body.get("event") == "payment.succeeded" and "object" in body
def check_yookassa_payment(external_id: str) -> str:
auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode()
shop_id, secret_key = settings.yookassa_credentials()
auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode()
data = _get_json(
f"https://api.yookassa.ru/v3/payments/{external_id}",
{"Authorization": f"Basic {auth}"},
@ -76,6 +81,7 @@ def check_yookassa_payment(external_id: str) -> str:
def create_platega_payment(payment_id: str, amount_rub: int, description: str) -> str:
merchant_id, secret = settings.platega_credentials()
data = _post_json(
"https://app.platega.io/transaction/process",
{
@ -87,8 +93,8 @@ def create_platega_payment(payment_id: str, amount_rub: int, description: str) -
},
{
"Content-Type": "application/json",
"X-MerchantId": PLATEGA_MERCHANT_ID,
"X-Secret": PLATEGA_SECRET,
"X-MerchantId": merchant_id,
"X-Secret": secret,
},
)
external_id = data.get("id") or data.get("transactionId")
@ -99,14 +105,16 @@ def create_platega_payment(payment_id: str, amount_rub: int, description: str) -
def verify_platega_signature(raw_body: bytes, signature: str) -> bool:
if not signature:
return False
expected = hmac.new(PLATEGA_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
_, secret = settings.platega_credentials()
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def check_platega_payment(external_id: str) -> str:
merchant_id, secret = settings.platega_credentials()
data = _get_json(
f"https://app.platega.io/transaction/{external_id}",
{"X-MerchantId": PLATEGA_MERCHANT_ID, "X-Secret": PLATEGA_SECRET},
{"X-MerchantId": merchant_id, "X-Secret": secret},
)
return data.get("status", "")

142
settings.py Normal file
View file

@ -0,0 +1,142 @@
import config
import legal
PRICE_ENV_KEYS = {"7d": "PRICE_7D", "1m": "PRICE_1M", "3m": "PRICE_3M", "6m": "PRICE_6M", "1y": "PRICE_1Y"}
def _bool(raw: str, default: bool) -> bool:
if raw is None or raw == "":
return default
return raw.strip().lower() == "true"
def _positive_int(raw: str, default: int) -> int:
if raw and raw.strip().lstrip("-").isdigit():
parsed = int(raw)
if parsed >= 0:
return parsed
return default
def get_plans() -> list:
raw = legal.read_env_vars(list(PRICE_ENV_KEYS.values()))
plans = []
for p in config.PLANS:
env_key = PRICE_ENV_KEYS[p["code"]]
plans.append({
"code": p["code"],
"label": p["label"],
"days": p["days"],
"price": _positive_int(raw.get(env_key), p["price"]),
})
return plans
def get_plans_by_code() -> dict:
return {p["code"]: p for p in get_plans()}
def set_plan_prices(prices: dict):
for code, price in prices.items():
if code in PRICE_ENV_KEYS:
legal.update_env_var(PRICE_ENV_KEYS[code], str(int(price)))
def get_payment_settings() -> dict:
raw = legal.read_env_vars(["PAYMENTS_ENABLED", "YOOKASSA_ENABLED", "PLATEGA_ENABLED"])
return {
"payments_enabled": _bool(raw.get("PAYMENTS_ENABLED"), config.PAYMENTS_ENABLED),
"yookassa_enabled": _bool(raw.get("YOOKASSA_ENABLED"), config.YOOKASSA_ENABLED),
"platega_enabled": _bool(raw.get("PLATEGA_ENABLED"), config.PLATEGA_ENABLED),
}
def yookassa_credentials():
raw = legal.read_env_vars(["YOOKASSA_SHOP_ID", "YOOKASSA_SECRET_KEY"])
return (
raw.get("YOOKASSA_SHOP_ID") or config.YOOKASSA_SHOP_ID,
raw.get("YOOKASSA_SECRET_KEY") or config.YOOKASSA_SECRET_KEY,
)
def platega_credentials():
raw = legal.read_env_vars(["PLATEGA_MERCHANT_ID", "PLATEGA_SECRET"])
return (
raw.get("PLATEGA_MERCHANT_ID") or config.PLATEGA_MERCHANT_ID,
raw.get("PLATEGA_SECRET") or config.PLATEGA_SECRET,
)
def get_brand_name() -> str:
raw = legal.read_env_var("BRAND_NAME", "")
return raw.strip() if raw.strip() else config.BRAND_NAME
def bot_credentials():
raw = legal.read_env_vars(["BOT_TOKEN", "BOT_USERNAME"])
return (
raw.get("BOT_TOKEN") or config.BOT_TOKEN,
raw.get("BOT_USERNAME") or config.BOT_USERNAME,
)
def get_hwid_settings() -> dict:
raw = legal.read_env_vars(["HWID_LIMIT_ENABLED", "HWID_FALLBACK_LIMIT"])
limit = _positive_int(raw.get("HWID_FALLBACK_LIMIT"), config.HWID_FALLBACK_LIMIT)
return {
"enabled": _bool(raw.get("HWID_LIMIT_ENABLED"), config.HWID_LIMIT_ENABLED),
"fallback_limit": limit if limit > 0 else config.HWID_FALLBACK_LIMIT,
}
def get_referral_settings() -> dict:
raw = legal.read_env_vars(["REFERRAL_ENABLED", "REFERRAL_BONUS_DAYS"])
days = _positive_int(raw.get("REFERRAL_BONUS_DAYS"), config.REFERRAL_BONUS_DAYS)
return {
"enabled": _bool(raw.get("REFERRAL_ENABLED"), config.REFERRAL_ENABLED),
"bonus_days": days if days > 0 else config.REFERRAL_BONUS_DAYS,
}
def set_referral_settings(enabled: bool, bonus_days: int):
legal.update_env_var("REFERRAL_ENABLED", "true" if enabled else "false")
legal.update_env_var("REFERRAL_BONUS_DAYS", str(int(bonus_days)))
def get_features() -> dict:
keys = ["TRIAL_ENABLED", "TRIAL_DAYS", "TRIAL_NODE", "TRIAL_TRAFFIC_GB", "DEFAULT_TRAFFIC_GB",
"REMINDERS_ENABLED", "NODE_ALERTS_ENABLED"]
raw = legal.read_env_vars(keys)
trial_days = _positive_int(raw.get("TRIAL_DAYS"), 1)
return {
"trial_enabled": _bool(raw.get("TRIAL_ENABLED"), False),
"trial_days": trial_days if trial_days > 0 else 1,
"trial_node": (raw.get("TRIAL_NODE") or "").strip(),
"trial_traffic_gb": _positive_int(raw.get("TRIAL_TRAFFIC_GB"), 2),
"default_traffic_gb": _positive_int(raw.get("DEFAULT_TRAFFIC_GB"), 0),
"reminders_enabled": _bool(raw.get("REMINDERS_ENABLED"), True),
"node_alerts_enabled": _bool(raw.get("NODE_ALERTS_ENABLED"), True),
}
def set_features(values: dict):
mapping = {
"trial_enabled": ("TRIAL_ENABLED", lambda v: "true" if v else "false"),
"trial_days": ("TRIAL_DAYS", lambda v: str(max(int(v), 1))),
"trial_node": ("TRIAL_NODE", lambda v: str(v or "").strip()),
"trial_traffic_gb": ("TRIAL_TRAFFIC_GB", lambda v: str(max(int(v), 0))),
"default_traffic_gb": ("DEFAULT_TRAFFIC_GB", lambda v: str(max(int(v), 0))),
"reminders_enabled": ("REMINDERS_ENABLED", lambda v: "true" if v else "false"),
"node_alerts_enabled": ("NODE_ALERTS_ENABLED", lambda v: "true" if v else "false"),
}
for key, (env_key, conv) in mapping.items():
if key in values:
legal.update_env_var(env_key, conv(values[key]))
GB = 1024 ** 3
def default_traffic_limit_bytes():
gb = get_features()["default_traffic_gb"]
return gb * GB if gb > 0 else None

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Личный кабинет — MBS Panel</title>
<title>Личный кабинет — {{BRAND_NAME}}</title>
<style>
:root {
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
@ -81,11 +81,11 @@
</div>
<div id="err"></div>
<div id="content"></div>
<p class="hint">Токен выдаёт бот <a class="tglink" href="https://t.me/YourBot_robot" target="_blank">@YourBot_robot</a> — «Моя подписка»</p>
<p class="hint">Токен выдаёт бот <a class="tglink" href="https://t.me/{{BOT_USERNAME}}" target="_blank">@{{BOT_USERNAME}}</a> — «Моя подписка»</p>
</div>
<script>
const API = "https://sub.example.com";
const API = "https://{{SUB_DOMAIN}}";
function esc(s) {
if (s === null || s === undefined) return "";

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MBS Panel</title>
<title>{{BRAND_NAME}}</title>
<style>
:root {
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
@ -117,7 +117,7 @@
<body>
<div class="wrap">
<header>
<div class="logo">MBS Panel</div>
<div class="logo">{{BRAND_NAME}}</div>
<nav>
<a href="#features">Возможности</a>
<a href="#plans">Тарифы</a>
@ -128,7 +128,7 @@
<section class="hero">
<h1 class="reveal">Интернет без границ<br><span class="accent">и без замедлений</span></h1>
<p class="reveal">Быстрый доступ к любимым сайтам и сервисам. Трафик не отличить от обычного HTTPS, скорость — на выделенных мощностях.</p>
<a class="btn reveal" href="https://t.me/YourBot_robot" target="_blank">Получить доступ</a>
<a class="btn reveal" href="https://t.me/{{BOT_USERNAME}}" target="_blank">Получить доступ</a>
</section>
</div>
@ -164,23 +164,19 @@
<section class="plans" id="plans">
<h2 class="reveal">Тарифы</h2>
<div class="plan-row reveal">
<div class="plan"><div class="d">7 дней</div><div class="l">пробный</div></div>
<div class="plan"><div class="d">1 месяц</div><div class="l">стандарт</div></div>
<div class="plan"><div class="d">3 месяца</div><div class="l">выгодно</div></div>
<div class="plan"><div class="d">6 месяцев</div><div class="l">выгоднее</div></div>
<div class="plan"><div class="d">1 год</div><div class="l">максимум</div></div>
<div class="plan-row reveal" id="plan-row">
<div class="plan"><div class="d">…</div></div>
</div>
</section>
<div class="cta reveal">
<a class="btn ghost" href="https://t.me/YourBot_robot" target="_blank">Выбрать тариф в боте</a>
<a class="btn ghost" href="https://t.me/{{BOT_USERNAME}}" target="_blank">Выбрать тариф в боте</a>
</div>
</div>
<footer>
<div class="wrap">
example.com — <a href="/cabinet.html">личный кабинет</a> — подписка через <a href="https://sub.example.com" target="_blank">sub.example.com</a> — <a href="/offer.html">оферта</a> — <a href="/privacy.html">конфиденциальность</a>
{{SITE_DOMAIN}} — <a href="/cabinet.html">личный кабинет</a> — подписка через <a href="https://{{SUB_DOMAIN}}" target="_blank">{{SUB_DOMAIN}}</a> — <a href="/offer">оферта</a> — <a href="/privacy">конфиденциальность</a>
</div>
</footer>
@ -195,6 +191,22 @@
});
}, { threshold: 0.15 });
document.querySelectorAll(".reveal").forEach((el) => io.observe(el));
function esc(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
const PLAN_TAGLINES = { "7d": "пробный", "1m": "стандарт", "3m": "выгодно", "6m": "выгоднее", "1y": "максимум" };
fetch("/api/plans").then((r) => r.json()).then((data) => {
const row = document.getElementById("plan-row");
row.innerHTML = data.plans.map((p) => `
<div class="plan">
<div class="d">${esc(p.label)}</div>
<div class="l">${data.payments_enabled && p.price > 0 ? esc(p.price) + " ₽" : esc(PLAN_TAGLINES[p.code] || "")}</div>
</div>
`).join("");
}).catch(() => {});
</script>
</body>
</html>

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MBS Panel — Публичная оферта</title>
<title>{{BRAND_NAME}} — Публичная оферта</title>
<style>
:root {
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
@ -34,12 +34,12 @@
<div class="wrap">
<a class="back" href="/">← На главную</a>
<h1>Публичная оферта</h1>
<p class="updated">Действует с <span class="fill">[дата]</span></p>
<p class="updated">Действует с {{EFFECTIVE_DATE}}</p>
<p>Настоящий документ является публичной офертой <span class="fill">[ФИО ИП / наименование самозанятого или юрлица]</span>, <span class="fill">[ИНН]</span> (далее — «Исполнитель»), адресованной любому дееспособному физическому лицу (далее — «Клиент»), на заключение договора о предоставлении доступа к VPN-сервису на условиях, указанных ниже. Оплата услуги означает полное и безоговорочное принятие условий оферты (акцепт).</p>
<p>Настоящий документ является публичной офертой {{LEGAL_NAME}}, {{INN}} (далее — «Исполнитель»), адресованной любому дееспособному физическому лицу (далее — «Клиент»), на заключение договора о предоставлении доступа к VPN-сервису на условиях, указанных ниже. Оплата услуги означает полное и безоговорочное принятие условий оферты (акцепт).</p>
<h2>1. Предмет договора</h2>
<p>Исполнитель предоставляет Клиенту доступ к серверу VPN (VLESS/Hysteria2) на срок, соответствующий выбранному тарифу, через Telegram-бота <span class="fill">[@ваш_бот]</span>. Доступ выдаётся автоматически после подтверждения оплаты.</p>
<p>Исполнитель предоставляет Клиенту доступ к серверу VPN (VLESS/Hysteria2) на срок, соответствующий выбранному тарифу, через Telegram-бота {{BOT_USERNAME}}. Доступ выдаётся автоматически после подтверждения оплаты.</p>
<h2>2. Стоимость и порядок оплаты</h2>
<ol>
@ -52,7 +52,7 @@
<p>Доступ предоставляется на срок выбранного тарифа (от 7 дней до 1 года) и автоматически прекращается по истечении срока. Продление — отдельной оплатой, автосписание не производится.</p>
<h2>4. Возврат средств</h2>
<p>Возврат возможен в течение <span class="fill">[N]</span> часов с момента оплаты, если доступ ни разу не был использован (не было подключений к серверу), — по обращению в поддержку <span class="fill">[контакт]</span>. После начала использования услуга считается оказанной.</p>
<p>Возврат возможен в течение {{REFUND_HOURS}} часов с момента оплаты, если доступ ни разу не был использован (не было подключений к серверу), — по обращению в поддержку {{SUPPORT_CONTACT}}. После начала использования услуга считается оказанной.</p>
<h2>5. Права и обязанности сторон</h2>
<ol>
@ -63,10 +63,10 @@
<h2>6. Реквизиты Исполнителя</h2>
<p class="muted">
<span class="fill">[ФИО / наименование]</span><br>
ИНН <span class="fill">[номер]</span><br>
Email: <span class="fill">[email]</span><br>
Telegram: <span class="fill">[контакт поддержки]</span>
{{LEGAL_NAME}}<br>
ИНН {{INN}}<br>
Email: {{SUPPORT_EMAIL}}<br>
Telegram: {{SUPPORT_CONTACT}}
</p>
</div>
</body>

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MBS Panel — Политика конфиденциальности</title>
<title>{{BRAND_NAME}} — Политика конфиденциальности</title>
<style>
:root {
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
@ -34,9 +34,9 @@
<div class="wrap">
<a class="back" href="/">← На главную</a>
<h1>Политика конфиденциальности</h1>
<p class="updated">Действует с <span class="fill">[дата]</span></p>
<p class="updated">Действует с {{EFFECTIVE_DATE}}</p>
<p>Настоящая политика описывает, какие данные собирает и как обрабатывает <span class="fill">[ФИО ИП / наименование]</span> (далее — «Оператор») при использовании Telegram-бота и сайта MBS Panel.</p>
<p>Настоящая политика описывает, какие данные собирает и как обрабатывает {{LEGAL_NAME}} (далее — «Оператор») при использовании Telegram-бота и сайта {{BRAND_NAME}}.</p>
<h2>1. Какие данные собираются</h2>
<ul>
@ -58,13 +58,13 @@
<p>Данные хранятся на серверах Оператора и не передаются третьим лицам, кроме платёжных провайдеров (ЮKassa, Platega) в объёме, необходимом для обработки оплаты, и в случаях, прямо предусмотренных законодательством РФ.</p>
<h2>4. Права пользователя</h2>
<p>Пользователь вправе запросить удаление своих данных и прекращение обработки, обратившись на <span class="fill">[email/контакт поддержки]</span>. Удаление данных влечёт прекращение доступа к активным подпискам.</p>
<p>Пользователь вправе запросить удаление своих данных и прекращение обработки, обратившись на {{SUPPORT_EMAIL}} или {{SUPPORT_CONTACT}}. Удаление данных влечёт прекращение доступа к активным подпискам.</p>
<h2>5. Контакты</h2>
<p class="muted">
<span class="fill">[ФИО / наименование]</span><br>
Email: <span class="fill">[email]</span><br>
Telegram: <span class="fill">[контакт поддержки]</span>
{{LEGAL_NAME}}<br>
Email: {{SUPPORT_EMAIL}}<br>
Telegram: {{SUPPORT_CONTACT}}
</p>
</div>
</body>

View file

@ -0,0 +1,8 @@
[Unit]
Description=MBS Panel auto update
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/mbs update

View file

@ -0,0 +1,10 @@
[Unit]
Description=MBS Panel auto update, daily
[Timer]
OnCalendar=*-*-* 04:00:00
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target

174
tests/test_features.py Normal file
View file

@ -0,0 +1,174 @@
import datetime
import os
import sys
import tempfile
import types
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
os.environ.update({
"BOT_TOKEN": "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"BOT_USERNAME": "x",
"ADMIN_IDS": "1",
"ADMIN_PANEL_PASSWORD": "ci-test-password-not-real",
"PANEL_DOMAIN": "panel.test",
"SUB_DOMAIN": "sub.test",
"SITE_DOMAIN": "test",
"XRAY_PUBLIC_KEY": "x",
"XRAY_SHORT_ID_TCP": "x",
"XRAY_SHORT_ID_GRPC": "x",
"XRAY_SHORT_ID_XHTTP": "x",
})
try:
import fcntl
except ImportError:
sys.modules["fcntl"] = types.ModuleType("fcntl")
import db
import features
import settings
tmp = tempfile.mkdtemp()
db.DB_PATH = os.path.join(tmp, "test.db")
db.init_db()
passed = 0
failed = 0
def check(name, cond):
global passed, failed
if cond:
passed += 1
print("PASS", name)
else:
failed += 1
print("FAIL", name)
GB = settings.GB
db.get_or_create_user(100, "alice")
sub = db.create_subscription(100, "de1", 30, "1m", traffic_limit=2 * GB)
uid = sub["uuid"]
used = db.add_traffic_sample(uid, 500)
check("first sample counts whole value", used == 500)
used = db.add_traffic_sample(uid, 1500)
check("second sample adds only the delta", used == 1500)
used = db.add_traffic_sample(uid, 300)
check("counter reset (xray restart) adds the new raw value", used == 1800)
used = db.add_traffic_sample(uid, 300)
check("same raw value adds nothing", used == 1800)
check("under the limit is not flagged", db.list_over_limit() == [])
db.add_traffic_sample(uid, 300 + 2 * GB)
over = db.list_over_limit()
check("over the limit is flagged", len(over) == 1 and over[0]["uuid"] == uid)
db.mark_limit_hit(uid)
check("limit hit deactivates the subscription", db.get_subscription(uid)["active"] == 0)
check("deactivated subscription is not in the active list", db.list_active_subscriptions(tg_id=100) == [])
check("limit hit is not flagged twice", db.list_over_limit() == [])
db.set_traffic_limit(uid, 10 * GB)
fresh = db.get_subscription(uid)
check("raising the limit reactivates", fresh["active"] == 1 and fresh["limit_hit_at"] is None)
db.add_traffic_sample(uid, 12 * GB)
db.mark_limit_hit(uid)
check("reset counter reactivates a limited subscription", db.reset_traffic_counter(uid) is True)
fresh = db.get_subscription(uid)
check("reset counter zeroes usage", fresh["traffic_used"] == 0 and fresh["active"] == 1)
db.set_traffic_limit(uid, None)
check("no limit means never flagged", db.list_over_limit() == [])
expired_sub = db.create_subscription(100, "de1", 30, "1m")
revoked_before = db.get_subscription(expired_sub["uuid"])
check("subscription without limit has no limit stored", revoked_before["traffic_limit"] is None)
check("trial is available for a user without subscriptions", db.get_or_create_user(200, "bob") and db.trial_available(200))
check("trial claim succeeds once", db.claim_trial(200) is True)
check("trial claim fails the second time", db.claim_trial(200) is False)
check("trial is not offered after claim", db.trial_available(200) is False)
check("trial is not offered to users with a subscription", db.trial_available(100) is False)
promo = db.create_promo("sale20", "percent", 20, max_uses=2)
check("promo code is stored uppercase", promo["code"] == "SALE20")
check("percent discount is applied", db.discounted_price(1000, promo) == 800)
fixed = db.create_promo("minus100", "fixed", 100)
check("fixed discount is applied", db.discounted_price(399, fixed) == 299)
check("fixed discount never goes below zero", db.discounted_price(50, fixed) == 0)
try:
db.create_promo("SALE20", "percent", 10)
check("duplicate promo is rejected", False)
except ValueError:
check("duplicate promo is rejected", True)
try:
db.create_promo("bad", "percent", 150)
check("percent over 100 is rejected", False)
except ValueError:
check("percent over 100 is rejected", True)
try:
db.create_promo("bad code!", "fixed", 5)
check("promo with symbols is rejected", False)
except ValueError:
check("promo with symbols is rejected", True)
found, err = db.validate_promo("sale20", 100)
check("valid promo validates", err is None and found["code"] == "SALE20")
found, err = db.validate_promo("nope", 100)
check("unknown promo is not found", err == "not_found")
db.create_payment("p1", 100, "de1", "1m", "yookassa", 319)
db.set_payment_promo("p1", "SALE20", 399)
check("promo is not consumed before payment", db.get_promo("SALE20")["used_count"] == 0)
db.mark_payment_paid("p1")
check("promo is consumed when payment is paid", db.get_promo("SALE20")["used_count"] == 1)
db.mark_payment_paid("p1")
check("paying twice does not consume twice", db.get_promo("SALE20")["used_count"] == 1)
_, err = db.validate_promo("sale20", 100)
check("same user cannot reuse the promo", err == "already_used")
for tg in (300, 301):
db.get_or_create_user(tg, None)
db.create_payment(f"pp{tg}", tg, "de1", "1m", "yookassa", 319)
db.set_payment_promo(f"pp{tg}", "SALE20", 399)
db.mark_payment_paid(f"pp{tg}")
_, err = db.validate_promo("sale20", 999)
check("promo with exhausted uses is refused", err == "exhausted")
db.set_promo_active("MINUS100", False)
_, err = db.validate_promo("minus100", 100)
check("disabled promo is refused", err == "not_found")
past = (datetime.datetime.utcnow() - datetime.timedelta(days=1)).isoformat()
db.create_promo("OLDONE", "fixed", 10, expires_at=past)
_, err = db.validate_promo("oldone", 100)
check("expired promo is refused", err == "expired")
db.create_promo("BONUS5", "days", 5)
db.get_or_create_user(400, None)
db.create_subscription(400, "de1", 10, "7d")
before = db.list_active_subscriptions(tg_id=400)[0]["expires_at"]
promo_days, err = db.redeem_days_promo("bonus5", 400)
after = db.list_active_subscriptions(tg_id=400)[0]["expires_at"]
delta = datetime.datetime.fromisoformat(after) - datetime.datetime.fromisoformat(before)
check("days promo extends the subscription", err is None and delta == datetime.timedelta(days=5))
_, err = db.redeem_days_promo("bonus5", 400)
check("days promo works once per user", err == "already_used")
db.set_promo_pending(400, "SALE20")
check("pending promo that is exhausted is dropped", db.get_pending_promo(400) is None)
soon = db.create_subscription(500 if db.get_or_create_user(500, None) else 500, "de1", 1, "7d")
due = features.reminders_due()
check("subscription ending within a day is due", any(item[0]["uuid"] == soon["uuid"] for item in due))
for item in due:
features.mark_stage_sent(item[0]["uuid"], item[0]["expires_at"])
check("reminders are not repeated after sending", features.reminders_due() == [])
print(f"RESULT pass={passed} fail={failed}")
sys.exit(1 if failed else 0)

155
tests/test_mbs_update.sh Normal file
View file

@ -0,0 +1,155 @@
#!/bin/bash
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
T=$(mktemp -d)
cd "$T"
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.autocrlf GIT_CONFIG_VALUE_0=false
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
mkdir -p stub bin sysd
cat > stub/systemctl << 'EOF'
#!/bin/sh
exit 0
EOF
chmod +x stub/systemctl
export PATH="$T/stub:$PATH"
export MBS_BACKUP_DIR="$T/backups"
PASS=0
FAIL=0
ok() { echo "PASS $1"; PASS=$((PASS+1)); }
bad() { echo "FAIL $1 :: $2"; FAIL=$((FAIL+1)); }
check() { if eval "$2"; then ok "$1"; else bad "$1" "$3"; fi; }
sed -e "s#APP_DIR=\"/opt/mbs-panel\"#APP_DIR=\"$T/app\"#" -e "s#/usr/local/bin/mbs#$T/bin/mbs#g" -e "s#/etc/systemd/system#$T/sysd#g" "$REPO/mbs" > "$T/mbs-run"
git init -q --bare -b main origin.git
git clone -q origin.git seed 2>/dev/null
cd seed
git checkout -q -b main 2>/dev/null || true
mkdir -p systemd
cp "$REPO/mbs" mbs
echo "v1" > bot.py
echo "v1" > config.py
echo "v1" > db.py
echo "v1" > settings.py
echo "x" > requirements.txt
echo "[Service]" > systemd/mbs-bot.service
echo "ExecStart=x --workers __WORKERS__" > systemd/mbs-api.service
cp "$REPO/.gitignore" .gitignore
git add -A && git commit -q -m A && git push -q origin main
cd "$T"
git clone -q origin.git app
mkdir -p app/venv/bin
printf '#!/bin/sh\nexit 0\n' > app/venv/bin/pip
printf '#!/bin/sh\nexec python "$@"\n' > app/venv/bin/python
chmod +x app/venv/bin/pip app/venv/bin/python
echo "SECRET=1" > app/.env
python -c "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute('pragma journal_mode=wal'); c.execute('create table t(x)'); c.execute('insert into t values (42)'); c.commit(); c.close()" app/mbs.db
cd seed && echo "v2" > bot.py && echo "v2" > db.py && git commit -qam B && git push -q origin main && cd "$T"
echo "manual edit" >> app/bot.py
echo "manual edit" >> app/config.py
echo "manual edit" >> app/db.py
echo "manual edit" >> app/settings.py
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
echo "$OUT" > out1.txt
check "update succeeds despite manual edits on the server (the reported bug)" "[ $RC -eq 0 ]" "rc=$RC $OUT"
check "bot.py is the new version" "[ \"\$(cat app/bot.py)\" = v2 ]" "$(cat app/bot.py)"
check "a stash with the manual edits exists" "[ \$(git -C app stash list | wc -l) -eq 1 ]"
check "patch with the manual edits saved" "ls app/local-changes/*.patch >/dev/null 2>&1 && grep -q 'manual edit' app/local-changes/*.patch"
check "user is told where the edits went" "grep -q 'ручные правки' out1.txt && grep -q 'патч' out1.txt"
check "working tree clean after update" "[ -z \"\$(git -C app status --porcelain --untracked-files=no)\" ]"
check "cli copied" "[ -f bin/mbs ]"
check "a pre-update backup was made" "[ \$(ls backups/mbs-before-update-*.tar.gz 2>/dev/null | wc -l) -eq 1 ]" "$(ls backups 2>&1)"
check "update output points at the backup" "grep -q 'резервная копия перед обновлением' out1.txt"
mkdir -p unpack && tar xzf backups/mbs-before-update-*.tar.gz -C unpack
APPREL="${T#/}/app"
check "backup keeps .env" "grep -q SECRET=1 unpack/$APPREL/.env"
check "backup keeps the manual edits as they were before the update" "grep -q 'manual edit' unpack/$APPREL/bot.py && grep -q 'manual edit' unpack/$APPREL/config.py"
check "backup database is a consistent sqlite copy" "[ \"\$(python -c \"import sqlite3,sys; print(sqlite3.connect(sys.argv[1]).execute('select x from t').fetchone()[0])\" unpack/$APPREL/mbs.db)\" = 42 ]"
check "backup does not drag the venv along" "[ ! -d unpack/$APPREL/venv ]"
check "no snapshot temp file is left behind" "[ ! -e app/.mbs.db.snapshot ]"
OUT=$(bash "$T/mbs-run" backup 2>&1); RC=$?
check "mbs backup makes a manual copy" "[ $RC -eq 0 ] && ls backups/mbs-manual-*.tar.gz >/dev/null 2>&1" "$OUT"
for i in 1 2 3 4 5 6 7; do sleep 1.1; bash "$T/mbs-run" backup >/dev/null 2>&1; done
check "only the 5 newest backups are kept" "[ \$(ls backups/mbs-*.tar.gz | wc -l) -eq 5 ]" "$(ls backups | wc -l)"
OUT=$(bash "$T/mbs-run" update 2>&1); echo "$OUT" > out2.txt
check "second run says already latest" "grep -q 'уже последняя' out2.txt" "$OUT"
cd "$T/app" && git stash drop -q && git reset -q --hard HEAD; cd "$T"
git clone -q --bare origin.git mirror2.git
cd seed && git pull -q origin main 2>/dev/null; echo "v3" > bot.py && git commit -qam C && git push -q "$T/mirror2.git" main && cd "$T"
git -C mirror2.git update-server-info
python -m http.server 8799 --directory "$T" > http.log 2>&1 &
HTTP_PID=$!
sleep 1.5
OUT=$(bash "$T/mbs-run" update "http://127.0.0.1:8799/mirror2.git" 2>&1); RC=$?
echo "$OUT" > out3.txt
check "update by mirror url works" "[ $RC -eq 0 ] && [ \"\$(cat app/bot.py)\" = v3 ]" "rc=$RC $OUT"
check "url run mentions the source and how to save it" "grep -q 'источник: http://127.0.0.1:8799/mirror2.git' out3.txt && grep -q 'mbs mirror http' out3.txt" "$OUT"
check "url alone is not saved automatically" "[ ! -f app/.update_mirror ]"
bash "$T/mbs-run" mirror "http://127.0.0.1:8799/mirror2.git" > out4.txt 2>&1
check "mirror command saves the url" "grep -q 'http://127.0.0.1:8799/mirror2.git' app/.update_mirror"
check "mirror file is git-ignored" "[ -z \"\$(git -C app status --porcelain)\" ]" "$(git -C app status --porcelain)"
bash "$T/mbs-run" mirror > out5.txt 2>&1
check "mirror shows the saved url" "grep -q 'своё зеркало для обновлений: http://127.0.0.1:8799' out5.txt"
cd seed && echo "v4" > bot.py && git commit -qam D && git push -q "$T/mirror2.git" main && cd "$T"
git -C mirror2.git update-server-info
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
echo "$OUT" > out6.txt
check "plain update prefers the saved mirror" "[ $RC -eq 0 ] && grep -q 'источник: своё зеркало' out6.txt && [ \"\$(cat app/bot.py)\" = v4 ]" "rc=$RC $OUT"
bash "$T/mbs-run" mirror off > out7.txt 2>&1
check "mirror off removes it" "[ ! -f app/.update_mirror ]"
for bad_url in "ext::sh -c 'touch $T/pwned'" "-uHEAD" "file:///etc" "ftp://x/y" "https://a b/c" "--upload-pack=touch $T/pwned2"; do
OUT=$(bash "$T/mbs-run" update "$bad_url" 2>&1); RC=$?
check "rejects unsafe source '$bad_url'" "[ $RC -ne 0 ] && grep -q 'не похоже на ссылку' <<< \"\$OUT\"" "rc=$RC $OUT"
done
check "no command was executed via a crafted url" "[ ! -e pwned ] && [ ! -e pwned2 ]"
OUT=$(bash "$T/mbs-run" mirror "file:///etc" 2>&1); RC=$?
check "mirror refuses unsafe urls too" "[ $RC -ne 0 ] && [ ! -f app/.update_mirror ]"
OUT=$(bash "$T/mbs-run" update "http://127.0.0.1:1/nope.git" 2>&1); RC=$?
check "unreachable mirror fails cleanly" "[ $RC -ne 0 ] && grep -q 'не удалось получить обновления по ссылке' <<< \"\$OUT\"" "rc=$RC $OUT"
cd seed && echo "v5" > bot.py && echo "broken(" > broken.py && git add -A && git commit -qm E && git push -q origin main && cd "$T"
git -C app fetch -q origin main
git -C app merge -q --ff-only origin/main~1 2>/dev/null || true
cd app && git reset -q --hard origin/main~1 2>/dev/null; cd "$T"
echo "local tweak" >> app/settings.py
BEFORE=$(git -C app rev-parse HEAD)
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
echo "$OUT" > out8.txt
check "broken new code is rolled back" "[ $RC -ne 0 ] && grep -q 'откатываюсь' out8.txt && [ \"\$(git -C app rev-parse HEAD)\" = \"$BEFORE\" ]" "rc=$RC $OUT"
check "manual edits are restored after the rollback" "grep -q 'local tweak' app/settings.py && [ \$(git -C app stash list | wc -l) -eq 0 ]" "$(git -C app stash list)"
cd app && git checkout -q -- . && git reset -q --hard origin/main~1 && cd "$T"
cd app && echo "own" > own.txt && git add own.txt && git commit -qm "local commit" && cd "$T"
cd seed && git rm -q broken.py && echo "v6" > bot.py && git commit -qam F && git push -q origin main && cd "$T"
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
echo "$OUT" > out9.txt
check "diverged server history is refused, not merged" "[ $RC -ne 0 ] && grep -q 'свои коммиты' out9.txt" "rc=$RC $OUT"
check "refused update leaves the local commit alone" "git -C app log --oneline | grep -q 'local commit'"
cd app && git reset -q --hard origin/main && echo "ahead" > ahead.txt && git add ahead.txt && git commit -qm ahead && cd "$T"
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
check "server newer than the source is left alone" "[ $RC -eq 0 ] && grep -q 'версия новее' <<< \"\$OUT\"" "rc=$RC $OUT"
cd "$T/app" && git reset -q --hard origin/main && cd "$T"
cd seed && echo "v7" > bot.py && git commit -qam G && git push -q origin main && cd "$T"
BEFORE=$(git -C app rev-parse HEAD)
echo "x" > notadir
OUT=$(MBS_BACKUP_DIR="$T/notadir/x" bash "$T/mbs-run" update 2>&1); RC=$?
check "update refuses to start when no backup can be made" "[ $RC -ne 0 ] && grep -q 'не начинаю' <<< \"\$OUT\" && [ \"\$(git -C app rev-parse HEAD)\" = \"$BEFORE\" ]" "rc=$RC $OUT"
check "refused update leaves the code untouched" "[ \"\$(cat app/bot.py)\" != v7 ]"
kill $HTTP_PID 2>/dev/null
cd /
rm -rf "$T"
echo "RESULT pass=$PASS fail=$FAIL"
[ "$FAIL" -eq 0 ]

23
webhooks.py Normal file
View file

@ -0,0 +1,23 @@
import hashlib
import hmac
import json
import urllib.request
from legal import read_env_var
def send(event: str, data: dict):
url = read_env_var("WEBHOOK_URL")
secret = read_env_var("WEBHOOK_SECRET")
if not url or not secret:
return
body = json.dumps({"event": event, "data": data}).encode()
signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
req = urllib.request.Request(
url, data=body, method="POST",
headers={"Content-Type": "application/json", "X-Signature": signature},
)
try:
urllib.request.urlopen(req, timeout=10)
except Exception:
pass

View file

@ -1,14 +1,15 @@
import json
import os
import socket
import subprocess
import fcntl
import contextlib
import time
from config import XRAY_CONFIG_PATH, DE1_TRANSPORTS
import chains
from config import XRAY_CONFIG_PATH
_LOCK_PATH = XRAY_CONFIG_PATH + ".lock"
_LOCAL_TAGS = {t["tag"] for t in DE1_TRANSPORTS}
_TAG_FLOW = {t["tag"]: t.get("flow") for t in DE1_TRANSPORTS}
@contextlib.contextmanager
@ -101,7 +102,7 @@ def _reload_xray():
def _local_inbounds(cfg):
return [ib for ib in cfg["inbounds"] if ib.get("tag") in _LOCAL_TAGS]
return [ib for ib in cfg["inbounds"] if chains.is_user_tag(ib.get("tag"))]
def add_client(client_uuid: str, email: str):
@ -113,7 +114,7 @@ def add_client(client_uuid: str, email: str):
if any(c["id"] == client_uuid for c in clients):
continue
entry = {"id": client_uuid, "email": email}
flow = _TAG_FLOW.get(ib["tag"])
flow = chains.flow_for_tag(ib["tag"])
if flow:
entry["flow"] = flow
clients.append(entry)
@ -138,38 +139,138 @@ def remove_client(client_uuid: str):
_reload_xray()
def _node_usable(node):
return bool(node["enabled"]) and node["status"] == "active"
def desired_state(node):
import db as dbmod
active = dbmod.list_active_subscriptions(node=node["code"])
wanted = {s["uuid"]: s["uuid"] for s in active}
nodes_by_code = {n["code"]: n for n in dbmod.list_nodes()}
entry_chains = []
exit_nodes = {}
relay_wanted = {}
for chain in dbmod.list_chains(enabled_only=True):
entry = nodes_by_code.get(chain["entry_node"])
exit_node = nodes_by_code.get(chain["exit_node"])
if not entry or not exit_node:
continue
if not _node_usable(entry) or not _node_usable(exit_node):
continue
if chain["entry_node"] == node["code"]:
entry_chains.append(chain)
exit_nodes[chain["exit_node"]] = exit_node
if chain["exit_node"] == node["code"] and node["kind"] in ("local", "managed") and chain.get("relay_uuid"):
relay_wanted[chain["relay_uuid"]] = chains.relay_email(chain["code"])
return wanted, relay_wanted, entry_chains, exit_nodes
def _read_config_text():
with open(XRAY_CONFIG_PATH, "r", encoding="utf-8") as f:
return f.read()
def _restore_config_text(text):
tmp = XRAY_CONFIG_PATH + ".restore.tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp, XRAY_CONFIG_PATH)
subprocess.run(["systemctl", "restart", "xray"], timeout=20)
def _reload_and_verify():
subprocess.run(["systemctl", "restart", "xray"], check=True, timeout=20)
time.sleep(1)
state = subprocess.run(["systemctl", "is-active", "xray"], capture_output=True, text=True).stdout.strip()
if state != "active":
raise ConfigValidationError("xray не поднялся после применения конфига, вернули старый")
def _port_busy(port):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.bind(("0.0.0.0", int(port)))
return False
except OSError:
return True
finally:
sock.close()
def _open_firewall(port):
subprocess.run(
["sh", "-c", f"command -v ufw >/dev/null 2>&1 && ufw allow {int(port)}/tcp || true"],
timeout=20,
)
def _reconcile_local(wanted, relay_wanted, entry_chains, exit_nodes, apply_chains=True):
with _locked():
before_text = _read_config_text()
cfg = json.loads(before_text)
usable = entry_chains
skipped = []
new_ports = []
if apply_chains:
busy = {c["port"] for c in entry_chains if _port_busy(c["port"])}
usable, skipped = chains.split_busy_chains(cfg, entry_chains, busy)
new_ports = chains.new_ports_needed(cfg, usable)
changed, problems = chains.sync_config(cfg, wanted, relay_wanted, usable, exit_nodes, apply_chains=apply_chains)
if changed:
_save(cfg)
try:
_reload_and_verify()
except Exception:
_restore_config_text(before_text)
raise
for port in new_ports:
_open_firewall(port)
return {"changed": changed, "new_ports": new_ports, "problems": skipped + problems}
def sync_node(node):
wanted, relay_wanted, entry_chains, exit_nodes = desired_state(node)
if node["kind"] == "managed":
import nodeprov
reconcile = nodeprov.remote_reconcile
args = (node, wanted, relay_wanted, entry_chains, exit_nodes)
elif node["kind"] == "local":
reconcile = _reconcile_local
args = (wanted, relay_wanted, entry_chains, exit_nodes)
else:
return {"changed": False, "new_ports": [], "problems": []}
try:
return reconcile(*args)
except Exception as first_error:
if not entry_chains:
raise
result = reconcile(*args, apply_chains=False)
result["problems"].append(f"цепочки не применились, клиенты синхронизированы: {first_error}")
return result
def sync_from_db():
import db as dbmod
expired = dbmod.deactivate_expired()
active = dbmod.list_active_subscriptions(node="de1")
active_by_id = {s["uuid"]: s for s in active}
node = dbmod.get_node("de1")
result = sync_node(node)
wanted = desired_state(node)[0]
return {
"removed_expired": len(expired), "active_now": len(wanted),
"reloaded": result["changed"], "problems": result["problems"],
}
with _locked():
cfg = _load()
changed = False
for ib in _local_inbounds(cfg):
clients = ib["settings"]["clients"]
current_ids = {c["id"] for c in clients}
if current_ids == set(active_by_id.keys()):
continue
new_clients = [c for c in clients if c["id"] in active_by_id]
existing_ids = {c["id"] for c in new_clients}
flow = _TAG_FLOW.get(ib["tag"])
for cid, sub in active_by_id.items():
if cid not in existing_ids:
entry = {"id": cid, "email": cid}
if flow:
entry["flow"] = flow
new_clients.append(entry)
ib["settings"]["clients"] = new_clients
changed = True
if changed:
_save(cfg)
_reload_xray()
return {"removed_expired": len(expired), "active_now": len(active_by_id), "reloaded": changed}
def probe_from_node(node: dict, host: str, port: int):
if node["kind"] == "local":
return chains.tcp_connect_ms(host, port)
if node["kind"] == "managed":
import nodeprov
return nodeprov.remote_probe(node, host, port)
raise ValueError("нода не под управлением панели, замерить с неё нельзя")
def add_client_to_node(node: dict, client_uuid: str, email: str):
@ -266,7 +367,6 @@ def local_node_status() -> dict:
def sync_all():
import db as dbmod
import nodeprov
expired = dbmod.deactivate_expired()
results = {}
@ -276,8 +376,15 @@ def sync_all():
elif node["kind"] == "managed":
active = dbmod.list_active_subscriptions(node=node["code"])
try:
nodeprov.remote_sync(node, active)
results[node["code"]] = {"active_now": len(active), "ok": True}
res = sync_node(node)
results[node["code"]] = {
"active_now": len(active), "ok": True,
"changed": res["changed"], "problems": res["problems"],
}
except Exception as e:
results[node["code"]] = {"active_now": len(active), "ok": False, "error": str(e)}
return {"removed_expired": len(expired), "nodes": results}
reloaded = any(r.get("changed") or r.get("reloaded") for r in results.values())
return {
"removed_expired": len(expired), "active_now": len(dbmod.list_active_subscriptions()),
"reloaded": reloaded, "nodes": results,
}