Compare commits

...

57 commits
v1.0.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
61bcd41561 docs: README feature list catches up with tonight's additions, v1.1.0
Backup&Restore, multi-admin+2FA, xray config validation, drag-n-drop
nodes, and login rate-limiting were all shipped but never made it
into the README's feature list. Also switched the README's install
command to the mbs.savsis.xyz one-liner to match the landing page
(cfac7c1) — kept the raw GitHub clone as a documented alternative,
same script either way. Version tag in admin.html bumped to v1.1.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 17:09:19 +05:00
52f5c551cb security: rate-limit admin login and TOTP verification
Neither endpoint had any brute-force protection — TOTP codes are only
6 digits (1M combinations) and HMAC-SHA1 verification is cheap, so an
unthrottled /admin/api/login/totp is a realistic brute-force target
within a pending token's 5-minute window. Password login had the same
gap.

DB-backed (new login_attempts table), not in-memory — this matters
now that mbs-api runs multiple worker processes (see 11c75c1): an
in-process counter would let an attacker split requests across
workers and bypass it entirely, same class of mistake as an
unsynchronized in-memory cache. Keyed by client IP (nginx already
sets X-Real-IP on every proxied request, install.sh has always done
this).

10 failed attempts / 15min for password, 10 / 5min for TOTP codes,
counted per-IP per-kind. Successful login clears that IP's recent
failures. Old rows pruned in the existing 90s periodic_sync cleanup
alongside sessions and pending_totp.

Verified: threshold counting, per-IP isolation, per-kind isolation
(password vs totp tracked separately), clear-on-success, and the
age-based cleanup only removing rows older than the cutoff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:42:20 +05:00
11c75c13d1 perf: multi-worker uvicorn + fix N+1 on the hottest path in the app
mbs-api ran single-worker uvicorn with no --workers flag at all — one
event loop handling every request. Now install.sh (and mbs update, so
existing installs pick it up too) compute a worker count from nproc
(clamped 1-4, matching typical VPS core counts) and bake it into the
systemd unit via sed substitution of a __WORKERS__ placeholder. Safe
to parallelize: verified no api.py module-level mutable state, all
of it already goes through sqlite (payment idempotency and the
xray-config file lock are already correct across separate processes,
not just asyncio tasks within one — confirmed both are OS/db-level,
not in-process). Verified with a mock dry-run of the new systemd-unit
section (fake nproc, real sed substitution) producing the expected
ExecStart line for several core counts.

Also: build_subscription_text() — called on every single hit of
/sub/{token}, the single most frequently called endpoint in the whole
app, since every VPN client re-fetches on every reconnect — was doing
one db.get_node() call per distinct node in a user's subscriptions
instead of fetching once. Same N+1 shape as the admin-endpoint bugs
fixed yesterday, except this one is on the hot path, not just the
admin panel. Fixed to batch-fetch via db.list_nodes() once.

Verified: correct output for a 4-node subscription (each node's
address appears exactly once, de1's 4 transports all present,
nothing silently dropped) and measured ~1.3ms/call average.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:13:44 +05:00
7098e59967 feat: TOTP two-factor auth for admin login
Matches Remnawave's security-first positioning (passkeys/OAuth there)
with the more universal standard instead — RFC 6238 TOTP, works with
Google Authenticator/Authy/1Password/anything. Implemented from
scratch on stdlib only (hashlib/hmac/struct/base64) — no new
dependency — and verified against the official RFC 4226 HOTP test
vectors (all 10 pass exactly) before wiring it into any auth path.

Per-admin, optional: setup shows the secret + otpauth:// URI (no QR
render, just copyable text — didn't want to fake a QR library),
confirmed by entering a real code before it's persisted. Login is now
two-step when 2FA is on: password first (returns a short-lived
pending_token instead of a session if totp_secret is set), then a
second call with the pending_token + code creates the real session.
pending_totp rows are single-use and expire in 5 minutes, cleaned up
alongside the existing admin_sessions cleanup in periodic_sync.
Disabling 2FA requires re-entering the current password.

Verified end-to-end: enable/disable round trip, pending-token
resolve+single-use+expiry, code verification against the stored
secret, wrong-code and wrong-password rejection — on top of the raw
HOTP correctness check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:43:52 +05:00
9e6e314c94 feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.

Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.

Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
81cbc4e391 fix: prune old backup safety-copies and expired admin sessions instead of letting them pile up forever
Every backup restore was leaving a .before-restore-<timestamp> safety
copy of both mbs.db and .env with no cleanup — would accumulate
forever on a panel that restores regularly. Now keeps the 5 most
recent and prunes the rest right after each restore. Verified: 8
fake copies pruned down to exactly the 5 newest, oldest-first.

admin_sessions rows also never got deleted once expired (only ever
filtered out of queries via expires_at>?) — same shape of problem.
Piggybacks on the existing 90s periodic_sync heartbeat in bot.py
instead of adding a new one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:08:34 +05:00
2a68b2ff40 feat: drag-and-drop node reordering in the admin panel
Matches Remnawave/Marzban's 'Host Sorting via Web UI' — was the one
concrete UI gap flagged in the comparison research. Nodes get a
persistent sort_order column (backfilled once from the existing
de1-first/created_at order on migration, verified idempotent — re-running
_migrate() on an already-migrated db does not reshuffle it back).

Plain HTML5 drag-and-drop (dragstart/dragover/drop), no library —
matches the project's no-frameworks admin.html. Drop reorders the
in-memory list optimistically, re-renders immediately, then persists
via POST /admin/api/nodes/reorder; a failed save reloads from the
server instead of leaving the UI out of sync with the db.

db.reorder_nodes() rejects any list that doesn't contain exactly the
current set of node codes (no silent drops or duplicates). New nodes
append at the end (MAX(sort_order)+1) instead of jumping to the front.

Verified with a full test: initial order, reorder, two rejected
malformed reorders, a new node appending at the end, and migration
re-run not touching an already-backfilled order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 10:55:15 +05:00
91a8a4374b site: mark backup/restore as shipped on the landing comparison table
Was 'в разработке' — done as of f586cf9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 10:24:18 +05:00
f586cf9fa3 feat: backup & restore built into the admin panel
Neither Remnawave nor Marzban has this natively (community tools only,
per docs.rw's own comparison table) — one-click download of a tar.gz
with a consistent SQLite snapshot (via sqlite3's backup API, safe even
under WAL) plus .env, and upload-to-restore from the same file.

Restore validates the archive is real (gzip + tar structure), that
mbs.db is an actual sqlite database with the expected tables (not
just any file named mbs.db), and rejects oversized uploads — before
touching anything live. Takes a timestamped safety copy of the
current db/.env before overwriting, clears stale -wal/-shm siblings
so the restored file doesn't get replayed against the wrong WAL, and
restarts mbs-bot automatically when .env was part of the restore
(api.py isn't restarted from within its own request handler for the
obvious reason).

Verified with a full round-trip test: backup -> mutate state -> restore
-> confirm the mutation is reverted, plus three negative cases (garbage
data, oversized upload, a fake non-sqlite mbs.db) all correctly
rejected with no side effects.

Needs python-multipart for FastAPI's UploadFile — added to
requirements.txt, picked up by the next 'mbs update'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 10:23:38 +05:00
cfac7c1445 site: landing install command now routes through our own site, not raw github clone
Was: git clone https://github.com/devsavsis/mbs-panel.git && cd mbs-panel && sudo bash install.sh
Now: bash <(curl -Ls https://mbs.savsis.xyz/install.sh)

Matches the one-liner pattern already used for node installs
(nodeprov.ONE_COMMAND_TEMPLATE). install.sh itself already prefers the
api.savsis.xyz mirror over github.com for the actual repo clone (see
2604c2d) — this just stops sending first-time visitors through a raw
github.com URL as the very first step, before that fallback logic even
runs. mbs.savsis.xyz/install.sh is served as a static file kept fresh
by the existing mirror-sync cron (extended to export it on every sync).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 09:54:11 +05:00
54dc9e2dae site: project landing page, deployed at mbs.savsis.xyz
Standalone marketing/docs page for the mbs-panel project itself (not
the customer-facing site/ template that ships with installs) — what it
is, feature grid, an honest comparison table vs Remnawave/Marzban
(sourced from docs.rw's own comparison page), architecture, and a
step-by-step install guide with the clone-and-run command.

Same dark design system as site/index.html (colors, easing, scroll
reveal) for visual consistency across everything savsis.xyz.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 09:43:28 +05:00
abdf18faae feat: validate xray config before every restart, local + managed nodes
Runs 'xray run -test' against the candidate config before writing it and
restarting the service (local node, and over SSH for managed nodes) —
a bad config now fails loudly with the panel/bot call raising an error
instead of xray crash-looping in production.

Also checks TLS certificate/key file permissions against the actual
xray service user (nobody:nogroup) before accepting a config — this is
the exact class of bug that caused yesterday's WS+TLS outage (cert
readable by root but not by nobody). Verified live against the real
shayba server: replaying that exact bad config now gets rejected with
'cert permission problem(s): ... keyFile=... not readable' instead of
being written and restarted.

Managed-node restarts now also check systemctl's own exit status
instead of discarding it, so a restart failure surfaces as an error
too, not just silently swallowed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 09:34:52 +05:00
f9ee4f5933 fix: 18-point audit pass — payment races, hwid limit bugs, blocking SSH/HTTP in event loops, N+1 queries, ssh host-key pinning, dead code
payments: _grant_paid_subscription now validates plan/node exist before
marking a payment paid instead of after (was leaving charged-but-ungranted
payments with no error trail); mark_payment_paid is now a single atomic
UPDATE ... WHERE status='pending' instead of check-then-act, closing a
double-grant race between webhooks and the periodic reconciler; yookassa
webhook now re-verifies payment status server-side via the API instead of
trusting the posted body (platega already had HMAC verification).

hwid: 'user["hwid_limit"] or FALLBACK' treated an explicit 0 (admin fully
blocking a user) as unset — now an explicit None check. Device count-check
and insert are now one atomic transaction (db.add_device_if_under_limit)
instead of two raceable statements.

perf: payment webhooks and _grant_paid_subscription's SSH/HTTP calls now
run via asyncio.to_thread instead of blocking the event loop; same for
bot.py's periodic_sync/reconcile_pending_payments and the manual admin
sync button. Admin endpoints (traffic/subscriptions/payments/gift-codes/
user-card) now resolve node labels from one db.list_nodes() call instead
of a fresh db.get_node() per row. revoke/reset-traffic use a direct PK
lookup instead of scanning up to 5000 rows. Dashboard now asks the API
for 8 rows instead of fetching 200 and slicing client-side.

security: mbs.db (and -wal/-shm) now chmod 600 right after creation —
it held session tokens and subscription bearer tokens world-readable
by default. Node SSH connections now pin host keys via a persisted
known_hosts file (TOFU) instead of accepting any key on every connection.
delete_node now refuses to delete a node with active subscriptions
instead of silently orphaning their xray clients.

deadcode: removed unused xray_manager.list_client_ids and admin.html's
superseded staggerReveal (rows animate via rowAttr() inline now).

Also guards gift-code redemption against a plan/node deleted after the
code was created (was an unhandled KeyError/TypeError crash).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 22:20:54 +05:00
2604c2dfe7 feat: mirror repo on api.savsis.xyz as primary update source, github as fallback
install.sh clones from the mirror first (falls back to github.com if
unreachable); mbs update fetches origin (mirror) first, falls back to
a github remote if that fetch fails. Mirror itself is a bare repo on
финка2, kept in sync from GitHub every 10 min via an authenticated
token (needed because that box's IP gets rate-limited/blocked by
GitHub for anonymous git clones).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 17:02:22 +05:00
36fa7d55b0 fix: xray (runs as nobody) couldn't read root-only letsencrypt certs for WS+TLS — copy to /etc/xray/certs with correct perms, keep it fresh via renewal hook
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 08:23:21 +05:00
a8deb1fa8b fix: no-cache headers on admin panel HTML so updates show up without a hard refresh
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:34:23 +05:00
320742bd63 cli: mbs update now also refreshes /usr/local/bin/mbs itself after pulling
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:32:20 +05:00
bfc2fd7b4c settings: change Telegram bot token/username from the admin UI (validated via getMe); mbs restart now covers bot+api+xray+nginx
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:29:58 +05:00
04db4a359e ui: dark scrollbars everywhere, Документация section (architecture/nodes/password/fail2ban)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:25:37 +05:00
5725922bdc fix: refresh node cache on Gifts/User card open (new nodes weren't showing up); preselect country flag in node edit modal
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:06:46 +05:00
8a3ac30dbb chore: trigger CI (diagnosing missing runs)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 22:59:05 +05:00
76eef6fece cli: mbs update — safe git pull with syntax check + auto-rollback on failure
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 22:57:49 +05:00
f5f8f21f5d payments: status verification (check + auto-reconcile pending), admin Payments view
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 22:46:55 +05:00
34 changed files with 8119 additions and 494 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)
@ -10,16 +10,32 @@
Сделано by savsis. Изначально писалось под конкретный проект (шеринг VPN среди своих), но получилось достаточно универсально, чтобы выложить как есть.
Лендинг с фичами и честным сравнением с Remnawave/Marzban: **[mbs.savsis.xyz](https://mbs.savsis.xyz)**
## Что внутри
- **Бот** (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).
- **Лимит устройств (HWID)** — как у Remnawave, опционально: ограничение числа устройств на подписку через `x-hwid` заголовок.
- **Приём оплаты** — ЮKassa и Platega из коробки, опционально; без них бот просто бесплатно выдаёт по кнопке.
- **CLI `mbs`** — управление панелью прямо с сервера: пароль, статус, рестарт, логи.
- **CLI `mbs`** — управление панелью прямо с сервера: пароль, статус, рестарт, логи, обновление.
- **Backup & Restore прямо в админке** — скачал архив (база + `.env`) одной кнопкой, восстановил загрузкой файла. Ни у Remnawave, ни у Marzban такого нет из коробки, только community-скрипты.
- **Мультиадминство + 2FA** — отдельные логины вместо одного пароля на всех, опциональная TOTP-двухфакторка (любой Google Authenticator/Authy) поверх пароля.
- **Проверка конфига Xray перед рестартом** — `xray run -test` плюс проверка что серты реально читаемы юзером, под которым крутится Xray, до того как что-то применится и уронит сервис.
- **Drag-n-drop ноды** — порядок нод в списке настраивается мышкой, как у Remnawave.
- **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код.
- **Свой путь входа** — страницу логина можно увести с дефолтного `/admin` на любой другой (`ADMIN_PATH` в `.env`), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще.
- **Пауза подписки** — временно отключить доступ без потери оплаченных дней (Marzban это умеет, Remnawave — нет): при возобновлении срок сдвигается ровно на длительность паузы.
- **Исходящие вебхуки** — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды и создание/удаление/вкл-выкл цепочки, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса.
- **Поиск и фильтр по подпискам** — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице. Плюс экспорт всех подписок в CSV одной кнопкой.
- **Цепочки серверов (v1.6)** — `клиент → нода A → нода B → интернет`: собираются в админке мышкой, зажал ЛКМ на клиенте и протянул провод через серверы к интернету. Больше двух серверов нельзя специально, на двух панель предупреждает про задержку и сама меряет RTT между нодами. Подробности ниже в разделе «Цепочки серверов».
- **Юзеры и выдача подписок (v1.6)** — отдельная страница со всеми, кто уже есть в системе, даже если подписок у них ни разу не было (в «Подписках» таких не видно): поиск по юзернейму и Telegram ID, счётчики активных подписок и устройств, кнопка «Выдать подписку». Можно выдать и по Telegram ID тому, кого в базе ещё нет, подписка дождётся, пока он зайдёт в бота.
- **Журнал действий (v1.6)** — кто из админов и когда менял ноды, цепочки, подписки, настройки, качал бэкап и входил (включая неудачные входы с IP). Пароли и ключи в журнал не попадают, только факт действия.
- **Пинг нод и палитра команд (v1.6)** — живая задержка от панели до каждой ноды на дашборде и в списке нод; `Ctrl K` открывает поиск по страницам, нодам, цепочкам и действиям. Акцентный цвет панели меняется кружками сверху.
## Архитектура
@ -96,9 +112,11 @@ sequenceDiagram
Нужен чистый сервер на **Ubuntu 22.04/24.04** или **Debian 11/12**, root-доступ и три поднятых DNS A-записи (см. таблицу ниже).
```bash
git clone https://github.com/devsavsis/mbs-panel.git && cd mbs-panel && sudo bash install.sh
bash <(curl -Ls https://lab.savsis.xyz/savsisbtw/mbs-panel/raw/branch/main/install.sh)
```
(или так: `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. В конце покажет пароль от админки и ссылку на панель.
### DNS-записи
@ -120,6 +138,7 @@ git clone https://github.com/devsavsis/mbs-panel.git && cd mbs-panel && sudo bas
- Зайди на `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`.
@ -133,8 +152,19 @@ mbs status статус bot / api / xray / nginx
mbs restart перезапустить bot + api
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
mbs domain текущий домен панели
mbs backup полная копия панели в /root/mbs-backups (база, .env, твои правки, конфиг Xray)
mbs update [ссылка] обновить код и перезапустить; со ссылкой на git-зеркало берёт обновление оттуда
mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, его mbs update проверяет первым
```
`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`.
## Добавление ноды
В панели: Ноды → Добавить ноду → выбираешь страну, протоколы (Reality-транспорты всегда включены, WS+TLS и Hysteria2 — опционально) → получаешь команду вида:
@ -160,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`). Чтобы продавать доступ:
@ -197,7 +243,7 @@ sequenceDiagram
PR и issues welcome. CI на каждый пуш гоняет compile-check по питону, синтаксис-проверку шелл-скриптов и smoke-тест генерации install-скрипта ноды.
## Авторы:
github.com/devsavsis
github.com/savsisbtw
github.com/welfizx
## Лицензия

2733
admin.html

File diff suppressed because it is too large Load diff

1114
api.py

File diff suppressed because it is too large Load diff

130
backup.py Normal file
View file

@ -0,0 +1,130 @@
import glob
import io
import json
import os
import shutil
import sqlite3
import tarfile
import datetime
from config import DB_PATH, BASE_DIR
ENV_PATH = os.path.join(BASE_DIR, ".env")
MAX_RESTORE_SIZE = 200 * 1024 * 1024
KEEP_SAFETY_COPIES = 5
class RestoreError(Exception):
pass
def _prune_old_safety_copies(keep: int = KEEP_SAFETY_COPIES):
for base in (DB_PATH, ENV_PATH):
copies = sorted(glob.glob(f"{base}.before-restore-*"))
for path in copies[:-keep] if keep > 0 else copies:
try:
os.remove(path)
except OSError:
pass
def create_backup() -> bytes:
buf = io.BytesIO()
db_tmp = DB_PATH + ".backup_snapshot.tmp"
src = sqlite3.connect(DB_PATH)
dst = sqlite3.connect(db_tmp)
try:
with dst:
src.backup(dst)
finally:
src.close()
dst.close()
try:
files = ["mbs.db"]
if os.path.exists(ENV_PATH):
files.append(".env")
manifest = {
"created_at": datetime.datetime.utcnow().isoformat(),
"files": files,
}
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
tar.add(db_tmp, arcname="mbs.db")
if os.path.exists(ENV_PATH):
tar.add(ENV_PATH, arcname=".env")
manifest_bytes = json.dumps(manifest, indent=2).encode()
info = tarfile.TarInfo(name="manifest.json")
info.size = len(manifest_bytes)
tar.addfile(info, io.BytesIO(manifest_bytes))
finally:
try:
os.remove(db_tmp)
except OSError:
pass
return buf.getvalue()
def restore_backup(data: bytes) -> dict:
if len(data) > MAX_RESTORE_SIZE:
raise RestoreError("backup file too large")
try:
tar = tarfile.open(fileobj=io.BytesIO(data), mode="r:gz")
except Exception as e:
raise RestoreError(f"not a valid backup archive: {e}")
members = {m.name: m for m in tar.getmembers()}
if "mbs.db" not in members:
raise RestoreError("archive has no mbs.db")
tmp_db_path = DB_PATH + ".restore_candidate.tmp"
db_member = tar.extractfile(members["mbs.db"])
with open(tmp_db_path, "wb") as f:
shutil.copyfileobj(db_member, f)
try:
check_conn = sqlite3.connect(tmp_db_path)
try:
tables = {r[0] for r in check_conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
finally:
check_conn.close()
except sqlite3.DatabaseError as e:
os.remove(tmp_db_path)
raise RestoreError(f"archive's mbs.db is not a valid sqlite database: {e}")
required = {"users", "subscriptions", "nodes", "payments"}
if not required.issubset(tables):
os.remove(tmp_db_path)
raise RestoreError("archive's mbs.db is missing expected tables")
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
safety_copy = f"{DB_PATH}.before-restore-{stamp}"
shutil.copy2(DB_PATH, safety_copy)
restored_env = False
if ".env" in members and os.path.exists(ENV_PATH):
env_safety = f"{ENV_PATH}.before-restore-{stamp}"
shutil.copy2(ENV_PATH, env_safety)
env_member = tar.extractfile(members[".env"])
env_tmp = ENV_PATH + ".restore.tmp"
with open(env_tmp, "wb") as f:
shutil.copyfileobj(env_member, f)
os.chmod(env_tmp, 0o600)
os.replace(env_tmp, ENV_PATH)
restored_env = True
for suffix in ("-wal", "-shm"):
try:
os.remove(DB_PATH + suffix)
except OSError:
pass
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}

360
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,10 +125,13 @@ 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[gift["plan"]]
sub = db.create_subscription(message.from_user.id, gift["node"], plan["days"], plan["code"], source="gift", )
plan = settings.get_plans_by_code().get(gift["plan"])
gift_node = db.get_node(gift["node"])
xray_manager.add_client_to_node(gift_node, sub["uuid"], email=sub["uuid"])
if not plan or not gift_node:
await message.answer("Этот подарок больше недоступен.")
return await send_main_menu(message)
sub = db.create_subscription(message.from_user.id, gift["node"], plan["days"], plan["code"], source="gift", )
await asyncio.to_thread(xray_manager.add_client_to_node, gift_node, sub["uuid"], email=sub["uuid"])
await message.answer(
f"<b>Подарок активирован</b>\n\n"
f"Сервер: {gift_node['label']}\n"
@ -116,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)
@ -130,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()
@ -143,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()
@ -158,20 +216,29 @@ 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)
xray_manager.add_client_to_node(node_row, sub["uuid"], email=sub["uuid"])
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
kb = connect_kb(user["token"], extra_rows=[
[InlineKeyboardButton(text="Моя подписка", callback_data="menu:mysub")],
[InlineKeyboardButton(text="В меню", callback_data="menu:main")],
@ -190,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")
@ -208,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()
@ -223,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)
@ -279,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"
@ -309,7 +383,7 @@ async def cb_admin_stats(cb: CallbackQuery):
async def cb_admin_sync(cb: CallbackQuery):
if not is_admin(cb.from_user.id):
return await cb.answer("Нет доступа", show_alert=True)
result = xray_manager.sync_all()
result = await asyncio.to_thread(xray_manager.sync_all)
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В админку", callback_data="menu:admin")]])
await cb.message.edit_text(
f"Синхронизация xray выполнена.\nАктивно клиентов: {result['active_now']}\n"
@ -319,12 +393,224 @@ async def cb_admin_sync(cb: CallbackQuery):
await cb.answer()
async def periodic_sync():
while True:
@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:
xray_manager.sync_all()
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
try:
status = await asyncio.to_thread(payments.check_payment_status, payment["provider"], payment["external_id"])
except Exception:
continue
if status in payments.PAID_STATUSES:
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",
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:
await bot.send_message(
payment["tg_id"],
f"<b>Оплата получена</b>\n\n"
f"Сервер: {node_row['label']}\n"
f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n"
f"Ссылка-подписка:\n{sub_url_for(user['token'])}",
)
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:
log.exception("payment reconciliation failed")
try:
db.delete_expired_admin_sessions()
db.delete_expired_pending_totp()
db.delete_old_login_attempts()
except Exception:
log.exception("expired admin session cleanup failed")
await asyncio.sleep(90)

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"))

844
db.py

File diff suppressed because it is too large Load diff

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,7 +2,9 @@
set -e
set -o pipefail
REPO_URL="https://github.com/devsavsis/mbs-panel.git"
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/savsisbtw/mbs-panel.git"
APP_DIR="/opt/mbs-panel"
WEBROOT="/var/www/certbot"
@ -46,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")
@ -82,7 +85,18 @@ echo "клонируем репозиторий в $APP_DIR..."
if [ -d "$APP_DIR/.git" ]; then
retry git -C "$APP_DIR" pull --quiet
else
retry git clone --quiet "$REPO_URL" "$APP_DIR"
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
cd "$APP_DIR"
@ -99,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
@ -156,9 +171,22 @@ retry certbot certonly --webroot -w "$WEBROOT" --non-interactive --agree-tos \
retry certbot certonly --webroot -w "$WEBROOT" --non-interactive --agree-tos \
--register-unsafely-without-email -d "$DE1_ADDRESS"
echo "готовлю серт для xray (он не root, letsencrypt/live ему не почитать)..."
mkdir -p /etc/xray/certs
cp "/etc/letsencrypt/live/$DE1_ADDRESS/fullchain.pem" /etc/xray/certs/de1.crt
cp "/etc/letsencrypt/live/$DE1_ADDRESS/privkey.pem" /etc/xray/certs/de1.key
chmod 644 /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
chown nobody:nogroup /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
cat > /etc/letsencrypt/renewal-hooks/deploy/mbs-reload.sh << 'HOOKEOF'
cat > /etc/letsencrypt/renewal-hooks/deploy/mbs-reload.sh << HOOKEOF
#!/bin/bash
if [ -d "/etc/letsencrypt/live/$DE1_ADDRESS" ]; then
cp "/etc/letsencrypt/live/$DE1_ADDRESS/fullchain.pem" /etc/xray/certs/de1.crt
cp "/etc/letsencrypt/live/$DE1_ADDRESS/privkey.pem" /etc/xray/certs/de1.key
chmod 644 /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
chown nobody:nogroup /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
fi
systemctl reload nginx || true
systemctl restart xray || true
HOOKEOF
@ -343,8 +371,8 @@ cat > /usr/local/etc/xray/config.json << XRAYEOF
"wsSettings": { "path": "/mbs-ws" },
"tlsSettings": {
"certificates": [{
"certificateFile": "/etc/letsencrypt/live/$DE1_ADDRESS/fullchain.pem",
"keyFile": "/etc/letsencrypt/live/$DE1_ADDRESS/privkey.pem"
"certificateFile": "/etc/xray/certs/de1.crt",
"keyFile": "/etc/xray/certs/de1.key"
}]
}
}
@ -358,8 +386,15 @@ cat > /usr/local/etc/xray/config.json << XRAYEOF
XRAYEOF
echo "systemd-юниты..."
CPU_COUNT=$(nproc 2>/dev/null || echo 1)
if [ "$CPU_COUNT" -lt 2 ]; then API_WORKERS=1
elif [ "$CPU_COUNT" -gt 4 ]; then API_WORKERS=4
else API_WORKERS=$CPU_COUNT
fi
cp "$APP_DIR/systemd/mbs-bot.service" /etc/systemd/system/mbs-bot.service
cp "$APP_DIR/systemd/mbs-api.service" /etc/systemd/system/mbs-api.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..."
@ -381,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
@ -394,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 "статус сервисов:"

336
landing/index.html Normal file
View file

@ -0,0 +1,336 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MBS Panel</title>
<meta name="description" content="Самостоятельная VPN-панель на Xray-core: VLESS+Reality, Hysteria2, Telegram-бот, платежи и админка — без Docker и без чужого закрытого кода внутри.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2222%22 fill=%22%230a0b0f%22/><path d=%22M20 65 L50 25 L80 65%22 stroke=%22%237c6cf0%22 stroke-width=%228%22 fill=%22none%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22/></svg>">
<style>
:root {
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
--text: #eceef2; --muted: #868c99; --accent: #7c6cf0;
--good: #5fd48a; --bad: #e8748a;
--mono: "SF Mono", "Cascadia Code", Consolas, monospace;
--ease: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; }
.wrap { max-width: 920px; margin: 0 auto; padding: 0 24px; }
header { display: flex; align-items: center; justify-content: space-between; padding: 26px 0; }
.logo { display: flex; align-items: center; gap: 9px; font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }
.logo svg { width: 20px; height: 20px; }
nav { display: flex; align-items: center; gap: 26px; }
nav a { position: relative; text-decoration: none; color: var(--muted); font-size: 14px; transition: color .25s var(--ease); }
nav a::after {
content: ""; position: absolute; left: 0; bottom: -4px; width: 100%; height: 1px;
background: currentColor; transform: scaleX(0); transform-origin: left; transition: transform .3s var(--ease);
}
nav a:hover { color: var(--text); }
nav a:hover::after { transform: scaleX(1); }
.btn {
display: inline-flex; align-items: center; gap: 8px; padding: 13px 22px; border-radius: 10px;
background: var(--text); color: var(--bg); text-decoration: none; font-weight: 600; font-size: 14px;
border: none; cursor: pointer; transition: transform .15s var(--ease), opacity .15s var(--ease);
}
.btn:hover { opacity: .85; }
.btn:active { transform: scale(.97); }
.btn.ghost {
background: transparent; color: var(--text); border: 1px solid var(--border);
transition: transform .15s var(--ease), border-color .25s var(--ease), background .25s var(--ease);
}
.btn.ghost:hover { border-color: #333947; background: var(--card); opacity: 1; }
.btn.sm { padding: 9px 14px; font-size: 13px; }
.reveal { opacity: 0; transform: translateY(14px); filter: blur(6px); transition: opacity .7s var(--ease), transform .7s var(--ease), filter .7s var(--ease); }
.reveal.in { opacity: 1; transform: translateY(0); filter: blur(0); }
.hero { padding: 80px 0 56px; }
.badges { display: flex; gap: 8px; margin-bottom: 22px; flex-wrap: wrap; }
.badges img { height: 20px; display: block; }
.hero h1 { font-size: 44px; line-height: 1.14; margin: 0 0 18px; letter-spacing: -.03em; font-weight: 600; max-width: 680px; }
.hero .accent { color: var(--accent); }
.hero p { color: var(--muted); font-size: 17px; max-width: 560px; margin: 0 0 30px; line-height: 1.6; }
.hero-ctas { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 34px; }
.install {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 16px 18px; max-width: 620px; display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.install code { font-family: var(--mono); font-size: 13.5px; color: var(--text); overflow-x: auto; white-space: nowrap; }
.install code .dim { color: var(--muted); }
.copy-btn {
flex-shrink: 0; background: transparent; border: 1px solid var(--border); color: var(--muted);
border-radius: 7px; padding: 7px 11px; font-size: 12px; cursor: pointer; font-family: inherit;
transition: all .2s var(--ease);
}
.copy-btn:hover { color: var(--text); border-color: #333947; }
.copy-btn.copied { color: var(--good); border-color: var(--good); }
.divider { height: 1px; background: var(--border); margin: 0; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); margin: 0; }
.feature { background: var(--bg); padding: 32px 28px; }
.feature .idx { font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; margin-bottom: 14px; }
.feature h3 { font-size: 16px; margin: 0 0 8px; font-weight: 600; }
.feature p { color: var(--muted); font-size: 14px; margin: 0; line-height: 1.55; }
section { padding: 72px 0; }
.section-label { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 12px; }
h2 { font-size: 26px; margin: 0 0 12px; font-weight: 600; letter-spacing: -.01em; }
.section-sub { color: var(--muted); font-size: 15px; max-width: 560px; line-height: 1.6; margin: 0 0 36px; }
.cmp-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 12px; }
table.cmp { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 560px; }
table.cmp th, table.cmp td { padding: 13px 18px; text-align: left; border-bottom: 1px solid var(--border); }
table.cmp th { color: var(--muted); font-weight: 500; font-size: 12.5px; text-transform: uppercase; letter-spacing: .04em; background: var(--card); }
table.cmp th:not(:first-child), table.cmp td:not(:first-child) { text-align: center; }
table.cmp tr:last-child td { border-bottom: none; }
table.cmp td:first-child { color: var(--text); }
table.cmp .us { background: rgba(124,108,240,.06); font-weight: 600; }
.yes { color: var(--good); }
.no { color: var(--muted); }
.soon { color: var(--accent); font-size: 12.5px; }
.cmp-note { color: var(--muted); font-size: 13px; margin-top: 14px; line-height: 1.5; }
.arch-diagram {
background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 28px;
font-family: var(--mono); font-size: 13px; color: var(--muted); line-height: 2; overflow-x: auto; white-space: pre;
}
.arch-diagram .n { color: var(--text); } .arch-diagram .a { color: var(--accent); }
.how-steps { display: flex; flex-direction: column; gap: 18px; max-width: 640px; }
.how-step { display: flex; gap: 16px; color: var(--muted); font-size: 14.5px; line-height: 1.6; }
.how-step span {
flex-shrink: 0; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--accent);
font-variant-numeric: tabular-nums;
}
.how-step b { color: var(--text); font-weight: 600; }
code.inline { font-family: var(--mono); background: var(--card); border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px; font-size: 13px; color: var(--text); }
.stack { display: flex; flex-wrap: wrap; gap: 10px; }
.stack span {
border: 1px solid var(--border); border-radius: 8px; padding: 7px 13px; font-size: 13px; color: var(--muted);
font-family: var(--mono);
}
.cta { text-align: center; padding: 20px 0 90px; }
.cta h2 { margin-bottom: 8px; }
.cta .section-sub { margin: 0 auto 28px; text-align: center; }
.cta-row { display: flex; justify-content: center; gap: 12px; flex-wrap: wrap; }
footer { border-top: 1px solid var(--border); padding: 28px 0; color: var(--muted); font-size: 13px; }
footer .wrap { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
footer a { text-decoration: underline; text-underline-offset: 2px; }
@media (max-width: 640px) {
.hero h1 { font-size: 32px; }
.grid { grid-template-columns: 1fr; }
.install { flex-direction: column; align-items: stretch; }
.install code { white-space: normal; word-break: break-all; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
.reveal { opacity: 1 !important; transform: none !important; filter: none !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">
<svg viewBox="0 0 100 100" fill="none"><path d="M20 65 L50 25 L80 65" stroke="#7c6cf0" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/></svg>
MBS Panel
</div>
<nav>
<a href="#features">Возможности</a>
<a href="#comparison">Сравнение</a>
<a href="#install">Установка</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/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/savsisbtw/mbs-panel" target="_blank">Смотреть на GitHub</a>
<a class="btn ghost" href="#install">Установка</a>
</div>
<div class="install reveal">
<code><span class="dim">$</span> bash &lt;(curl -Ls https://mbs.savsis.xyz/install.sh)</code>
<button class="copy-btn" onclick="copyInstall(this)">Копировать</button>
</div>
</section>
</div>
<div class="divider" style="margin-top:56px"></div>
<div class="wrap">
<div class="grid" id="features">
<div class="feature reveal">
<div class="idx">01</div>
<h3>Телеграм-бот как основа</h3>
<p>Не уведомления сбоку, а весь флоу покупки и управления — тарифы, гифт-коды, авто-отключение по истечении раз в 90 секунд, а не раз в полчаса.</p>
</div>
<div class="feature reveal">
<div class="idx">02</div>
<h3>Админка без фреймворков</h3>
<p>Чистый HTML/CSS/JS. Дашборд, карточка юзера с историей и устройствами, ноды с полным редактированием, трафик по Stats API самого Xray.</p>
</div>
<div class="feature reveal">
<div class="idx">03</div>
<h3>Мультинодовость в одну команду</h3>
<p>Добавил ноду в панели — получил одну bash-команду. Вставил на чистый сервер — сам ставит Xray, генерит ключи, регистрируется.</p>
</div>
<div class="feature reveal">
<div class="idx">04</div>
<h3>Протоколы на выбор</h3>
<p>VLESS Reality (TCP / gRPC / XHTTP), VLESS WS+TLS с реальным Let's Encrypt сертификатом, Hysteria2 отдельным процессом.</p>
</div>
<div class="feature reveal">
<div class="idx">05</div>
<h3>HWID-лимит устройств</h3>
<p>Ограничение числа устройств на подписку через заголовок клиента — опционально, как у Remnawave, но необязательно.</p>
</div>
<div class="feature reveal">
<div class="idx">06</div>
<h3>Платежи из коробки</h3>
<p>ЮKassa и Platega — опционально. Без них бот просто выдаёт подписку по кнопке, для своих или для теста.</p>
</div>
</div>
</div>
<div class="wrap">
<section id="comparison">
<div class="section-label reveal">Сравнение</div>
<h2 class="reveal">Чем MBS Panel отличается</h2>
<p class="section-sub reveal">Честно, без "мы лучше всех" — Remnawave и Marzban старше, крупнее и во многом функциональнее. Вот где реальная разница, а не маркетинг.</p>
<div class="cmp-wrap reveal">
<table class="cmp">
<thead><tr><th>Функция</th><th class="us">MBS Panel</th><th>Remnawave</th><th>Marzban</th></tr></thead>
<tbody>
<tr><td>Telegram-бот — основной UX покупки</td><td class="us yes">✓</td><td class="no">только уведомления</td><td class="no">уведомления + команды</td></tr>
<tr><td>HWID-лимит устройств</td><td class="us yes">✓</td><td class="yes">✓</td><td class="no">—</td></tr>
<tr><td>База данных</td><td class="us">SQLite</td><td>PostgreSQL</td><td>на выбор</td></tr>
<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>
</div>
<p class="cmp-note reveal">Данные по Remnawave/Marzban — из их собственной документации на момент публикации (<a href="https://docs.rw/overview/comparison-of-functions" target="_blank" style="text-decoration:underline">docs.rw</a>), проверяй актуальность сам, проекты активно развиваются.</p>
</section>
</div>
<div class="divider"></div>
<div class="wrap">
<section>
<div class="section-label reveal">Как это работает</div>
<h2 class="reveal">Что происходит под капотом</h2>
<p class="section-sub reveal">Панель и первая VPN-нода живут на одном сервере. Дополнительные ноды подключаются по SSH — без агента, без открытых портов управления наружу.</p>
<div class="arch-diagram reveal">Telegram / браузер
│
▼
<span class="n">bot.py</span> (aiogram) ──┐
<span class="n">api.py</span> (FastAPI) ──┼──▶ <span class="n">SQLite</span>
│ │
▼ ▼
локальный <span class="a">Xray</span> SSH ──▶ <span class="a">Xray</span> на удалённой ноде
(management-ключ, генерится сам)</div>
<div class="how-steps reveal">
<div class="how-step"><span>1</span>Устанавливаешь на чистый сервер — скрипт сам ставит Xray, nginx, certbot, генерит Reality-ключи, поднимает бота и API systemd-юнитами.</div>
<div class="how-step"><span>2</span>Бот — точка входа для юзеров: тарифы, гифт-коды, оплата (если включена). Подписка выдаётся сразу и добавляется в клиент по одной ссылке.</div>
<div class="how-step"><span>3</span>Админка — точка входа для тебя: ноды, юзеры, устройства, трафик по Stats API самого Xray. Всё через браузер, ничего руками на сервере.</div>
<div class="how-step"><span>4</span>Новую ноду добавляешь в панели — получаешь одну bash-команду. Вставляешь на чистый сервер, она сама ставит Xray и регистрируется — SSH-ключ панель добавляет туда сама, пароль не спрашивает ни разу.</div>
</div>
<div class="stack reveal" style="margin-top:28px">
<span>Python 3.10+</span><span>FastAPI</span><span>aiogram 3</span><span>SQLite (WAL)</span><span>paramiko</span><span>Xray-core</span>
</div>
</section>
</div>
<div class="divider"></div>
<div class="wrap">
<section id="install">
<div class="section-label reveal">Как скачать и установить</div>
<h2 class="reveal">Чистый сервер → готовая панель за одну команду</h2>
<p class="section-sub reveal">Код открыт, MIT — клонируешь и ставишь на свой сервер, ничего не покупаешь и никуда не регистрируешься.</p>
<div class="how-steps reveal">
<div class="how-step"><span>1</span>Подними чистый сервер — <b>Ubuntu 22.04/24.04</b> или <b>Debian 11/12</b>, root-доступ — и три DNS A-записи на его IP: под панель, под подписку и под первую VPN-ноду.</div>
<div class="how-step"><span>2</span>Запусти установочный скрипт от root — одна команда ниже, сама подтянет исходники.</div>
<div class="how-step"><span>3</span>Скрипт спросит домены и пароль админки, сам поставит Xray/nginx/certbot, сгенерит ключи и выпустит сертификаты.</div>
<div class="how-step"><span>4</span>Готово: бот отвечает в Telegram, сайт с подпиской и админка — на своих доменах. Обновления потом — командой <code class="inline">mbs update</code>.</div>
</div>
<div class="install reveal" style="max-width:none;margin-top:8px">
<code><span class="dim">$</span> bash &lt;(curl -Ls https://mbs.savsis.xyz/install.sh)</code>
<button class="copy-btn" onclick="copyInstall(this)">Копировать</button>
</div>
</section>
<div class="cta reveal">
<h2>Открытый исходник, MIT</h2>
<p class="section-sub">Изначально писалось под конкретный проект — получилось достаточно универсально, чтобы выложить как есть. Issues и PR приветствуются.</p>
<div class="cta-row">
<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/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>
<script>
const io = new IntersectionObserver((entries) => {
entries.forEach((e, i) => {
if (e.isIntersecting) {
e.target.style.transitionDelay = (i % 4) * 0.07 + "s";
e.target.classList.add("in");
io.unobserve(e.target);
}
});
}, { threshold: 0.12 });
document.querySelectorAll(".reveal").forEach((el) => io.observe(el));
function copyInstall(btn) {
const text = "bash <(curl -Ls https://mbs.savsis.xyz/install.sh)";
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = "Скопировано";
btn.classList.add("copied");
setTimeout(() => { btn.textContent = orig; btn.classList.remove("copied"); }, 1600);
});
}
</script>
</body>
</html>

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
@ -98,9 +109,14 @@ def build_subscription_text(subs: list[dict]) -> str:
if cur is None or s["expires_at"] > cur["expires_at"]:
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 = db.get_node(node_code)
node = nodes_by_code.get(node_code)
if not node:
continue
base_name = display_name(node["label"])
@ -108,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()

262
mbs
View file

@ -10,9 +10,14 @@ mbs — управление MBS Panel
mbs pass [новый_пароль] сменить пароль админ-панели (без аргумента — сгенерировать случайный)
mbs status статус всех сервисов (bot, api, xray, nginx)
mbs restart перезапустить bot + api
mbs restart перезапустить всё (bot, api, xray, reload nginx)
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
mbs domain показать текущий домен панели
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
}
@ -35,8 +40,9 @@ cmd_status() {
}
cmd_restart() {
systemctl restart mbs-bot mbs-api
echo "Перезапущено."
systemctl restart mbs-bot mbs-api xray
systemctl reload nginx 2>/dev/null || true
echo "Перезапущено: bot, api, xray (+ reload nginx)."
}
cmd_logs() {
@ -53,11 +59,261 @@ cmd_domain() {
grep "^PANEL_DOMAIN=" "$ENV_FILE"
}
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
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 "не удалось получить обновления ни с зеркала, ни с github"
return 1
fi
fi
before=$(git rev-parse HEAD)
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 "новая: $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 "обновляю зависимости..."
venv/bin/pip install --quiet -r requirements.txt
echo "проверяю код..."
if ! venv/bin/python -m py_compile *.py; then
echo "новый код не проходит проверку, откатываюсь на $before..."
git reset --hard "$before" --quiet
venv/bin/pip install --quiet -r requirements.txt
restore_stash
return 1
fi
echo "обновляю сам CLI..."
cp "$APP_DIR/mbs" /usr/local/bin/mbs
chmod +x /usr/local/bin/mbs
echo "обновляю systemd-юниты..."
local cpu_count api_workers
cpu_count=$(nproc 2>/dev/null || echo 1)
if [ "$cpu_count" -lt 2 ]; then api_workers=1
elif [ "$cpu_count" -gt 4 ]; then api_workers=4
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
systemctl daemon-reload
echo "перезапускаю..."
systemctl restart mbs-bot mbs-api xray
systemctl reload nginx 2>/dev/null || true
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 "$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,11 +8,12 @@ import subprocess
import paramiko
import chains
from config import PANEL_DOMAIN
MGMT_KEY_PATH = "/root/.ssh/mbs_nodes_ed25519"
LOCAL_TAGS = {"vless-tcp-reality", "vless-grpc-reality", "vless-xhttp-reality", "vless-ws-tls"}
TAG_FLOW = {"vless-tcp-reality": "xtls-rprx-vision"}
MGMT_KNOWN_HOSTS_PATH = "/root/.ssh/mbs_nodes_known_hosts"
REMOTE_CONFIG_PATH = "/usr/local/etc/xray/config.json"
ONE_COMMAND_TEMPLATE = "bash <(curl -Ls https://{panel}/install/{token}.sh)"
@ -17,9 +21,18 @@ CERTBOT_SNIPPET = """echo "issuing a real TLS cert for {address} (needed for WS+
command -v certbot >/dev/null 2>&1 || apt-get install -y certbot
ss -ltnp | grep -q ':80 ' && {{ echo "something is already on port 80, stop it first"; exit 1; }}
certbot certonly --standalone --non-interactive --agree-tos --register-unsafely-without-email -d {address}
mkdir -p /etc/xray/certs
cp /etc/letsencrypt/live/{address}/fullchain.pem /etc/xray/certs/node.crt
cp /etc/letsencrypt/live/{address}/privkey.pem /etc/xray/certs/node.key
chmod 644 /etc/xray/certs/node.crt /etc/xray/certs/node.key
chown nobody:nogroup /etc/xray/certs/node.crt /etc/xray/certs/node.key
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
cat > /etc/letsencrypt/renewal-hooks/deploy/mbs-restart-xray.sh << 'HOOK'
cat > /etc/letsencrypt/renewal-hooks/deploy/mbs-restart-xray.sh << HOOK
#!/bin/bash
cp /etc/letsencrypt/live/{address}/fullchain.pem /etc/xray/certs/node.crt
cp /etc/letsencrypt/live/{address}/privkey.pem /etc/xray/certs/node.key
chmod 644 /etc/xray/certs/node.crt /etc/xray/certs/node.key
chown nobody:nogroup /etc/xray/certs/node.crt /etc/xray/certs/node.key
systemctl restart xray || true
HOOK
chmod +x /etc/letsencrypt/renewal-hooks/deploy/mbs-restart-xray.sh
@ -58,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
@ -78,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)
@ -185,8 +230,8 @@ def _build_config_json(transports, private_key, address):
elif t["security"] == "tls":
ib["streamSettings"] = {"network": "ws", "security": "tls", "wsSettings": {"path": t["path"]},
"tlsSettings": {"certificates": [{
"certificateFile": f"/etc/letsencrypt/live/{address}/fullchain.pem",
"keyFile": f"/etc/letsencrypt/live/{address}/privkey.pem",
"certificateFile": "/etc/xray/certs/node.crt",
"keyFile": "/etc/xray/certs/node.key",
}]}}
inbounds.append(ib)
@ -221,45 +266,108 @@ 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"],
)
def _mgmt_connect(address: str, ssh_port: int = 22) -> paramiko.SSHClient:
client = paramiko.SSHClient()
try:
client.load_host_keys(MGMT_KNOWN_HOSTS_PATH)
except IOError:
pass
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
key = paramiko.Ed25519Key.from_private_key_file(MGMT_KEY_PATH)
client.connect(address, port=ssh_port, username="root", pkey=key, timeout=15, banner_timeout=15, auth_timeout=15)
client.save_host_keys(MGMT_KNOWN_HOSTS_PATH)
return client
def _remote_edit_clients(node: dict, mutate_fn):
class RemoteConfigError(Exception):
pass
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())
result = mutate_fn(cfg, client)
if not result["changed"]:
sftp.close()
return result
data = json.dumps(cfg, indent=2).encode()
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)
test_exit = stdout.channel.recv_exit_status()
test_out = (stdout.read().decode(errors="replace") + stderr.read().decode(errors="replace")).strip()
if test_exit != 0:
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"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 && 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()
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"]:
if ib.get("tag") not in LOCAL_TAGS:
tag = ib.get("tag")
if not chains.is_user_tag(tag):
continue
clients = ib["settings"]["clients"]
new_clients = mutate_fn(clients, ib["tag"])
new_clients = mutate_fn(ib["settings"]["clients"], tag)
if new_clients is not None:
ib["settings"]["clients"] = new_clients
changed = True
if changed:
data = json.dumps(cfg, indent=2).encode()
with sftp.open("/usr/local/etc/xray/config.json", "wb") as f:
f.write(data)
sftp.close()
client.exec_command("systemctl restart xray")[1].channel.recv_exit_status()
else:
sftp.close()
finally:
client.close()
return {"changed": changed}
_remote_edit_config(node, mutate)
def remote_add_client(node: dict, client_uuid: str, email: str):
@ -267,7 +375,7 @@ def remote_add_client(node: dict, client_uuid: str, email: str):
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)
@ -282,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
@ -34,8 +32,15 @@ def _post_json(url: str, body: dict, headers: dict, timeout: int = 15) -> dict:
return json.loads(resp.read().decode())
def _get_json(url: str, headers: dict, timeout: int = 15) -> dict:
req = urllib.request.Request(url, method="GET", headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
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",
{
@ -56,11 +61,27 @@ 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:
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}"},
)
return data.get("status", "")
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",
{
@ -72,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")
@ -84,13 +105,35 @@ 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": merchant_id, "X-Secret": secret},
)
return data.get("status", "")
PAID_STATUSES = {"succeeded", "CONFIRMED"}
FAILED_STATUSES = {"canceled", "CANCELED", "CHARGEBACKED"}
def create_payment_link(provider: str, payment_id: str, amount_rub: int, description: str):
if provider == "yookassa":
return create_yookassa_payment(payment_id, amount_rub, description)
if provider == "platega":
return create_platega_payment(payment_id, amount_rub, description)
raise ValueError(f"unknown provider: {provider}")
def check_payment_status(provider: str, external_id: str) -> str:
if provider == "yookassa":
return check_yookassa_payment(external_id)
if provider == "platega":
return check_platega_payment(external_id)
raise ValueError(f"unknown provider: {provider}")

View file

@ -2,3 +2,4 @@ aiogram==3.15.0
fastapi==0.115.6
uvicorn[standard]==0.32.1
paramiko==3.5.0
python-multipart==0.0.20

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

@ -5,7 +5,7 @@ After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/mbs-panel
ExecStart=/opt/mbs-panel/venv/bin/uvicorn api:app --host 127.0.0.1 --port 8001
ExecStart=/opt/mbs-panel/venv/bin/uvicorn api:app --host 127.0.0.1 --port 8001 --workers __WORKERS__
Restart=on-failure
RestartSec=3
User=root

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 ]

45
totp.py Normal file
View file

@ -0,0 +1,45 @@
import base64
import hashlib
import hmac
import os
import struct
import time as timemod
import urllib.parse
def generate_secret() -> str:
return base64.b32encode(os.urandom(20)).decode("ascii").rstrip("=")
def _hotp(secret_b32: str, counter: int) -> str:
padded = secret_b32 + "=" * ((8 - len(secret_b32) % 8) % 8)
key = base64.b32decode(padded.upper())
msg = struct.pack(">Q", counter)
h = hmac.new(key, msg, hashlib.sha1).digest()
offset = h[-1] & 0x0F
code = (struct.unpack(">I", h[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000
return f"{code:06d}"
def now_code(secret_b32: str, for_time: float | None = None) -> str:
t = for_time if for_time is not None else timemod.time()
counter = int(t // 30)
return _hotp(secret_b32, counter)
def verify(secret_b32: str, code: str, window: int = 1) -> bool:
if not code or not code.isdigit() or len(code) != 6:
return False
counter = int(timemod.time() // 30)
for offset in range(-window, window + 1):
if hmac.compare_digest(_hotp(secret_b32, counter + offset), code):
return True
return False
def uri(secret_b32: str, username: str, issuer: str = "MBS Panel") -> str:
label = urllib.parse.quote(f"{issuer}:{username}")
return (
f"otpauth://totp/{label}?secret={secret_b32}"
f"&issuer={urllib.parse.quote(issuer)}&algorithm=SHA1&digits=6&period=30"
)

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
@ -26,7 +27,70 @@ def _load():
return json.load(f)
class ConfigValidationError(Exception):
pass
def _readable_by(path, uid, gid) -> bool:
try:
st = os.stat(path)
except OSError:
return False
mode = st.st_mode
if st.st_uid == uid and mode & 0o400:
return True
if st.st_gid == gid and mode & 0o040:
return True
if mode & 0o004:
return True
return False
def check_cert_permissions(cfg, uid=65534, gid=65534) -> list:
problems = []
for ib in cfg.get("inbounds", []):
stream = ib.get("streamSettings") or {}
tls = stream.get("tlsSettings")
if not tls:
continue
for cert in tls.get("certificates") or []:
for key in ("certificateFile", "keyFile"):
path = cert.get(key)
if path and not _readable_by(path, uid, gid):
problems.append(f"{ib.get('tag', '?')}: {key}={path} not readable by the xray service user")
return problems
def validate_config(cfg, xray_bin="/usr/local/bin/xray") -> tuple:
tmp = XRAY_CONFIG_PATH + ".validate.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
try:
result = subprocess.run(
[xray_bin, "run", "-test", "-format=json", "-config", tmp],
capture_output=True, text=True, timeout=15,
)
ok = result.returncode == 0
detail = (result.stdout + result.stderr).strip()
except Exception as e:
return False, str(e)
finally:
try:
os.remove(tmp)
except OSError:
pass
if not ok:
return False, detail
perm_problems = check_cert_permissions(cfg)
if perm_problems:
return False, "cert permission problem(s): " + "; ".join(perm_problems)
return True, detail
def _save(cfg):
ok, detail = validate_config(cfg)
if not ok:
raise ConfigValidationError(detail)
tmp = XRAY_CONFIG_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
@ -38,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):
@ -50,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)
@ -75,47 +139,138 @@ def remove_client(client_uuid: str):
_reload_xray()
def list_client_ids():
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():
cfg = _load()
ids = set()
for ib in _local_inbounds(cfg):
ids |= {c["id"] for c in ib["settings"]["clients"]}
return ids
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):
@ -212,7 +367,6 @@ def local_node_status() -> dict:
def sync_all():
import db as dbmod
import nodeprov
expired = dbmod.deactivate_expired()
results = {}
@ -222,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,
}