Compare commits

..

15 commits
v1.2.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
23 changed files with 4991 additions and 335 deletions

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
@ -310,10 +320,10 @@ jobs:
import settings
db.init_db()
db.create_node("n1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
db.create_node("bk1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
sub_a = db.create_subscription(111, "n1", 30, "1m", source="bot")
sub_b = db.create_subscription(222, "n1", 30, "1m", source="bot")
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})
@ -333,7 +343,7 @@ jobs:
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, "n1", 30, "1m", source="bot")
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
@ -384,3 +394,209 @@ jobs:
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)
@ -30,8 +30,12 @@
- **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код.
- **Свой путь входа** — страницу логина можно увести с дефолтного `/admin` на любой другой (`ADMIN_PATH` в `.env`), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще.
- **Пауза подписки** — временно отключить доступ без потери оплаченных дней (Marzban это умеет, Remnawave — нет): при возобновлении срок сдвигается ровно на длительность паузы.
- **Исходящие вебхуки** — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса.
- **Поиск и фильтр по подпискам** — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице.
- **Исходящие вебхуки** — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды и создание/удаление/вкл-выкл цепочки, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса.
- **Поиск и фильтр по подпискам** — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице. Плюс экспорт всех подписок в CSV одной кнопкой.
- **Цепочки серверов (v1.6)** — `клиент → нода A → нода B → интернет`: собираются в админке мышкой, зажал ЛКМ на клиенте и протянул провод через серверы к интернету. Больше двух серверов нельзя специально, на двух панель предупреждает про задержку и сама меряет RTT между нодами. Подробности ниже в разделе «Цепочки серверов».
- **Юзеры и выдача подписок (v1.6)** — отдельная страница со всеми, кто уже есть в системе, даже если подписок у них ни разу не было (в «Подписках» таких не видно): поиск по юзернейму и Telegram ID, счётчики активных подписок и устройств, кнопка «Выдать подписку». Можно выдать и по Telegram ID тому, кого в базе ещё нет, подписка дождётся, пока он зайдёт в бота.
- **Журнал действий (v1.6)** — кто из админов и когда менял ноды, цепочки, подписки, настройки, качал бэкап и входил (включая неудачные входы с IP). Пароли и ключи в журнал не попадают, только факт действия.
- **Пинг нод и палитра команд (v1.6)** — живая задержка от панели до каждой ноды на дашборде и в списке нод; `Ctrl K` открывает поиск по страницам, нодам, цепочкам и действиям. Акцентный цвет панели меняется кружками сверху.
## Архитектура
@ -108,10 +112,10 @@ sequenceDiagram
Нужен чистый сервер на **Ubuntu 22.04/24.04** или **Debian 11/12**, root-доступ и три поднятых DNS A-записи (см. таблицу ниже).
```bash
bash <(curl -Ls https://mbs.savsis.xyz/install.sh)
bash <(curl -Ls https://lab.savsis.xyz/savsisbtw/mbs-panel/raw/branch/main/install.sh)
```
(или напрямую с GitHub, если так удобнее: `git clone https://github.com/devsavsis/mbs-panel.git && cd mbs-panel && sudo bash install.sh` — скрипт один и тот же, `mbs.savsis.xyz` просто зеркало с автосинком)
(или так: `git clone https://lab.savsis.xyz/savsisbtw/mbs-panel.git && cd mbs-panel && sudo bash install.sh`. Основной репозиторий лежит на lab.savsis.xyz, GitHub и api.savsis.xyz остаются зеркалами на случай, если lab недоступен, установщик и `mbs update` сами переключаются на них)
Скрипт спросит домен панели, домен подписки, токен бота от [@BotFather](https://t.me/BotFather) и список Telegram ID админов — и дальше всё сам: ставит зависимости, Xray, nginx, выпускает сертификаты Let's Encrypt, генерирует Reality-ключи, поднимает systemd-сервисы, настраивает firewall (ufw) и fail2ban. В конце покажет пароль от админки и ссылку на панель.
@ -148,10 +152,18 @@ mbs status статус bot / api / xray / nginx
mbs restart перезапустить bot + api
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
mbs domain текущий домен панели
mbs update обновить код с GitHub и перезапустить
mbs backup полная копия панели в /root/mbs-backups (база, .env, твои правки, конфиг Xray)
mbs update [ссылка] обновить код и перезапустить; со ссылкой на git-зеркало берёт обновление оттуда
mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, его mbs update проверяет первым
```
`mbs update` тянет `git pull` (только fast-forward — если на сервере что-то правили руками, честно откажется и не полезет мержить), ставит зависимости, **проверяет, что новый код вообще компилируется**, и только потом перезапускает. Если после рестарта `mbs-bot`/`mbs-api` не поднялись — сам откатывает на предыдущий коммит и поднимает его. `.env` и база (`mbs.db`) не в гите — их не тронет ни при каком раскладе.
`mbs update` тянет обновление (только fast-forward, чужую историю на сервере не мержит), ставит зависимости, **проверяет, что новый код вообще компилируется**, и только потом перезапускает. Если после рестарта `mbs-bot`/`mbs-api` не поднялись — сам откатывает на предыдущий коммит и поднимает его. `.env` и база (`mbs.db`) не в гите — их не тронет ни при каком раскладе.
Перед каждым обновлением `mbs update` сам снимает полную копию в `/root/mbs-backups/` (консистентный снапшот базы через backup API SQLite, `.env`, код вместе с твоими ручными правками, конфиг Xray, без `venv`), хранит 5 последних, права `600`. Не получилось сделать копию (нет места на диске), обновление даже не начнётся. То же вручную: `mbs backup`. Вернуть всё как было: `tar xzf /root/mbs-backups/mbs-before-update-<время>.tar.gz -C /` и `mbs restart`.
Если на сервере правили файлы руками (бывает, `bot.py`/`config.py`/`db.py` под себя), обновление больше на этом не падает: правки откладываются в `git stash` и сохраняются патчем в `local-changes/local-changes-<время>.patch`, потом подтягивается новая версия. Вернуть своё поверх новой: `git stash pop` (может быть конфликт, если новая версия правила те же строки, тогда смотри патч). Если новый код не прошёл проверку или сервисы не поднялись, откат на старый коммит возвращает и твои правки.
Источники по порядку: своё зеркало (если задано через `mbs mirror`), потом `lab.savsis.xyz`, потом `api.savsis.xyz`, потом GitHub. Установщик включает автообновление: каждый день около 04:00 сервер сам делает `mbs update` (перед ним всегда резервная копия). Выключить: `mbs autoupdate off`, включить обратно: `mbs autoupdate on`. Появилось новое зеркало или GitHub недоступен, а ссылка на репо есть: `mbs update https://example.com/путь/mbs-panel.git` возьмёт обновление именно оттуда, один раз. Чтобы всегда обновляться с него: `mbs mirror https://example.com/путь/mbs-panel.git` (убрать: `mbs mirror off`). Принимаются только `https://`, `http://`, `ssh://` и `git@хост:путь`, всё остальное (в том числе `file://` и хитрые транспорты типа `ext::`) отбрасывается, ветка берётся `main`.
## Добавление ноды
@ -178,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`). Чтобы продавать доступ:
@ -215,7 +243,7 @@ sequenceDiagram
PR и issues welcome. CI на каждый пуш гоняет compile-check по питону, синтаксис-проверку шелл-скриптов и smoke-тест генерации install-скрипта ноды.
## Авторы:
github.com/devsavsis
github.com/savsisbtw
github.com/welfizx
## Лицензия

1935
admin.html

File diff suppressed because it is too large Load diff

387
api.py
View file

@ -1,17 +1,22 @@
import asyncio
import csv
import datetime
import io
import json
import os
import re
import secrets
import subprocess
import urllib.request
import uuid as uuidlib
from concurrent.futures import ThreadPoolExecutor
from fastapi import FastAPI, HTTPException, Request, Response, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi import Body
from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse
import backup
import chains
import db
import legal
import links
@ -52,6 +57,64 @@ def require_admin(request: Request):
raise HTTPException(401, "unauthorized")
AUDIT_RULES = [
("POST", r"^/admin/api/nodes$", "node.add"),
("POST", r"^/admin/api/nodes/provision-guide$", "node.provision"),
("POST", r"^/admin/api/nodes/reorder$", "node.reorder"),
("PATCH", r"^/admin/api/nodes/[^/]+$", "node.edit"),
("DELETE", r"^/admin/api/nodes/[^/]+$", "node.delete"),
("POST", r"^/admin/api/chains$", "chain.create"),
("PATCH", r"^/admin/api/chains/[^/]+$", "chain.edit"),
("DELETE", r"^/admin/api/chains/[^/]+$", "chain.delete"),
("POST", r"^/admin/api/subscriptions/[^/]+/revoke$", "sub.revoke"),
("POST", r"^/admin/api/subscriptions/[^/]+/hold$", "sub.hold"),
("POST", r"^/admin/api/subscriptions/[^/]+/resume$", "sub.resume"),
("POST", r"^/admin/api/subscriptions/[^/]+/reset-traffic$", "sub.reset_traffic"),
("POST", r"^/admin/api/users/-?\d+/grant$", "user.grant"),
("POST", r"^/admin/api/users/-?\d+/hwid-limit$", "user.hwid_limit"),
("DELETE", r"^/admin/api/users/-?\d+/devices/\d+$", "user.device_delete"),
("POST", r"^/admin/api/gift-codes$", "gift.create"),
("POST", r"^/admin/api/admins$", "admin.add"),
("DELETE", r"^/admin/api/admins/\d+$", "admin.delete"),
("POST", r"^/admin/api/2fa/enable$", "2fa.enable"),
("POST", r"^/admin/api/2fa/disable$", "2fa.disable"),
("GET", r"^/admin/api/backup$", "backup.download"),
("POST", r"^/admin/api/backup/restore$", "backup.restore"),
("POST", r"^/admin/api/settings/bot$", "settings.bot"),
("POST", r"^/admin/api/branding$", "settings.brand"),
("POST", r"^/admin/api/webhook-settings$", "settings.webhook"),
("POST", r"^/admin/api/hwid-settings$", "settings.hwid"),
("POST", r"^/admin/api/features$", "settings.features"),
("POST", r"^/admin/api/promos$", "promo.create"),
("PATCH", r"^/admin/api/promos/[^/]+$", "promo.edit"),
("DELETE", r"^/admin/api/promos/[^/]+$", "promo.delete"),
("POST", r"^/admin/api/subscriptions/[^/]+/traffic-limit$", "sub.traffic_limit"),
("POST", r"^/admin/api/payments/(yookassa-settings|platega-settings|plan-settings|legal-settings)$", "settings.payments"),
]
AUDIT_COMPILED = [(method, re.compile(pattern), action) for method, pattern, action in AUDIT_RULES]
def _audit_action(method: str, path: str):
for rule_method, pattern, action in AUDIT_COMPILED:
if rule_method == method and pattern.match(path):
return action
return None
@app.middleware("http")
async def audit_middleware(request: Request, call_next):
path = request.url.path
action = _audit_action(request.method, path) if path.startswith("/admin/api/") else None
admin_name = None
if action:
admin = await asyncio.to_thread(db.get_session_admin, request.cookies.get(ADMIN_COOKIE))
admin_name = admin["username"] if admin else None
response = await call_next(request)
if action and response.status_code < 400:
await asyncio.to_thread(db.add_audit, admin_name, action, path, _client_ip(request))
return response
def _days_left(sub: dict) -> int:
exp = datetime.datetime.fromisoformat(sub["expires_at"])
reference = datetime.datetime.fromisoformat(sub["held_at"]) if sub.get("held_at") else datetime.datetime.utcnow()
@ -311,7 +374,10 @@ def _grant_paid_subscription(payment_id: str):
payment = db.mark_payment_paid(payment_id)
if not payment:
return
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
sub = db.create_subscription(
payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment",
traffic_limit=settings.default_traffic_limit_bytes(),
)
xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
user = db.get_or_create_user(payment["tg_id"], None)
_tg_send_message(
@ -614,11 +680,13 @@ def admin_login(request: Request, response: Response, body: dict = Body(...)):
admin = db.verify_admin_login(username, password)
if not admin:
db.record_login_attempt(ip, "password")
db.add_audit(username[:64] or None, "login.failed", "", ip)
raise HTTPException(401, "wrong username or password")
db.clear_login_attempts(ip, "password")
if admin.get("totp_secret"):
pending_token = db.create_pending_totp(admin["id"])
return {"ok": True, "needs_totp": True, "pending_token": pending_token}
db.add_audit(admin["username"], "login.ok", "", ip)
token = db.create_admin_session(admin["id"])
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
return {"ok": True}
@ -637,9 +705,11 @@ def admin_login_totp(request: Request, response: Response, body: dict = Body(...
admin = db.get_admin_by_id(pending["admin_id"])
if not admin or not admin.get("totp_secret") or not totp.verify(admin["totp_secret"], code):
db.record_login_attempt(ip, "totp")
db.add_audit(admin["username"] if admin else None, "login.totp_failed", "", ip)
raise HTTPException(401, "wrong code")
db.clear_login_attempts(ip, "totp")
db.delete_pending_totp(pending_token)
db.add_audit(admin["username"], "login.ok", "2fa", ip)
token = db.create_admin_session(admin["id"])
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
return {"ok": True}
@ -939,7 +1009,88 @@ def admin_reset_traffic(uuid: str, request: Request):
if not node:
raise HTTPException(404, "node not found")
ok = xray_manager.reset_stats_for_node(node, uuid)
return {"ok": ok}
reactivated = db.reset_traffic_counter(uuid)
if reactivated:
xray_manager.add_client_to_node(node, uuid, email=uuid)
return {"ok": ok, "reactivated": reactivated}
@app.post("/admin/api/subscriptions/{uuid}/traffic-limit")
def admin_set_traffic_limit(uuid: str, request: Request, body: dict = Body(...)):
require_admin(request)
sub = db.get_subscription(uuid)
if not sub:
raise HTTPException(404, "not found")
try:
gb = float(body.get("gb", 0))
except (TypeError, ValueError):
raise HTTPException(400, "gb должен быть числом")
if gb < 0 or gb > 100000:
raise HTTPException(400, "лимит от 0 до 100000 ГБ, 0 значит без лимита")
db.set_traffic_limit(uuid, int(gb * settings.GB) if gb > 0 else None)
fresh = db.get_subscription(uuid)
node = db.get_node(fresh["node"])
if fresh["active"] and not sub["active"] and node:
xray_manager.add_client_to_node(node, uuid, email=uuid)
return {"ok": True, "traffic_limit": fresh["traffic_limit"], "active": bool(fresh["active"])}
@app.get("/admin/api/features")
def admin_get_features(request: Request):
require_admin(request)
return settings.get_features()
@app.post("/admin/api/features")
def admin_set_features(request: Request, body: dict = Body(...)):
require_admin(request)
try:
settings.set_features(body)
except (TypeError, ValueError):
raise HTTPException(400, "неверные значения настроек")
return settings.get_features()
@app.get("/admin/api/promos")
def admin_list_promos(request: Request):
require_admin(request)
return db.list_promos()
@app.post("/admin/api/promos")
def admin_create_promo(request: Request, body: dict = Body(...)):
require_admin(request)
try:
return db.create_promo(
str(body.get("code", "")), str(body.get("kind", "")), int(body.get("value", 0)),
int(body["max_uses"]) if body.get("max_uses") else None,
str(body.get("expires_at") or "") or None,
)
except (ValueError, TypeError) as e:
raise HTTPException(400, str(e))
@app.patch("/admin/api/promos/{code}")
def admin_update_promo(code: str, request: Request, body: dict = Body(...)):
require_admin(request)
if not db.get_promo(code):
raise HTTPException(404, "not found")
if "active" in body:
db.set_promo_active(code, bool(body["active"]))
return db.get_promo(code)
@app.delete("/admin/api/promos/{code}")
def admin_delete_promo(code: str, request: Request):
require_admin(request)
db.delete_promo(code)
return {"ok": True}
@app.get("/admin/api/users")
def admin_list_users(request: Request, q: str = "", limit: int = 200):
require_admin(request)
return db.list_users(q=q, limit=limit)
@app.get("/admin/api/users/{tg_id}")
@ -947,7 +1098,11 @@ def admin_user_card(tg_id: int, request: Request):
require_admin(request)
user = db.get_user(tg_id)
if not user:
raise HTTPException(404, "not found")
return {
"tg_id": tg_id, "username": None, "created_at": None, "token": None, "exists": False,
"subscriptions": [], "devices": [], "hwid_limit": None,
"hwid_fallback_limit": settings.get_hwid_settings()["fallback_limit"],
}
subs = db.list_subscriptions_for_user(tg_id)
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
plans_by_code = settings.get_plans_by_code()
@ -966,6 +1121,7 @@ def admin_user_card(tg_id: int, request: Request):
"username": user["username"],
"created_at": user["created_at"],
"token": user["token"],
"exists": True,
"subscriptions": out_subs,
"devices": db.list_devices(tg_id),
"hwid_limit": user.get("hwid_limit"),
@ -983,7 +1139,14 @@ def admin_grant_subscription(tg_id: int, request: Request, body: dict = Body(...
if not node or not plan:
raise HTTPException(400, "unknown node or plan")
db.get_or_create_user(tg_id, None)
sub = db.create_subscription(tg_id, node_code, plan["days"], plan_code, source="admin")
limit = settings.default_traffic_limit_bytes()
if body.get("traffic_gb") not in (None, ""):
try:
gb = float(body["traffic_gb"])
except (TypeError, ValueError):
raise HTTPException(400, "traffic_gb должен быть числом")
limit = int(gb * settings.GB) if gb > 0 else None
sub = db.create_subscription(tg_id, node_code, plan["days"], plan_code, source="admin", traffic_limit=limit)
xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
webhooks.send("subscription.granted_by_admin", {
"tg_id": tg_id, "node": node_code, "plan": plan_code,
@ -1231,6 +1394,222 @@ def admin_node_status(code: str, request: Request):
return {"status": node["status"], "enabled": bool(node["enabled"])}
def _panel_latency(node: dict) -> dict:
samples = chains.tcp_connect_ms(node["address"], node["port"], samples=2, timeout=2.0)
return {"code": node["code"], "ms": chains.median_ms(samples)}
@app.get("/admin/api/nodes/latency")
def admin_nodes_latency(request: Request):
require_admin(request)
targets = [n for n in db.list_nodes() if n["address"] and n["status"] == "active" and n["enabled"]]
if not targets:
return []
with ThreadPoolExecutor(max_workers=min(8, len(targets))) as pool:
return list(pool.map(_panel_latency, targets))
def _chain_view(chain: dict, nodes_by_code: dict) -> dict:
entry = nodes_by_code.get(chain["entry_node"])
exit_node = nodes_by_code.get(chain["exit_node"])
return {
"code": chain["code"], "label": chain["label"],
"entry_node": chain["entry_node"], "exit_node": chain["exit_node"],
"entry_label": entry["label"] if entry else chain["entry_node"],
"exit_label": exit_node["label"] if exit_node else chain["exit_node"],
"entry_address": entry["address"] if entry else None,
"port": chain["port"], "enabled": bool(chain["enabled"]),
"created_at": chain["created_at"],
}
def _csv_safe(value) -> str:
text = str(value)
if text and text[0] in "=+-@\t\r":
return "'" + text
return text
def _sync_chain_nodes(chain: dict) -> dict:
results = {}
for code in dict.fromkeys([chain["entry_node"], chain["exit_node"]]):
node = db.get_node(code)
if not node:
continue
try:
res = xray_manager.sync_node(node)
results[code] = {"ok": not res["problems"], "problems": res["problems"]}
except Exception as e:
results[code] = {"ok": False, "problems": [str(e)]}
return results
def _chain_failures(results: dict) -> list:
failures = []
for code, res in results.items():
for problem in res["problems"]:
failures.append(f"{code}: {problem}")
return failures
@app.get("/admin/api/chains")
def admin_list_chains(request: Request):
require_admin(request)
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
return [_chain_view(c, nodes_by_code) for c in db.list_chains()]
@app.post("/admin/api/chains")
def admin_create_chain(request: Request, body: dict = Body(...)):
require_admin(request)
entry_code = str(body.get("entry", "")).strip()
exit_code = str(body.get("exit", "")).strip()
entry = db.get_node(entry_code)
exit_node = db.get_node(exit_code)
if not entry or not exit_node:
raise HTTPException(400, "выбери входной и выходной серверы из списка нод")
if entry_code == exit_code:
raise HTTPException(400, "вход и выход цепочки должны быть разными серверами")
if entry["kind"] not in chains.CHAIN_KINDS_ENTRY:
raise HTTPException(400, "входной сервер должен быть под управлением панели (локальный или управляемый)")
if exit_node["kind"] not in chains.CHAIN_KINDS_EXIT:
raise HTTPException(400, "этот тип ноды нельзя использовать как выход цепочки")
for node in (entry, exit_node):
if not node["enabled"] or node["status"] != "active":
raise HTTPException(400, f"нода «{node['label']}» выключена или ещё не установлена")
relay_uuid = None
if exit_node["kind"] == "external":
if not exit_node.get("shared_uuid"):
raise HTTPException(400, "у внешней ноды не задан shared UUID — через неё цепочку не построить")
else:
relay_uuid = str(uuidlib.uuid4())
label = str(body.get("label", "")).strip()[:80] or f"{entry['label']} → {exit_node['label']}"
try:
chain = db.create_chain(label, entry_code, exit_code, relay_uuid)
except ValueError as e:
raise HTTPException(400, str(e))
results = _sync_chain_nodes(chain)
failures = _chain_failures(results)
if failures:
db.delete_chain(chain["code"])
_sync_chain_nodes(chain)
raise HTTPException(502, "цепочка не применилась, всё откатили назад: " + "; ".join(failures))
webhooks.send("chain.created", {
"code": chain["code"], "label": chain["label"], "entry": entry_code, "exit": exit_code,
})
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
return _chain_view(chain, nodes_by_code)
@app.patch("/admin/api/chains/{code}")
def admin_update_chain(code: str, request: Request, body: dict = Body(...)):
require_admin(request)
chain = db.get_chain(code)
if not chain:
raise HTTPException(404, "not found")
fields = {}
if "label" in body:
label = str(body["label"]).strip()[:80]
if not label:
raise HTTPException(400, "название не может быть пустым")
fields["label"] = label
toggled = "enabled" in body and bool(body["enabled"]) != bool(chain["enabled"])
if "enabled" in body:
fields["enabled"] = 1 if body["enabled"] else 0
updated = db.update_chain(code, **fields)
if toggled:
failures = _chain_failures(_sync_chain_nodes(updated))
if failures:
db.update_chain(code, enabled=chain["enabled"])
_sync_chain_nodes(chain)
raise HTTPException(502, "не получилось применить: " + "; ".join(failures))
webhooks.send("chain.enabled" if updated["enabled"] else "chain.disabled", {
"code": code, "label": updated["label"],
})
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
return _chain_view(db.get_chain(code), nodes_by_code)
@app.delete("/admin/api/chains/{code}")
def admin_delete_chain(code: str, request: Request):
require_admin(request)
chain = db.get_chain(code)
if not chain:
raise HTTPException(404, "not found")
db.delete_chain(code)
results = _sync_chain_nodes(chain)
webhooks.send("chain.deleted", {"code": code, "label": chain["label"]})
return {"ok": True, "warnings": _chain_failures(results)}
def _hop_probe(entry: dict, exit_node: dict) -> dict:
try:
samples = xray_manager.probe_from_node(entry, exit_node["address"], exit_node["port"])
except Exception as e:
return {"rtt_ms": None, "samples": [], "level": "unknown", "error": str(e)}
rtt = chains.median_ms(samples)
return {"rtt_ms": rtt, "samples": samples, "level": chains.latency_level(rtt)}
@app.get("/admin/api/chains/probe")
def admin_probe_chain(entry: str, exit: str, request: Request):
require_admin(request)
entry_node = db.get_node(entry)
exit_node = db.get_node(exit)
if not entry_node or not exit_node or entry == exit:
raise HTTPException(400, "нужны две разные ноды")
return _hop_probe(entry_node, exit_node)
@app.post("/admin/api/chains/{code}/check")
def admin_check_chain(code: str, request: Request):
require_admin(request)
chain = db.get_chain(code)
if not chain:
raise HTTPException(404, "not found")
entry = db.get_node(chain["entry_node"])
exit_node = db.get_node(chain["exit_node"])
if not entry or not exit_node:
raise HTTPException(400, "одна из нод цепочки удалена")
entry_alive = nodeprov.check_node_alive(entry["address"], chain["port"])
hop = _hop_probe(entry, exit_node)
return {"entry_alive": entry_alive, **hop}
@app.get("/admin/api/audit")
def admin_audit(request: Request, limit: int = 100):
require_admin(request)
return db.list_audit(limit=max(1, min(limit, 500)))
@app.get("/admin/api/subscriptions/export.csv")
def admin_export_subscriptions(request: Request):
require_admin(request)
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
plans_by_code = settings.get_plans_by_code()
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["tg_id", "username", "node", "plan", "created_at", "expires_at", "days_left", "status", "uuid"])
for s in db.list_all_subscriptions(limit=100000):
node = nodes_by_code.get(s["node"])
plan = plans_by_code.get(s["plan"])
if s.get("held_at"):
status = "held"
elif not s["active"] or s["expires_at"] <= db.now_iso():
status = "expired"
else:
status = "active"
writer.writerow([_csv_safe(v) for v in [
s["tg_id"], s.get("username") or "", node["label"] if node else s["node"],
plan["label"] if plan else s["plan"], s["created_at"], s["expires_at"],
_days_left(s), status, s["uuid"],
]])
return Response(
content="" + buf.getvalue(), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="subscriptions.csv"'},
)
ADMIN_HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "admin.html")

View file

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

248
bot.py
View file

@ -2,12 +2,14 @@ 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 features
import payments
import settings
import webhooks
@ -30,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):
@ -40,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):
@ -48,11 +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 settings.get_plans():
label = f"{p['label']} — {p['price']} ₽" if payments_enabled and p["price"] > 0 else p["label"]
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)
@ -90,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)
@ -140,6 +166,32 @@ async def cb_about(cb: CallbackQuery):
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()
@dp.callback_query(F.data == "menu:get")
async def cb_get(cb: CallbackQuery):
await cb.message.edit_text("Выбери сервер:", reply_markup=nodes_kb("node"))
@ -149,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()
@ -167,15 +219,24 @@ async def cb_plan(cb: CallbackQuery):
plan = settings.get_plans_by_code()[plan_code]
db.get_or_create_user(cb.from_user.id, cb.from_user.username)
if settings.get_payment_settings()["payments_enabled"] and plan["price"] > 0 and payments.available_providers():
promo = db.get_pending_promo(cb.from_user.id)
final_price = db.discounted_price(plan["price"], promo)
if settings.get_payment_settings()["payments_enabled"] and final_price > 0 and payments.available_providers():
price_line = f"{final_price} ₽" if final_price == plan["price"] else f"{final_price} ₽ (скидка по промокоду {promo['code']})"
await cb.message.edit_text(
f"<b>{plan['label']}</b> — {plan['price']} ₽\n\nВыбери способ оплаты:",
f"<b>{plan['label']}</b> — {price_line}\n\nВыбери способ оплаты:",
reply_markup=providers_kb(node_code, plan_code),
)
return await cb.answer()
if promo and settings.get_payment_settings()["payments_enabled"] and plan["price"] > 0 and final_price == 0:
db.consume_promo(promo["code"], cb.from_user.id)
db.set_promo_pending(cb.from_user.id, None)
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
sub = db.create_subscription(cb.from_user.id, node_code, plan["days"], plan_code, source="bot")
sub = db.create_subscription(
cb.from_user.id, node_code, plan["days"], plan_code, source="bot",
traffic_limit=settings.default_traffic_limit_bytes(),
)
node_row = db.get_node(node_code)
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
kb = connect_kb(user["token"], extra_rows=[
@ -199,10 +260,15 @@ async def cb_pay(cb: CallbackQuery):
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"{settings.get_brand_name()} — {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")
@ -214,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()
@ -235,7 +301,7 @@ async def cb_mysub(cb: CallbackQuery):
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)
@ -327,6 +393,140 @@ async def cb_admin_sync(cb: CallbackQuery):
await cb.answer()
@dp.callback_query(F.data == "trial:start")
async def cb_trial(cb: CallbackQuery):
feats = settings.get_features()
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
if not feats["trial_enabled"] or not db.trial_available(cb.from_user.id):
return await cb.answer("Пробный период недоступен", show_alert=True)
node_row = features.pick_trial_node()
if not node_row:
return await cb.answer("Сейчас нет доступных серверов", show_alert=True)
if not db.claim_trial(cb.from_user.id):
return await cb.answer("Пробный период уже использован", show_alert=True)
limit = feats["trial_traffic_gb"] * settings.GB if feats["trial_traffic_gb"] > 0 else None
sub = db.create_subscription(
cb.from_user.id, node_row["code"], feats["trial_days"], "trial", source="trial", traffic_limit=limit,
)
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
traffic_line = f"\nТрафик: до {feats['trial_traffic_gb']} ГБ" if limit else ""
kb = connect_kb(user["token"], extra_rows=[[InlineKeyboardButton(text="В меню", callback_data="menu:main")]])
await cb.message.edit_text(
f"<b>Пробный период активен</b>\n\n"
f"Сервер: {node_row['label']}\n"
f"Срок: до {sub['expires_at'][:10]}{traffic_line}\n\n"
f"{DIVIDER}\n"
f"Ссылка-подписка:\n<code>{sub_url_for(user['token'])}</code>",
reply_markup=kb,
)
await cb.answer("Пробный период выдан")
await asyncio.to_thread(webhooks.send, "subscription.trial", {
"tg_id": cb.from_user.id, "node": node_row["code"], "subscription_uuid": sub["uuid"],
"expires_at": sub["expires_at"],
})
PROMO_ERRORS = {
"not_found": "Такого промокода нет.",
"expired": "Срок действия промокода закончился.",
"exhausted": "Этот промокод уже использован максимальное число раз.",
"already_used": "Ты уже использовал этот промокод.",
}
async def apply_promo_code(message: Message, raw_code: str):
code = (raw_code or "").strip()
if not code:
return await message.answer("Напиши промокод так: /promo КОД")
db.get_or_create_user(message.from_user.id, message.from_user.username)
promo, err = db.validate_promo(code, message.from_user.id)
if err:
return await message.answer(PROMO_ERRORS.get(err, "Промокод не подошёл."))
if promo["kind"] == "days":
redeemed, err = db.redeem_days_promo(code, message.from_user.id)
if err:
return await message.answer(PROMO_ERRORS.get(err, "Промокод не подошёл."))
return await message.answer(f"Промокод принят: +{promo['value']} дн. к подписке.")
db.set_promo_pending(message.from_user.id, promo["code"])
what = f"{promo['value']}%" if promo["kind"] == "percent" else f"{promo['value']} ₽"
await message.answer(f"Промокод принят: скидка {what}. Она применится на следующей оплате, выбери срок в меню.")
@dp.message(Command("promo"))
async def cmd_promo(message: Message, command: CommandObject):
await apply_promo_code(message, command.args or "")
@dp.callback_query(F.data == "menu:promo")
async def cb_promo_hint(cb: CallbackQuery):
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
await cb.message.edit_text("Отправь команду с кодом, например:\n<code>/promo КОД</code>", reply_markup=kb)
await cb.answer()
async def notify_limit_reached(subs: list):
for sub in subs:
try:
await bot.send_message(
sub["tg_id"],
"<b>Лимит трафика исчерпан</b>\n\nДоступ приостановлен. Продли подписку или напиши в поддержку, "
"чтобы получить ещё трафик.",
)
except Exception:
log.exception("failed to notify about traffic limit")
await asyncio.to_thread(webhooks.send, "subscription.limit_reached", {
"tg_id": sub["tg_id"], "subscription_uuid": sub["uuid"], "node": sub["node"],
"limit": sub["traffic_limit"], "used": sub["traffic_used"],
})
async def send_expiry_reminders():
if not settings.get_features()["reminders_enabled"]:
return
for sub, kind, stage in features.reminders_due():
left = "3 дня" if stage == "3d" else "сутки"
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Продлить", callback_data="menu:get")]])
try:
await bot.send_message(
sub["tg_id"],
f"<b>Подписка скоро закончится</b>\n\nДо конца осталось меньше чем {left} "
f"(до {sub['expires_at'][:10]}). Продли заранее, чтобы доступ не прерывался.",
reply_markup=kb,
)
except Exception:
log.exception("failed to send expiry reminder")
features.mark_stage_sent(sub["uuid"], sub["expires_at"])
_node_state: dict = {}
async def check_nodes_and_alert():
if not settings.get_features()["node_alerts_enabled"]:
return
for node in db.list_nodes(enabled_only=True):
if node["kind"] == "local" or node["status"] != "active" or not node.get("address"):
continue
samples = await asyncio.to_thread(chains.tcp_connect_ms, node["address"], node["port"], 2, 3.0)
alive = chains.median_ms(samples) is not None
previous = _node_state.get(node["code"])
_node_state[node["code"]] = alive
if previous is None or previous == alive:
continue
text = (
f"Нода «{node['label']}» ({node['address']}) снова доступна."
if alive else f"Нода «{node['label']}» ({node['address']}) не отвечает."
)
for admin_id in ADMIN_IDS:
try:
await bot.send_message(admin_id, text)
except Exception:
log.exception("failed to send node alert")
await asyncio.to_thread(webhooks.send, "node.up" if alive else "node.down", {
"node": node["code"], "label": node["label"], "address": node["address"],
})
async def reconcile_pending_payments():
if not settings.get_payment_settings()["payments_enabled"]:
return
@ -347,7 +547,10 @@ async def reconcile_pending_payments():
granted = db.mark_payment_paid(payment["id"])
if not granted:
continue
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
sub = db.create_subscription(
payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment",
traffic_limit=settings.default_traffic_limit_bytes(),
)
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
user = db.get_or_create_user(payment["tg_id"], None)
try:
@ -374,11 +577,30 @@ async def reconcile_pending_payments():
async def periodic_sync():
tick = 0
while True:
if tick % 3 == 0:
try:
exceeded = await asyncio.to_thread(features.update_traffic_and_find_exceeded)
if exceeded:
await notify_limit_reached(exceeded)
except Exception:
log.exception("traffic accounting failed")
try:
await asyncio.to_thread(xray_manager.sync_all)
except Exception:
log.exception("periodic sync failed")
if tick % 20 == 0:
try:
await send_expiry_reminders()
except Exception:
log.exception("expiry reminders failed")
if tick % 2 == 0:
try:
await check_nodes_and_alert()
except Exception:
log.exception("node alerts failed")
tick += 1
try:
await reconcile_pending_payments()
except Exception:

233
chains.py Normal file
View file

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

View file

@ -120,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"))

509
db.py
View file

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

94
features.py Normal file
View file

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

View file

@ -2,8 +2,9 @@
set -e
set -o pipefail
LAB_URL="https://lab.savsis.xyz/savsisbtw/mbs-panel.git"
MIRROR_URL="https://api.savsis.xyz/git/mbs-panel.git/"
REPO_URL="https://github.com/devsavsis/mbs-panel.git"
REPO_URL="https://github.com/savsisbtw/mbs-panel.git"
APP_DIR="/opt/mbs-panel"
WEBROOT="/var/www/certbot"
@ -84,11 +85,17 @@ echo "клонируем репозиторий в $APP_DIR..."
if [ -d "$APP_DIR/.git" ]; then
retry git -C "$APP_DIR" pull --quiet
else
if ! git clone --quiet "$MIRROR_URL" "$APP_DIR" 2>/dev/null; then
echo "зеркало недоступно, клонирую напрямую с GitHub..."
retry git clone --quiet "$REPO_URL" "$APP_DIR"
git -C "$APP_DIR" remote set-url origin "$MIRROR_URL"
if ! git clone --quiet "$LAB_URL" "$APP_DIR" 2>/dev/null; then
echo "lab.savsis.xyz недоступен, пробую зеркало..."
rm -rf "$APP_DIR"
if ! git clone --quiet "$MIRROR_URL" "$APP_DIR" 2>/dev/null; then
echo "зеркало недоступно, клонирую напрямую с GitHub..."
rm -rf "$APP_DIR"
retry git clone --quiet "$REPO_URL" "$APP_DIR"
fi
git -C "$APP_DIR" remote set-url origin "$LAB_URL"
fi
git -C "$APP_DIR" remote add mirror "$MIRROR_URL" 2>/dev/null || true
git -C "$APP_DIR" remote add github "$REPO_URL" 2>/dev/null || true
fi
@ -386,6 +393,8 @@ else API_WORKERS=$CPU_COUNT
fi
cp "$APP_DIR/systemd/mbs-bot.service" /etc/systemd/system/mbs-bot.service
sed "s/__WORKERS__/$API_WORKERS/" "$APP_DIR/systemd/mbs-api.service" > /etc/systemd/system/mbs-api.service
cp "$APP_DIR/systemd/mbs-autoupdate.service" /etc/systemd/system/mbs-autoupdate.service
cp "$APP_DIR/systemd/mbs-autoupdate.timer" /etc/systemd/system/mbs-autoupdate.timer
systemctl daemon-reload
echo "ставим CLI mbs..."
@ -407,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

View file

@ -155,20 +155,20 @@
<a href="#features">Возможности</a>
<a href="#comparison">Сравнение</a>
<a href="#install">Установка</a>
<a href="https://github.com/devsavsis/mbs-panel" target="_blank">GitHub</a>
<a href="https://github.com/savsisbtw/mbs-panel" target="_blank">GitHub</a>
</nav>
</header>
<section class="hero" style="padding-bottom:0">
<div class="badges reveal">
<img src="https://img.shields.io/github/actions/workflow/status/devsavsis/mbs-panel/ci.yml?label=CI&style=flat-square&color=7c6cf0" alt="CI">
<img src="https://img.shields.io/github/license/devsavsis/mbs-panel?style=flat-square&color=7c6cf0" alt="License">
<img src="https://img.shields.io/github/stars/devsavsis/mbs-panel?style=flat-square&color=7c6cf0" alt="Stars">
<img src="https://img.shields.io/github/actions/workflow/status/savsisbtw/mbs-panel/ci.yml?label=CI&style=flat-square&color=7c6cf0" alt="CI">
<img src="https://img.shields.io/github/license/savsisbtw/mbs-panel?style=flat-square&color=7c6cf0" alt="License">
<img src="https://img.shields.io/github/stars/savsisbtw/mbs-panel?style=flat-square&color=7c6cf0" alt="Stars">
</div>
<h1 class="reveal">VPN-панель, которую<br>можно <span class="accent">понять за вечер</span></h1>
<p class="reveal">Xray-core, Reality, Hysteria2, Telegram-бот и платежи — в одном небольшом репозитории на Python. Без Docker, без чужой закрытой панели под капотом, без разбора чужого фреймворка перед первым коммитом.</p>
<div class="hero-ctas reveal">
<a class="btn" href="https://github.com/devsavsis/mbs-panel" target="_blank">Смотреть на GitHub</a>
<a class="btn" href="https://github.com/savsisbtw/mbs-panel" target="_blank">Смотреть на GitHub</a>
<a class="btn ghost" href="#install">Установка</a>
</div>
<div class="install reveal">
@ -233,6 +233,11 @@
<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>
@ -292,16 +297,16 @@
<h2>Открытый исходник, MIT</h2>
<p class="section-sub">Изначально писалось под конкретный проект — получилось достаточно универсально, чтобы выложить как есть. Issues и PR приветствуются.</p>
<div class="cta-row">
<a class="btn" href="https://github.com/devsavsis/mbs-panel" target="_blank">github.com/devsavsis/mbs-panel</a>
<a class="btn ghost" href="https://github.com/devsavsis/mbs-panel#readme" target="_blank">Читать README</a>
<a class="btn" href="https://github.com/savsisbtw/mbs-panel" target="_blank">github.com/savsisbtw/mbs-panel</a>
<a class="btn ghost" href="https://github.com/savsisbtw/mbs-panel#readme" target="_blank">Читать README</a>
</div>
</div>
</div>
<footer>
<div class="wrap">
<div>MBS Panel — made by <a href="https://github.com/devsavsis" target="_blank">savsis</a></div>
<div><a href="https://github.com/devsavsis/mbs-panel/blob/main/LICENSE" target="_blank">MIT License</a></div>
<div>MBS Panel — made by <a href="https://github.com/savsisbtw" target="_blank">savsis</a></div>
<div><a href="https://github.com/savsisbtw/mbs-panel/blob/main/LICENSE" target="_blank">MIT License</a></div>
</div>
</footer>

View file

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

221
mbs
View file

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

View file

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

View file

@ -87,3 +87,56 @@ def get_hwid_settings() -> dict:
"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

@ -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 ]

View file

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