Compare commits
34 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82f83eede7 | |||
| ebdea51221 | |||
| 181bf69778 | |||
| 990bdc403a | |||
| 6372d649da | |||
| 4791c5ca2b | |||
| a701d55e3b | |||
| e3bd279407 | |||
| 264991593a | |||
| 2c5ec8b06c | |||
| da6200ae4a | |||
| 6546d5bed1 | |||
| 695771c79a | |||
| 2b34b11391 | |||
| 02ff43095c | |||
| 591b1ba692 | |||
| 8343a66a14 | |||
| 59f67b8e4e | |||
| f37b8a5018 | |||
| bbbab9998e | |||
| b8c4949201 | |||
| 670579fccd | |||
| f6e4e52b65 | |||
| 71c98c5032 | |||
| 906b1f5842 | |||
| 7cc50973f6 | |||
| 7d140711fd | |||
| 1747d63539 | |||
| 6d94b36c31 | |||
| 4b86406041 | |||
| 24a1b26df2 | |||
| dcfcf8f650 | |||
| 6311532c45 | |||
| 3bd38103c3 |
31 changed files with 6413 additions and 478 deletions
17
.env.example
17
.env.example
|
|
@ -1,5 +1,9 @@
|
|||
# Copy this to .env and fill in real values. Never commit .env.
|
||||
|
||||
# Shown to clients everywhere: site, bot, subscription page, offer/privacy, panel
|
||||
# login. Also editable live from Настройки in the admin panel, no restart needed.
|
||||
BRAND_NAME=MBS Panel
|
||||
|
||||
# From @BotFather
|
||||
BOT_TOKEN=123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
BOT_USERNAME=YourBot_robot
|
||||
|
|
@ -17,6 +21,15 @@ PANEL_DOMAIN=panel.example.com
|
|||
SUB_DOMAIN=sub.example.com
|
||||
SITE_DOMAIN=example.com
|
||||
|
||||
# Optional: move the admin login off the well-known /admin path (e.g. to a
|
||||
# random string) so it doesn't show up to anyone scanning for /admin,
|
||||
# /login etc. Leave unset for the default. This is on top of the existing
|
||||
# rate-limiting and 2FA, not instead of them. Requires `mbs restart` to
|
||||
# take effect (it's a route, not a setting the running process can pick up
|
||||
# live) — write it down somewhere before you restart, there's no UI for
|
||||
# this on purpose, only .env + SSH can get you back in if you forget it.
|
||||
ADMIN_PATH=admin
|
||||
|
||||
# Reality identity for the local node (this same box). Generate with:
|
||||
# /usr/local/bin/xray x25519
|
||||
# XRAY_PUBLIC_KEY is the "Password (PublicKey)" line; keep the matching
|
||||
|
|
@ -34,6 +47,8 @@ DE1_ADDRESS=de1.example.com
|
|||
|
||||
# Payments — off by default, bot keeps handing out free subscriptions on button press.
|
||||
# Flip to true only once at least one provider below is configured and its webhook is live.
|
||||
# All of this (toggle, prices, provider keys) is also editable live from the admin panel
|
||||
# (Платежи tab) after first boot — no need to hand-edit this file or restart afterwards.
|
||||
PAYMENTS_ENABLED=false
|
||||
|
||||
# Prices in RUB per plan (whole numbers). Only used when PAYMENTS_ENABLED=true.
|
||||
|
|
@ -58,6 +73,6 @@ PLATEGA_SECRET=
|
|||
# Device limit (HWID) — off by default. Requires the VPN client app to send an
|
||||
# x-hwid header on subscription fetch (Happ/v2rayTun-class apps do this); clients
|
||||
# that don't send it get refused once enabled, so only flip this on if your users'
|
||||
# apps actually support it.
|
||||
# apps actually support it. Also editable live from Настройки in the admin panel.
|
||||
HWID_LIMIT_ENABLED=false
|
||||
HWID_FALLBACK_LIMIT=3
|
||||
|
|
|
|||
602
.forgejo/workflows/ci.yml
Normal file
602
.forgejo/workflows/ci.yml
Normal 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
|
||||
490
.github/workflows/ci.yml
vendored
490
.github/workflows/ci.yml
vendored
|
|
@ -24,6 +24,13 @@ jobs:
|
|||
run: |
|
||||
bash -n install.sh
|
||||
bash -n mbs
|
||||
bash -n tests/test_mbs_update.sh
|
||||
|
||||
- name: Smoke test mbs update and mirror (manual edits on the server, url mirror, unsafe urls, rollback)
|
||||
run: bash tests/test_mbs_update.sh
|
||||
|
||||
- name: Test traffic limits, promo codes, trial and reminders
|
||||
run: python tests/test_features.py
|
||||
|
||||
- name: Smoke test install-script rendering
|
||||
env:
|
||||
|
|
@ -57,6 +64,9 @@ jobs:
|
|||
}
|
||||
script = nodeprov.render_install_script(node)
|
||||
assert "PRIVKEY" in script
|
||||
assert "PREFLIGHT_FAIL" in script and "443 2053 2087" in script, "node install must refuse a non-empty server before touching it"
|
||||
assert script.index("PREFLIGHT_FAIL") < script.index("authorized_keys"), "preflight must run before the management key is added"
|
||||
assert "OK=$((OK+1))" in script and "STATUS=failed" in script, "node must report active only after xray stays up"
|
||||
assert len(script) > 500
|
||||
print("node install script rendered OK,", len(script), "bytes")
|
||||
PYEOF
|
||||
|
|
@ -109,4 +119,484 @@ jobs:
|
|||
assert db.get_device(1, "hwid-aaaaaaaaaa") is not None
|
||||
|
||||
print("app wiring + payments + HWID logic OK")
|
||||
|
||||
import legal
|
||||
import settings
|
||||
|
||||
assert settings.get_brand_name() == "MBS Panel"
|
||||
legal.update_env_var("BRAND_NAME", "CI Test Brand")
|
||||
assert settings.get_brand_name() == "CI Test Brand"
|
||||
|
||||
index_html = legal.render_site_page("index.html")
|
||||
assert "CI Test Brand" in index_html
|
||||
assert "MBS Panel" not in index_html
|
||||
assert "example.com" not in index_html
|
||||
assert "YourBot_robot" not in index_html
|
||||
assert "{{" not in index_html and "}}" not in index_html
|
||||
|
||||
cabinet_html = legal.render_site_page("cabinet.html")
|
||||
assert "CI Test Brand" in cabinet_html
|
||||
assert "{{" not in cabinet_html and "}}" not in cabinet_html
|
||||
|
||||
fake_request = type("FakeRequest", (), {"headers": {}})()
|
||||
root_resp = api.root(fake_request)
|
||||
assert "CI Test Brand" in root_resp
|
||||
|
||||
plans_resp = api.public_plans()
|
||||
assert plans_resp["plans"][0]["code"] == "7d"
|
||||
|
||||
branding_resp = api.public_branding()
|
||||
assert branding_resp["brand_name"] == "CI Test Brand"
|
||||
|
||||
print("branding: site templates + public routes render live, no restart OK")
|
||||
PYEOF
|
||||
|
||||
- name: Smoke test TOTP, backup/restore, node reorder, multi-admin, rate-limit
|
||||
env:
|
||||
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
BOT_USERNAME: "x"
|
||||
ADMIN_IDS: "1"
|
||||
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
|
||||
PANEL_DOMAIN: "panel.test"
|
||||
SUB_DOMAIN: "sub.test"
|
||||
SITE_DOMAIN: "test"
|
||||
XRAY_PUBLIC_KEY: "x"
|
||||
XRAY_SHORT_ID_TCP: "x"
|
||||
XRAY_SHORT_ID_GRPC: "x"
|
||||
XRAY_SHORT_ID_XHTTP: "x"
|
||||
run: |
|
||||
python - << 'PYEOF'
|
||||
import base64
|
||||
import db
|
||||
import totp
|
||||
|
||||
raw_key = b"12345678901234567890"
|
||||
secret = base64.b32encode(raw_key).decode("ascii").rstrip("=")
|
||||
expected = ["755224","287082","359152","969429","338314","254676","287922","162583","399871","520489"]
|
||||
for counter, exp in enumerate(expected):
|
||||
assert totp._hotp(secret, counter) == exp, f"RFC 4226 vector failed at counter={counter}"
|
||||
print("TOTP: all 10 RFC 4226 test vectors pass")
|
||||
|
||||
db.init_db()
|
||||
|
||||
db.create_node("n1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
|
||||
db.create_node("n2", "Node Two", "managed", "2.2.2.2", 443, "pub2", "sid2", "sni2", "xtls-rprx-vision")
|
||||
order = [n["code"] for n in db.list_nodes()]
|
||||
assert order == ["de1", "n1", "n2"], order
|
||||
db.reorder_nodes(["n2", "de1", "n1"])
|
||||
assert [n["code"] for n in db.list_nodes()] == ["n2", "de1", "n1"]
|
||||
try:
|
||||
db.reorder_nodes(["n2", "de1"])
|
||||
assert False, "should reject incomplete reorder list"
|
||||
except ValueError:
|
||||
pass
|
||||
print("node reorder OK")
|
||||
|
||||
import backup
|
||||
data = backup.create_backup()
|
||||
db.create_node("n3", "Node Three", "managed", "3.3.3.3", 443, "pub3", "sid3", "sni3", "xtls-rprx-vision")
|
||||
assert len(db.list_nodes()) == 4
|
||||
backup.restore_backup(data)
|
||||
assert len(db.list_nodes()) == 3, "restore should have reverted the extra node"
|
||||
print("backup/restore round-trip OK")
|
||||
|
||||
admin = db.verify_admin_login("admin", "ci-test-password-not-real")
|
||||
assert admin is not None
|
||||
second = db.create_admin("second", "another-strong-password")
|
||||
assert len(db.list_admins()) == 2
|
||||
try:
|
||||
db.delete_admin(admin["id"])
|
||||
db.delete_admin(second["id"])
|
||||
assert False, "should refuse deleting the last admin"
|
||||
except ValueError:
|
||||
pass
|
||||
print("multi-admin OK")
|
||||
|
||||
ip = "203.0.113.9"
|
||||
for _ in range(10):
|
||||
db.record_login_attempt(ip, "password")
|
||||
assert db.count_recent_login_attempts(ip, "password", minutes=15) >= 10
|
||||
db.clear_login_attempts(ip, "password")
|
||||
assert db.count_recent_login_attempts(ip, "password", minutes=15) == 0
|
||||
print("rate-limit counters OK")
|
||||
|
||||
import datetime as dt
|
||||
|
||||
hold_sub = db.create_subscription(999, "n1", 30, "1m", source="bot")
|
||||
original_expires = dt.datetime.fromisoformat(hold_sub["expires_at"])
|
||||
assert db.hold_subscription(hold_sub["uuid"])
|
||||
assert db.hold_subscription(hold_sub["uuid"]) is False
|
||||
assert len(db.list_active_subscriptions(tg_id=999)) == 0, "held sub must not count as active"
|
||||
with db.get_conn() as conn:
|
||||
simulated = (dt.datetime.utcnow() - dt.timedelta(hours=5)).isoformat()
|
||||
conn.execute("UPDATE subscriptions SET held_at=? WHERE uuid=?", (simulated, hold_sub["uuid"]))
|
||||
resumed = db.resume_subscription(hold_sub["uuid"])
|
||||
assert resumed["held_at"] is None
|
||||
shift_hours = (dt.datetime.fromisoformat(resumed["expires_at"]) - original_expires).total_seconds() / 3600
|
||||
assert 4.9 <= shift_hours <= 5.1, f"expected ~5h shift, got {shift_hours}"
|
||||
assert len(db.list_active_subscriptions(tg_id=999)) == 1, "resumed sub must count as active again"
|
||||
assert db.resume_subscription(hold_sub["uuid"]) is None
|
||||
print("subscription hold/resume OK")
|
||||
|
||||
print("all v1.1.0 feature smoke tests passed")
|
||||
PYEOF
|
||||
|
||||
- name: Smoke test live settings (.env-backed plans/toggles/HWID/credentials, no restart)
|
||||
env:
|
||||
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
BOT_USERNAME: "x"
|
||||
ADMIN_IDS: "1"
|
||||
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
|
||||
PANEL_DOMAIN: "panel.test"
|
||||
SUB_DOMAIN: "sub.test"
|
||||
SITE_DOMAIN: "test"
|
||||
XRAY_PUBLIC_KEY: "x"
|
||||
XRAY_SHORT_ID_TCP: "x"
|
||||
XRAY_SHORT_ID_GRPC: "x"
|
||||
XRAY_SHORT_ID_XHTTP: "x"
|
||||
run: |
|
||||
python - << 'PYEOF'
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
with open(".env", "a", encoding="utf-8") as f:
|
||||
f.write("PLATEGA_SECRET=old_secret\n")
|
||||
f.write("PLATEGA_ENABLED=true\n")
|
||||
f.write("PLATEGA_MERCHANT_ID=m1\n")
|
||||
|
||||
import legal
|
||||
import settings
|
||||
import payments
|
||||
|
||||
plans = settings.get_plans_by_code()
|
||||
assert plans["1m"]["price"] > 0, "default price should come from config before any .env override"
|
||||
|
||||
settings.set_plan_prices({"1m": 4242})
|
||||
assert settings.get_plans_by_code()["1m"]["price"] == 4242, "price edit should apply live, no reimport"
|
||||
assert settings.get_plans_by_code()["7d"]["price"] != 4242, "unrelated plan must stay untouched"
|
||||
|
||||
assert settings.get_hwid_settings()["enabled"] is False
|
||||
legal.update_env_var("HWID_LIMIT_ENABLED", "true")
|
||||
legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
|
||||
hwid = settings.get_hwid_settings()
|
||||
assert hwid["enabled"] is True and hwid["fallback_limit"] == 9, "HWID settings should apply live"
|
||||
|
||||
body = b'{"transactionId":"t1","status":"CONFIRMED"}'
|
||||
sig_old = hmac.new(b"old_secret", body, hashlib.sha256).hexdigest()
|
||||
assert payments.verify_platega_signature(body, sig_old), "signature must verify against the current secret"
|
||||
|
||||
legal.update_env_var("PLATEGA_SECRET", "rotated_secret")
|
||||
assert not payments.verify_platega_signature(body, sig_old), "OLD signature must be rejected right after rotation, same process, no restart"
|
||||
sig_new = hmac.new(b"rotated_secret", body, hashlib.sha256).hexdigest()
|
||||
assert payments.verify_platega_signature(body, sig_new), "NEW signature must verify immediately after rotation, same process, no restart"
|
||||
|
||||
for _ in range(5):
|
||||
legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
|
||||
with open(".env", encoding="utf-8") as f:
|
||||
lines = [l for l in f.readlines() if l.startswith("HWID_FALLBACK_LIMIT=")]
|
||||
assert len(lines) == 1, "repeated writes to the same key must not duplicate .env lines"
|
||||
|
||||
print("live settings: prices/HWID/credential-rotation all apply with zero reimport OK")
|
||||
PYEOF
|
||||
|
||||
- name: Smoke test backup/restore round-trip covers branding + live settings + held subscriptions
|
||||
env:
|
||||
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
BOT_USERNAME: "x"
|
||||
ADMIN_IDS: "1"
|
||||
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
|
||||
PANEL_DOMAIN: "panel.test"
|
||||
SUB_DOMAIN: "sub.test"
|
||||
SITE_DOMAIN: "test"
|
||||
XRAY_PUBLIC_KEY: "x"
|
||||
XRAY_SHORT_ID_TCP: "x"
|
||||
XRAY_SHORT_ID_GRPC: "x"
|
||||
XRAY_SHORT_ID_XHTTP: "x"
|
||||
run: |
|
||||
python - << 'PYEOF'
|
||||
import backup
|
||||
import db
|
||||
import legal
|
||||
import settings
|
||||
|
||||
db.init_db()
|
||||
db.create_node("bk1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
|
||||
|
||||
sub_a = db.create_subscription(111, "bk1", 30, "1m", source="bot")
|
||||
sub_b = db.create_subscription(222, "bk1", 30, "1m", source="bot")
|
||||
|
||||
legal.update_env_var("BRAND_NAME", "SnapshotBrand")
|
||||
settings.set_plan_prices({"1m": 555})
|
||||
legal.update_env_var("HWID_LIMIT_ENABLED", "true")
|
||||
legal.update_env_var("HWID_FALLBACK_LIMIT", "4")
|
||||
assert db.hold_subscription(sub_a["uuid"])
|
||||
|
||||
assert settings.get_brand_name() == "SnapshotBrand"
|
||||
assert settings.get_plans_by_code()["1m"]["price"] == 555
|
||||
assert settings.get_hwid_settings() == {"enabled": True, "fallback_limit": 4}
|
||||
assert len(db.list_active_subscriptions(tg_id=111)) == 0, "held sub excluded pre-backup"
|
||||
assert len(db.list_active_subscriptions(tg_id=222)) == 1
|
||||
|
||||
snapshot = backup.create_backup()
|
||||
|
||||
legal.update_env_var("BRAND_NAME", "MutatedAfterBackup")
|
||||
settings.set_plan_prices({"1m": 999})
|
||||
legal.update_env_var("HWID_LIMIT_ENABLED", "false")
|
||||
assert db.resume_subscription(sub_a["uuid"])["held_at"] is None
|
||||
sub_c = db.create_subscription(333, "bk1", 30, "1m", source="bot")
|
||||
assert settings.get_brand_name() == "MutatedAfterBackup"
|
||||
assert len(db.list_active_subscriptions(tg_id=111)) == 1
|
||||
|
||||
result = backup.restore_backup(snapshot)
|
||||
assert result["restored_env"] is True
|
||||
|
||||
assert settings.get_brand_name() == "SnapshotBrand", "brand must revert to snapshot value"
|
||||
assert settings.get_plans_by_code()["1m"]["price"] == 555, "price override must revert"
|
||||
assert settings.get_hwid_settings() == {"enabled": True, "fallback_limit": 4}, "hwid settings must revert"
|
||||
|
||||
restored_sub_a = db.get_subscription(sub_a["uuid"])
|
||||
assert restored_sub_a["held_at"] is not None, "held_at must round-trip through backup/restore"
|
||||
assert len(db.list_active_subscriptions(tg_id=111)) == 0, "sub_a held again after restore"
|
||||
assert len(db.list_active_subscriptions(tg_id=222)) == 1, "sub_b untouched"
|
||||
assert db.get_subscription(sub_c["uuid"]) is None, "sub_c created after backup point must be gone"
|
||||
|
||||
print("backup/restore correctly round-trips branding, live settings and held_at together OK")
|
||||
PYEOF
|
||||
|
||||
- name: Smoke test custom ADMIN_PATH actually moves the login page, not just adds a copy
|
||||
env:
|
||||
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
BOT_USERNAME: "x"
|
||||
ADMIN_IDS: "1"
|
||||
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
|
||||
PANEL_DOMAIN: "panel.test"
|
||||
SUB_DOMAIN: "sub.test"
|
||||
SITE_DOMAIN: "test"
|
||||
XRAY_PUBLIC_KEY: "x"
|
||||
XRAY_SHORT_ID_TCP: "x"
|
||||
XRAY_SHORT_ID_GRPC: "x"
|
||||
XRAY_SHORT_ID_XHTTP: "x"
|
||||
ADMIN_PATH: "xyz123secret"
|
||||
run: |
|
||||
python - << 'PYEOF'
|
||||
import api
|
||||
|
||||
paths = {r.path for r in api.app.routes}
|
||||
assert "/xyz123secret" in paths, "custom ADMIN_PATH must be registered as a route"
|
||||
assert "/admin" not in paths, "the default /admin page route must be GONE once a custom path is set, not just supplemented"
|
||||
assert "/admin/api/login" in paths, "the API namespace must stay fixed regardless of ADMIN_PATH"
|
||||
|
||||
fake_request = type("FakeRequest", (), {"headers": {"host": "panel.test"}})()
|
||||
root_response = api.root(fake_request)
|
||||
assert isinstance(root_response, str), \
|
||||
f"root() on PANEL_DOMAIN must return the rendered site page (a string), not admin.html, once ADMIN_PATH is customized — got {type(root_response)}"
|
||||
assert "admin.html" not in root_response
|
||||
|
||||
print("custom ADMIN_PATH: old /admin route gone, new path registered, root() no longer leaks the panel OK")
|
||||
PYEOF
|
||||
|
||||
- name: Smoke test server chains (xray config generation, relay clients, subscription entries, audit log, old-db migration)
|
||||
env:
|
||||
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
BOT_USERNAME: "x"
|
||||
ADMIN_IDS: "1"
|
||||
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
|
||||
PANEL_DOMAIN: "panel.test"
|
||||
SUB_DOMAIN: "sub.test"
|
||||
SITE_DOMAIN: "test"
|
||||
XRAY_PUBLIC_KEY: "x"
|
||||
XRAY_SHORT_ID_TCP: "x"
|
||||
XRAY_SHORT_ID_GRPC: "x"
|
||||
XRAY_SHORT_ID_XHTTP: "x"
|
||||
run: |
|
||||
python - << 'PYEOF'
|
||||
import base64
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import chains
|
||||
import db
|
||||
import links
|
||||
import nodeprov
|
||||
import xray_manager
|
||||
|
||||
transports = nodeprov.build_transports("a.example.com", 443, "www.microsoft.com", "PUBA", include_ws=False)
|
||||
entry_cfg = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
|
||||
exit_cfg = json.loads(nodeprov._build_config_json(
|
||||
nodeprov.build_transports("b.example.com", 443, "www.microsoft.com", "PUBB"), "PRIVB", "b.example.com"))
|
||||
|
||||
wanted = {"uuid-1": "uuid-1", "uuid-2": "uuid-2"}
|
||||
chain = {"code": "cabc12", "port": 10443, "short_id": "1234567890abcdef", "exit_node": "chb", "relay_uuid": "relay-uuid-1"}
|
||||
exit_nodes = {"chb": {
|
||||
"address": "b.example.com", "port": 443, "sni": "www.microsoft.com",
|
||||
"public_key": "PUBB", "short_id": "ffff", "kind": "managed", "shared_uuid": None,
|
||||
}}
|
||||
|
||||
base_before = json.dumps([ib for ib in entry_cfg["inbounds"] if ib["tag"] in chains.BASE_TAGS], sort_keys=True)
|
||||
|
||||
changed, problems = chains.sync_config(entry_cfg, wanted, {}, [chain], exit_nodes)
|
||||
assert changed and not problems, problems
|
||||
tags = [ib["tag"] for ib in entry_cfg["inbounds"]]
|
||||
assert "chain-cabc12" in tags, tags
|
||||
ci = chains.find_inbound(entry_cfg, "chain-cabc12")
|
||||
assert ci["port"] == 10443
|
||||
assert ci["streamSettings"]["realitySettings"]["shortIds"] == ["1234567890abcdef"]
|
||||
assert ci["streamSettings"]["realitySettings"]["privateKey"] == "PRIVA"
|
||||
assert [c["id"] for c in ci["settings"]["clients"]] == ["uuid-1", "uuid-2"]
|
||||
assert all(c["flow"] == "xtls-rprx-vision" for c in ci["settings"]["clients"])
|
||||
out = [o for o in entry_cfg["outbounds"] if o["tag"] == "chain-cabc12-out"]
|
||||
assert len(out) == 1
|
||||
vn = out[0]["settings"]["vnext"][0]
|
||||
assert vn["address"] == "b.example.com" and vn["port"] == 443 and vn["users"][0]["id"] == "relay-uuid-1"
|
||||
assert out[0]["streamSettings"]["realitySettings"]["publicKey"] == "PUBB"
|
||||
rules = [r for r in entry_cfg["routing"]["rules"] if r.get("outboundTag") == "chain-cabc12-out"]
|
||||
assert len(rules) == 1 and rules[0]["inboundTag"] == ["chain-cabc12"]
|
||||
assert entry_cfg["routing"]["rules"][0]["outboundTag"] == "api"
|
||||
assert entry_cfg["outbounds"][0]["tag"] == "direct", "default outbound must stay first"
|
||||
print("entry config: chain inbound/outbound/rule built OK")
|
||||
|
||||
changed2, problems2 = chains.sync_config(entry_cfg, wanted, {}, [chain], exit_nodes)
|
||||
assert not changed2 and not problems2, "second pass must be a no-op"
|
||||
print("idempotent OK")
|
||||
|
||||
wanted3 = {"uuid-2": "uuid-2", "uuid-3": "uuid-3"}
|
||||
changed3, _ = chains.sync_config(entry_cfg, wanted3, {}, [chain], exit_nodes)
|
||||
assert changed3
|
||||
ci = chains.find_inbound(entry_cfg, "chain-cabc12")
|
||||
assert [c["id"] for c in ci["settings"]["clients"]] == ["uuid-2", "uuid-3"]
|
||||
for tag in ("vless-tcp-reality", "vless-grpc-reality", "vless-xhttp-reality"):
|
||||
assert [c["id"] for c in chains.find_inbound(entry_cfg, tag)["settings"]["clients"]] == ["uuid-2", "uuid-3"]
|
||||
print("clients follow the active set on every inbound incl. chain OK")
|
||||
|
||||
changed4, _ = chains.sync_config(entry_cfg, {"uuid-9": "uuid-9"}, {}, [], exit_nodes, apply_chains=False)
|
||||
assert changed4
|
||||
assert chains.find_inbound(entry_cfg, "chain-cabc12") is not None, "apply_chains=False must not drop chains"
|
||||
assert [c["id"] for c in chains.find_inbound(entry_cfg, "chain-cabc12")["settings"]["clients"]] == ["uuid-9"]
|
||||
print("clients-only fallback keeps existing chains and still syncs their clients OK")
|
||||
|
||||
chains.sync_config(entry_cfg, wanted, {}, [], exit_nodes)
|
||||
assert chains.find_inbound(entry_cfg, "chain-cabc12") is None
|
||||
assert not [o for o in entry_cfg["outbounds"] if o["tag"].startswith("chain-")]
|
||||
assert not [r for r in entry_cfg["routing"]["rules"] if str(r.get("outboundTag", "")).startswith("chain-")]
|
||||
base_after = json.dumps([ib for ib in entry_cfg["inbounds"] if ib["tag"] in chains.BASE_TAGS], sort_keys=True)
|
||||
assert json.loads(base_after) != [] and len(json.loads(base_after)) == len(json.loads(base_before))
|
||||
print("removing the chain cleans inbound/outbound/rule OK")
|
||||
|
||||
changed5, p5 = chains.sync_config(exit_cfg, wanted, {"relay-uuid-1": "relay-cabc12"}, [], {})
|
||||
assert changed5 and not p5
|
||||
tcp_ids = [c["id"] for c in chains.find_inbound(exit_cfg, "vless-tcp-reality")["settings"]["clients"]]
|
||||
grpc_ids = [c["id"] for c in chains.find_inbound(exit_cfg, "vless-grpc-reality")["settings"]["clients"]]
|
||||
assert "relay-uuid-1" in tcp_ids and "relay-uuid-1" not in grpc_ids
|
||||
relay_entry = [c for c in chains.find_inbound(exit_cfg, "vless-tcp-reality")["settings"]["clients"] if c["id"] == "relay-uuid-1"][0]
|
||||
assert relay_entry["flow"] == "xtls-rprx-vision" and relay_entry["email"] == "relay-cabc12"
|
||||
changed6, _ = chains.sync_config(exit_cfg, wanted, {"relay-uuid-1": "relay-cabc12"}, [], {})
|
||||
assert not changed6
|
||||
print("exit node keeps the relay client only on the TCP inbound and survives sync OK")
|
||||
|
||||
busy_cfg = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
|
||||
usable, skipped = chains.split_busy_chains(busy_cfg, [chain], {10443})
|
||||
assert usable == [] and len(skipped) == 1
|
||||
usable2, skipped2 = chains.split_busy_chains(busy_cfg, [chain], set())
|
||||
assert usable2 == [chain] and skipped2 == []
|
||||
chains.sync_config(busy_cfg, wanted, {}, [chain], exit_nodes)
|
||||
usable3, skipped3 = chains.split_busy_chains(busy_cfg, [chain], {10443})
|
||||
assert usable3 == [chain], "an already-applied chain is not a new port, busy check must ignore it"
|
||||
print("busy port handling OK")
|
||||
|
||||
ext_nodes = {"chb": dict(exit_nodes["chb"], kind="external", shared_uuid="shared-1")}
|
||||
ext_chain = dict(chain, relay_uuid=None)
|
||||
cfg_e = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
|
||||
ch, pr = chains.sync_config(cfg_e, wanted, {}, [ext_chain], ext_nodes)
|
||||
assert ch and not pr
|
||||
assert [o for o in cfg_e["outbounds"] if o["tag"] == "chain-cabc12-out"][0]["settings"]["vnext"][0]["users"][0]["id"] == "shared-1"
|
||||
no_key = dict(ext_nodes["chb"], shared_uuid=None)
|
||||
cfg_f = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
|
||||
ch, pr = chains.sync_config(cfg_f, wanted, {}, [ext_chain], {"chb": no_key})
|
||||
assert pr and chains.find_inbound(cfg_f, "chain-cabc12") is None
|
||||
print("external exit uses shared uuid, missing key is reported OK")
|
||||
|
||||
assert chains.latency_level(10) == "low" and chains.latency_level(80) == "medium" and chains.latency_level(300) == "high"
|
||||
assert chains.latency_level(None) == "unknown"
|
||||
assert chains.median_ms([-1, -1]) is None and chains.median_ms([30, 10, -1]) == 30
|
||||
print("latency helpers OK")
|
||||
|
||||
db.init_db()
|
||||
db.create_node("cha", "🇫🇮 Финляндия", "managed", "fi.example.com", 443, "PUBFI", "sidfi", "www.microsoft.com", "xtls-rprx-vision")
|
||||
db.create_node("chb", "🇳🇱 Нидерланды", "managed", "nl.example.com", 443, "PUBNL", "sidnl", "www.microsoft.com", "xtls-rprx-vision")
|
||||
db.create_node("chx", "Внешняя", "external", "ex.example.com", 443, "PUBEX", "sidex", "www.microsoft.com", "xtls-rprx-vision", shared_uuid="shared-ex")
|
||||
|
||||
c1 = db.create_chain("Финка → Голландия", "cha", "chb", "relay-1")
|
||||
assert c1["port"] == 10443 and len(c1["short_id"]) == 16 and c1["code"].startswith("c")
|
||||
c2 = db.create_chain("Финка → Внешняя", "cha", "chx", None)
|
||||
assert c2["port"] == 10444
|
||||
try:
|
||||
db.create_chain("dup", "cha", "chb", "x")
|
||||
assert False
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
db.delete_node("chb")
|
||||
assert False, "node used in chain must not be deletable"
|
||||
except ValueError as e:
|
||||
assert "chain" in str(e)
|
||||
assert [c["code"] for c in db.list_chains()] == [c1["code"], c2["code"]]
|
||||
assert len(db.list_chains(enabled_only=True)) == 2
|
||||
db.update_chain(c2["code"], enabled=0)
|
||||
assert len(db.list_chains(enabled_only=True)) == 1
|
||||
assert db.stats()["chains"] == 1
|
||||
print("db chains CRUD, port allocation, node-delete guard OK")
|
||||
|
||||
sub = db.create_subscription(500, "cha", 30, "1m", source="bot")
|
||||
text = base64.b64decode(links.build_subscription_text([sub])).decode()
|
||||
lines = text.split("\n")
|
||||
chain_lines = [l for l in lines if ":10443?" in l]
|
||||
assert len(chain_lines) == 1, lines
|
||||
assert "10444" not in text, "disabled chain must not leak into the subscription"
|
||||
parsed = urllib.parse.urlparse(chain_lines[0])
|
||||
assert parsed.hostname == "fi.example.com" and parsed.port == 10443
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
assert qs["sid"] == [c1["short_id"]] and qs["pbk"] == ["PUBFI"] and qs["flow"] == ["xtls-rprx-vision"]
|
||||
assert urllib.parse.unquote(parsed.fragment) == "🇫🇮 Финляндия → 🇳🇱 Нидерланды"
|
||||
assert parsed.username == sub["uuid"]
|
||||
print("subscription text carries the chain entry for the entry node's subscribers OK")
|
||||
|
||||
other = db.create_subscription(501, "chb", 30, "1m", source="bot")
|
||||
text2 = base64.b64decode(links.build_subscription_text([other])).decode()
|
||||
assert ":10443?" not in text2, "subscribers of the exit node must not get the entry node's chain"
|
||||
print("chain is only offered to entry-node subscribers OK")
|
||||
|
||||
db.update_node("cha", enabled=0)
|
||||
text3 = base64.b64decode(links.build_subscription_text([sub])).decode()
|
||||
assert text3.strip() == ""
|
||||
db.update_node("cha", enabled=1)
|
||||
|
||||
db.update_chain(c2["code"], enabled=1)
|
||||
node_n1 = db.get_node("cha")
|
||||
w, relay, entry_chains, exit_n = xray_manager.desired_state(node_n1)
|
||||
assert sub["uuid"] in w and [c["code"] for c in entry_chains] == [c1["code"], c2["code"]] and relay == {}
|
||||
node_n2 = db.get_node("chb")
|
||||
w2, relay2, entry2, exit2 = xray_manager.desired_state(node_n2)
|
||||
assert relay2 == {"relay-1": chains.relay_email(c1["code"])} and entry2 == []
|
||||
node_ex = db.get_node("chx")
|
||||
w3, relay3, entry3, exit3 = xray_manager.desired_state(node_ex)
|
||||
assert relay3 == {}
|
||||
db.update_node("chb", enabled=0)
|
||||
w4, relay4, entry4, exit4 = xray_manager.desired_state(node_n1)
|
||||
assert [c["code"] for c in entry4] == [c2["code"]], "chain whose exit is disabled must drop out"
|
||||
print("desired_state: entry/relay/disabled-node logic OK")
|
||||
|
||||
db.add_audit("admin", "node.add", "/admin/api/nodes", "1.2.3.4")
|
||||
db.add_audit(None, "login.failed", "", "5.6.7.8")
|
||||
rows = db.list_audit(10)
|
||||
assert rows[0]["action"] == "login.failed" and rows[1]["admin"] == "admin"
|
||||
print("audit log OK")
|
||||
|
||||
with db.get_conn() as conn:
|
||||
conn.execute("DROP TABLE chains")
|
||||
conn.execute("DROP TABLE audit_log")
|
||||
db.init_db()
|
||||
assert db.list_chains() == [] and db.list_audit() == []
|
||||
print("init_db recreates chain/audit tables on an old database OK")
|
||||
|
||||
print("chains: all smoke tests passed")
|
||||
PYEOF
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -7,3 +7,5 @@ __pycache__/
|
|||
*.pyc
|
||||
venv/
|
||||
.claude/
|
||||
.update_mirror
|
||||
local-changes/
|
||||
|
|
|
|||
50
README.md
50
README.md
|
|
@ -1,8 +1,8 @@
|
|||
# MBS Panel
|
||||
|
||||
[](https://github.com/devsavsis/mbs-panel/actions/workflows/ci.yml)
|
||||
[](https://lab.savsis.xyz/savsisbtw/mbs-panel/actions)
|
||||
[](LICENSE)
|
||||
[](https://github.com/devsavsis/mbs-panel/releases)
|
||||
[](https://lab.savsis.xyz/savsisbtw/mbs-panel/releases)
|
||||
[](https://www.python.org/)
|
||||
[](https://github.com/XTLS/Xray-core)
|
||||
|
||||
|
|
@ -15,7 +15,8 @@
|
|||
## Что внутри
|
||||
|
||||
- **Бот** (aiogram 3) — выдача подписок по кнопкам, гифт-коды, привязка тарифов (7 дней / месяц / 3 месяца / полгода / год), автоматическое отключение по истечении подписки (не раз в полчаса, а раз в 90 секунд — важно, чтобы просрочка реально обрывала доступ, а не продолжала работать).
|
||||
- **API + сайт** (FastAPI) — страница подписки со ссылкой `happ://` и QR-кодом, личный кабинет, JSON API для сайта.
|
||||
- **API + сайт** (FastAPI) — страница подписки со ссылкой `happ://` и QR-кодом, готовый клиентский лендинг + личный кабинет (живут прямо в панели, подставляют название и реальные тарифы сами, ничего отдельно хостить не надо).
|
||||
- **Своё название бренда** — панель, бот, сайт, страница подписки, оферта/политика показывают одно и то же настраиваемое название вместо дефолтного «MBS Panel», меняется в один клик из Настроек, применяется сразу.
|
||||
- **Админ-панель** (чистый HTML/CSS/JS, без фреймворков и сборки) — дашборд, полная карточка юзера (история подписок, ручная выдача, устройства), подписки, гифт-коды, ноды (полное редактирование, не только вкл/выкл), трафик по Stats API самого Xray со сбросом счётчика по клику.
|
||||
- **Мультинодовость** — добавляешь новую ноду в панели, получаешь одну команду `bash <(curl ...)`, вставляешь на чистый сервер — нода сама ставит Xray, генерит ключи, регистрируется в панели. Как у Remnawave/3x-ui, только свой велосипед.
|
||||
- **Протоколы на выбор при добавлении ноды**: VLESS TCP+Reality, VLESS gRPC+Reality, VLESS XHTTP+Reality, VLESS WS+TLS (с реальным Let's Encrypt сертификатом), Hysteria2 (QUIC, отдельный процесс, obfs).
|
||||
|
|
@ -27,6 +28,14 @@
|
|||
- **Проверка конфига Xray перед рестартом** — `xray run -test` плюс проверка что серты реально читаемы юзером, под которым крутится Xray, до того как что-то применится и уронит сервис.
|
||||
- **Drag-n-drop ноды** — порядок нод в списке настраивается мышкой, как у Remnawave.
|
||||
- **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код.
|
||||
- **Свой путь входа** — страницу логина можно увести с дефолтного `/admin` на любой другой (`ADMIN_PATH` в `.env`), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще.
|
||||
- **Пауза подписки** — временно отключить доступ без потери оплаченных дней (Marzban это умеет, Remnawave — нет): при возобновлении срок сдвигается ровно на длительность паузы.
|
||||
- **Исходящие вебхуки** — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды и создание/удаление/вкл-выкл цепочки, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса.
|
||||
- **Поиск и фильтр по подпискам** — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице. Плюс экспорт всех подписок в CSV одной кнопкой.
|
||||
- **Цепочки серверов (v1.6)** — `клиент → нода A → нода B → интернет`: собираются в админке мышкой, зажал ЛКМ на клиенте и протянул провод через серверы к интернету. Больше двух серверов нельзя специально, на двух панель предупреждает про задержку и сама меряет RTT между нодами. Подробности ниже в разделе «Цепочки серверов».
|
||||
- **Юзеры и выдача подписок (v1.6)** — отдельная страница со всеми, кто уже есть в системе, даже если подписок у них ни разу не было (в «Подписках» таких не видно): поиск по юзернейму и Telegram ID, счётчики активных подписок и устройств, кнопка «Выдать подписку». Можно выдать и по Telegram ID тому, кого в базе ещё нет, подписка дождётся, пока он зайдёт в бота.
|
||||
- **Журнал действий (v1.6)** — кто из админов и когда менял ноды, цепочки, подписки, настройки, качал бэкап и входил (включая неудачные входы с IP). Пароли и ключи в журнал не попадают, только факт действия.
|
||||
- **Пинг нод и палитра команд (v1.6)** — живая задержка от панели до каждой ноды на дашборде и в списке нод; `Ctrl K` открывает поиск по страницам, нодам, цепочкам и действиям. Акцентный цвет панели меняется кружками сверху.
|
||||
|
||||
## Архитектура
|
||||
|
||||
|
|
@ -103,10 +112,10 @@ sequenceDiagram
|
|||
Нужен чистый сервер на **Ubuntu 22.04/24.04** или **Debian 11/12**, root-доступ и три поднятых DNS A-записи (см. таблицу ниже).
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://mbs.savsis.xyz/install.sh)
|
||||
bash <(curl -Ls https://lab.savsis.xyz/savsisbtw/mbs-panel/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
(или напрямую с GitHub, если так удобнее: `git clone https://github.com/devsavsis/mbs-panel.git && cd mbs-panel && sudo bash install.sh` — скрипт один и тот же, `mbs.savsis.xyz` просто зеркало с автосинком)
|
||||
(или так: `git clone https://lab.savsis.xyz/savsisbtw/mbs-panel.git && cd mbs-panel && sudo bash install.sh`. Основной репозиторий лежит на lab.savsis.xyz, GitHub и api.savsis.xyz остаются зеркалами на случай, если lab недоступен, установщик и `mbs update` сами переключаются на них)
|
||||
|
||||
Скрипт спросит домен панели, домен подписки, токен бота от [@BotFather](https://t.me/BotFather) и список Telegram ID админов — и дальше всё сам: ставит зависимости, Xray, nginx, выпускает сертификаты Let's Encrypt, генерирует Reality-ключи, поднимает systemd-сервисы, настраивает firewall (ufw) и fail2ban. В конце покажет пароль от админки и ссылку на панель.
|
||||
|
||||
|
|
@ -129,6 +138,7 @@ bash <(curl -Ls https://mbs.savsis.xyz/install.sh)
|
|||
- Зайди на `https://panel.example.com`, залогинься паролем из вывода скрипта.
|
||||
- Смени пароль в любой момент: `mbs pass новый_пароль` (без аргумента — сгенерит случайный).
|
||||
- В боте у себя (Telegram ID из ADMIN_IDS) появится админ-меню.
|
||||
- На `https://sub.example.com` уже живёт готовый клиентский сайт (лендинг + личный кабинет) с подставленным названием и реальными тарифами — ничего отдельно разворачивать не нужно. Название меняется в Настройки → «Название» в панели, применяется сразу везде (сайт, бот, страница подписки, оферта/политика).
|
||||
|
||||
В конце установки `install.sh` шлёт один пинг на `stats.api.savsis.xyz` (только название ОС) — просто счётчик "сколько раз панель установили", никаких доменов/токенов/паролей туда не уходит, IP не сохраняется. Отключить: `MBS_SKIP_STATS=1 sudo bash install.sh`.
|
||||
|
||||
|
|
@ -142,10 +152,18 @@ mbs status статус bot / api / xray / nginx
|
|||
mbs restart перезапустить bot + api
|
||||
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
|
||||
mbs domain текущий домен панели
|
||||
mbs update обновить код с GitHub и перезапустить
|
||||
mbs backup полная копия панели в /root/mbs-backups (база, .env, твои правки, конфиг Xray)
|
||||
mbs update [ссылка] обновить код и перезапустить; со ссылкой на git-зеркало берёт обновление оттуда
|
||||
mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, его mbs update проверяет первым
|
||||
```
|
||||
|
||||
`mbs update` тянет `git pull` (только fast-forward — если на сервере что-то правили руками, честно откажется и не полезет мержить), ставит зависимости, **проверяет, что новый код вообще компилируется**, и только потом перезапускает. Если после рестарта `mbs-bot`/`mbs-api` не поднялись — сам откатывает на предыдущий коммит и поднимает его. `.env` и база (`mbs.db`) не в гите — их не тронет ни при каком раскладе.
|
||||
`mbs update` тянет обновление (только fast-forward, чужую историю на сервере не мержит), ставит зависимости, **проверяет, что новый код вообще компилируется**, и только потом перезапускает. Если после рестарта `mbs-bot`/`mbs-api` не поднялись — сам откатывает на предыдущий коммит и поднимает его. `.env` и база (`mbs.db`) не в гите — их не тронет ни при каком раскладе.
|
||||
|
||||
Перед каждым обновлением `mbs update` сам снимает полную копию в `/root/mbs-backups/` (консистентный снапшот базы через backup API SQLite, `.env`, код вместе с твоими ручными правками, конфиг Xray, без `venv`), хранит 5 последних, права `600`. Не получилось сделать копию (нет места на диске), обновление даже не начнётся. То же вручную: `mbs backup`. Вернуть всё как было: `tar xzf /root/mbs-backups/mbs-before-update-<время>.tar.gz -C /` и `mbs restart`.
|
||||
|
||||
Если на сервере правили файлы руками (бывает, `bot.py`/`config.py`/`db.py` под себя), обновление больше на этом не падает: правки откладываются в `git stash` и сохраняются патчем в `local-changes/local-changes-<время>.patch`, потом подтягивается новая версия. Вернуть своё поверх новой: `git stash pop` (может быть конфликт, если новая версия правила те же строки, тогда смотри патч). Если новый код не прошёл проверку или сервисы не поднялись, откат на старый коммит возвращает и твои правки.
|
||||
|
||||
Источники по порядку: своё зеркало (если задано через `mbs mirror`), потом `lab.savsis.xyz`, потом `api.savsis.xyz`, потом GitHub. Установщик включает автообновление: каждый день около 04:00 сервер сам делает `mbs update` (перед ним всегда резервная копия). Выключить: `mbs autoupdate off`, включить обратно: `mbs autoupdate on`. Появилось новое зеркало или GitHub недоступен, а ссылка на репо есть: `mbs update https://example.com/путь/mbs-panel.git` возьмёт обновление именно оттуда, один раз. Чтобы всегда обновляться с него: `mbs mirror https://example.com/путь/mbs-panel.git` (убрать: `mbs mirror off`). Принимаются только `https://`, `http://`, `ssh://` и `git@хост:путь`, всё остальное (в том числе `file://` и хитрые транспорты типа `ext::`) отбрасывается, ветка берётся `main`.
|
||||
|
||||
## Добавление ноды
|
||||
|
||||
|
|
@ -172,6 +190,22 @@ sequenceDiagram
|
|||
Panel->>Panel: нода активна, доступна в боте
|
||||
```
|
||||
|
||||
## Цепочки серверов
|
||||
|
||||
Обычное подключение это `клиент → нода → интернет`. Цепочка добавляет второй прыжок: `клиент → нода A → нода B → интернет`. Сайты видят IP ноды B, а клиент коннектится к A. Пригождается, когда вход хочется держать в одном регионе (ближе, не режут), а выход нужен в другой стране. Больше двух серверов не даёт специально: каждый лишний прыжок это задержка, а скорость упирается в самое слабое звено.
|
||||
|
||||
Собирается в админке: Цепочки → зажимаешь ЛКМ на «Клиенте», тянешь провод через серверы из пула и отпускаешь на «Интернете». Пока ведёшь, сервер под курсором цепляется после короткой задержки (чтоб не хватать всё подряд по пути). Можно и без перетаскивания, просто кликами по серверам и по «Интернету», `Esc` сбрасывает. Один сервер это обычное подключение, оно и так есть у каждой ноды, а вот два уже цепочка: панель сразу показывает предупреждение про высокую задержку и замеряет реальный RTT между нодами (TCP-коннект с входной ноды до выходной).
|
||||
|
||||
Что реально происходит под капотом:
|
||||
|
||||
- на входной ноде появляется отдельный Xray-inbound `chain-<код>` (тот же Reality-ключ что у ноды, но свой порт из 10443–10999 и свой shortId), outbound `chain-<код>-out` до выходной ноды и routing-правило «всё из этого inbound уходит в этот outbound»;
|
||||
- на выходной ноде заводится служебный клиент `relay-<код>`, под ним входная нода и ходит на выход (только на TCP+Reality inbound, на обоих прыжках `xtls-rprx-vision`);
|
||||
- порт открывается в ufw сам, конфиг прогоняется через `xray run -test`, после рестарта проверяется что Xray реально поднялся, если нет, конфиг откатывается;
|
||||
- подписчикам входной ноды в подписку добавляется ещё одна ссылка «A → B», подписчикам выходной цепочку не выдаём;
|
||||
- любая проблема с цепочкой не блокирует обычную синхронизацию клиентов, они применятся в любом случае.
|
||||
|
||||
Ограничения: входом может быть только локальная или управляемая нода (панель правит её конфиг), выходом ещё и внешняя нода с общим UUID. Ноду, которая сидит в цепочке, удалить нельзя, сначала удали цепочку. И важный момент про Reality: SNI-маскировка (`dest`) не должна быть сайтом с пост-квантовым обменом ключами (например `www.microsoft.com`), на таком Reality не заводится вообще, ни в цепочке, ни без неё, проверено руками. Дефолтный `www.wildberries.ru` подходит.
|
||||
|
||||
## Приём оплаты
|
||||
|
||||
По умолчанию бот выдаёт подписки бесплатно по кнопке — платежи выключены (`PAYMENTS_ENABLED=false`). Чтобы продавать доступ:
|
||||
|
|
@ -209,7 +243,7 @@ sequenceDiagram
|
|||
PR и issues welcome. CI на каждый пуш гоняет compile-check по питону, синтаксис-проверку шелл-скриптов и smoke-тест генерации install-скрипта ноды.
|
||||
|
||||
## Авторы:
|
||||
github.com/devsavsis
|
||||
github.com/savsisbtw
|
||||
github.com/welfizx
|
||||
|
||||
## Лицензия
|
||||
|
|
|
|||
2350
admin.html
2350
admin.html
File diff suppressed because it is too large
Load diff
|
|
@ -123,5 +123,8 @@ def restore_backup(data: bytes) -> dict:
|
|||
os.chmod(tmp_db_path, 0o600)
|
||||
os.replace(tmp_db_path, DB_PATH)
|
||||
|
||||
import db
|
||||
db.init_db()
|
||||
|
||||
_prune_old_safety_copies()
|
||||
return {"restored_env": restored_env, "safety_copy": safety_copy}
|
||||
|
|
|
|||
308
bot.py
308
bot.py
|
|
@ -2,16 +2,19 @@ import asyncio
|
|||
import logging
|
||||
|
||||
from aiogram import Bot, Dispatcher, F
|
||||
from aiogram.filters import CommandStart, CommandObject
|
||||
from aiogram.filters import Command, CommandStart, CommandObject
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
import chains
|
||||
import db
|
||||
import links
|
||||
import features
|
||||
import payments
|
||||
import settings
|
||||
import webhooks
|
||||
import xray_manager
|
||||
from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN, PAYMENTS_ENABLED
|
||||
from config import BOT_TOKEN, ADMIN_IDS, SUB_DOMAIN, SITE_DOMAIN
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger("mbs-bot")
|
||||
|
|
@ -29,9 +32,14 @@ def is_admin(tg_id: int) -> bool:
|
|||
|
||||
|
||||
def main_menu_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||
rows = [
|
||||
rows = []
|
||||
if settings.get_features()["trial_enabled"] and db.trial_available(tg_id):
|
||||
rows.append([InlineKeyboardButton(text="Попробовать бесплатно", callback_data="trial:start")])
|
||||
rows += [
|
||||
[InlineKeyboardButton(text="Получить VPN", callback_data="menu:get")],
|
||||
[InlineKeyboardButton(text="Моя подписка", callback_data="menu:mysub")],
|
||||
[InlineKeyboardButton(text="Промокод", callback_data="menu:promo")],
|
||||
[InlineKeyboardButton(text="Пригласить друга", callback_data="menu:referral")],
|
||||
[InlineKeyboardButton(text="О сервисе", callback_data="menu:about")],
|
||||
]
|
||||
if is_admin(tg_id):
|
||||
|
|
@ -39,6 +47,14 @@ def main_menu_kb(tg_id: int) -> InlineKeyboardMarkup:
|
|||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
async def get_bot_username() -> str:
|
||||
global _bot_username
|
||||
if _bot_username is None:
|
||||
me = await bot.get_me()
|
||||
_bot_username = me.username
|
||||
return _bot_username
|
||||
|
||||
|
||||
def nodes_kb(prefix: str) -> InlineKeyboardMarkup:
|
||||
rows = []
|
||||
for n in db.list_nodes(enabled_only=True):
|
||||
|
|
@ -47,10 +63,16 @@ def nodes_kb(prefix: str) -> InlineKeyboardMarkup:
|
|||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def plans_kb(prefix: str, node_code: str) -> InlineKeyboardMarkup:
|
||||
def plans_kb(prefix: str, node_code: str, tg_id: int | None = None) -> InlineKeyboardMarkup:
|
||||
payments_enabled = settings.get_payment_settings()["payments_enabled"]
|
||||
promo = db.get_pending_promo(tg_id) if tg_id else None
|
||||
rows = []
|
||||
for p in PLANS:
|
||||
label = f"{p['label']} — {p['price']} ₽" if PAYMENTS_ENABLED and p["price"] > 0 else p["label"]
|
||||
for p in settings.get_plans():
|
||||
if payments_enabled and p["price"] > 0:
|
||||
final = db.discounted_price(p["price"], promo)
|
||||
label = f"{p['label']} — {final} ₽" if final == p["price"] else f"{p['label']} — {final} ₽ (было {p['price']})"
|
||||
else:
|
||||
label = p["label"]
|
||||
rows.append([InlineKeyboardButton(text=label, callback_data=f"{prefix}:{node_code}:{p['code']}")])
|
||||
rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:get")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
|
@ -70,13 +92,14 @@ def connect_kb(token: str, extra_rows: list[list[InlineKeyboardButton]] | None =
|
|||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
ABOUT_TEXT = (
|
||||
"<b>MBS Panel</b>\n\n"
|
||||
"Быстрый и незаметный доступ без границ. Протокол VLESS+Reality "
|
||||
"маскируется под обычный HTTPS-трафик, ничем не палится.\n\n"
|
||||
f"{DIVIDER}\n"
|
||||
f"Сайт: {SITE_DOMAIN}"
|
||||
)
|
||||
def about_text() -> str:
|
||||
return (
|
||||
f"<b>{settings.get_brand_name()}</b>\n\n"
|
||||
"Быстрый и незаметный доступ без границ. Протокол VLESS+Reality "
|
||||
"маскируется под обычный HTTPS-трафик, ничем не палится.\n\n"
|
||||
f"{DIVIDER}\n"
|
||||
f"Сайт: {SITE_DOMAIN}"
|
||||
)
|
||||
|
||||
|
||||
async def send_main_menu(message: Message):
|
||||
|
|
@ -87,6 +110,12 @@ async def send_main_menu(message: Message):
|
|||
async def start_deeplink(message: Message, command: CommandObject):
|
||||
user = db.get_or_create_user(message.from_user.id, message.from_user.username)
|
||||
payload = command.args or ""
|
||||
if payload.startswith("ref_") or payload.startswith("ref-"):
|
||||
ref_code = payload[4:]
|
||||
referrer = db.get_user_by_ref_code(ref_code)
|
||||
if referrer and settings.get_referral_settings()["enabled"]:
|
||||
db.set_referred_by(message.from_user.id, referrer["tg_id"])
|
||||
return await send_main_menu(message)
|
||||
if payload.startswith("gift_") or payload.startswith("gift-"):
|
||||
code = payload[5:]
|
||||
gift, err = db.redeem_gift_code(code, message.from_user.id)
|
||||
|
|
@ -96,7 +125,7 @@ async def start_deeplink(message: Message, command: CommandObject):
|
|||
if err == "already_used":
|
||||
await message.answer("Этот код уже был использован.")
|
||||
return await send_main_menu(message)
|
||||
plan = PLANS_BY_CODE.get(gift["plan"])
|
||||
plan = settings.get_plans_by_code().get(gift["plan"])
|
||||
gift_node = db.get_node(gift["node"])
|
||||
if not plan or not gift_node:
|
||||
await message.answer("Этот подарок больше недоступен.")
|
||||
|
|
@ -119,7 +148,7 @@ async def start_deeplink(message: Message, command: CommandObject):
|
|||
async def start_plain(message: Message):
|
||||
db.get_or_create_user(message.from_user.id, message.from_user.username)
|
||||
await message.answer(
|
||||
"Привет! Это бот MBS Panel.\nВыбери действие ниже.",
|
||||
f"Привет! Это бот {settings.get_brand_name()}.\nВыбери действие ниже.",
|
||||
)
|
||||
await send_main_menu(message)
|
||||
|
||||
|
|
@ -133,7 +162,33 @@ async def cb_menu_main(cb: CallbackQuery):
|
|||
@dp.callback_query(F.data == "menu:about")
|
||||
async def cb_about(cb: CallbackQuery):
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
|
||||
await cb.message.edit_text(ABOUT_TEXT, reply_markup=kb)
|
||||
await cb.message.edit_text(about_text(), reply_markup=kb)
|
||||
await cb.answer()
|
||||
|
||||
|
||||
@dp.callback_query(F.data == "menu:referral")
|
||||
async def cb_referral(cb: CallbackQuery):
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
|
||||
ref_settings = settings.get_referral_settings()
|
||||
if not ref_settings["enabled"]:
|
||||
await cb.message.edit_text("Реферальная программа сейчас отключена.", reply_markup=kb)
|
||||
return await cb.answer()
|
||||
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||
stats = db.referral_stats(cb.from_user.id)
|
||||
username = await get_bot_username()
|
||||
link = f"https://t.me/{username}?start=ref_{user['ref_code']}"
|
||||
days = ref_settings["bonus_days"]
|
||||
text = (
|
||||
f"<b>Пригласи друга</b>\n\n"
|
||||
f"За каждого друга, который активирует подписку по твоей ссылке, "
|
||||
f"вы <b>оба</b> получаете +{days} дн. к подписке.\n\n"
|
||||
f"{DIVIDER}\n"
|
||||
f"Твоя ссылка:\n<code>{link}</code>\n\n"
|
||||
f"Приглашено: {stats['referred_count']}\n"
|
||||
)
|
||||
if stats["bonus_days_pending"]:
|
||||
text += f"Накоплено бонусных дней (зачислятся при следующей подписке): {stats['bonus_days_pending']}\n"
|
||||
await cb.message.edit_text(text, reply_markup=kb)
|
||||
await cb.answer()
|
||||
|
||||
|
||||
|
|
@ -146,7 +201,7 @@ async def cb_get(cb: CallbackQuery):
|
|||
@dp.callback_query(F.data.startswith("node:"))
|
||||
async def cb_node(cb: CallbackQuery):
|
||||
node_code = cb.data.split(":")[1]
|
||||
await cb.message.edit_text("Выбери срок:", reply_markup=plans_kb("plan", node_code))
|
||||
await cb.message.edit_text("Выбери срок:", reply_markup=plans_kb("plan", node_code, cb.from_user.id))
|
||||
await cb.answer()
|
||||
|
||||
|
||||
|
|
@ -161,18 +216,27 @@ def providers_kb(node_code: str, plan_code: str) -> InlineKeyboardMarkup:
|
|||
@dp.callback_query(F.data.startswith("plan:"))
|
||||
async def cb_plan(cb: CallbackQuery):
|
||||
_, node_code, plan_code = cb.data.split(":")
|
||||
plan = PLANS_BY_CODE[plan_code]
|
||||
plan = settings.get_plans_by_code()[plan_code]
|
||||
db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||
|
||||
if PAYMENTS_ENABLED and plan["price"] > 0 and payments.available_providers():
|
||||
promo = db.get_pending_promo(cb.from_user.id)
|
||||
final_price = db.discounted_price(plan["price"], promo)
|
||||
if settings.get_payment_settings()["payments_enabled"] and final_price > 0 and payments.available_providers():
|
||||
price_line = f"{final_price} ₽" if final_price == plan["price"] else f"{final_price} ₽ (скидка по промокоду {promo['code']})"
|
||||
await cb.message.edit_text(
|
||||
f"<b>{plan['label']}</b> — {plan['price']} ₽\n\nВыбери способ оплаты:",
|
||||
f"<b>{plan['label']}</b> — {price_line}\n\nВыбери способ оплаты:",
|
||||
reply_markup=providers_kb(node_code, plan_code),
|
||||
)
|
||||
return await cb.answer()
|
||||
if promo and settings.get_payment_settings()["payments_enabled"] and plan["price"] > 0 and final_price == 0:
|
||||
db.consume_promo(promo["code"], cb.from_user.id)
|
||||
db.set_promo_pending(cb.from_user.id, None)
|
||||
|
||||
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||
sub = db.create_subscription(cb.from_user.id, node_code, plan["days"], plan_code, source="bot")
|
||||
sub = db.create_subscription(
|
||||
cb.from_user.id, node_code, plan["days"], plan_code, source="bot",
|
||||
traffic_limit=settings.default_traffic_limit_bytes(),
|
||||
)
|
||||
node_row = db.get_node(node_code)
|
||||
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
|
||||
kb = connect_kb(user["token"], extra_rows=[
|
||||
|
|
@ -193,13 +257,18 @@ async def cb_plan(cb: CallbackQuery):
|
|||
@dp.callback_query(F.data.startswith("pay:"))
|
||||
async def cb_pay(cb: CallbackQuery):
|
||||
_, provider, node_code, plan_code = cb.data.split(":")
|
||||
plan = PLANS_BY_CODE[plan_code]
|
||||
plan = settings.get_plans_by_code()[plan_code]
|
||||
node_row = db.get_node(node_code)
|
||||
payment_id = payments.new_payment_id()
|
||||
db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, plan["price"])
|
||||
promo = db.get_pending_promo(cb.from_user.id)
|
||||
final_price = db.discounted_price(plan["price"], promo)
|
||||
db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, final_price)
|
||||
if promo and final_price != plan["price"]:
|
||||
db.set_payment_promo(payment_id, promo["code"], plan["price"])
|
||||
db.set_promo_pending(cb.from_user.id, None)
|
||||
try:
|
||||
external_id, pay_url = payments.create_payment_link(
|
||||
provider, payment_id, plan["price"], f"MBS Panel — {node_row['label']}, {plan['label']}",
|
||||
provider, payment_id, final_price, f"{settings.get_brand_name()} — {node_row['label']}, {plan['label']}",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("payment creation failed")
|
||||
|
|
@ -211,7 +280,7 @@ async def cb_pay(cb: CallbackQuery):
|
|||
[InlineKeyboardButton(text="Назад", callback_data=f"plan:{node_code}:{plan_code}")],
|
||||
])
|
||||
await cb.message.edit_text(
|
||||
f"Счёт на {plan['price']} ₽ создан.\nПосле оплаты подписка выдастся автоматически.",
|
||||
f"Счёт на {final_price} ₽ создан.\nПосле оплаты подписка выдастся автоматически.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
await cb.answer()
|
||||
|
|
@ -226,11 +295,13 @@ async def cb_mysub(cb: CallbackQuery):
|
|||
await cb.message.edit_text("У тебя пока нет активных подписок.", reply_markup=kb)
|
||||
return await cb.answer()
|
||||
lines = ["<b>Твои подписки</b>\n"]
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
plans_by_code = settings.get_plans_by_code()
|
||||
for s in subs:
|
||||
plan = PLANS_BY_CODE.get(s["plan"], {}).get("label", s["plan"])
|
||||
node_info = db.get_node(s["node"])
|
||||
plan = plans_by_code.get(s["plan"], {}).get("label", s["plan"])
|
||||
node_info = nodes_by_code.get(s["node"])
|
||||
node = node_info["label"] if node_info else s["node"]
|
||||
lines.append(f"{node} — {plan}, до {s['expires_at'][:10]}")
|
||||
lines.append(f"{node} — {plan}, до {s['expires_at'][:10]}\nТрафик: {features.traffic_text(s)}")
|
||||
lines.append(f"\n{DIVIDER}\nСсылка-подписка:\n<code>{sub_url_for(user['token'])}</code>")
|
||||
kb = connect_kb(user["token"], extra_rows=[[InlineKeyboardButton(text="В меню", callback_data="menu:main")]])
|
||||
await cb.message.edit_text("\n".join(lines), reply_markup=kb)
|
||||
|
|
@ -282,7 +353,7 @@ async def cb_admin_giftmake(cb: CallbackQuery):
|
|||
me = await bot.get_me()
|
||||
_bot_username = me.username
|
||||
link = f"https://t.me/{_bot_username}?start=gift_{code}"
|
||||
plan = PLANS_BY_CODE[plan_code]
|
||||
plan = settings.get_plans_by_code()[plan_code]
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В админку", callback_data="menu:admin")]])
|
||||
await cb.message.edit_text(
|
||||
f"Гифт-ссылка готова ({db.get_node(node_code)['label']}, {plan['label']}):\n\n"
|
||||
|
|
@ -322,9 +393,145 @@ async def cb_admin_sync(cb: CallbackQuery):
|
|||
await cb.answer()
|
||||
|
||||
|
||||
async def reconcile_pending_payments():
|
||||
if not PAYMENTS_ENABLED:
|
||||
@dp.callback_query(F.data == "trial:start")
|
||||
async def cb_trial(cb: CallbackQuery):
|
||||
feats = settings.get_features()
|
||||
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||
if not feats["trial_enabled"] or not db.trial_available(cb.from_user.id):
|
||||
return await cb.answer("Пробный период недоступен", show_alert=True)
|
||||
node_row = features.pick_trial_node()
|
||||
if not node_row:
|
||||
return await cb.answer("Сейчас нет доступных серверов", show_alert=True)
|
||||
if not db.claim_trial(cb.from_user.id):
|
||||
return await cb.answer("Пробный период уже использован", show_alert=True)
|
||||
limit = feats["trial_traffic_gb"] * settings.GB if feats["trial_traffic_gb"] > 0 else None
|
||||
sub = db.create_subscription(
|
||||
cb.from_user.id, node_row["code"], feats["trial_days"], "trial", source="trial", traffic_limit=limit,
|
||||
)
|
||||
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
|
||||
traffic_line = f"\nТрафик: до {feats['trial_traffic_gb']} ГБ" if limit else ""
|
||||
kb = connect_kb(user["token"], extra_rows=[[InlineKeyboardButton(text="В меню", callback_data="menu:main")]])
|
||||
await cb.message.edit_text(
|
||||
f"<b>Пробный период активен</b>\n\n"
|
||||
f"Сервер: {node_row['label']}\n"
|
||||
f"Срок: до {sub['expires_at'][:10]}{traffic_line}\n\n"
|
||||
f"{DIVIDER}\n"
|
||||
f"Ссылка-подписка:\n<code>{sub_url_for(user['token'])}</code>",
|
||||
reply_markup=kb,
|
||||
)
|
||||
await cb.answer("Пробный период выдан")
|
||||
await asyncio.to_thread(webhooks.send, "subscription.trial", {
|
||||
"tg_id": cb.from_user.id, "node": node_row["code"], "subscription_uuid": sub["uuid"],
|
||||
"expires_at": sub["expires_at"],
|
||||
})
|
||||
|
||||
|
||||
PROMO_ERRORS = {
|
||||
"not_found": "Такого промокода нет.",
|
||||
"expired": "Срок действия промокода закончился.",
|
||||
"exhausted": "Этот промокод уже использован максимальное число раз.",
|
||||
"already_used": "Ты уже использовал этот промокод.",
|
||||
}
|
||||
|
||||
|
||||
async def apply_promo_code(message: Message, raw_code: str):
|
||||
code = (raw_code or "").strip()
|
||||
if not code:
|
||||
return await message.answer("Напиши промокод так: /promo КОД")
|
||||
db.get_or_create_user(message.from_user.id, message.from_user.username)
|
||||
promo, err = db.validate_promo(code, message.from_user.id)
|
||||
if err:
|
||||
return await message.answer(PROMO_ERRORS.get(err, "Промокод не подошёл."))
|
||||
if promo["kind"] == "days":
|
||||
redeemed, err = db.redeem_days_promo(code, message.from_user.id)
|
||||
if err:
|
||||
return await message.answer(PROMO_ERRORS.get(err, "Промокод не подошёл."))
|
||||
return await message.answer(f"Промокод принят: +{promo['value']} дн. к подписке.")
|
||||
db.set_promo_pending(message.from_user.id, promo["code"])
|
||||
what = f"{promo['value']}%" if promo["kind"] == "percent" else f"{promo['value']} ₽"
|
||||
await message.answer(f"Промокод принят: скидка {what}. Она применится на следующей оплате, выбери срок в меню.")
|
||||
|
||||
|
||||
@dp.message(Command("promo"))
|
||||
async def cmd_promo(message: Message, command: CommandObject):
|
||||
await apply_promo_code(message, command.args or "")
|
||||
|
||||
|
||||
@dp.callback_query(F.data == "menu:promo")
|
||||
async def cb_promo_hint(cb: CallbackQuery):
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
|
||||
await cb.message.edit_text("Отправь команду с кодом, например:\n<code>/promo КОД</code>", reply_markup=kb)
|
||||
await cb.answer()
|
||||
|
||||
|
||||
async def notify_limit_reached(subs: list):
|
||||
for sub in subs:
|
||||
try:
|
||||
await bot.send_message(
|
||||
sub["tg_id"],
|
||||
"<b>Лимит трафика исчерпан</b>\n\nДоступ приостановлен. Продли подписку или напиши в поддержку, "
|
||||
"чтобы получить ещё трафик.",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("failed to notify about traffic limit")
|
||||
await asyncio.to_thread(webhooks.send, "subscription.limit_reached", {
|
||||
"tg_id": sub["tg_id"], "subscription_uuid": sub["uuid"], "node": sub["node"],
|
||||
"limit": sub["traffic_limit"], "used": sub["traffic_used"],
|
||||
})
|
||||
|
||||
|
||||
async def send_expiry_reminders():
|
||||
if not settings.get_features()["reminders_enabled"]:
|
||||
return
|
||||
for sub, kind, stage in features.reminders_due():
|
||||
left = "3 дня" if stage == "3d" else "сутки"
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Продлить", callback_data="menu:get")]])
|
||||
try:
|
||||
await bot.send_message(
|
||||
sub["tg_id"],
|
||||
f"<b>Подписка скоро закончится</b>\n\nДо конца осталось меньше чем {left} "
|
||||
f"(до {sub['expires_at'][:10]}). Продли заранее, чтобы доступ не прерывался.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("failed to send expiry reminder")
|
||||
features.mark_stage_sent(sub["uuid"], sub["expires_at"])
|
||||
|
||||
|
||||
_node_state: dict = {}
|
||||
|
||||
|
||||
async def check_nodes_and_alert():
|
||||
if not settings.get_features()["node_alerts_enabled"]:
|
||||
return
|
||||
for node in db.list_nodes(enabled_only=True):
|
||||
if node["kind"] == "local" or node["status"] != "active" or not node.get("address"):
|
||||
continue
|
||||
samples = await asyncio.to_thread(chains.tcp_connect_ms, node["address"], node["port"], 2, 3.0)
|
||||
alive = chains.median_ms(samples) is not None
|
||||
previous = _node_state.get(node["code"])
|
||||
_node_state[node["code"]] = alive
|
||||
if previous is None or previous == alive:
|
||||
continue
|
||||
text = (
|
||||
f"Нода «{node['label']}» ({node['address']}) снова доступна."
|
||||
if alive else f"Нода «{node['label']}» ({node['address']}) не отвечает."
|
||||
)
|
||||
for admin_id in ADMIN_IDS:
|
||||
try:
|
||||
await bot.send_message(admin_id, text)
|
||||
except Exception:
|
||||
log.exception("failed to send node alert")
|
||||
await asyncio.to_thread(webhooks.send, "node.up" if alive else "node.down", {
|
||||
"node": node["code"], "label": node["label"], "address": node["address"],
|
||||
})
|
||||
|
||||
|
||||
async def reconcile_pending_payments():
|
||||
if not settings.get_payment_settings()["payments_enabled"]:
|
||||
return
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
plans_by_code = settings.get_plans_by_code()
|
||||
for payment in db.list_payments():
|
||||
if payment["status"] != "pending" or not payment.get("external_id"):
|
||||
continue
|
||||
|
|
@ -333,14 +540,17 @@ async def reconcile_pending_payments():
|
|||
except Exception:
|
||||
continue
|
||||
if status in payments.PAID_STATUSES:
|
||||
plan = PLANS_BY_CODE.get(payment["plan"])
|
||||
node_row = db.get_node(payment["node"])
|
||||
plan = plans_by_code.get(payment["plan"])
|
||||
node_row = nodes_by_code.get(payment["node"])
|
||||
if not plan or not node_row:
|
||||
continue
|
||||
granted = db.mark_payment_paid(payment["id"])
|
||||
if not granted:
|
||||
continue
|
||||
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
|
||||
sub = db.create_subscription(
|
||||
payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment",
|
||||
traffic_limit=settings.default_traffic_limit_bytes(),
|
||||
)
|
||||
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
|
||||
user = db.get_or_create_user(payment["tg_id"], None)
|
||||
try:
|
||||
|
|
@ -353,16 +563,44 @@ async def reconcile_pending_payments():
|
|||
)
|
||||
except Exception:
|
||||
log.exception("failed to notify user about payment")
|
||||
await asyncio.to_thread(webhooks.send, "payment.paid", {
|
||||
"tg_id": payment["tg_id"],
|
||||
"amount": payment["amount"],
|
||||
"provider": payment["provider"],
|
||||
"node": payment["node"],
|
||||
"plan": payment["plan"],
|
||||
"subscription_uuid": sub["uuid"],
|
||||
"expires_at": sub["expires_at"],
|
||||
})
|
||||
elif status in payments.FAILED_STATUSES:
|
||||
db.mark_payment_failed(payment["id"])
|
||||
|
||||
|
||||
async def periodic_sync():
|
||||
tick = 0
|
||||
while True:
|
||||
if tick % 3 == 0:
|
||||
try:
|
||||
exceeded = await asyncio.to_thread(features.update_traffic_and_find_exceeded)
|
||||
if exceeded:
|
||||
await notify_limit_reached(exceeded)
|
||||
except Exception:
|
||||
log.exception("traffic accounting failed")
|
||||
try:
|
||||
await asyncio.to_thread(xray_manager.sync_all)
|
||||
except Exception:
|
||||
log.exception("periodic sync failed")
|
||||
if tick % 20 == 0:
|
||||
try:
|
||||
await send_expiry_reminders()
|
||||
except Exception:
|
||||
log.exception("expiry reminders failed")
|
||||
if tick % 2 == 0:
|
||||
try:
|
||||
await check_nodes_and_alert()
|
||||
except Exception:
|
||||
log.exception("node alerts failed")
|
||||
tick += 1
|
||||
try:
|
||||
await reconcile_pending_payments()
|
||||
except Exception:
|
||||
|
|
|
|||
233
chains.py
Normal file
233
chains.py
Normal 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"
|
||||
|
|
@ -38,6 +38,8 @@ if ADMIN_PANEL_PASSWORD in ("change-me", "changeme", "admin", "password") or len
|
|||
PANEL_DOMAIN = env("PANEL_DOMAIN", required=True)
|
||||
SUB_DOMAIN = env("SUB_DOMAIN", required=True)
|
||||
SITE_DOMAIN = env("SITE_DOMAIN", required=True)
|
||||
BRAND_NAME = env("BRAND_NAME", "MBS Panel")
|
||||
ADMIN_PATH = env("ADMIN_PATH", "admin").strip("/") or "admin"
|
||||
|
||||
DB_PATH = os.path.join(BASE_DIR, "mbs.db")
|
||||
XRAY_CONFIG_PATH = "/usr/local/etc/xray/config.json"
|
||||
|
|
@ -118,3 +120,6 @@ PLATEGA_SECRET = env("PLATEGA_SECRET", "")
|
|||
|
||||
HWID_LIMIT_ENABLED = env("HWID_LIMIT_ENABLED", "false").lower() == "true"
|
||||
HWID_FALLBACK_LIMIT = int(env("HWID_FALLBACK_LIMIT", "3"))
|
||||
|
||||
REFERRAL_ENABLED = env("REFERRAL_ENABLED", "true").lower() == "true"
|
||||
REFERRAL_BONUS_DAYS = int(env("REFERRAL_BONUS_DAYS", "3"))
|
||||
|
|
|
|||
549
db.py
549
db.py
|
|
@ -6,6 +6,7 @@ import secrets
|
|||
import datetime
|
||||
import contextlib
|
||||
|
||||
import chains as chainsmod
|
||||
from config import DB_PATH
|
||||
|
||||
SCHEMA = """
|
||||
|
|
@ -113,6 +114,56 @@ CREATE TABLE IF NOT EXISTS nodes (
|
|||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chains (
|
||||
code TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
entry_node TEXT NOT NULL,
|
||||
exit_node TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
short_id TEXT NOT NULL,
|
||||
relay_uuid TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
admin TEXT,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
ip TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS promo_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
value INTEGER NOT NULL,
|
||||
max_uses INTEGER,
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS promo_uses (
|
||||
code TEXT NOT NULL,
|
||||
tg_id INTEGER NOT NULL,
|
||||
used_at TEXT NOT NULL,
|
||||
PRIMARY KEY (code, tg_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sub_notices (
|
||||
sub_uuid TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
sent_at TEXT NOT NULL,
|
||||
PRIMARY KEY (sub_uuid, kind)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_chains_pair ON chains (entry_node, exit_node);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_chains_entry_port ON chains (entry_node, port);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log (ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_subs_active_expires ON subscriptions (active, expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_subs_tg_id ON subscriptions (tg_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subs_node ON subscriptions (node);
|
||||
|
|
@ -133,6 +184,12 @@ _NEW_NODE_COLUMNS = {
|
|||
|
||||
_NEW_USER_COLUMNS = {
|
||||
"hwid_limit": "INTEGER",
|
||||
"ref_code": "TEXT",
|
||||
"referred_by": "INTEGER",
|
||||
"referral_rewarded": "INTEGER NOT NULL DEFAULT 0",
|
||||
"bonus_days_pending": "INTEGER NOT NULL DEFAULT 0",
|
||||
"trial_used": "INTEGER NOT NULL DEFAULT 0",
|
||||
"promo_pending": "TEXT",
|
||||
}
|
||||
|
||||
_NEW_ADMIN_SESSION_COLUMNS = {
|
||||
|
|
@ -143,6 +200,19 @@ _NEW_ADMIN_COLUMNS = {
|
|||
"totp_secret": "TEXT",
|
||||
}
|
||||
|
||||
_NEW_SUBSCRIPTION_COLUMNS = {
|
||||
"held_at": "TEXT",
|
||||
"traffic_limit": "INTEGER",
|
||||
"traffic_used": "INTEGER NOT NULL DEFAULT 0",
|
||||
"traffic_last_raw": "INTEGER NOT NULL DEFAULT 0",
|
||||
"limit_hit_at": "TEXT",
|
||||
}
|
||||
|
||||
_NEW_PAYMENT_COLUMNS = {
|
||||
"promo_code": "TEXT",
|
||||
"original_amount": "INTEGER",
|
||||
}
|
||||
|
||||
|
||||
def _migrate():
|
||||
with get_conn() as conn:
|
||||
|
|
@ -155,6 +225,7 @@ def _migrate():
|
|||
for name, decl in _NEW_USER_COLUMNS.items():
|
||||
if name not in ucols:
|
||||
conn.execute(f"ALTER TABLE users ADD COLUMN {name} {decl}")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ref_code ON users(ref_code)")
|
||||
scols = {r["name"] for r in conn.execute("PRAGMA table_info(admin_sessions)").fetchall()}
|
||||
for name, decl in _NEW_ADMIN_SESSION_COLUMNS.items():
|
||||
if name not in scols:
|
||||
|
|
@ -163,6 +234,14 @@ def _migrate():
|
|||
for name, decl in _NEW_ADMIN_COLUMNS.items():
|
||||
if name not in acols:
|
||||
conn.execute(f"ALTER TABLE admins ADD COLUMN {name} {decl}")
|
||||
subcols = {r["name"] for r in conn.execute("PRAGMA table_info(subscriptions)").fetchall()}
|
||||
for name, decl in _NEW_SUBSCRIPTION_COLUMNS.items():
|
||||
if name not in subcols:
|
||||
conn.execute(f"ALTER TABLE subscriptions ADD COLUMN {name} {decl}")
|
||||
pcols = {r["name"] for r in conn.execute("PRAGMA table_info(payments)").fetchall()}
|
||||
for name, decl in _NEW_PAYMENT_COLUMNS.items():
|
||||
if name not in pcols:
|
||||
conn.execute(f"ALTER TABLE payments ADD COLUMN {name} {decl}")
|
||||
if needs_sort_order_backfill:
|
||||
rows = conn.execute(
|
||||
"SELECT code FROM nodes ORDER BY (code='de1') DESC, created_at ASC"
|
||||
|
|
@ -279,6 +358,8 @@ def reorder_nodes(codes: list):
|
|||
|
||||
|
||||
def create_node(code, label, kind, address, port, public_key, short_id, sni, flow, shared_uuid=None):
|
||||
if get_node(code):
|
||||
raise ValueError("node with this code already exists")
|
||||
with get_conn() as conn:
|
||||
next_order = _next_sort_order(conn)
|
||||
conn.execute(
|
||||
|
|
@ -340,48 +421,216 @@ def delete_node(code: str):
|
|||
).fetchone()["c"]
|
||||
if active:
|
||||
raise ValueError(f"node has {active} active subscriptions, revoke them first")
|
||||
in_chains = conn.execute(
|
||||
"SELECT COUNT(*) c FROM chains WHERE entry_node=? OR exit_node=?", (code, code)
|
||||
).fetchone()["c"]
|
||||
if in_chains:
|
||||
raise ValueError(f"node is used in {in_chains} chain(s), delete them first")
|
||||
conn.execute("DELETE FROM nodes WHERE code=?", (code,))
|
||||
|
||||
|
||||
def list_chains(enabled_only: bool = False):
|
||||
q = "SELECT * FROM chains"
|
||||
if enabled_only:
|
||||
q += " WHERE enabled=1"
|
||||
q += " ORDER BY sort_order ASC, created_at ASC"
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_chain(code: str):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM chains WHERE code=?", (code,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def create_chain(label: str, entry_node: str, exit_node: str, relay_uuid: str | None):
|
||||
with get_conn() as conn:
|
||||
dup = conn.execute(
|
||||
"SELECT 1 FROM chains WHERE entry_node=? AND exit_node=?", (entry_node, exit_node)
|
||||
).fetchone()
|
||||
if dup:
|
||||
raise ValueError("такая цепочка уже есть")
|
||||
used = {r["port"] for r in conn.execute(
|
||||
"SELECT port FROM chains WHERE entry_node=?", (entry_node,)
|
||||
).fetchall()}
|
||||
port = None
|
||||
for candidate in range(chainsmod.PORT_MIN, chainsmod.PORT_MAX + 1):
|
||||
if candidate not in used:
|
||||
port = candidate
|
||||
break
|
||||
if port is None:
|
||||
raise ValueError("закончились свободные порты под цепочки на этой ноде")
|
||||
row = conn.execute("SELECT MAX(sort_order) m FROM chains").fetchone()
|
||||
next_order = (row["m"] or 0) + 1
|
||||
code = "c" + secrets.token_hex(3)
|
||||
conn.execute(
|
||||
"INSERT INTO chains (code, label, entry_node, exit_node, port, short_id, relay_uuid, enabled, sort_order, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,1,?,?)",
|
||||
(code, label, entry_node, exit_node, port, secrets.token_hex(8), relay_uuid, next_order, now_iso()),
|
||||
)
|
||||
return get_chain(code)
|
||||
|
||||
|
||||
def update_chain(code: str, **fields):
|
||||
if not fields:
|
||||
return get_chain(code)
|
||||
cols = ", ".join(f"{k}=?" for k in fields)
|
||||
with get_conn() as conn:
|
||||
conn.execute(f"UPDATE chains SET {cols} WHERE code=?", (*fields.values(), code))
|
||||
return get_chain(code)
|
||||
|
||||
|
||||
def delete_chain(code: str):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM chains WHERE code=?", (code,))
|
||||
|
||||
|
||||
def add_audit(admin: str | None, action: str, detail: str = "", ip: str | None = None):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (ts, admin, action, detail, ip) VALUES (?,?,?,?,?)",
|
||||
(now_iso(), admin, action, detail[:500], ip),
|
||||
)
|
||||
conn.execute("DELETE FROM audit_log WHERE id <= (SELECT MAX(id) FROM audit_log) - 5000")
|
||||
|
||||
|
||||
def list_audit(limit: int = 100):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM audit_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _generate_ref_code(conn) -> str:
|
||||
for _ in range(20):
|
||||
code = secrets.token_hex(4)
|
||||
if not conn.execute("SELECT 1 FROM users WHERE ref_code=?", (code,)).fetchone():
|
||||
return code
|
||||
raise RuntimeError("could not generate a unique ref_code")
|
||||
|
||||
|
||||
def get_or_create_user(tg_id: int, username: str | None):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||
if row:
|
||||
if username and row["username"] != username:
|
||||
conn.execute("UPDATE users SET username=? WHERE tg_id=?", (username, tg_id))
|
||||
if not row["ref_code"]:
|
||||
conn.execute(
|
||||
"UPDATE users SET ref_code=? WHERE tg_id=?", (_generate_ref_code(conn), tg_id)
|
||||
)
|
||||
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||
return dict(row)
|
||||
token = secrets.token_hex(16)
|
||||
ref_code = _generate_ref_code(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO users (tg_id, token, username, created_at) VALUES (?,?,?,?)",
|
||||
(tg_id, token, username, now_iso()),
|
||||
"INSERT INTO users (tg_id, token, username, ref_code, created_at) VALUES (?,?,?,?,?)",
|
||||
(tg_id, token, username, ref_code, now_iso()),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def get_user_by_ref_code(ref_code: str):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE ref_code=?", (ref_code,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def set_referred_by(tg_id: int, referrer_tg_id: int) -> bool:
|
||||
"""First-touch attribution: only takes effect for a brand-new account
|
||||
(no subscriptions yet) that isn't already attributed, and never to self."""
|
||||
if tg_id == referrer_tg_id:
|
||||
return False
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT referred_by FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||
if not row or row["referred_by"] is not None:
|
||||
return False
|
||||
has_sub = conn.execute("SELECT 1 FROM subscriptions WHERE tg_id=?", (tg_id,)).fetchone()
|
||||
if has_sub:
|
||||
return False
|
||||
referrer = conn.execute("SELECT 1 FROM users WHERE tg_id=?", (referrer_tg_id,)).fetchone()
|
||||
if not referrer:
|
||||
return False
|
||||
conn.execute("UPDATE users SET referred_by=? WHERE tg_id=?", (referrer_tg_id, tg_id))
|
||||
return True
|
||||
|
||||
|
||||
def _apply_bonus_days(conn, tg_id: int, days: int):
|
||||
if days <= 0:
|
||||
return
|
||||
row = conn.execute(
|
||||
"SELECT uuid, expires_at FROM subscriptions WHERE tg_id=? AND active=1 AND held_at IS NULL "
|
||||
"ORDER BY expires_at DESC LIMIT 1",
|
||||
(tg_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
new_expires = datetime.datetime.fromisoformat(row["expires_at"]) + datetime.timedelta(days=days)
|
||||
conn.execute("UPDATE subscriptions SET expires_at=? WHERE uuid=?", (new_expires.isoformat(), row["uuid"]))
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE users SET bonus_days_pending = COALESCE(bonus_days_pending, 0) + ? WHERE tg_id=?",
|
||||
(days, tg_id),
|
||||
)
|
||||
|
||||
|
||||
def credit_bonus_days(tg_id: int, days: int):
|
||||
with get_conn() as conn:
|
||||
_apply_bonus_days(conn, tg_id, days)
|
||||
|
||||
|
||||
def referral_stats(tg_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT ref_code, bonus_days_pending FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) c FROM users WHERE referred_by=? AND referral_rewarded=1", (tg_id,)
|
||||
).fetchone()["c"]
|
||||
return {
|
||||
"ref_code": user["ref_code"] if user else None,
|
||||
"bonus_days_pending": user["bonus_days_pending"] if user else 0,
|
||||
"referred_count": count,
|
||||
}
|
||||
|
||||
|
||||
def get_user_by_token(token: str):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE token=?", (token,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def create_subscription(tg_id: int, node: str, plan_days: int, plan_code: str, source: str = "bot", client_uuid: str | None = None):
|
||||
def create_subscription(tg_id: int, node: str, plan_days: int, plan_code: str, source: str = "bot", client_uuid: str | None = None, traffic_limit: int | None = None):
|
||||
import uuid as uuidlib
|
||||
import config
|
||||
|
||||
cid = client_uuid or str(uuidlib.uuid4())
|
||||
created = datetime.datetime.utcnow()
|
||||
expires = created + datetime.timedelta(days=plan_days)
|
||||
with get_conn() as conn:
|
||||
is_first = conn.execute("SELECT 1 FROM subscriptions WHERE tg_id=?", (tg_id,)).fetchone() is None
|
||||
urow = conn.execute(
|
||||
"SELECT referred_by, referral_rewarded, bonus_days_pending FROM users WHERE tg_id=?", (tg_id,)
|
||||
).fetchone()
|
||||
pending = urow["bonus_days_pending"] if urow else 0
|
||||
if pending:
|
||||
expires += datetime.timedelta(days=pending)
|
||||
conn.execute("UPDATE users SET bonus_days_pending=0 WHERE tg_id=?", (tg_id,))
|
||||
conn.execute(
|
||||
"INSERT INTO subscriptions (uuid, tg_id, node, plan, created_at, expires_at, active, source) "
|
||||
"VALUES (?,?,?,?,?,?,1,?)",
|
||||
(cid, tg_id, node, plan_code, created.isoformat(), expires.isoformat(), source),
|
||||
"INSERT INTO subscriptions (uuid, tg_id, node, plan, created_at, expires_at, active, source, traffic_limit) "
|
||||
"VALUES (?,?,?,?,?,?,1,?,?)",
|
||||
(cid, tg_id, node, plan_code, created.isoformat(), expires.isoformat(), source, traffic_limit),
|
||||
)
|
||||
return {"uuid": cid, "tg_id": tg_id, "node": node, "plan": plan_code, "expires_at": expires.isoformat()}
|
||||
if is_first and urow and urow["referred_by"] and not urow["referral_rewarded"] and config.REFERRAL_ENABLED:
|
||||
conn.execute("UPDATE users SET referral_rewarded=1 WHERE tg_id=?", (tg_id,))
|
||||
bonus = config.REFERRAL_BONUS_DAYS
|
||||
_apply_bonus_days(conn, tg_id, bonus)
|
||||
_apply_bonus_days(conn, urow["referred_by"], bonus)
|
||||
expires_final = conn.execute("SELECT expires_at FROM subscriptions WHERE uuid=?", (cid,)).fetchone()["expires_at"]
|
||||
return {"uuid": cid, "tg_id": tg_id, "node": node, "plan": plan_code, "expires_at": expires_final}
|
||||
|
||||
|
||||
def list_active_subscriptions(tg_id: int | None = None, node: str | None = None):
|
||||
q = "SELECT * FROM subscriptions WHERE active=1 AND expires_at > ?"
|
||||
q = "SELECT * FROM subscriptions WHERE active=1 AND held_at IS NULL AND expires_at > ?"
|
||||
params = [now_iso()]
|
||||
if tg_id is not None:
|
||||
q += " AND tg_id=?"
|
||||
|
|
@ -597,6 +846,34 @@ def revoke_subscription(client_uuid: str):
|
|||
conn.execute("UPDATE subscriptions SET active=0 WHERE uuid=?", (client_uuid,))
|
||||
|
||||
|
||||
def hold_subscription(client_uuid: str) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE subscriptions SET held_at=? WHERE uuid=? AND active=1 AND held_at IS NULL AND expires_at > ?",
|
||||
(now_iso(), client_uuid, now_iso()),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def resume_subscription(client_uuid: str):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM subscriptions WHERE uuid=? AND held_at IS NOT NULL", (client_uuid,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
held_at = datetime.datetime.fromisoformat(row["held_at"])
|
||||
shift = datetime.datetime.utcnow() - held_at
|
||||
new_expires = (datetime.datetime.fromisoformat(row["expires_at"]) + shift).isoformat()
|
||||
cur = conn.execute(
|
||||
"UPDATE subscriptions SET expires_at=?, held_at=NULL WHERE uuid=? AND held_at IS NOT NULL",
|
||||
(new_expires, client_uuid),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
return get_subscription(client_uuid)
|
||||
|
||||
|
||||
def get_subscription(client_uuid: str):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
|
|
@ -607,6 +884,32 @@ def get_subscription(client_uuid: str):
|
|||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_users(q: str = "", limit: int = 200):
|
||||
limit = max(1, min(int(limit), 1000))
|
||||
q = (q or "").strip()
|
||||
where = ""
|
||||
params = [now_iso(), now_iso()]
|
||||
if q:
|
||||
if q.lstrip("-").isdigit():
|
||||
where = "WHERE u.tg_id = ? OR instr(lower(COALESCE(u.username, '')), ?) > 0"
|
||||
params += [int(q), q.lower()]
|
||||
else:
|
||||
where = "WHERE instr(lower(COALESCE(u.username, '')), ?) > 0"
|
||||
params.append(q.lower().lstrip("@"))
|
||||
params.append(limit)
|
||||
query = (
|
||||
"SELECT u.tg_id, u.username, u.created_at, "
|
||||
"(SELECT COUNT(*) FROM subscriptions s WHERE s.tg_id=u.tg_id) AS subs_total, "
|
||||
"(SELECT COUNT(*) FROM subscriptions s WHERE s.tg_id=u.tg_id AND s.active=1 AND s.held_at IS NULL AND s.expires_at > ?) AS subs_active, "
|
||||
"(SELECT MAX(s.expires_at) FROM subscriptions s WHERE s.tg_id=u.tg_id AND s.active=1 AND s.held_at IS NULL AND s.expires_at > ?) AS active_until, "
|
||||
"(SELECT COUNT(*) FROM devices d WHERE d.tg_id=u.tg_id) AS devices "
|
||||
"FROM users u " + where + " ORDER BY u.created_at DESC LIMIT ?"
|
||||
)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_user(tg_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||
|
|
@ -638,12 +941,16 @@ def stats():
|
|||
total_subs = conn.execute("SELECT COUNT(*) c FROM subscriptions").fetchone()["c"]
|
||||
gifts_created = conn.execute("SELECT COUNT(*) c FROM gift_codes").fetchone()["c"]
|
||||
gifts_used = conn.execute("SELECT COUNT(*) c FROM gift_codes WHERE used_by IS NOT NULL").fetchone()["c"]
|
||||
nodes_n = conn.execute("SELECT COUNT(*) c FROM nodes WHERE enabled=1").fetchone()["c"]
|
||||
chains_n = conn.execute("SELECT COUNT(*) c FROM chains WHERE enabled=1").fetchone()["c"]
|
||||
return {
|
||||
"users": users_n,
|
||||
"active_subscriptions": active_n,
|
||||
"total_subscriptions": total_subs,
|
||||
"gifts_created": gifts_created,
|
||||
"gifts_used": gifts_used,
|
||||
"nodes": nodes_n,
|
||||
"chains": chains_n,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -680,6 +987,9 @@ def mark_payment_paid(payment_id: str):
|
|||
)
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
row = conn.execute("SELECT tg_id, promo_code FROM payments WHERE id=?", (payment_id,)).fetchone()
|
||||
if row and row["promo_code"]:
|
||||
_consume_promo(conn, row["promo_code"], row["tg_id"])
|
||||
return get_payment(payment_id)
|
||||
|
||||
|
||||
|
|
@ -754,3 +1064,226 @@ def delete_device(device_id: int):
|
|||
def set_user_hwid_limit(tg_id: int, limit: int | None):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET hwid_limit=? WHERE tg_id=?", (limit, tg_id))
|
||||
|
||||
|
||||
def add_traffic_sample(client_uuid: str, raw_total: int) -> int:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT traffic_used, traffic_last_raw FROM subscriptions WHERE uuid=?", (client_uuid,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return 0
|
||||
last = row["traffic_last_raw"]
|
||||
delta = raw_total - last if raw_total >= last else raw_total
|
||||
used = row["traffic_used"] + max(delta, 0)
|
||||
conn.execute(
|
||||
"UPDATE subscriptions SET traffic_used=?, traffic_last_raw=? WHERE uuid=?",
|
||||
(used, raw_total, client_uuid),
|
||||
)
|
||||
return used
|
||||
|
||||
|
||||
def set_traffic_limit(client_uuid: str, limit_bytes: int | None):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE subscriptions SET traffic_limit=? WHERE uuid=?", (limit_bytes, client_uuid))
|
||||
conn.execute(
|
||||
"UPDATE subscriptions SET active=1, limit_hit_at=NULL "
|
||||
"WHERE uuid=? AND limit_hit_at IS NOT NULL AND expires_at > ? "
|
||||
"AND (traffic_limit IS NULL OR traffic_limit <= 0 OR traffic_used < traffic_limit)",
|
||||
(client_uuid, now_iso()),
|
||||
)
|
||||
|
||||
|
||||
def list_over_limit():
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM subscriptions WHERE active=1 AND traffic_limit IS NOT NULL AND traffic_limit > 0 "
|
||||
"AND traffic_used >= traffic_limit"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def mark_limit_hit(client_uuid: str):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE subscriptions SET active=0, limit_hit_at=? WHERE uuid=? AND active=1",
|
||||
(now_iso(), client_uuid),
|
||||
)
|
||||
|
||||
|
||||
def reset_traffic_counter(client_uuid: str) -> bool:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE subscriptions SET traffic_used=0, traffic_last_raw=0 WHERE uuid=?", (client_uuid,)
|
||||
)
|
||||
cur = conn.execute(
|
||||
"UPDATE subscriptions SET active=1, limit_hit_at=NULL "
|
||||
"WHERE uuid=? AND limit_hit_at IS NOT NULL AND expires_at > ?",
|
||||
(client_uuid, now_iso()),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def list_subscriptions_expiring(within_hours: int):
|
||||
now = datetime.datetime.utcnow()
|
||||
until = (now + datetime.timedelta(hours=within_hours)).isoformat()
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM subscriptions WHERE active=1 AND held_at IS NULL AND expires_at > ? AND expires_at <= ?",
|
||||
(now.isoformat(), until),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def notice_already_sent(sub_uuid: str, kind: str) -> bool:
|
||||
with get_conn() as conn:
|
||||
return conn.execute(
|
||||
"SELECT 1 FROM sub_notices WHERE sub_uuid=? AND kind=?", (sub_uuid, kind)
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
def mark_notice_sent(sub_uuid: str, kind: str):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO sub_notices (sub_uuid, kind, sent_at) VALUES (?,?,?)",
|
||||
(sub_uuid, kind, now_iso()),
|
||||
)
|
||||
|
||||
|
||||
def claim_trial(tg_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("UPDATE users SET trial_used=1 WHERE tg_id=? AND trial_used=0", (tg_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def trial_available(tg_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT trial_used FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||
if not row or row["trial_used"]:
|
||||
return False
|
||||
has_sub = conn.execute("SELECT 1 FROM subscriptions WHERE tg_id=?", (tg_id,)).fetchone()
|
||||
return has_sub is None
|
||||
|
||||
|
||||
PROMO_KINDS = ("percent", "fixed", "days")
|
||||
|
||||
|
||||
def create_promo(code: str, kind: str, value: int, max_uses: int | None = None, expires_at: str | None = None):
|
||||
code = code.strip().upper()
|
||||
if not code or len(code) > 40 or not all(c.isalnum() or c in "-_" for c in code):
|
||||
raise ValueError("код: только буквы, цифры, - и _, до 40 символов")
|
||||
if kind not in PROMO_KINDS:
|
||||
raise ValueError("тип промокода: percent, fixed или days")
|
||||
value = int(value)
|
||||
if value <= 0 or (kind == "percent" and value > 100):
|
||||
raise ValueError("значение должно быть больше нуля, для процентов не больше 100")
|
||||
if max_uses is not None and int(max_uses) <= 0:
|
||||
max_uses = None
|
||||
with get_conn() as conn:
|
||||
if conn.execute("SELECT 1 FROM promo_codes WHERE code=?", (code,)).fetchone():
|
||||
raise ValueError("такой промокод уже есть")
|
||||
conn.execute(
|
||||
"INSERT INTO promo_codes (code, kind, value, max_uses, expires_at, created_at) VALUES (?,?,?,?,?,?)",
|
||||
(code, kind, value, max_uses, expires_at or None, now_iso()),
|
||||
)
|
||||
return get_promo(code)
|
||||
|
||||
|
||||
def get_promo(code: str):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM promo_codes WHERE code=?", ((code or "").strip().upper(),)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_promos():
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM promo_codes ORDER BY created_at DESC").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def set_promo_active(code: str, active: bool):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE promo_codes SET active=? WHERE code=?", (1 if active else 0, code.strip().upper()))
|
||||
|
||||
|
||||
def delete_promo(code: str):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM promo_codes WHERE code=?", (code.strip().upper(),))
|
||||
|
||||
|
||||
def validate_promo(code: str, tg_id: int):
|
||||
promo = get_promo(code)
|
||||
if not promo or not promo["active"]:
|
||||
return None, "not_found"
|
||||
if promo["expires_at"] and promo["expires_at"] <= now_iso():
|
||||
return None, "expired"
|
||||
if promo["max_uses"] is not None and promo["used_count"] >= promo["max_uses"]:
|
||||
return None, "exhausted"
|
||||
with get_conn() as conn:
|
||||
used = conn.execute(
|
||||
"SELECT 1 FROM promo_uses WHERE code=? AND tg_id=?", (promo["code"], tg_id)
|
||||
).fetchone()
|
||||
if used:
|
||||
return None, "already_used"
|
||||
return promo, None
|
||||
|
||||
|
||||
def discounted_price(price: int, promo: dict | None) -> int:
|
||||
if not promo or promo["kind"] == "days":
|
||||
return price
|
||||
if promo["kind"] == "percent":
|
||||
return max(price - price * promo["value"] // 100, 0)
|
||||
return max(price - promo["value"], 0)
|
||||
|
||||
|
||||
def _consume_promo(conn, code: str, tg_id: int) -> bool:
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO promo_uses (code, tg_id, used_at) VALUES (?,?,?)", (code, tg_id, now_iso())
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return False
|
||||
conn.execute("UPDATE promo_codes SET used_count=used_count+1 WHERE code=?", (code,))
|
||||
return True
|
||||
|
||||
|
||||
def redeem_days_promo(code: str, tg_id: int):
|
||||
promo, err = validate_promo(code, tg_id)
|
||||
if err:
|
||||
return None, err
|
||||
if promo["kind"] != "days":
|
||||
return None, "not_days"
|
||||
with get_conn() as conn:
|
||||
if not _consume_promo(conn, promo["code"], tg_id):
|
||||
return None, "already_used"
|
||||
_apply_bonus_days(conn, tg_id, promo["value"])
|
||||
return promo, None
|
||||
|
||||
|
||||
def set_promo_pending(tg_id: int, code: str | None):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET promo_pending=? WHERE tg_id=?", (code, tg_id))
|
||||
|
||||
|
||||
def get_pending_promo(tg_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT promo_pending FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||
if not row or not row["promo_pending"]:
|
||||
return None
|
||||
promo, err = validate_promo(row["promo_pending"], tg_id)
|
||||
if err:
|
||||
set_promo_pending(tg_id, None)
|
||||
return None
|
||||
return promo
|
||||
|
||||
|
||||
def set_payment_promo(payment_id: str, promo_code: str | None, original_amount: int | None):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE payments SET promo_code=?, original_amount=? WHERE id=?",
|
||||
(promo_code, original_amount, payment_id),
|
||||
)
|
||||
|
||||
|
||||
def consume_promo(code: str, tg_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
return _consume_promo(conn, code, tg_id)
|
||||
|
|
|
|||
94
features.py
Normal file
94
features.py
Normal 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)}, без лимита"
|
||||
23
install.sh
23
install.sh
|
|
@ -2,8 +2,9 @@
|
|||
set -e
|
||||
set -o pipefail
|
||||
|
||||
LAB_URL="https://lab.savsis.xyz/savsisbtw/mbs-panel.git"
|
||||
MIRROR_URL="https://api.savsis.xyz/git/mbs-panel.git/"
|
||||
REPO_URL="https://github.com/devsavsis/mbs-panel.git"
|
||||
REPO_URL="https://github.com/savsisbtw/mbs-panel.git"
|
||||
APP_DIR="/opt/mbs-panel"
|
||||
WEBROOT="/var/www/certbot"
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ case "$ID" in
|
|||
*) echo "тестировалось на Ubuntu 22/24 и Debian 11/12, но пробуем всё равно на $PRETTY_NAME" ;;
|
||||
esac
|
||||
|
||||
BRAND_NAME=$(ask "Название твоего сервиса (видят клиенты — сайт/бот/подписка)" "MBS Panel")
|
||||
PANEL_DOMAIN=$(ask "Домен панели (админка)" "")
|
||||
SUB_DOMAIN=$(ask "Домен подписок" "")
|
||||
SITE_DOMAIN=$(ask "Домен сайта (для CORS и ссылок в боте)" "$PANEL_DOMAIN")
|
||||
|
|
@ -83,11 +85,17 @@ echo "клонируем репозиторий в $APP_DIR..."
|
|||
if [ -d "$APP_DIR/.git" ]; then
|
||||
retry git -C "$APP_DIR" pull --quiet
|
||||
else
|
||||
if ! git clone --quiet "$MIRROR_URL" "$APP_DIR" 2>/dev/null; then
|
||||
echo "зеркало недоступно, клонирую напрямую с GitHub..."
|
||||
retry git clone --quiet "$REPO_URL" "$APP_DIR"
|
||||
git -C "$APP_DIR" remote set-url origin "$MIRROR_URL"
|
||||
if ! git clone --quiet "$LAB_URL" "$APP_DIR" 2>/dev/null; then
|
||||
echo "lab.savsis.xyz недоступен, пробую зеркало..."
|
||||
rm -rf "$APP_DIR"
|
||||
if ! git clone --quiet "$MIRROR_URL" "$APP_DIR" 2>/dev/null; then
|
||||
echo "зеркало недоступно, клонирую напрямую с GitHub..."
|
||||
rm -rf "$APP_DIR"
|
||||
retry git clone --quiet "$REPO_URL" "$APP_DIR"
|
||||
fi
|
||||
git -C "$APP_DIR" remote set-url origin "$LAB_URL"
|
||||
fi
|
||||
git -C "$APP_DIR" remote add mirror "$MIRROR_URL" 2>/dev/null || true
|
||||
git -C "$APP_DIR" remote add github "$REPO_URL" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
|
|
@ -105,6 +113,7 @@ XRAY_SHORT_ID_GRPC=$(openssl rand -hex 8)
|
|||
XRAY_SHORT_ID_XHTTP=$(openssl rand -hex 8)
|
||||
|
||||
cat > "$APP_DIR/.env" << ENVEOF
|
||||
BRAND_NAME=$BRAND_NAME
|
||||
BOT_TOKEN=$BOT_TOKEN
|
||||
BOT_USERNAME=$BOT_USERNAME
|
||||
ADMIN_IDS=$ADMIN_IDS
|
||||
|
|
@ -384,6 +393,8 @@ else API_WORKERS=$CPU_COUNT
|
|||
fi
|
||||
cp "$APP_DIR/systemd/mbs-bot.service" /etc/systemd/system/mbs-bot.service
|
||||
sed "s/__WORKERS__/$API_WORKERS/" "$APP_DIR/systemd/mbs-api.service" > /etc/systemd/system/mbs-api.service
|
||||
cp "$APP_DIR/systemd/mbs-autoupdate.service" /etc/systemd/system/mbs-autoupdate.service
|
||||
cp "$APP_DIR/systemd/mbs-autoupdate.timer" /etc/systemd/system/mbs-autoupdate.timer
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "ставим CLI mbs..."
|
||||
|
|
@ -405,6 +416,7 @@ systemctl reload nginx
|
|||
systemctl enable --now xray
|
||||
systemctl enable --now mbs-bot
|
||||
systemctl enable --now mbs-api
|
||||
systemctl enable --now mbs-autoupdate.timer
|
||||
|
||||
sleep 2
|
||||
|
||||
|
|
@ -418,6 +430,7 @@ echo "== готово =="
|
|||
echo "Панель: https://$PANEL_DOMAIN"
|
||||
echo "Пароль: $ADMIN_PANEL_PASSWORD (сменить: mbs pass)"
|
||||
echo "Подписки: https://$SUB_DOMAIN"
|
||||
echo "Сайт: https://$SUB_DOMAIN (готовый лендинг, название/тарифы уже подставлены — правь site/index.html под себя, если нужно)"
|
||||
echo "Нода: $DE1_ADDRESS"
|
||||
echo
|
||||
echo "статус сервисов:"
|
||||
|
|
|
|||
|
|
@ -155,20 +155,20 @@
|
|||
<a href="#features">Возможности</a>
|
||||
<a href="#comparison">Сравнение</a>
|
||||
<a href="#install">Установка</a>
|
||||
<a href="https://github.com/devsavsis/mbs-panel" target="_blank">GitHub</a>
|
||||
<a href="https://github.com/savsisbtw/mbs-panel" target="_blank">GitHub</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section class="hero" style="padding-bottom:0">
|
||||
<div class="badges reveal">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/devsavsis/mbs-panel/ci.yml?label=CI&style=flat-square&color=7c6cf0" alt="CI">
|
||||
<img src="https://img.shields.io/github/license/devsavsis/mbs-panel?style=flat-square&color=7c6cf0" alt="License">
|
||||
<img src="https://img.shields.io/github/stars/devsavsis/mbs-panel?style=flat-square&color=7c6cf0" alt="Stars">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/savsisbtw/mbs-panel/ci.yml?label=CI&style=flat-square&color=7c6cf0" alt="CI">
|
||||
<img src="https://img.shields.io/github/license/savsisbtw/mbs-panel?style=flat-square&color=7c6cf0" alt="License">
|
||||
<img src="https://img.shields.io/github/stars/savsisbtw/mbs-panel?style=flat-square&color=7c6cf0" alt="Stars">
|
||||
</div>
|
||||
<h1 class="reveal">VPN-панель, которую<br>можно <span class="accent">понять за вечер</span></h1>
|
||||
<p class="reveal">Xray-core, Reality, Hysteria2, Telegram-бот и платежи — в одном небольшом репозитории на Python. Без Docker, без чужой закрытой панели под капотом, без разбора чужого фреймворка перед первым коммитом.</p>
|
||||
<div class="hero-ctas reveal">
|
||||
<a class="btn" href="https://github.com/devsavsis/mbs-panel" target="_blank">Смотреть на GitHub</a>
|
||||
<a class="btn" href="https://github.com/savsisbtw/mbs-panel" target="_blank">Смотреть на GitHub</a>
|
||||
<a class="btn ghost" href="#install">Установка</a>
|
||||
</div>
|
||||
<div class="install reveal">
|
||||
|
|
@ -230,6 +230,14 @@
|
|||
<tr><td>Установка</td><td class="us">1 bash-команда</td><td>Docker Compose</td><td>bash-скрипт / Docker</td></tr>
|
||||
<tr><td>Backup & Restore в самой панели</td><td class="us yes">✓</td><td class="no">community tools</td><td class="no">community tools</td></tr>
|
||||
<tr><td>Xray config pre-flight проверка</td><td class="us yes">✓</td><td class="yes">full-featured</td><td class="no">только JSON-синтаксис</td></tr>
|
||||
<tr><td>Мультиадминство (раздельные логины)</td><td class="us yes">✓</td><td class="no">—</td><td class="no">в разработке</td></tr>
|
||||
<tr><td>2FA на вход в админку</td><td class="us yes">✓ TOTP</td><td>есть (passkeys/OAuth)</td><td class="no">—</td></tr>
|
||||
<tr><td>Rate-limit на вход/2FA-код</td><td class="us yes">✓</td><td class="no">не документировано</td><td class="no">не документировано</td></tr>
|
||||
<tr><td>Свой путь входа в админку</td><td class="us yes">✓</td><td class="yes">заявлено</td><td class="no">—</td></tr>
|
||||
<tr><td>Цепочки серверов (клиент → A → B → интернет)</td><td class="us yes">✓ мышкой, с замером задержки</td><td>руками в конфиге Xray</td><td>руками в конфиге Xray</td></tr>
|
||||
<tr><td>Сортировка нод мышкой</td><td class="us yes">✓</td><td class="yes">Web UI</td><td class="no">только через конфиг Xray</td></tr>
|
||||
<tr><td>Пауза подписки без потери оплаченных дней</td><td class="us yes">✓</td><td class="no">—</td><td class="yes">есть</td></tr>
|
||||
<tr><td>Исходящие вебхуки (пользователи + ноды)</td><td class="us yes">✓</td><td class="yes">✓</td><td class="no">только пользователи</td></tr>
|
||||
<tr><td>Лицензия</td><td class="us">MIT</td><td>AGPL-3.0</td><td>AGPL-3.0</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -289,16 +297,16 @@
|
|||
<h2>Открытый исходник, MIT</h2>
|
||||
<p class="section-sub">Изначально писалось под конкретный проект — получилось достаточно универсально, чтобы выложить как есть. Issues и PR приветствуются.</p>
|
||||
<div class="cta-row">
|
||||
<a class="btn" href="https://github.com/devsavsis/mbs-panel" target="_blank">github.com/devsavsis/mbs-panel</a>
|
||||
<a class="btn ghost" href="https://github.com/devsavsis/mbs-panel#readme" target="_blank">Читать README</a>
|
||||
<a class="btn" href="https://github.com/savsisbtw/mbs-panel" target="_blank">github.com/savsisbtw/mbs-panel</a>
|
||||
<a class="btn ghost" href="https://github.com/savsisbtw/mbs-panel#readme" target="_blank">Читать README</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<div>MBS Panel — made by <a href="https://github.com/devsavsis" target="_blank">savsis</a></div>
|
||||
<div><a href="https://github.com/devsavsis/mbs-panel/blob/main/LICENSE" target="_blank">MIT License</a></div>
|
||||
<div>MBS Panel — made by <a href="https://github.com/savsisbtw" target="_blank">savsis</a></div>
|
||||
<div><a href="https://github.com/savsisbtw/mbs-panel/blob/main/LICENSE" target="_blank">MIT License</a></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
|
|
|||
108
legal.py
Normal file
108
legal.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import html
|
||||
import os
|
||||
|
||||
import config
|
||||
|
||||
ENV_PATH = os.path.join(config.BASE_DIR, ".env")
|
||||
SITE_DIR = os.path.join(config.BASE_DIR, "site")
|
||||
|
||||
FIELD_KEYS = ["LEGAL_NAME", "LEGAL_INN", "REFUND_HOURS", "SUPPORT_CONTACT", "SUPPORT_EMAIL", "OFFER_EFFECTIVE_DATE"]
|
||||
|
||||
|
||||
def read_env_vars(keys: list) -> dict:
|
||||
result = {key: None for key in keys}
|
||||
if not os.path.exists(ENV_PATH):
|
||||
return result
|
||||
wanted = set(keys)
|
||||
with open(ENV_PATH, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if "=" not in line or line.startswith("#"):
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
if key in wanted and result[key] is None:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def read_env_var(key: str, default: str = "") -> str:
|
||||
value = read_env_vars([key])[key]
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def update_env_var(key: str, value: str):
|
||||
lines = []
|
||||
if os.path.exists(ENV_PATH):
|
||||
with open(ENV_PATH, encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith(f"{key}="):
|
||||
lines[i] = f"{key}={value}\n"
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
lines.append(f"{key}={value}\n")
|
||||
with open(ENV_PATH, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
|
||||
def get_settings() -> dict:
|
||||
raw = read_env_vars(FIELD_KEYS)
|
||||
return {key: (raw[key] or "") for key in FIELD_KEYS}
|
||||
|
||||
|
||||
def _fallback(label: str) -> str:
|
||||
return f'<span class="fill">{html.escape(label)}</span>'
|
||||
|
||||
|
||||
def _field(value: str, fallback_label: str) -> str:
|
||||
return html.escape(value) if value else _fallback(fallback_label)
|
||||
|
||||
|
||||
def live_bot_username() -> str:
|
||||
raw = read_env_var("BOT_USERNAME", "")
|
||||
return raw.strip() if raw.strip() else config.BOT_USERNAME
|
||||
|
||||
|
||||
def live_brand_name() -> str:
|
||||
raw = read_env_var("BRAND_NAME", "")
|
||||
return raw.strip() if raw.strip() else config.BRAND_NAME
|
||||
|
||||
|
||||
def render(template_name: str) -> str:
|
||||
path = os.path.join(SITE_DIR, template_name)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
s = get_settings()
|
||||
bot_username = live_bot_username()
|
||||
replacements = {
|
||||
"EFFECTIVE_DATE": _field(s["OFFER_EFFECTIVE_DATE"], "дата не указана"),
|
||||
"LEGAL_NAME": _field(s["LEGAL_NAME"], "название/ФИО не указано"),
|
||||
"INN": _field(s["LEGAL_INN"], "ИНН не указан"),
|
||||
"BOT_USERNAME": _field(f"@{bot_username}" if bot_username else "", "бот не указан"),
|
||||
"REFUND_HOURS": html.escape(s["REFUND_HOURS"]) if s["REFUND_HOURS"] else "24",
|
||||
"SUPPORT_CONTACT": _field(s["SUPPORT_CONTACT"], "контакт не указан"),
|
||||
"SUPPORT_EMAIL": _field(s["SUPPORT_EMAIL"], "email не указан"),
|
||||
"BRAND_NAME": html.escape(live_brand_name()),
|
||||
}
|
||||
for token, value in replacements.items():
|
||||
content = content.replace("{{" + token + "}}", value)
|
||||
return content
|
||||
|
||||
|
||||
def render_site_page(template_name: str) -> str:
|
||||
path = os.path.join(SITE_DIR, template_name)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
replacements = {
|
||||
"BRAND_NAME": html.escape(live_brand_name()),
|
||||
"SITE_DOMAIN": html.escape(config.SITE_DOMAIN),
|
||||
"SUB_DOMAIN": html.escape(config.SUB_DOMAIN),
|
||||
"BOT_USERNAME": html.escape(live_bot_username()),
|
||||
}
|
||||
for token, value in replacements.items():
|
||||
content = content.replace("{{" + token + "}}", value)
|
||||
return content
|
||||
20
links.py
20
links.py
|
|
@ -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
221
mbs
|
|
@ -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 $?
|
||||
|
|
|
|||
179
nodeprov.py
179
nodeprov.py
|
|
@ -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:
|
||||
|
|
|
|||
34
payments.py
34
payments.py
|
|
@ -5,20 +5,18 @@ import json
|
|||
import secrets
|
||||
import urllib.request
|
||||
|
||||
from config import (
|
||||
PANEL_DOMAIN,
|
||||
YOOKASSA_ENABLED, YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY,
|
||||
PLATEGA_ENABLED, PLATEGA_MERCHANT_ID, PLATEGA_SECRET,
|
||||
)
|
||||
from config import PANEL_DOMAIN
|
||||
import settings
|
||||
|
||||
PROVIDER_NAMES = {"yookassa": "ЮKassa", "platega": "Platega"}
|
||||
|
||||
|
||||
def available_providers() -> list[str]:
|
||||
enabled = settings.get_payment_settings()
|
||||
providers = []
|
||||
if YOOKASSA_ENABLED:
|
||||
if enabled["yookassa_enabled"]:
|
||||
providers.append("yookassa")
|
||||
if PLATEGA_ENABLED:
|
||||
if enabled["platega_enabled"]:
|
||||
providers.append("platega")
|
||||
return providers
|
||||
|
||||
|
|
@ -41,7 +39,8 @@ def _get_json(url: str, headers: dict, timeout: int = 15) -> dict:
|
|||
|
||||
|
||||
def create_yookassa_payment(payment_id: str, amount_rub: int, description: str) -> str:
|
||||
auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode()
|
||||
shop_id, secret_key = settings.yookassa_credentials()
|
||||
auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode()
|
||||
data = _post_json(
|
||||
"https://api.yookassa.ru/v3/payments",
|
||||
{
|
||||
|
|
@ -62,12 +61,18 @@ def create_yookassa_payment(payment_id: str, amount_rub: int, description: str)
|
|||
return external_id, pay_url
|
||||
|
||||
|
||||
def validate_yookassa_credentials(shop_id: str, secret_key: str) -> dict:
|
||||
auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode()
|
||||
return _get_json("https://api.yookassa.ru/v3/me", {"Authorization": f"Basic {auth}"})
|
||||
|
||||
|
||||
def verify_yookassa_notification(body: dict) -> bool:
|
||||
return body.get("event") == "payment.succeeded" and "object" in body
|
||||
|
||||
|
||||
def check_yookassa_payment(external_id: str) -> str:
|
||||
auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode()
|
||||
shop_id, secret_key = settings.yookassa_credentials()
|
||||
auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode()
|
||||
data = _get_json(
|
||||
f"https://api.yookassa.ru/v3/payments/{external_id}",
|
||||
{"Authorization": f"Basic {auth}"},
|
||||
|
|
@ -76,6 +81,7 @@ def check_yookassa_payment(external_id: str) -> str:
|
|||
|
||||
|
||||
def create_platega_payment(payment_id: str, amount_rub: int, description: str) -> str:
|
||||
merchant_id, secret = settings.platega_credentials()
|
||||
data = _post_json(
|
||||
"https://app.platega.io/transaction/process",
|
||||
{
|
||||
|
|
@ -87,8 +93,8 @@ def create_platega_payment(payment_id: str, amount_rub: int, description: str) -
|
|||
},
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"X-MerchantId": PLATEGA_MERCHANT_ID,
|
||||
"X-Secret": PLATEGA_SECRET,
|
||||
"X-MerchantId": merchant_id,
|
||||
"X-Secret": secret,
|
||||
},
|
||||
)
|
||||
external_id = data.get("id") or data.get("transactionId")
|
||||
|
|
@ -99,14 +105,16 @@ def create_platega_payment(payment_id: str, amount_rub: int, description: str) -
|
|||
def verify_platega_signature(raw_body: bytes, signature: str) -> bool:
|
||||
if not signature:
|
||||
return False
|
||||
expected = hmac.new(PLATEGA_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
_, secret = settings.platega_credentials()
|
||||
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def check_platega_payment(external_id: str) -> str:
|
||||
merchant_id, secret = settings.platega_credentials()
|
||||
data = _get_json(
|
||||
f"https://app.platega.io/transaction/{external_id}",
|
||||
{"X-MerchantId": PLATEGA_MERCHANT_ID, "X-Secret": PLATEGA_SECRET},
|
||||
{"X-MerchantId": merchant_id, "X-Secret": secret},
|
||||
)
|
||||
return data.get("status", "")
|
||||
|
||||
|
|
|
|||
142
settings.py
Normal file
142
settings.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import config
|
||||
import legal
|
||||
|
||||
PRICE_ENV_KEYS = {"7d": "PRICE_7D", "1m": "PRICE_1M", "3m": "PRICE_3M", "6m": "PRICE_6M", "1y": "PRICE_1Y"}
|
||||
|
||||
|
||||
def _bool(raw: str, default: bool) -> bool:
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
return raw.strip().lower() == "true"
|
||||
|
||||
|
||||
def _positive_int(raw: str, default: int) -> int:
|
||||
if raw and raw.strip().lstrip("-").isdigit():
|
||||
parsed = int(raw)
|
||||
if parsed >= 0:
|
||||
return parsed
|
||||
return default
|
||||
|
||||
|
||||
def get_plans() -> list:
|
||||
raw = legal.read_env_vars(list(PRICE_ENV_KEYS.values()))
|
||||
plans = []
|
||||
for p in config.PLANS:
|
||||
env_key = PRICE_ENV_KEYS[p["code"]]
|
||||
plans.append({
|
||||
"code": p["code"],
|
||||
"label": p["label"],
|
||||
"days": p["days"],
|
||||
"price": _positive_int(raw.get(env_key), p["price"]),
|
||||
})
|
||||
return plans
|
||||
|
||||
|
||||
def get_plans_by_code() -> dict:
|
||||
return {p["code"]: p for p in get_plans()}
|
||||
|
||||
|
||||
def set_plan_prices(prices: dict):
|
||||
for code, price in prices.items():
|
||||
if code in PRICE_ENV_KEYS:
|
||||
legal.update_env_var(PRICE_ENV_KEYS[code], str(int(price)))
|
||||
|
||||
|
||||
def get_payment_settings() -> dict:
|
||||
raw = legal.read_env_vars(["PAYMENTS_ENABLED", "YOOKASSA_ENABLED", "PLATEGA_ENABLED"])
|
||||
return {
|
||||
"payments_enabled": _bool(raw.get("PAYMENTS_ENABLED"), config.PAYMENTS_ENABLED),
|
||||
"yookassa_enabled": _bool(raw.get("YOOKASSA_ENABLED"), config.YOOKASSA_ENABLED),
|
||||
"platega_enabled": _bool(raw.get("PLATEGA_ENABLED"), config.PLATEGA_ENABLED),
|
||||
}
|
||||
|
||||
|
||||
def yookassa_credentials():
|
||||
raw = legal.read_env_vars(["YOOKASSA_SHOP_ID", "YOOKASSA_SECRET_KEY"])
|
||||
return (
|
||||
raw.get("YOOKASSA_SHOP_ID") or config.YOOKASSA_SHOP_ID,
|
||||
raw.get("YOOKASSA_SECRET_KEY") or config.YOOKASSA_SECRET_KEY,
|
||||
)
|
||||
|
||||
|
||||
def platega_credentials():
|
||||
raw = legal.read_env_vars(["PLATEGA_MERCHANT_ID", "PLATEGA_SECRET"])
|
||||
return (
|
||||
raw.get("PLATEGA_MERCHANT_ID") or config.PLATEGA_MERCHANT_ID,
|
||||
raw.get("PLATEGA_SECRET") or config.PLATEGA_SECRET,
|
||||
)
|
||||
|
||||
|
||||
def get_brand_name() -> str:
|
||||
raw = legal.read_env_var("BRAND_NAME", "")
|
||||
return raw.strip() if raw.strip() else config.BRAND_NAME
|
||||
|
||||
|
||||
def bot_credentials():
|
||||
raw = legal.read_env_vars(["BOT_TOKEN", "BOT_USERNAME"])
|
||||
return (
|
||||
raw.get("BOT_TOKEN") or config.BOT_TOKEN,
|
||||
raw.get("BOT_USERNAME") or config.BOT_USERNAME,
|
||||
)
|
||||
|
||||
|
||||
def get_hwid_settings() -> dict:
|
||||
raw = legal.read_env_vars(["HWID_LIMIT_ENABLED", "HWID_FALLBACK_LIMIT"])
|
||||
limit = _positive_int(raw.get("HWID_FALLBACK_LIMIT"), config.HWID_FALLBACK_LIMIT)
|
||||
return {
|
||||
"enabled": _bool(raw.get("HWID_LIMIT_ENABLED"), config.HWID_LIMIT_ENABLED),
|
||||
"fallback_limit": limit if limit > 0 else config.HWID_FALLBACK_LIMIT,
|
||||
}
|
||||
|
||||
|
||||
def get_referral_settings() -> dict:
|
||||
raw = legal.read_env_vars(["REFERRAL_ENABLED", "REFERRAL_BONUS_DAYS"])
|
||||
days = _positive_int(raw.get("REFERRAL_BONUS_DAYS"), config.REFERRAL_BONUS_DAYS)
|
||||
return {
|
||||
"enabled": _bool(raw.get("REFERRAL_ENABLED"), config.REFERRAL_ENABLED),
|
||||
"bonus_days": days if days > 0 else config.REFERRAL_BONUS_DAYS,
|
||||
}
|
||||
|
||||
|
||||
def set_referral_settings(enabled: bool, bonus_days: int):
|
||||
legal.update_env_var("REFERRAL_ENABLED", "true" if enabled else "false")
|
||||
legal.update_env_var("REFERRAL_BONUS_DAYS", str(int(bonus_days)))
|
||||
|
||||
|
||||
def get_features() -> dict:
|
||||
keys = ["TRIAL_ENABLED", "TRIAL_DAYS", "TRIAL_NODE", "TRIAL_TRAFFIC_GB", "DEFAULT_TRAFFIC_GB",
|
||||
"REMINDERS_ENABLED", "NODE_ALERTS_ENABLED"]
|
||||
raw = legal.read_env_vars(keys)
|
||||
trial_days = _positive_int(raw.get("TRIAL_DAYS"), 1)
|
||||
return {
|
||||
"trial_enabled": _bool(raw.get("TRIAL_ENABLED"), False),
|
||||
"trial_days": trial_days if trial_days > 0 else 1,
|
||||
"trial_node": (raw.get("TRIAL_NODE") or "").strip(),
|
||||
"trial_traffic_gb": _positive_int(raw.get("TRIAL_TRAFFIC_GB"), 2),
|
||||
"default_traffic_gb": _positive_int(raw.get("DEFAULT_TRAFFIC_GB"), 0),
|
||||
"reminders_enabled": _bool(raw.get("REMINDERS_ENABLED"), True),
|
||||
"node_alerts_enabled": _bool(raw.get("NODE_ALERTS_ENABLED"), True),
|
||||
}
|
||||
|
||||
|
||||
def set_features(values: dict):
|
||||
mapping = {
|
||||
"trial_enabled": ("TRIAL_ENABLED", lambda v: "true" if v else "false"),
|
||||
"trial_days": ("TRIAL_DAYS", lambda v: str(max(int(v), 1))),
|
||||
"trial_node": ("TRIAL_NODE", lambda v: str(v or "").strip()),
|
||||
"trial_traffic_gb": ("TRIAL_TRAFFIC_GB", lambda v: str(max(int(v), 0))),
|
||||
"default_traffic_gb": ("DEFAULT_TRAFFIC_GB", lambda v: str(max(int(v), 0))),
|
||||
"reminders_enabled": ("REMINDERS_ENABLED", lambda v: "true" if v else "false"),
|
||||
"node_alerts_enabled": ("NODE_ALERTS_ENABLED", lambda v: "true" if v else "false"),
|
||||
}
|
||||
for key, (env_key, conv) in mapping.items():
|
||||
if key in values:
|
||||
legal.update_env_var(env_key, conv(values[key]))
|
||||
|
||||
|
||||
GB = 1024 ** 3
|
||||
|
||||
|
||||
def default_traffic_limit_bytes():
|
||||
gb = get_features()["default_traffic_gb"]
|
||||
return gb * GB if gb > 0 else None
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Личный кабинет — MBS Panel</title>
|
||||
<title>Личный кабинет — {{BRAND_NAME}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
|
|
@ -81,11 +81,11 @@
|
|||
</div>
|
||||
<div id="err"></div>
|
||||
<div id="content"></div>
|
||||
<p class="hint">Токен выдаёт бот <a class="tglink" href="https://t.me/YourBot_robot" target="_blank">@YourBot_robot</a> — «Моя подписка»</p>
|
||||
<p class="hint">Токен выдаёт бот <a class="tglink" href="https://t.me/{{BOT_USERNAME}}" target="_blank">@{{BOT_USERNAME}}</a> — «Моя подписка»</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = "https://sub.example.com";
|
||||
const API = "https://{{SUB_DOMAIN}}";
|
||||
|
||||
function esc(s) {
|
||||
if (s === null || s === undefined) return "";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MBS Panel</title>
|
||||
<title>{{BRAND_NAME}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo">MBS Panel</div>
|
||||
<div class="logo">{{BRAND_NAME}}</div>
|
||||
<nav>
|
||||
<a href="#features">Возможности</a>
|
||||
<a href="#plans">Тарифы</a>
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
<section class="hero">
|
||||
<h1 class="reveal">Интернет без границ<br><span class="accent">и без замедлений</span></h1>
|
||||
<p class="reveal">Быстрый доступ к любимым сайтам и сервисам. Трафик не отличить от обычного HTTPS, скорость — на выделенных мощностях.</p>
|
||||
<a class="btn reveal" href="https://t.me/YourBot_robot" target="_blank">Получить доступ</a>
|
||||
<a class="btn reveal" href="https://t.me/{{BOT_USERNAME}}" target="_blank">Получить доступ</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
|
@ -164,23 +164,19 @@
|
|||
|
||||
<section class="plans" id="plans">
|
||||
<h2 class="reveal">Тарифы</h2>
|
||||
<div class="plan-row reveal">
|
||||
<div class="plan"><div class="d">7 дней</div><div class="l">пробный</div></div>
|
||||
<div class="plan"><div class="d">1 месяц</div><div class="l">стандарт</div></div>
|
||||
<div class="plan"><div class="d">3 месяца</div><div class="l">выгодно</div></div>
|
||||
<div class="plan"><div class="d">6 месяцев</div><div class="l">выгоднее</div></div>
|
||||
<div class="plan"><div class="d">1 год</div><div class="l">максимум</div></div>
|
||||
<div class="plan-row reveal" id="plan-row">
|
||||
<div class="plan"><div class="d">…</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="cta reveal">
|
||||
<a class="btn ghost" href="https://t.me/YourBot_robot" target="_blank">Выбрать тариф в боте</a>
|
||||
<a class="btn ghost" href="https://t.me/{{BOT_USERNAME}}" target="_blank">Выбрать тариф в боте</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
example.com — <a href="/cabinet.html">личный кабинет</a> — подписка через <a href="https://sub.example.com" target="_blank">sub.example.com</a> — <a href="/offer.html">оферта</a> — <a href="/privacy.html">конфиденциальность</a>
|
||||
{{SITE_DOMAIN}} — <a href="/cabinet.html">личный кабинет</a> — подписка через <a href="https://{{SUB_DOMAIN}}" target="_blank">{{SUB_DOMAIN}}</a> — <a href="/offer">оферта</a> — <a href="/privacy">конфиденциальность</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
|
@ -195,6 +191,22 @@
|
|||
});
|
||||
}, { threshold: 0.15 });
|
||||
document.querySelectorAll(".reveal").forEach((el) => io.observe(el));
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
const PLAN_TAGLINES = { "7d": "пробный", "1m": "стандарт", "3m": "выгодно", "6m": "выгоднее", "1y": "максимум" };
|
||||
|
||||
fetch("/api/plans").then((r) => r.json()).then((data) => {
|
||||
const row = document.getElementById("plan-row");
|
||||
row.innerHTML = data.plans.map((p) => `
|
||||
<div class="plan">
|
||||
<div class="d">${esc(p.label)}</div>
|
||||
<div class="l">${data.payments_enabled && p.price > 0 ? esc(p.price) + " ₽" : esc(PLAN_TAGLINES[p.code] || "")}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}).catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MBS Panel — Публичная оферта</title>
|
||||
<title>{{BRAND_NAME}} — Публичная оферта</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
|
|
@ -34,12 +34,12 @@
|
|||
<div class="wrap">
|
||||
<a class="back" href="/">← На главную</a>
|
||||
<h1>Публичная оферта</h1>
|
||||
<p class="updated">Действует с <span class="fill">[дата]</span></p>
|
||||
<p class="updated">Действует с {{EFFECTIVE_DATE}}</p>
|
||||
|
||||
<p>Настоящий документ является публичной офертой <span class="fill">[ФИО ИП / наименование самозанятого или юрлица]</span>, <span class="fill">[ИНН]</span> (далее — «Исполнитель»), адресованной любому дееспособному физическому лицу (далее — «Клиент»), на заключение договора о предоставлении доступа к VPN-сервису на условиях, указанных ниже. Оплата услуги означает полное и безоговорочное принятие условий оферты (акцепт).</p>
|
||||
<p>Настоящий документ является публичной офертой {{LEGAL_NAME}}, {{INN}} (далее — «Исполнитель»), адресованной любому дееспособному физическому лицу (далее — «Клиент»), на заключение договора о предоставлении доступа к VPN-сервису на условиях, указанных ниже. Оплата услуги означает полное и безоговорочное принятие условий оферты (акцепт).</p>
|
||||
|
||||
<h2>1. Предмет договора</h2>
|
||||
<p>Исполнитель предоставляет Клиенту доступ к серверу VPN (VLESS/Hysteria2) на срок, соответствующий выбранному тарифу, через Telegram-бота <span class="fill">[@ваш_бот]</span>. Доступ выдаётся автоматически после подтверждения оплаты.</p>
|
||||
<p>Исполнитель предоставляет Клиенту доступ к серверу VPN (VLESS/Hysteria2) на срок, соответствующий выбранному тарифу, через Telegram-бота {{BOT_USERNAME}}. Доступ выдаётся автоматически после подтверждения оплаты.</p>
|
||||
|
||||
<h2>2. Стоимость и порядок оплаты</h2>
|
||||
<ol>
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
<p>Доступ предоставляется на срок выбранного тарифа (от 7 дней до 1 года) и автоматически прекращается по истечении срока. Продление — отдельной оплатой, автосписание не производится.</p>
|
||||
|
||||
<h2>4. Возврат средств</h2>
|
||||
<p>Возврат возможен в течение <span class="fill">[N]</span> часов с момента оплаты, если доступ ни разу не был использован (не было подключений к серверу), — по обращению в поддержку <span class="fill">[контакт]</span>. После начала использования услуга считается оказанной.</p>
|
||||
<p>Возврат возможен в течение {{REFUND_HOURS}} часов с момента оплаты, если доступ ни разу не был использован (не было подключений к серверу), — по обращению в поддержку {{SUPPORT_CONTACT}}. После начала использования услуга считается оказанной.</p>
|
||||
|
||||
<h2>5. Права и обязанности сторон</h2>
|
||||
<ol>
|
||||
|
|
@ -63,10 +63,10 @@
|
|||
|
||||
<h2>6. Реквизиты Исполнителя</h2>
|
||||
<p class="muted">
|
||||
<span class="fill">[ФИО / наименование]</span><br>
|
||||
ИНН <span class="fill">[номер]</span><br>
|
||||
Email: <span class="fill">[email]</span><br>
|
||||
Telegram: <span class="fill">[контакт поддержки]</span>
|
||||
{{LEGAL_NAME}}<br>
|
||||
ИНН {{INN}}<br>
|
||||
Email: {{SUPPORT_EMAIL}}<br>
|
||||
Telegram: {{SUPPORT_CONTACT}}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MBS Panel — Политика конфиденциальности</title>
|
||||
<title>{{BRAND_NAME}} — Политика конфиденциальности</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
|
|
@ -34,9 +34,9 @@
|
|||
<div class="wrap">
|
||||
<a class="back" href="/">← На главную</a>
|
||||
<h1>Политика конфиденциальности</h1>
|
||||
<p class="updated">Действует с <span class="fill">[дата]</span></p>
|
||||
<p class="updated">Действует с {{EFFECTIVE_DATE}}</p>
|
||||
|
||||
<p>Настоящая политика описывает, какие данные собирает и как обрабатывает <span class="fill">[ФИО ИП / наименование]</span> (далее — «Оператор») при использовании Telegram-бота и сайта MBS Panel.</p>
|
||||
<p>Настоящая политика описывает, какие данные собирает и как обрабатывает {{LEGAL_NAME}} (далее — «Оператор») при использовании Telegram-бота и сайта {{BRAND_NAME}}.</p>
|
||||
|
||||
<h2>1. Какие данные собираются</h2>
|
||||
<ul>
|
||||
|
|
@ -58,13 +58,13 @@
|
|||
<p>Данные хранятся на серверах Оператора и не передаются третьим лицам, кроме платёжных провайдеров (ЮKassa, Platega) в объёме, необходимом для обработки оплаты, и в случаях, прямо предусмотренных законодательством РФ.</p>
|
||||
|
||||
<h2>4. Права пользователя</h2>
|
||||
<p>Пользователь вправе запросить удаление своих данных и прекращение обработки, обратившись на <span class="fill">[email/контакт поддержки]</span>. Удаление данных влечёт прекращение доступа к активным подпискам.</p>
|
||||
<p>Пользователь вправе запросить удаление своих данных и прекращение обработки, обратившись на {{SUPPORT_EMAIL}} или {{SUPPORT_CONTACT}}. Удаление данных влечёт прекращение доступа к активным подпискам.</p>
|
||||
|
||||
<h2>5. Контакты</h2>
|
||||
<p class="muted">
|
||||
<span class="fill">[ФИО / наименование]</span><br>
|
||||
Email: <span class="fill">[email]</span><br>
|
||||
Telegram: <span class="fill">[контакт поддержки]</span>
|
||||
{{LEGAL_NAME}}<br>
|
||||
Email: {{SUPPORT_EMAIL}}<br>
|
||||
Telegram: {{SUPPORT_CONTACT}}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
8
systemd/mbs-autoupdate.service
Normal file
8
systemd/mbs-autoupdate.service
Normal 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
|
||||
10
systemd/mbs-autoupdate.timer
Normal file
10
systemd/mbs-autoupdate.timer
Normal 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
174
tests/test_features.py
Normal 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
155
tests/test_mbs_update.sh
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
#!/bin/bash
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
T=$(mktemp -d)
|
||||
cd "$T"
|
||||
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.autocrlf GIT_CONFIG_VALUE_0=false
|
||||
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
|
||||
mkdir -p stub bin sysd
|
||||
cat > stub/systemctl << 'EOF'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x stub/systemctl
|
||||
export PATH="$T/stub:$PATH"
|
||||
export MBS_BACKUP_DIR="$T/backups"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
ok() { echo "PASS $1"; PASS=$((PASS+1)); }
|
||||
bad() { echo "FAIL $1 :: $2"; FAIL=$((FAIL+1)); }
|
||||
check() { if eval "$2"; then ok "$1"; else bad "$1" "$3"; fi; }
|
||||
|
||||
sed -e "s#APP_DIR=\"/opt/mbs-panel\"#APP_DIR=\"$T/app\"#" -e "s#/usr/local/bin/mbs#$T/bin/mbs#g" -e "s#/etc/systemd/system#$T/sysd#g" "$REPO/mbs" > "$T/mbs-run"
|
||||
|
||||
git init -q --bare -b main origin.git
|
||||
git clone -q origin.git seed 2>/dev/null
|
||||
cd seed
|
||||
git checkout -q -b main 2>/dev/null || true
|
||||
mkdir -p systemd
|
||||
cp "$REPO/mbs" mbs
|
||||
echo "v1" > bot.py
|
||||
echo "v1" > config.py
|
||||
echo "v1" > db.py
|
||||
echo "v1" > settings.py
|
||||
echo "x" > requirements.txt
|
||||
echo "[Service]" > systemd/mbs-bot.service
|
||||
echo "ExecStart=x --workers __WORKERS__" > systemd/mbs-api.service
|
||||
cp "$REPO/.gitignore" .gitignore
|
||||
git add -A && git commit -q -m A && git push -q origin main
|
||||
cd "$T"
|
||||
git clone -q origin.git app
|
||||
mkdir -p app/venv/bin
|
||||
printf '#!/bin/sh\nexit 0\n' > app/venv/bin/pip
|
||||
printf '#!/bin/sh\nexec python "$@"\n' > app/venv/bin/python
|
||||
chmod +x app/venv/bin/pip app/venv/bin/python
|
||||
echo "SECRET=1" > app/.env
|
||||
python -c "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute('pragma journal_mode=wal'); c.execute('create table t(x)'); c.execute('insert into t values (42)'); c.commit(); c.close()" app/mbs.db
|
||||
|
||||
cd seed && echo "v2" > bot.py && echo "v2" > db.py && git commit -qam B && git push -q origin main && cd "$T"
|
||||
|
||||
echo "manual edit" >> app/bot.py
|
||||
echo "manual edit" >> app/config.py
|
||||
echo "manual edit" >> app/db.py
|
||||
echo "manual edit" >> app/settings.py
|
||||
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
|
||||
echo "$OUT" > out1.txt
|
||||
check "update succeeds despite manual edits on the server (the reported bug)" "[ $RC -eq 0 ]" "rc=$RC $OUT"
|
||||
check "bot.py is the new version" "[ \"\$(cat app/bot.py)\" = v2 ]" "$(cat app/bot.py)"
|
||||
check "a stash with the manual edits exists" "[ \$(git -C app stash list | wc -l) -eq 1 ]"
|
||||
check "patch with the manual edits saved" "ls app/local-changes/*.patch >/dev/null 2>&1 && grep -q 'manual edit' app/local-changes/*.patch"
|
||||
check "user is told where the edits went" "grep -q 'ручные правки' out1.txt && grep -q 'патч' out1.txt"
|
||||
check "working tree clean after update" "[ -z \"\$(git -C app status --porcelain --untracked-files=no)\" ]"
|
||||
check "cli copied" "[ -f bin/mbs ]"
|
||||
check "a pre-update backup was made" "[ \$(ls backups/mbs-before-update-*.tar.gz 2>/dev/null | wc -l) -eq 1 ]" "$(ls backups 2>&1)"
|
||||
check "update output points at the backup" "grep -q 'резервная копия перед обновлением' out1.txt"
|
||||
mkdir -p unpack && tar xzf backups/mbs-before-update-*.tar.gz -C unpack
|
||||
APPREL="${T#/}/app"
|
||||
check "backup keeps .env" "grep -q SECRET=1 unpack/$APPREL/.env"
|
||||
check "backup keeps the manual edits as they were before the update" "grep -q 'manual edit' unpack/$APPREL/bot.py && grep -q 'manual edit' unpack/$APPREL/config.py"
|
||||
check "backup database is a consistent sqlite copy" "[ \"\$(python -c \"import sqlite3,sys; print(sqlite3.connect(sys.argv[1]).execute('select x from t').fetchone()[0])\" unpack/$APPREL/mbs.db)\" = 42 ]"
|
||||
check "backup does not drag the venv along" "[ ! -d unpack/$APPREL/venv ]"
|
||||
check "no snapshot temp file is left behind" "[ ! -e app/.mbs.db.snapshot ]"
|
||||
OUT=$(bash "$T/mbs-run" backup 2>&1); RC=$?
|
||||
check "mbs backup makes a manual copy" "[ $RC -eq 0 ] && ls backups/mbs-manual-*.tar.gz >/dev/null 2>&1" "$OUT"
|
||||
for i in 1 2 3 4 5 6 7; do sleep 1.1; bash "$T/mbs-run" backup >/dev/null 2>&1; done
|
||||
check "only the 5 newest backups are kept" "[ \$(ls backups/mbs-*.tar.gz | wc -l) -eq 5 ]" "$(ls backups | wc -l)"
|
||||
|
||||
OUT=$(bash "$T/mbs-run" update 2>&1); echo "$OUT" > out2.txt
|
||||
check "second run says already latest" "grep -q 'уже последняя' out2.txt" "$OUT"
|
||||
|
||||
cd "$T/app" && git stash drop -q && git reset -q --hard HEAD; cd "$T"
|
||||
|
||||
git clone -q --bare origin.git mirror2.git
|
||||
cd seed && git pull -q origin main 2>/dev/null; echo "v3" > bot.py && git commit -qam C && git push -q "$T/mirror2.git" main && cd "$T"
|
||||
git -C mirror2.git update-server-info
|
||||
python -m http.server 8799 --directory "$T" > http.log 2>&1 &
|
||||
HTTP_PID=$!
|
||||
sleep 1.5
|
||||
OUT=$(bash "$T/mbs-run" update "http://127.0.0.1:8799/mirror2.git" 2>&1); RC=$?
|
||||
echo "$OUT" > out3.txt
|
||||
check "update by mirror url works" "[ $RC -eq 0 ] && [ \"\$(cat app/bot.py)\" = v3 ]" "rc=$RC $OUT"
|
||||
check "url run mentions the source and how to save it" "grep -q 'источник: http://127.0.0.1:8799/mirror2.git' out3.txt && grep -q 'mbs mirror http' out3.txt" "$OUT"
|
||||
check "url alone is not saved automatically" "[ ! -f app/.update_mirror ]"
|
||||
|
||||
bash "$T/mbs-run" mirror "http://127.0.0.1:8799/mirror2.git" > out4.txt 2>&1
|
||||
check "mirror command saves the url" "grep -q 'http://127.0.0.1:8799/mirror2.git' app/.update_mirror"
|
||||
check "mirror file is git-ignored" "[ -z \"\$(git -C app status --porcelain)\" ]" "$(git -C app status --porcelain)"
|
||||
bash "$T/mbs-run" mirror > out5.txt 2>&1
|
||||
check "mirror shows the saved url" "grep -q 'своё зеркало для обновлений: http://127.0.0.1:8799' out5.txt"
|
||||
|
||||
cd seed && echo "v4" > bot.py && git commit -qam D && git push -q "$T/mirror2.git" main && cd "$T"
|
||||
git -C mirror2.git update-server-info
|
||||
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
|
||||
echo "$OUT" > out6.txt
|
||||
check "plain update prefers the saved mirror" "[ $RC -eq 0 ] && grep -q 'источник: своё зеркало' out6.txt && [ \"\$(cat app/bot.py)\" = v4 ]" "rc=$RC $OUT"
|
||||
|
||||
bash "$T/mbs-run" mirror off > out7.txt 2>&1
|
||||
check "mirror off removes it" "[ ! -f app/.update_mirror ]"
|
||||
|
||||
for bad_url in "ext::sh -c 'touch $T/pwned'" "-uHEAD" "file:///etc" "ftp://x/y" "https://a b/c" "--upload-pack=touch $T/pwned2"; do
|
||||
OUT=$(bash "$T/mbs-run" update "$bad_url" 2>&1); RC=$?
|
||||
check "rejects unsafe source '$bad_url'" "[ $RC -ne 0 ] && grep -q 'не похоже на ссылку' <<< \"\$OUT\"" "rc=$RC $OUT"
|
||||
done
|
||||
check "no command was executed via a crafted url" "[ ! -e pwned ] && [ ! -e pwned2 ]"
|
||||
OUT=$(bash "$T/mbs-run" mirror "file:///etc" 2>&1); RC=$?
|
||||
check "mirror refuses unsafe urls too" "[ $RC -ne 0 ] && [ ! -f app/.update_mirror ]"
|
||||
|
||||
OUT=$(bash "$T/mbs-run" update "http://127.0.0.1:1/nope.git" 2>&1); RC=$?
|
||||
check "unreachable mirror fails cleanly" "[ $RC -ne 0 ] && grep -q 'не удалось получить обновления по ссылке' <<< \"\$OUT\"" "rc=$RC $OUT"
|
||||
|
||||
cd seed && echo "v5" > bot.py && echo "broken(" > broken.py && git add -A && git commit -qm E && git push -q origin main && cd "$T"
|
||||
git -C app fetch -q origin main
|
||||
git -C app merge -q --ff-only origin/main~1 2>/dev/null || true
|
||||
cd app && git reset -q --hard origin/main~1 2>/dev/null; cd "$T"
|
||||
echo "local tweak" >> app/settings.py
|
||||
BEFORE=$(git -C app rev-parse HEAD)
|
||||
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
|
||||
echo "$OUT" > out8.txt
|
||||
check "broken new code is rolled back" "[ $RC -ne 0 ] && grep -q 'откатываюсь' out8.txt && [ \"\$(git -C app rev-parse HEAD)\" = \"$BEFORE\" ]" "rc=$RC $OUT"
|
||||
check "manual edits are restored after the rollback" "grep -q 'local tweak' app/settings.py && [ \$(git -C app stash list | wc -l) -eq 0 ]" "$(git -C app stash list)"
|
||||
|
||||
cd app && git checkout -q -- . && git reset -q --hard origin/main~1 && cd "$T"
|
||||
cd app && echo "own" > own.txt && git add own.txt && git commit -qm "local commit" && cd "$T"
|
||||
cd seed && git rm -q broken.py && echo "v6" > bot.py && git commit -qam F && git push -q origin main && cd "$T"
|
||||
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
|
||||
echo "$OUT" > out9.txt
|
||||
check "diverged server history is refused, not merged" "[ $RC -ne 0 ] && grep -q 'свои коммиты' out9.txt" "rc=$RC $OUT"
|
||||
check "refused update leaves the local commit alone" "git -C app log --oneline | grep -q 'local commit'"
|
||||
|
||||
cd app && git reset -q --hard origin/main && echo "ahead" > ahead.txt && git add ahead.txt && git commit -qm ahead && cd "$T"
|
||||
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
|
||||
check "server newer than the source is left alone" "[ $RC -eq 0 ] && grep -q 'версия новее' <<< \"\$OUT\"" "rc=$RC $OUT"
|
||||
|
||||
cd "$T/app" && git reset -q --hard origin/main && cd "$T"
|
||||
cd seed && echo "v7" > bot.py && git commit -qam G && git push -q origin main && cd "$T"
|
||||
BEFORE=$(git -C app rev-parse HEAD)
|
||||
echo "x" > notadir
|
||||
OUT=$(MBS_BACKUP_DIR="$T/notadir/x" bash "$T/mbs-run" update 2>&1); RC=$?
|
||||
check "update refuses to start when no backup can be made" "[ $RC -ne 0 ] && grep -q 'не начинаю' <<< \"\$OUT\" && [ \"\$(git -C app rev-parse HEAD)\" = \"$BEFORE\" ]" "rc=$RC $OUT"
|
||||
check "refused update leaves the code untouched" "[ \"\$(cat app/bot.py)\" != v7 ]"
|
||||
|
||||
kill $HTTP_PID 2>/dev/null
|
||||
cd /
|
||||
rm -rf "$T"
|
||||
echo "RESULT pass=$PASS fail=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
23
webhooks.py
Normal file
23
webhooks.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
from legal import read_env_var
|
||||
|
||||
|
||||
def send(event: str, data: dict):
|
||||
url = read_env_var("WEBHOOK_URL")
|
||||
secret = read_env_var("WEBHOOK_SECRET")
|
||||
if not url or not secret:
|
||||
return
|
||||
body = json.dumps({"event": event, "data": data}).encode()
|
||||
signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
req = urllib.request.Request(
|
||||
url, data=body, method="POST",
|
||||
headers={"Content-Type": "application/json", "X-Signature": signature},
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
177
xray_manager.py
177
xray_manager.py
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue