Commit graph

12 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
5b30d1f148 payments: Platega + YooKassa integration, opt-in via PAYMENTS_ENABLED
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 21:44:45 +05:00
073c4fb9f0 bot + api: telegram bot (aiogram) and FastAPI backend
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 17:45:43 +05:00