Compare commits
57 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 | |||
| 61bcd41561 | |||
| 52f5c551cb | |||
| 11c75c13d1 | |||
| 7098e59967 | |||
| 9e6e314c94 | |||
| 81cbc4e391 | |||
| 2a68b2ff40 | |||
| 91a8a4374b | |||
| f586cf9fa3 | |||
| cfac7c1445 | |||
| 54dc9e2dae | |||
| abdf18faae | |||
| f9ee4f5933 | |||
| 2604c2dfe7 | |||
| 36fa7d55b0 | |||
| a8deb1fa8b | |||
| 320742bd63 | |||
| bfc2fd7b4c | |||
| 04db4a359e | |||
| 5725922bdc | |||
| 8a3ac30dbb | |||
| 76eef6fece | |||
| f5f8f21f5d |
34 changed files with 8119 additions and 494 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/
|
||||
|
|
|
|||
58
README.md
58
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)
|
||||
|
||||
|
|
@ -10,16 +10,32 @@
|
|||
|
||||
Сделано by savsis. Изначально писалось под конкретный проект (шеринг VPN среди своих), но получилось достаточно универсально, чтобы выложить как есть.
|
||||
|
||||
Лендинг с фичами и честным сравнением с Remnawave/Marzban: **[mbs.savsis.xyz](https://mbs.savsis.xyz)**
|
||||
|
||||
## Что внутри
|
||||
|
||||
- **Бот** (aiogram 3) — выдача подписок по кнопкам, гифт-коды, привязка тарифов (7 дней / месяц / 3 месяца / полгода / год), автоматическое отключение по истечении подписки (не раз в полчаса, а раз в 90 секунд — важно, чтобы просрочка реально обрывала доступ, а не продолжала работать).
|
||||
- **API + сайт** (FastAPI) — страница подписки со ссылкой `happ://` и QR-кодом, личный кабинет, JSON API для сайта.
|
||||
- **API + сайт** (FastAPI) — страница подписки со ссылкой `happ://` и QR-кодом, готовый клиентский лендинг + личный кабинет (живут прямо в панели, подставляют название и реальные тарифы сами, ничего отдельно хостить не надо).
|
||||
- **Своё название бренда** — панель, бот, сайт, страница подписки, оферта/политика показывают одно и то же настраиваемое название вместо дефолтного «MBS Panel», меняется в один клик из Настроек, применяется сразу.
|
||||
- **Админ-панель** (чистый HTML/CSS/JS, без фреймворков и сборки) — дашборд, полная карточка юзера (история подписок, ручная выдача, устройства), подписки, гифт-коды, ноды (полное редактирование, не только вкл/выкл), трафик по Stats API самого Xray со сбросом счётчика по клику.
|
||||
- **Мультинодовость** — добавляешь новую ноду в панели, получаешь одну команду `bash <(curl ...)`, вставляешь на чистый сервер — нода сама ставит Xray, генерит ключи, регистрируется в панели. Как у Remnawave/3x-ui, только свой велосипед.
|
||||
- **Протоколы на выбор при добавлении ноды**: VLESS TCP+Reality, VLESS gRPC+Reality, VLESS XHTTP+Reality, VLESS WS+TLS (с реальным Let's Encrypt сертификатом), Hysteria2 (QUIC, отдельный процесс, obfs).
|
||||
- **Лимит устройств (HWID)** — как у Remnawave, опционально: ограничение числа устройств на подписку через `x-hwid` заголовок.
|
||||
- **Приём оплаты** — ЮKassa и Platega из коробки, опционально; без них бот просто бесплатно выдаёт по кнопке.
|
||||
- **CLI `mbs`** — управление панелью прямо с сервера: пароль, статус, рестарт, логи.
|
||||
- **CLI `mbs`** — управление панелью прямо с сервера: пароль, статус, рестарт, логи, обновление.
|
||||
- **Backup & Restore прямо в админке** — скачал архив (база + `.env`) одной кнопкой, восстановил загрузкой файла. Ни у Remnawave, ни у Marzban такого нет из коробки, только community-скрипты.
|
||||
- **Мультиадминство + 2FA** — отдельные логины вместо одного пароля на всех, опциональная TOTP-двухфакторка (любой Google Authenticator/Authy) поверх пароля.
|
||||
- **Проверка конфига Xray перед рестартом** — `xray run -test` плюс проверка что серты реально читаемы юзером, под которым крутится Xray, до того как что-то применится и уронит сервис.
|
||||
- **Drag-n-drop ноды** — порядок нод в списке настраивается мышкой, как у Remnawave.
|
||||
- **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код.
|
||||
- **Свой путь входа** — страницу логина можно увести с дефолтного `/admin` на любой другой (`ADMIN_PATH` в `.env`), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще.
|
||||
- **Пауза подписки** — временно отключить доступ без потери оплаченных дней (Marzban это умеет, Remnawave — нет): при возобновлении срок сдвигается ровно на длительность паузы.
|
||||
- **Исходящие вебхуки** — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды и создание/удаление/вкл-выкл цепочки, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса.
|
||||
- **Поиск и фильтр по подпискам** — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице. Плюс экспорт всех подписок в CSV одной кнопкой.
|
||||
- **Цепочки серверов (v1.6)** — `клиент → нода A → нода B → интернет`: собираются в админке мышкой, зажал ЛКМ на клиенте и протянул провод через серверы к интернету. Больше двух серверов нельзя специально, на двух панель предупреждает про задержку и сама меряет RTT между нодами. Подробности ниже в разделе «Цепочки серверов».
|
||||
- **Юзеры и выдача подписок (v1.6)** — отдельная страница со всеми, кто уже есть в системе, даже если подписок у них ни разу не было (в «Подписках» таких не видно): поиск по юзернейму и Telegram ID, счётчики активных подписок и устройств, кнопка «Выдать подписку». Можно выдать и по Telegram ID тому, кого в базе ещё нет, подписка дождётся, пока он зайдёт в бота.
|
||||
- **Журнал действий (v1.6)** — кто из админов и когда менял ноды, цепочки, подписки, настройки, качал бэкап и входил (включая неудачные входы с IP). Пароли и ключи в журнал не попадают, только факт действия.
|
||||
- **Пинг нод и палитра команд (v1.6)** — живая задержка от панели до каждой ноды на дашборде и в списке нод; `Ctrl K` открывает поиск по страницам, нодам, цепочкам и действиям. Акцентный цвет панели меняется кружками сверху.
|
||||
|
||||
## Архитектура
|
||||
|
||||
|
|
@ -96,9 +112,11 @@ sequenceDiagram
|
|||
Нужен чистый сервер на **Ubuntu 22.04/24.04** или **Debian 11/12**, root-доступ и три поднятых DNS A-записи (см. таблицу ниже).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/devsavsis/mbs-panel.git && cd mbs-panel && sudo bash install.sh
|
||||
bash <(curl -Ls https://lab.savsis.xyz/savsisbtw/mbs-panel/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
(или так: `git clone https://lab.savsis.xyz/savsisbtw/mbs-panel.git && cd mbs-panel && sudo bash install.sh`. Основной репозиторий лежит на lab.savsis.xyz, GitHub и api.savsis.xyz остаются зеркалами на случай, если lab недоступен, установщик и `mbs update` сами переключаются на них)
|
||||
|
||||
Скрипт спросит домен панели, домен подписки, токен бота от [@BotFather](https://t.me/BotFather) и список Telegram ID админов — и дальше всё сам: ставит зависимости, Xray, nginx, выпускает сертификаты Let's Encrypt, генерирует Reality-ключи, поднимает systemd-сервисы, настраивает firewall (ufw) и fail2ban. В конце покажет пароль от админки и ссылку на панель.
|
||||
|
||||
### DNS-записи
|
||||
|
|
@ -120,6 +138,7 @@ git clone https://github.com/devsavsis/mbs-panel.git && cd mbs-panel && sudo bas
|
|||
- Зайди на `https://panel.example.com`, залогинься паролем из вывода скрипта.
|
||||
- Смени пароль в любой момент: `mbs pass новый_пароль` (без аргумента — сгенерит случайный).
|
||||
- В боте у себя (Telegram ID из ADMIN_IDS) появится админ-меню.
|
||||
- На `https://sub.example.com` уже живёт готовый клиентский сайт (лендинг + личный кабинет) с подставленным названием и реальными тарифами — ничего отдельно разворачивать не нужно. Название меняется в Настройки → «Название» в панели, применяется сразу везде (сайт, бот, страница подписки, оферта/политика).
|
||||
|
||||
В конце установки `install.sh` шлёт один пинг на `stats.api.savsis.xyz` (только название ОС) — просто счётчик "сколько раз панель установили", никаких доменов/токенов/паролей туда не уходит, IP не сохраняется. Отключить: `MBS_SKIP_STATS=1 sudo bash install.sh`.
|
||||
|
||||
|
|
@ -133,8 +152,19 @@ mbs status статус bot / api / xray / nginx
|
|||
mbs restart перезапустить bot + api
|
||||
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
|
||||
mbs domain текущий домен панели
|
||||
mbs backup полная копия панели в /root/mbs-backups (база, .env, твои правки, конфиг Xray)
|
||||
mbs update [ссылка] обновить код и перезапустить; со ссылкой на git-зеркало берёт обновление оттуда
|
||||
mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, его mbs update проверяет первым
|
||||
```
|
||||
|
||||
`mbs update` тянет обновление (только fast-forward, чужую историю на сервере не мержит), ставит зависимости, **проверяет, что новый код вообще компилируется**, и только потом перезапускает. Если после рестарта `mbs-bot`/`mbs-api` не поднялись — сам откатывает на предыдущий коммит и поднимает его. `.env` и база (`mbs.db`) не в гите — их не тронет ни при каком раскладе.
|
||||
|
||||
Перед каждым обновлением `mbs update` сам снимает полную копию в `/root/mbs-backups/` (консистентный снапшот базы через backup API SQLite, `.env`, код вместе с твоими ручными правками, конфиг Xray, без `venv`), хранит 5 последних, права `600`. Не получилось сделать копию (нет места на диске), обновление даже не начнётся. То же вручную: `mbs backup`. Вернуть всё как было: `tar xzf /root/mbs-backups/mbs-before-update-<время>.tar.gz -C /` и `mbs restart`.
|
||||
|
||||
Если на сервере правили файлы руками (бывает, `bot.py`/`config.py`/`db.py` под себя), обновление больше на этом не падает: правки откладываются в `git stash` и сохраняются патчем в `local-changes/local-changes-<время>.patch`, потом подтягивается новая версия. Вернуть своё поверх новой: `git stash pop` (может быть конфликт, если новая версия правила те же строки, тогда смотри патч). Если новый код не прошёл проверку или сервисы не поднялись, откат на старый коммит возвращает и твои правки.
|
||||
|
||||
Источники по порядку: своё зеркало (если задано через `mbs mirror`), потом `lab.savsis.xyz`, потом `api.savsis.xyz`, потом GitHub. Установщик включает автообновление: каждый день около 04:00 сервер сам делает `mbs update` (перед ним всегда резервная копия). Выключить: `mbs autoupdate off`, включить обратно: `mbs autoupdate on`. Появилось новое зеркало или GitHub недоступен, а ссылка на репо есть: `mbs update https://example.com/путь/mbs-panel.git` возьмёт обновление именно оттуда, один раз. Чтобы всегда обновляться с него: `mbs mirror https://example.com/путь/mbs-panel.git` (убрать: `mbs mirror off`). Принимаются только `https://`, `http://`, `ssh://` и `git@хост:путь`, всё остальное (в том числе `file://` и хитрые транспорты типа `ext::`) отбрасывается, ветка берётся `main`.
|
||||
|
||||
## Добавление ноды
|
||||
|
||||
В панели: Ноды → Добавить ноду → выбираешь страну, протоколы (Reality-транспорты всегда включены, WS+TLS и Hysteria2 — опционально) → получаешь команду вида:
|
||||
|
|
@ -160,6 +190,22 @@ sequenceDiagram
|
|||
Panel->>Panel: нода активна, доступна в боте
|
||||
```
|
||||
|
||||
## Цепочки серверов
|
||||
|
||||
Обычное подключение это `клиент → нода → интернет`. Цепочка добавляет второй прыжок: `клиент → нода A → нода B → интернет`. Сайты видят IP ноды B, а клиент коннектится к A. Пригождается, когда вход хочется держать в одном регионе (ближе, не режут), а выход нужен в другой стране. Больше двух серверов не даёт специально: каждый лишний прыжок это задержка, а скорость упирается в самое слабое звено.
|
||||
|
||||
Собирается в админке: Цепочки → зажимаешь ЛКМ на «Клиенте», тянешь провод через серверы из пула и отпускаешь на «Интернете». Пока ведёшь, сервер под курсором цепляется после короткой задержки (чтоб не хватать всё подряд по пути). Можно и без перетаскивания, просто кликами по серверам и по «Интернету», `Esc` сбрасывает. Один сервер это обычное подключение, оно и так есть у каждой ноды, а вот два уже цепочка: панель сразу показывает предупреждение про высокую задержку и замеряет реальный RTT между нодами (TCP-коннект с входной ноды до выходной).
|
||||
|
||||
Что реально происходит под капотом:
|
||||
|
||||
- на входной ноде появляется отдельный Xray-inbound `chain-<код>` (тот же Reality-ключ что у ноды, но свой порт из 10443–10999 и свой shortId), outbound `chain-<код>-out` до выходной ноды и routing-правило «всё из этого inbound уходит в этот outbound»;
|
||||
- на выходной ноде заводится служебный клиент `relay-<код>`, под ним входная нода и ходит на выход (только на TCP+Reality inbound, на обоих прыжках `xtls-rprx-vision`);
|
||||
- порт открывается в ufw сам, конфиг прогоняется через `xray run -test`, после рестарта проверяется что Xray реально поднялся, если нет, конфиг откатывается;
|
||||
- подписчикам входной ноды в подписку добавляется ещё одна ссылка «A → B», подписчикам выходной цепочку не выдаём;
|
||||
- любая проблема с цепочкой не блокирует обычную синхронизацию клиентов, они применятся в любом случае.
|
||||
|
||||
Ограничения: входом может быть только локальная или управляемая нода (панель правит её конфиг), выходом ещё и внешняя нода с общим UUID. Ноду, которая сидит в цепочке, удалить нельзя, сначала удали цепочку. И важный момент про Reality: SNI-маскировка (`dest`) не должна быть сайтом с пост-квантовым обменом ключами (например `www.microsoft.com`), на таком Reality не заводится вообще, ни в цепочке, ни без неё, проверено руками. Дефолтный `www.wildberries.ru` подходит.
|
||||
|
||||
## Приём оплаты
|
||||
|
||||
По умолчанию бот выдаёт подписки бесплатно по кнопке — платежи выключены (`PAYMENTS_ENABLED=false`). Чтобы продавать доступ:
|
||||
|
|
@ -197,7 +243,7 @@ sequenceDiagram
|
|||
PR и issues welcome. CI на каждый пуш гоняет compile-check по питону, синтаксис-проверку шелл-скриптов и smoke-тест генерации install-скрипта ноды.
|
||||
|
||||
## Авторы:
|
||||
github.com/devsavsis
|
||||
github.com/savsisbtw
|
||||
github.com/welfizx
|
||||
|
||||
## Лицензия
|
||||
|
|
|
|||
2733
admin.html
2733
admin.html
File diff suppressed because it is too large
Load diff
130
backup.py
Normal file
130
backup.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import glob
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tarfile
|
||||
import datetime
|
||||
|
||||
from config import DB_PATH, BASE_DIR
|
||||
|
||||
ENV_PATH = os.path.join(BASE_DIR, ".env")
|
||||
MAX_RESTORE_SIZE = 200 * 1024 * 1024
|
||||
KEEP_SAFETY_COPIES = 5
|
||||
|
||||
|
||||
class RestoreError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _prune_old_safety_copies(keep: int = KEEP_SAFETY_COPIES):
|
||||
for base in (DB_PATH, ENV_PATH):
|
||||
copies = sorted(glob.glob(f"{base}.before-restore-*"))
|
||||
for path in copies[:-keep] if keep > 0 else copies:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def create_backup() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
db_tmp = DB_PATH + ".backup_snapshot.tmp"
|
||||
src = sqlite3.connect(DB_PATH)
|
||||
dst = sqlite3.connect(db_tmp)
|
||||
try:
|
||||
with dst:
|
||||
src.backup(dst)
|
||||
finally:
|
||||
src.close()
|
||||
dst.close()
|
||||
|
||||
try:
|
||||
files = ["mbs.db"]
|
||||
if os.path.exists(ENV_PATH):
|
||||
files.append(".env")
|
||||
manifest = {
|
||||
"created_at": datetime.datetime.utcnow().isoformat(),
|
||||
"files": files,
|
||||
}
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||
tar.add(db_tmp, arcname="mbs.db")
|
||||
if os.path.exists(ENV_PATH):
|
||||
tar.add(ENV_PATH, arcname=".env")
|
||||
manifest_bytes = json.dumps(manifest, indent=2).encode()
|
||||
info = tarfile.TarInfo(name="manifest.json")
|
||||
info.size = len(manifest_bytes)
|
||||
tar.addfile(info, io.BytesIO(manifest_bytes))
|
||||
finally:
|
||||
try:
|
||||
os.remove(db_tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def restore_backup(data: bytes) -> dict:
|
||||
if len(data) > MAX_RESTORE_SIZE:
|
||||
raise RestoreError("backup file too large")
|
||||
|
||||
try:
|
||||
tar = tarfile.open(fileobj=io.BytesIO(data), mode="r:gz")
|
||||
except Exception as e:
|
||||
raise RestoreError(f"not a valid backup archive: {e}")
|
||||
|
||||
members = {m.name: m for m in tar.getmembers()}
|
||||
if "mbs.db" not in members:
|
||||
raise RestoreError("archive has no mbs.db")
|
||||
|
||||
tmp_db_path = DB_PATH + ".restore_candidate.tmp"
|
||||
db_member = tar.extractfile(members["mbs.db"])
|
||||
with open(tmp_db_path, "wb") as f:
|
||||
shutil.copyfileobj(db_member, f)
|
||||
|
||||
try:
|
||||
check_conn = sqlite3.connect(tmp_db_path)
|
||||
try:
|
||||
tables = {r[0] for r in check_conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
finally:
|
||||
check_conn.close()
|
||||
except sqlite3.DatabaseError as e:
|
||||
os.remove(tmp_db_path)
|
||||
raise RestoreError(f"archive's mbs.db is not a valid sqlite database: {e}")
|
||||
|
||||
required = {"users", "subscriptions", "nodes", "payments"}
|
||||
if not required.issubset(tables):
|
||||
os.remove(tmp_db_path)
|
||||
raise RestoreError("archive's mbs.db is missing expected tables")
|
||||
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
||||
safety_copy = f"{DB_PATH}.before-restore-{stamp}"
|
||||
shutil.copy2(DB_PATH, safety_copy)
|
||||
|
||||
restored_env = False
|
||||
if ".env" in members and os.path.exists(ENV_PATH):
|
||||
env_safety = f"{ENV_PATH}.before-restore-{stamp}"
|
||||
shutil.copy2(ENV_PATH, env_safety)
|
||||
env_member = tar.extractfile(members[".env"])
|
||||
env_tmp = ENV_PATH + ".restore.tmp"
|
||||
with open(env_tmp, "wb") as f:
|
||||
shutil.copyfileobj(env_member, f)
|
||||
os.chmod(env_tmp, 0o600)
|
||||
os.replace(env_tmp, ENV_PATH)
|
||||
restored_env = True
|
||||
|
||||
for suffix in ("-wal", "-shm"):
|
||||
try:
|
||||
os.remove(DB_PATH + suffix)
|
||||
except OSError:
|
||||
pass
|
||||
os.chmod(tmp_db_path, 0o600)
|
||||
os.replace(tmp_db_path, DB_PATH)
|
||||
|
||||
import db
|
||||
db.init_db()
|
||||
|
||||
_prune_old_safety_copies()
|
||||
return {"restored_env": restored_env, "safety_copy": safety_copy}
|
||||
360
bot.py
360
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,10 +125,13 @@ async def start_deeplink(message: Message, command: CommandObject):
|
|||
if err == "already_used":
|
||||
await message.answer("Этот код уже был использован.")
|
||||
return await send_main_menu(message)
|
||||
plan = PLANS_BY_CODE[gift["plan"]]
|
||||
sub = db.create_subscription(message.from_user.id, gift["node"], plan["days"], plan["code"], source="gift", )
|
||||
plan = settings.get_plans_by_code().get(gift["plan"])
|
||||
gift_node = db.get_node(gift["node"])
|
||||
xray_manager.add_client_to_node(gift_node, sub["uuid"], email=sub["uuid"])
|
||||
if not plan or not gift_node:
|
||||
await message.answer("Этот подарок больше недоступен.")
|
||||
return await send_main_menu(message)
|
||||
sub = db.create_subscription(message.from_user.id, gift["node"], plan["days"], plan["code"], source="gift", )
|
||||
await asyncio.to_thread(xray_manager.add_client_to_node, gift_node, sub["uuid"], email=sub["uuid"])
|
||||
await message.answer(
|
||||
f"<b>Подарок активирован</b>\n\n"
|
||||
f"Сервер: {gift_node['label']}\n"
|
||||
|
|
@ -116,7 +148,7 @@ async def start_deeplink(message: Message, command: CommandObject):
|
|||
async def start_plain(message: Message):
|
||||
db.get_or_create_user(message.from_user.id, message.from_user.username)
|
||||
await message.answer(
|
||||
"Привет! Это бот MBS Panel.\nВыбери действие ниже.",
|
||||
f"Привет! Это бот {settings.get_brand_name()}.\nВыбери действие ниже.",
|
||||
)
|
||||
await send_main_menu(message)
|
||||
|
||||
|
|
@ -130,7 +162,33 @@ async def cb_menu_main(cb: CallbackQuery):
|
|||
@dp.callback_query(F.data == "menu:about")
|
||||
async def cb_about(cb: CallbackQuery):
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
|
||||
await cb.message.edit_text(ABOUT_TEXT, reply_markup=kb)
|
||||
await cb.message.edit_text(about_text(), reply_markup=kb)
|
||||
await cb.answer()
|
||||
|
||||
|
||||
@dp.callback_query(F.data == "menu:referral")
|
||||
async def cb_referral(cb: CallbackQuery):
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
|
||||
ref_settings = settings.get_referral_settings()
|
||||
if not ref_settings["enabled"]:
|
||||
await cb.message.edit_text("Реферальная программа сейчас отключена.", reply_markup=kb)
|
||||
return await cb.answer()
|
||||
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||
stats = db.referral_stats(cb.from_user.id)
|
||||
username = await get_bot_username()
|
||||
link = f"https://t.me/{username}?start=ref_{user['ref_code']}"
|
||||
days = ref_settings["bonus_days"]
|
||||
text = (
|
||||
f"<b>Пригласи друга</b>\n\n"
|
||||
f"За каждого друга, который активирует подписку по твоей ссылке, "
|
||||
f"вы <b>оба</b> получаете +{days} дн. к подписке.\n\n"
|
||||
f"{DIVIDER}\n"
|
||||
f"Твоя ссылка:\n<code>{link}</code>\n\n"
|
||||
f"Приглашено: {stats['referred_count']}\n"
|
||||
)
|
||||
if stats["bonus_days_pending"]:
|
||||
text += f"Накоплено бонусных дней (зачислятся при следующей подписке): {stats['bonus_days_pending']}\n"
|
||||
await cb.message.edit_text(text, reply_markup=kb)
|
||||
await cb.answer()
|
||||
|
||||
|
||||
|
|
@ -143,7 +201,7 @@ async def cb_get(cb: CallbackQuery):
|
|||
@dp.callback_query(F.data.startswith("node:"))
|
||||
async def cb_node(cb: CallbackQuery):
|
||||
node_code = cb.data.split(":")[1]
|
||||
await cb.message.edit_text("Выбери срок:", reply_markup=plans_kb("plan", node_code))
|
||||
await cb.message.edit_text("Выбери срок:", reply_markup=plans_kb("plan", node_code, cb.from_user.id))
|
||||
await cb.answer()
|
||||
|
||||
|
||||
|
|
@ -158,20 +216,29 @@ def providers_kb(node_code: str, plan_code: str) -> InlineKeyboardMarkup:
|
|||
@dp.callback_query(F.data.startswith("plan:"))
|
||||
async def cb_plan(cb: CallbackQuery):
|
||||
_, node_code, plan_code = cb.data.split(":")
|
||||
plan = PLANS_BY_CODE[plan_code]
|
||||
plan = settings.get_plans_by_code()[plan_code]
|
||||
db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||
|
||||
if PAYMENTS_ENABLED and plan["price"] > 0 and payments.available_providers():
|
||||
promo = db.get_pending_promo(cb.from_user.id)
|
||||
final_price = db.discounted_price(plan["price"], promo)
|
||||
if settings.get_payment_settings()["payments_enabled"] and final_price > 0 and payments.available_providers():
|
||||
price_line = f"{final_price} ₽" if final_price == plan["price"] else f"{final_price} ₽ (скидка по промокоду {promo['code']})"
|
||||
await cb.message.edit_text(
|
||||
f"<b>{plan['label']}</b> — {plan['price']} ₽\n\nВыбери способ оплаты:",
|
||||
f"<b>{plan['label']}</b> — {price_line}\n\nВыбери способ оплаты:",
|
||||
reply_markup=providers_kb(node_code, plan_code),
|
||||
)
|
||||
return await cb.answer()
|
||||
if promo and settings.get_payment_settings()["payments_enabled"] and plan["price"] > 0 and final_price == 0:
|
||||
db.consume_promo(promo["code"], cb.from_user.id)
|
||||
db.set_promo_pending(cb.from_user.id, None)
|
||||
|
||||
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||
sub = db.create_subscription(cb.from_user.id, node_code, plan["days"], plan_code, source="bot")
|
||||
sub = db.create_subscription(
|
||||
cb.from_user.id, node_code, plan["days"], plan_code, source="bot",
|
||||
traffic_limit=settings.default_traffic_limit_bytes(),
|
||||
)
|
||||
node_row = db.get_node(node_code)
|
||||
xray_manager.add_client_to_node(node_row, sub["uuid"], email=sub["uuid"])
|
||||
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
|
||||
kb = connect_kb(user["token"], extra_rows=[
|
||||
[InlineKeyboardButton(text="Моя подписка", callback_data="menu:mysub")],
|
||||
[InlineKeyboardButton(text="В меню", callback_data="menu:main")],
|
||||
|
|
@ -190,13 +257,18 @@ async def cb_plan(cb: CallbackQuery):
|
|||
@dp.callback_query(F.data.startswith("pay:"))
|
||||
async def cb_pay(cb: CallbackQuery):
|
||||
_, provider, node_code, plan_code = cb.data.split(":")
|
||||
plan = PLANS_BY_CODE[plan_code]
|
||||
plan = settings.get_plans_by_code()[plan_code]
|
||||
node_row = db.get_node(node_code)
|
||||
payment_id = payments.new_payment_id()
|
||||
db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, plan["price"])
|
||||
promo = db.get_pending_promo(cb.from_user.id)
|
||||
final_price = db.discounted_price(plan["price"], promo)
|
||||
db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, final_price)
|
||||
if promo and final_price != plan["price"]:
|
||||
db.set_payment_promo(payment_id, promo["code"], plan["price"])
|
||||
db.set_promo_pending(cb.from_user.id, None)
|
||||
try:
|
||||
external_id, pay_url = payments.create_payment_link(
|
||||
provider, payment_id, plan["price"], f"MBS Panel — {node_row['label']}, {plan['label']}",
|
||||
provider, payment_id, final_price, f"{settings.get_brand_name()} — {node_row['label']}, {plan['label']}",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("payment creation failed")
|
||||
|
|
@ -208,7 +280,7 @@ async def cb_pay(cb: CallbackQuery):
|
|||
[InlineKeyboardButton(text="Назад", callback_data=f"plan:{node_code}:{plan_code}")],
|
||||
])
|
||||
await cb.message.edit_text(
|
||||
f"Счёт на {plan['price']} ₽ создан.\nПосле оплаты подписка выдастся автоматически.",
|
||||
f"Счёт на {final_price} ₽ создан.\nПосле оплаты подписка выдастся автоматически.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
await cb.answer()
|
||||
|
|
@ -223,11 +295,13 @@ async def cb_mysub(cb: CallbackQuery):
|
|||
await cb.message.edit_text("У тебя пока нет активных подписок.", reply_markup=kb)
|
||||
return await cb.answer()
|
||||
lines = ["<b>Твои подписки</b>\n"]
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
plans_by_code = settings.get_plans_by_code()
|
||||
for s in subs:
|
||||
plan = PLANS_BY_CODE.get(s["plan"], {}).get("label", s["plan"])
|
||||
node_info = db.get_node(s["node"])
|
||||
plan = plans_by_code.get(s["plan"], {}).get("label", s["plan"])
|
||||
node_info = nodes_by_code.get(s["node"])
|
||||
node = node_info["label"] if node_info else s["node"]
|
||||
lines.append(f"{node} — {plan}, до {s['expires_at'][:10]}")
|
||||
lines.append(f"{node} — {plan}, до {s['expires_at'][:10]}\nТрафик: {features.traffic_text(s)}")
|
||||
lines.append(f"\n{DIVIDER}\nСсылка-подписка:\n<code>{sub_url_for(user['token'])}</code>")
|
||||
kb = connect_kb(user["token"], extra_rows=[[InlineKeyboardButton(text="В меню", callback_data="menu:main")]])
|
||||
await cb.message.edit_text("\n".join(lines), reply_markup=kb)
|
||||
|
|
@ -279,7 +353,7 @@ async def cb_admin_giftmake(cb: CallbackQuery):
|
|||
me = await bot.get_me()
|
||||
_bot_username = me.username
|
||||
link = f"https://t.me/{_bot_username}?start=gift_{code}"
|
||||
plan = PLANS_BY_CODE[plan_code]
|
||||
plan = settings.get_plans_by_code()[plan_code]
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В админку", callback_data="menu:admin")]])
|
||||
await cb.message.edit_text(
|
||||
f"Гифт-ссылка готова ({db.get_node(node_code)['label']}, {plan['label']}):\n\n"
|
||||
|
|
@ -309,7 +383,7 @@ async def cb_admin_stats(cb: CallbackQuery):
|
|||
async def cb_admin_sync(cb: CallbackQuery):
|
||||
if not is_admin(cb.from_user.id):
|
||||
return await cb.answer("Нет доступа", show_alert=True)
|
||||
result = xray_manager.sync_all()
|
||||
result = await asyncio.to_thread(xray_manager.sync_all)
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В админку", callback_data="menu:admin")]])
|
||||
await cb.message.edit_text(
|
||||
f"Синхронизация xray выполнена.\nАктивно клиентов: {result['active_now']}\n"
|
||||
|
|
@ -319,12 +393,224 @@ async def cb_admin_sync(cb: CallbackQuery):
|
|||
await cb.answer()
|
||||
|
||||
|
||||
async def periodic_sync():
|
||||
while True:
|
||||
@dp.callback_query(F.data == "trial:start")
|
||||
async def cb_trial(cb: CallbackQuery):
|
||||
feats = settings.get_features()
|
||||
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||
if not feats["trial_enabled"] or not db.trial_available(cb.from_user.id):
|
||||
return await cb.answer("Пробный период недоступен", show_alert=True)
|
||||
node_row = features.pick_trial_node()
|
||||
if not node_row:
|
||||
return await cb.answer("Сейчас нет доступных серверов", show_alert=True)
|
||||
if not db.claim_trial(cb.from_user.id):
|
||||
return await cb.answer("Пробный период уже использован", show_alert=True)
|
||||
limit = feats["trial_traffic_gb"] * settings.GB if feats["trial_traffic_gb"] > 0 else None
|
||||
sub = db.create_subscription(
|
||||
cb.from_user.id, node_row["code"], feats["trial_days"], "trial", source="trial", traffic_limit=limit,
|
||||
)
|
||||
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
|
||||
traffic_line = f"\nТрафик: до {feats['trial_traffic_gb']} ГБ" if limit else ""
|
||||
kb = connect_kb(user["token"], extra_rows=[[InlineKeyboardButton(text="В меню", callback_data="menu:main")]])
|
||||
await cb.message.edit_text(
|
||||
f"<b>Пробный период активен</b>\n\n"
|
||||
f"Сервер: {node_row['label']}\n"
|
||||
f"Срок: до {sub['expires_at'][:10]}{traffic_line}\n\n"
|
||||
f"{DIVIDER}\n"
|
||||
f"Ссылка-подписка:\n<code>{sub_url_for(user['token'])}</code>",
|
||||
reply_markup=kb,
|
||||
)
|
||||
await cb.answer("Пробный период выдан")
|
||||
await asyncio.to_thread(webhooks.send, "subscription.trial", {
|
||||
"tg_id": cb.from_user.id, "node": node_row["code"], "subscription_uuid": sub["uuid"],
|
||||
"expires_at": sub["expires_at"],
|
||||
})
|
||||
|
||||
|
||||
PROMO_ERRORS = {
|
||||
"not_found": "Такого промокода нет.",
|
||||
"expired": "Срок действия промокода закончился.",
|
||||
"exhausted": "Этот промокод уже использован максимальное число раз.",
|
||||
"already_used": "Ты уже использовал этот промокод.",
|
||||
}
|
||||
|
||||
|
||||
async def apply_promo_code(message: Message, raw_code: str):
|
||||
code = (raw_code or "").strip()
|
||||
if not code:
|
||||
return await message.answer("Напиши промокод так: /promo КОД")
|
||||
db.get_or_create_user(message.from_user.id, message.from_user.username)
|
||||
promo, err = db.validate_promo(code, message.from_user.id)
|
||||
if err:
|
||||
return await message.answer(PROMO_ERRORS.get(err, "Промокод не подошёл."))
|
||||
if promo["kind"] == "days":
|
||||
redeemed, err = db.redeem_days_promo(code, message.from_user.id)
|
||||
if err:
|
||||
return await message.answer(PROMO_ERRORS.get(err, "Промокод не подошёл."))
|
||||
return await message.answer(f"Промокод принят: +{promo['value']} дн. к подписке.")
|
||||
db.set_promo_pending(message.from_user.id, promo["code"])
|
||||
what = f"{promo['value']}%" if promo["kind"] == "percent" else f"{promo['value']} ₽"
|
||||
await message.answer(f"Промокод принят: скидка {what}. Она применится на следующей оплате, выбери срок в меню.")
|
||||
|
||||
|
||||
@dp.message(Command("promo"))
|
||||
async def cmd_promo(message: Message, command: CommandObject):
|
||||
await apply_promo_code(message, command.args or "")
|
||||
|
||||
|
||||
@dp.callback_query(F.data == "menu:promo")
|
||||
async def cb_promo_hint(cb: CallbackQuery):
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
|
||||
await cb.message.edit_text("Отправь команду с кодом, например:\n<code>/promo КОД</code>", reply_markup=kb)
|
||||
await cb.answer()
|
||||
|
||||
|
||||
async def notify_limit_reached(subs: list):
|
||||
for sub in subs:
|
||||
try:
|
||||
xray_manager.sync_all()
|
||||
await bot.send_message(
|
||||
sub["tg_id"],
|
||||
"<b>Лимит трафика исчерпан</b>\n\nДоступ приостановлен. Продли подписку или напиши в поддержку, "
|
||||
"чтобы получить ещё трафик.",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("failed to notify about traffic limit")
|
||||
await asyncio.to_thread(webhooks.send, "subscription.limit_reached", {
|
||||
"tg_id": sub["tg_id"], "subscription_uuid": sub["uuid"], "node": sub["node"],
|
||||
"limit": sub["traffic_limit"], "used": sub["traffic_used"],
|
||||
})
|
||||
|
||||
|
||||
async def send_expiry_reminders():
|
||||
if not settings.get_features()["reminders_enabled"]:
|
||||
return
|
||||
for sub, kind, stage in features.reminders_due():
|
||||
left = "3 дня" if stage == "3d" else "сутки"
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Продлить", callback_data="menu:get")]])
|
||||
try:
|
||||
await bot.send_message(
|
||||
sub["tg_id"],
|
||||
f"<b>Подписка скоро закончится</b>\n\nДо конца осталось меньше чем {left} "
|
||||
f"(до {sub['expires_at'][:10]}). Продли заранее, чтобы доступ не прерывался.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("failed to send expiry reminder")
|
||||
features.mark_stage_sent(sub["uuid"], sub["expires_at"])
|
||||
|
||||
|
||||
_node_state: dict = {}
|
||||
|
||||
|
||||
async def check_nodes_and_alert():
|
||||
if not settings.get_features()["node_alerts_enabled"]:
|
||||
return
|
||||
for node in db.list_nodes(enabled_only=True):
|
||||
if node["kind"] == "local" or node["status"] != "active" or not node.get("address"):
|
||||
continue
|
||||
samples = await asyncio.to_thread(chains.tcp_connect_ms, node["address"], node["port"], 2, 3.0)
|
||||
alive = chains.median_ms(samples) is not None
|
||||
previous = _node_state.get(node["code"])
|
||||
_node_state[node["code"]] = alive
|
||||
if previous is None or previous == alive:
|
||||
continue
|
||||
text = (
|
||||
f"Нода «{node['label']}» ({node['address']}) снова доступна."
|
||||
if alive else f"Нода «{node['label']}» ({node['address']}) не отвечает."
|
||||
)
|
||||
for admin_id in ADMIN_IDS:
|
||||
try:
|
||||
await bot.send_message(admin_id, text)
|
||||
except Exception:
|
||||
log.exception("failed to send node alert")
|
||||
await asyncio.to_thread(webhooks.send, "node.up" if alive else "node.down", {
|
||||
"node": node["code"], "label": node["label"], "address": node["address"],
|
||||
})
|
||||
|
||||
|
||||
async def reconcile_pending_payments():
|
||||
if not settings.get_payment_settings()["payments_enabled"]:
|
||||
return
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
plans_by_code = settings.get_plans_by_code()
|
||||
for payment in db.list_payments():
|
||||
if payment["status"] != "pending" or not payment.get("external_id"):
|
||||
continue
|
||||
try:
|
||||
status = await asyncio.to_thread(payments.check_payment_status, payment["provider"], payment["external_id"])
|
||||
except Exception:
|
||||
continue
|
||||
if status in payments.PAID_STATUSES:
|
||||
plan = plans_by_code.get(payment["plan"])
|
||||
node_row = nodes_by_code.get(payment["node"])
|
||||
if not plan or not node_row:
|
||||
continue
|
||||
granted = db.mark_payment_paid(payment["id"])
|
||||
if not granted:
|
||||
continue
|
||||
sub = db.create_subscription(
|
||||
payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment",
|
||||
traffic_limit=settings.default_traffic_limit_bytes(),
|
||||
)
|
||||
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
|
||||
user = db.get_or_create_user(payment["tg_id"], None)
|
||||
try:
|
||||
await bot.send_message(
|
||||
payment["tg_id"],
|
||||
f"<b>Оплата получена</b>\n\n"
|
||||
f"Сервер: {node_row['label']}\n"
|
||||
f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n"
|
||||
f"Ссылка-подписка:\n{sub_url_for(user['token'])}",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("failed to notify user about payment")
|
||||
await asyncio.to_thread(webhooks.send, "payment.paid", {
|
||||
"tg_id": payment["tg_id"],
|
||||
"amount": payment["amount"],
|
||||
"provider": payment["provider"],
|
||||
"node": payment["node"],
|
||||
"plan": payment["plan"],
|
||||
"subscription_uuid": sub["uuid"],
|
||||
"expires_at": sub["expires_at"],
|
||||
})
|
||||
elif status in payments.FAILED_STATUSES:
|
||||
db.mark_payment_failed(payment["id"])
|
||||
|
||||
|
||||
async def periodic_sync():
|
||||
tick = 0
|
||||
while True:
|
||||
if tick % 3 == 0:
|
||||
try:
|
||||
exceeded = await asyncio.to_thread(features.update_traffic_and_find_exceeded)
|
||||
if exceeded:
|
||||
await notify_limit_reached(exceeded)
|
||||
except Exception:
|
||||
log.exception("traffic accounting failed")
|
||||
try:
|
||||
await asyncio.to_thread(xray_manager.sync_all)
|
||||
except Exception:
|
||||
log.exception("periodic sync failed")
|
||||
if tick % 20 == 0:
|
||||
try:
|
||||
await send_expiry_reminders()
|
||||
except Exception:
|
||||
log.exception("expiry reminders failed")
|
||||
if tick % 2 == 0:
|
||||
try:
|
||||
await check_nodes_and_alert()
|
||||
except Exception:
|
||||
log.exception("node alerts failed")
|
||||
tick += 1
|
||||
try:
|
||||
await reconcile_pending_payments()
|
||||
except Exception:
|
||||
log.exception("payment reconciliation failed")
|
||||
try:
|
||||
db.delete_expired_admin_sessions()
|
||||
db.delete_expired_pending_totp()
|
||||
db.delete_old_login_attempts()
|
||||
except Exception:
|
||||
log.exception("expired admin session cleanup failed")
|
||||
await asyncio.sleep(90)
|
||||
|
||||
|
||||
|
|
|
|||
233
chains.py
Normal file
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"))
|
||||
|
|
|
|||
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)}, без лимита"
|
||||
49
install.sh
49
install.sh
|
|
@ -2,7 +2,9 @@
|
|||
set -e
|
||||
set -o pipefail
|
||||
|
||||
REPO_URL="https://github.com/devsavsis/mbs-panel.git"
|
||||
LAB_URL="https://lab.savsis.xyz/savsisbtw/mbs-panel.git"
|
||||
MIRROR_URL="https://api.savsis.xyz/git/mbs-panel.git/"
|
||||
REPO_URL="https://github.com/savsisbtw/mbs-panel.git"
|
||||
APP_DIR="/opt/mbs-panel"
|
||||
WEBROOT="/var/www/certbot"
|
||||
|
||||
|
|
@ -46,6 +48,7 @@ case "$ID" in
|
|||
*) echo "тестировалось на Ubuntu 22/24 и Debian 11/12, но пробуем всё равно на $PRETTY_NAME" ;;
|
||||
esac
|
||||
|
||||
BRAND_NAME=$(ask "Название твоего сервиса (видят клиенты — сайт/бот/подписка)" "MBS Panel")
|
||||
PANEL_DOMAIN=$(ask "Домен панели (админка)" "")
|
||||
SUB_DOMAIN=$(ask "Домен подписок" "")
|
||||
SITE_DOMAIN=$(ask "Домен сайта (для CORS и ссылок в боте)" "$PANEL_DOMAIN")
|
||||
|
|
@ -82,7 +85,18 @@ echo "клонируем репозиторий в $APP_DIR..."
|
|||
if [ -d "$APP_DIR/.git" ]; then
|
||||
retry git -C "$APP_DIR" pull --quiet
|
||||
else
|
||||
retry git clone --quiet "$REPO_URL" "$APP_DIR"
|
||||
if ! git clone --quiet "$LAB_URL" "$APP_DIR" 2>/dev/null; then
|
||||
echo "lab.savsis.xyz недоступен, пробую зеркало..."
|
||||
rm -rf "$APP_DIR"
|
||||
if ! git clone --quiet "$MIRROR_URL" "$APP_DIR" 2>/dev/null; then
|
||||
echo "зеркало недоступно, клонирую напрямую с GitHub..."
|
||||
rm -rf "$APP_DIR"
|
||||
retry git clone --quiet "$REPO_URL" "$APP_DIR"
|
||||
fi
|
||||
git -C "$APP_DIR" remote set-url origin "$LAB_URL"
|
||||
fi
|
||||
git -C "$APP_DIR" remote add mirror "$MIRROR_URL" 2>/dev/null || true
|
||||
git -C "$APP_DIR" remote add github "$REPO_URL" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
|
@ -99,6 +113,7 @@ XRAY_SHORT_ID_GRPC=$(openssl rand -hex 8)
|
|||
XRAY_SHORT_ID_XHTTP=$(openssl rand -hex 8)
|
||||
|
||||
cat > "$APP_DIR/.env" << ENVEOF
|
||||
BRAND_NAME=$BRAND_NAME
|
||||
BOT_TOKEN=$BOT_TOKEN
|
||||
BOT_USERNAME=$BOT_USERNAME
|
||||
ADMIN_IDS=$ADMIN_IDS
|
||||
|
|
@ -156,9 +171,22 @@ retry certbot certonly --webroot -w "$WEBROOT" --non-interactive --agree-tos \
|
|||
retry certbot certonly --webroot -w "$WEBROOT" --non-interactive --agree-tos \
|
||||
--register-unsafely-without-email -d "$DE1_ADDRESS"
|
||||
|
||||
echo "готовлю серт для xray (он не root, letsencrypt/live ему не почитать)..."
|
||||
mkdir -p /etc/xray/certs
|
||||
cp "/etc/letsencrypt/live/$DE1_ADDRESS/fullchain.pem" /etc/xray/certs/de1.crt
|
||||
cp "/etc/letsencrypt/live/$DE1_ADDRESS/privkey.pem" /etc/xray/certs/de1.key
|
||||
chmod 644 /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
|
||||
chown nobody:nogroup /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
|
||||
|
||||
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
|
||||
cat > /etc/letsencrypt/renewal-hooks/deploy/mbs-reload.sh << 'HOOKEOF'
|
||||
cat > /etc/letsencrypt/renewal-hooks/deploy/mbs-reload.sh << HOOKEOF
|
||||
#!/bin/bash
|
||||
if [ -d "/etc/letsencrypt/live/$DE1_ADDRESS" ]; then
|
||||
cp "/etc/letsencrypt/live/$DE1_ADDRESS/fullchain.pem" /etc/xray/certs/de1.crt
|
||||
cp "/etc/letsencrypt/live/$DE1_ADDRESS/privkey.pem" /etc/xray/certs/de1.key
|
||||
chmod 644 /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
|
||||
chown nobody:nogroup /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
|
||||
fi
|
||||
systemctl reload nginx || true
|
||||
systemctl restart xray || true
|
||||
HOOKEOF
|
||||
|
|
@ -343,8 +371,8 @@ cat > /usr/local/etc/xray/config.json << XRAYEOF
|
|||
"wsSettings": { "path": "/mbs-ws" },
|
||||
"tlsSettings": {
|
||||
"certificates": [{
|
||||
"certificateFile": "/etc/letsencrypt/live/$DE1_ADDRESS/fullchain.pem",
|
||||
"keyFile": "/etc/letsencrypt/live/$DE1_ADDRESS/privkey.pem"
|
||||
"certificateFile": "/etc/xray/certs/de1.crt",
|
||||
"keyFile": "/etc/xray/certs/de1.key"
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
|
@ -358,8 +386,15 @@ cat > /usr/local/etc/xray/config.json << XRAYEOF
|
|||
XRAYEOF
|
||||
|
||||
echo "systemd-юниты..."
|
||||
CPU_COUNT=$(nproc 2>/dev/null || echo 1)
|
||||
if [ "$CPU_COUNT" -lt 2 ]; then API_WORKERS=1
|
||||
elif [ "$CPU_COUNT" -gt 4 ]; then API_WORKERS=4
|
||||
else API_WORKERS=$CPU_COUNT
|
||||
fi
|
||||
cp "$APP_DIR/systemd/mbs-bot.service" /etc/systemd/system/mbs-bot.service
|
||||
cp "$APP_DIR/systemd/mbs-api.service" /etc/systemd/system/mbs-api.service
|
||||
sed "s/__WORKERS__/$API_WORKERS/" "$APP_DIR/systemd/mbs-api.service" > /etc/systemd/system/mbs-api.service
|
||||
cp "$APP_DIR/systemd/mbs-autoupdate.service" /etc/systemd/system/mbs-autoupdate.service
|
||||
cp "$APP_DIR/systemd/mbs-autoupdate.timer" /etc/systemd/system/mbs-autoupdate.timer
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "ставим CLI mbs..."
|
||||
|
|
@ -381,6 +416,7 @@ systemctl reload nginx
|
|||
systemctl enable --now xray
|
||||
systemctl enable --now mbs-bot
|
||||
systemctl enable --now mbs-api
|
||||
systemctl enable --now mbs-autoupdate.timer
|
||||
|
||||
sleep 2
|
||||
|
||||
|
|
@ -394,6 +430,7 @@ echo "== готово =="
|
|||
echo "Панель: https://$PANEL_DOMAIN"
|
||||
echo "Пароль: $ADMIN_PANEL_PASSWORD (сменить: mbs pass)"
|
||||
echo "Подписки: https://$SUB_DOMAIN"
|
||||
echo "Сайт: https://$SUB_DOMAIN (готовый лендинг, название/тарифы уже подставлены — правь site/index.html под себя, если нужно)"
|
||||
echo "Нода: $DE1_ADDRESS"
|
||||
echo
|
||||
echo "статус сервисов:"
|
||||
|
|
|
|||
336
landing/index.html
Normal file
336
landing/index.html
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MBS Panel</title>
|
||||
<meta name="description" content="Самостоятельная VPN-панель на Xray-core: VLESS+Reality, Hysteria2, Telegram-бот, платежи и админка — без Docker и без чужого закрытого кода внутри.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2222%22 fill=%22%230a0b0f%22/><path d=%22M20 65 L50 25 L80 65%22 stroke=%22%237c6cf0%22 stroke-width=%228%22 fill=%22none%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22/></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
--text: #eceef2; --muted: #868c99; --accent: #7c6cf0;
|
||||
--good: #5fd48a; --bad: #e8748a;
|
||||
--mono: "SF Mono", "Cascadia Code", Consolas, monospace;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
.wrap { max-width: 920px; margin: 0 auto; padding: 0 24px; }
|
||||
|
||||
header { display: flex; align-items: center; justify-content: space-between; padding: 26px 0; }
|
||||
.logo { display: flex; align-items: center; gap: 9px; font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }
|
||||
.logo svg { width: 20px; height: 20px; }
|
||||
nav { display: flex; align-items: center; gap: 26px; }
|
||||
nav a { position: relative; text-decoration: none; color: var(--muted); font-size: 14px; transition: color .25s var(--ease); }
|
||||
nav a::after {
|
||||
content: ""; position: absolute; left: 0; bottom: -4px; width: 100%; height: 1px;
|
||||
background: currentColor; transform: scaleX(0); transform-origin: left; transition: transform .3s var(--ease);
|
||||
}
|
||||
nav a:hover { color: var(--text); }
|
||||
nav a:hover::after { transform: scaleX(1); }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px; padding: 13px 22px; border-radius: 10px;
|
||||
background: var(--text); color: var(--bg); text-decoration: none; font-weight: 600; font-size: 14px;
|
||||
border: none; cursor: pointer; transition: transform .15s var(--ease), opacity .15s var(--ease);
|
||||
}
|
||||
.btn:hover { opacity: .85; }
|
||||
.btn:active { transform: scale(.97); }
|
||||
.btn.ghost {
|
||||
background: transparent; color: var(--text); border: 1px solid var(--border);
|
||||
transition: transform .15s var(--ease), border-color .25s var(--ease), background .25s var(--ease);
|
||||
}
|
||||
.btn.ghost:hover { border-color: #333947; background: var(--card); opacity: 1; }
|
||||
.btn.sm { padding: 9px 14px; font-size: 13px; }
|
||||
|
||||
.reveal { opacity: 0; transform: translateY(14px); filter: blur(6px); transition: opacity .7s var(--ease), transform .7s var(--ease), filter .7s var(--ease); }
|
||||
.reveal.in { opacity: 1; transform: translateY(0); filter: blur(0); }
|
||||
|
||||
.hero { padding: 80px 0 56px; }
|
||||
.badges { display: flex; gap: 8px; margin-bottom: 22px; flex-wrap: wrap; }
|
||||
.badges img { height: 20px; display: block; }
|
||||
.hero h1 { font-size: 44px; line-height: 1.14; margin: 0 0 18px; letter-spacing: -.03em; font-weight: 600; max-width: 680px; }
|
||||
.hero .accent { color: var(--accent); }
|
||||
.hero p { color: var(--muted); font-size: 17px; max-width: 560px; margin: 0 0 30px; line-height: 1.6; }
|
||||
.hero-ctas { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 34px; }
|
||||
|
||||
.install {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 16px 18px; max-width: 620px; display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
}
|
||||
.install code { font-family: var(--mono); font-size: 13.5px; color: var(--text); overflow-x: auto; white-space: nowrap; }
|
||||
.install code .dim { color: var(--muted); }
|
||||
.copy-btn {
|
||||
flex-shrink: 0; background: transparent; border: 1px solid var(--border); color: var(--muted);
|
||||
border-radius: 7px; padding: 7px 11px; font-size: 12px; cursor: pointer; font-family: inherit;
|
||||
transition: all .2s var(--ease);
|
||||
}
|
||||
.copy-btn:hover { color: var(--text); border-color: #333947; }
|
||||
.copy-btn.copied { color: var(--good); border-color: var(--good); }
|
||||
|
||||
.divider { height: 1px; background: var(--border); margin: 0; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); margin: 0; }
|
||||
.feature { background: var(--bg); padding: 32px 28px; }
|
||||
.feature .idx { font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; margin-bottom: 14px; }
|
||||
.feature h3 { font-size: 16px; margin: 0 0 8px; font-weight: 600; }
|
||||
.feature p { color: var(--muted); font-size: 14px; margin: 0; line-height: 1.55; }
|
||||
|
||||
section { padding: 72px 0; }
|
||||
.section-label { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 12px; }
|
||||
h2 { font-size: 26px; margin: 0 0 12px; font-weight: 600; letter-spacing: -.01em; }
|
||||
.section-sub { color: var(--muted); font-size: 15px; max-width: 560px; line-height: 1.6; margin: 0 0 36px; }
|
||||
|
||||
.cmp-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 12px; }
|
||||
table.cmp { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 560px; }
|
||||
table.cmp th, table.cmp td { padding: 13px 18px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
table.cmp th { color: var(--muted); font-weight: 500; font-size: 12.5px; text-transform: uppercase; letter-spacing: .04em; background: var(--card); }
|
||||
table.cmp th:not(:first-child), table.cmp td:not(:first-child) { text-align: center; }
|
||||
table.cmp tr:last-child td { border-bottom: none; }
|
||||
table.cmp td:first-child { color: var(--text); }
|
||||
table.cmp .us { background: rgba(124,108,240,.06); font-weight: 600; }
|
||||
.yes { color: var(--good); }
|
||||
.no { color: var(--muted); }
|
||||
.soon { color: var(--accent); font-size: 12.5px; }
|
||||
.cmp-note { color: var(--muted); font-size: 13px; margin-top: 14px; line-height: 1.5; }
|
||||
|
||||
.arch-diagram {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 28px;
|
||||
font-family: var(--mono); font-size: 13px; color: var(--muted); line-height: 2; overflow-x: auto; white-space: pre;
|
||||
}
|
||||
.arch-diagram .n { color: var(--text); } .arch-diagram .a { color: var(--accent); }
|
||||
|
||||
.how-steps { display: flex; flex-direction: column; gap: 18px; max-width: 640px; }
|
||||
.how-step { display: flex; gap: 16px; color: var(--muted); font-size: 14.5px; line-height: 1.6; }
|
||||
.how-step span {
|
||||
flex-shrink: 0; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.how-step b { color: var(--text); font-weight: 600; }
|
||||
code.inline { font-family: var(--mono); background: var(--card); border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px; font-size: 13px; color: var(--text); }
|
||||
|
||||
.stack { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.stack span {
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 7px 13px; font-size: 13px; color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.cta { text-align: center; padding: 20px 0 90px; }
|
||||
.cta h2 { margin-bottom: 8px; }
|
||||
.cta .section-sub { margin: 0 auto 28px; text-align: center; }
|
||||
.cta-row { display: flex; justify-content: center; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 28px 0; color: var(--muted); font-size: 13px; }
|
||||
footer .wrap { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
|
||||
footer a { text-decoration: underline; text-underline-offset: 2px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero h1 { font-size: 32px; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.install { flex-direction: column; align-items: stretch; }
|
||||
.install code { white-space: normal; word-break: break-all; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
||||
.reveal { opacity: 1 !important; transform: none !important; filter: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 100 100" fill="none"><path d="M20 65 L50 25 L80 65" stroke="#7c6cf0" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
MBS Panel
|
||||
</div>
|
||||
<nav>
|
||||
<a href="#features">Возможности</a>
|
||||
<a href="#comparison">Сравнение</a>
|
||||
<a href="#install">Установка</a>
|
||||
<a href="https://github.com/savsisbtw/mbs-panel" target="_blank">GitHub</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section class="hero" style="padding-bottom:0">
|
||||
<div class="badges reveal">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/savsisbtw/mbs-panel/ci.yml?label=CI&style=flat-square&color=7c6cf0" alt="CI">
|
||||
<img src="https://img.shields.io/github/license/savsisbtw/mbs-panel?style=flat-square&color=7c6cf0" alt="License">
|
||||
<img src="https://img.shields.io/github/stars/savsisbtw/mbs-panel?style=flat-square&color=7c6cf0" alt="Stars">
|
||||
</div>
|
||||
<h1 class="reveal">VPN-панель, которую<br>можно <span class="accent">понять за вечер</span></h1>
|
||||
<p class="reveal">Xray-core, Reality, Hysteria2, Telegram-бот и платежи — в одном небольшом репозитории на Python. Без Docker, без чужой закрытой панели под капотом, без разбора чужого фреймворка перед первым коммитом.</p>
|
||||
<div class="hero-ctas reveal">
|
||||
<a class="btn" href="https://github.com/savsisbtw/mbs-panel" target="_blank">Смотреть на GitHub</a>
|
||||
<a class="btn ghost" href="#install">Установка</a>
|
||||
</div>
|
||||
<div class="install reveal">
|
||||
<code><span class="dim">$</span> bash <(curl -Ls https://mbs.savsis.xyz/install.sh)</code>
|
||||
<button class="copy-btn" onclick="copyInstall(this)">Копировать</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="divider" style="margin-top:56px"></div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="grid" id="features">
|
||||
<div class="feature reveal">
|
||||
<div class="idx">01</div>
|
||||
<h3>Телеграм-бот как основа</h3>
|
||||
<p>Не уведомления сбоку, а весь флоу покупки и управления — тарифы, гифт-коды, авто-отключение по истечении раз в 90 секунд, а не раз в полчаса.</p>
|
||||
</div>
|
||||
<div class="feature reveal">
|
||||
<div class="idx">02</div>
|
||||
<h3>Админка без фреймворков</h3>
|
||||
<p>Чистый HTML/CSS/JS. Дашборд, карточка юзера с историей и устройствами, ноды с полным редактированием, трафик по Stats API самого Xray.</p>
|
||||
</div>
|
||||
<div class="feature reveal">
|
||||
<div class="idx">03</div>
|
||||
<h3>Мультинодовость в одну команду</h3>
|
||||
<p>Добавил ноду в панели — получил одну bash-команду. Вставил на чистый сервер — сам ставит Xray, генерит ключи, регистрируется.</p>
|
||||
</div>
|
||||
<div class="feature reveal">
|
||||
<div class="idx">04</div>
|
||||
<h3>Протоколы на выбор</h3>
|
||||
<p>VLESS Reality (TCP / gRPC / XHTTP), VLESS WS+TLS с реальным Let's Encrypt сертификатом, Hysteria2 отдельным процессом.</p>
|
||||
</div>
|
||||
<div class="feature reveal">
|
||||
<div class="idx">05</div>
|
||||
<h3>HWID-лимит устройств</h3>
|
||||
<p>Ограничение числа устройств на подписку через заголовок клиента — опционально, как у Remnawave, но необязательно.</p>
|
||||
</div>
|
||||
<div class="feature reveal">
|
||||
<div class="idx">06</div>
|
||||
<h3>Платежи из коробки</h3>
|
||||
<p>ЮKassa и Platega — опционально. Без них бот просто выдаёт подписку по кнопке, для своих или для теста.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<section id="comparison">
|
||||
<div class="section-label reveal">Сравнение</div>
|
||||
<h2 class="reveal">Чем MBS Panel отличается</h2>
|
||||
<p class="section-sub reveal">Честно, без "мы лучше всех" — Remnawave и Marzban старше, крупнее и во многом функциональнее. Вот где реальная разница, а не маркетинг.</p>
|
||||
<div class="cmp-wrap reveal">
|
||||
<table class="cmp">
|
||||
<thead><tr><th>Функция</th><th class="us">MBS Panel</th><th>Remnawave</th><th>Marzban</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Telegram-бот — основной UX покупки</td><td class="us yes">✓</td><td class="no">только уведомления</td><td class="no">уведомления + команды</td></tr>
|
||||
<tr><td>HWID-лимит устройств</td><td class="us yes">✓</td><td class="yes">✓</td><td class="no">—</td></tr>
|
||||
<tr><td>База данных</td><td class="us">SQLite</td><td>PostgreSQL</td><td>на выбор</td></tr>
|
||||
<tr><td>Установка</td><td class="us">1 bash-команда</td><td>Docker Compose</td><td>bash-скрипт / Docker</td></tr>
|
||||
<tr><td>Backup & Restore в самой панели</td><td class="us yes">✓</td><td class="no">community tools</td><td class="no">community tools</td></tr>
|
||||
<tr><td>Xray config pre-flight проверка</td><td class="us yes">✓</td><td class="yes">full-featured</td><td class="no">только JSON-синтаксис</td></tr>
|
||||
<tr><td>Мультиадминство (раздельные логины)</td><td class="us yes">✓</td><td class="no">—</td><td class="no">в разработке</td></tr>
|
||||
<tr><td>2FA на вход в админку</td><td class="us yes">✓ TOTP</td><td>есть (passkeys/OAuth)</td><td class="no">—</td></tr>
|
||||
<tr><td>Rate-limit на вход/2FA-код</td><td class="us yes">✓</td><td class="no">не документировано</td><td class="no">не документировано</td></tr>
|
||||
<tr><td>Свой путь входа в админку</td><td class="us yes">✓</td><td class="yes">заявлено</td><td class="no">—</td></tr>
|
||||
<tr><td>Цепочки серверов (клиент → A → B → интернет)</td><td class="us yes">✓ мышкой, с замером задержки</td><td>руками в конфиге Xray</td><td>руками в конфиге Xray</td></tr>
|
||||
<tr><td>Сортировка нод мышкой</td><td class="us yes">✓</td><td class="yes">Web UI</td><td class="no">только через конфиг Xray</td></tr>
|
||||
<tr><td>Пауза подписки без потери оплаченных дней</td><td class="us yes">✓</td><td class="no">—</td><td class="yes">есть</td></tr>
|
||||
<tr><td>Исходящие вебхуки (пользователи + ноды)</td><td class="us yes">✓</td><td class="yes">✓</td><td class="no">только пользователи</td></tr>
|
||||
<tr><td>Лицензия</td><td class="us">MIT</td><td>AGPL-3.0</td><td>AGPL-3.0</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="cmp-note reveal">Данные по Remnawave/Marzban — из их собственной документации на момент публикации (<a href="https://docs.rw/overview/comparison-of-functions" target="_blank" style="text-decoration:underline">docs.rw</a>), проверяй актуальность сам, проекты активно развиваются.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="wrap">
|
||||
<section>
|
||||
<div class="section-label reveal">Как это работает</div>
|
||||
<h2 class="reveal">Что происходит под капотом</h2>
|
||||
<p class="section-sub reveal">Панель и первая VPN-нода живут на одном сервере. Дополнительные ноды подключаются по SSH — без агента, без открытых портов управления наружу.</p>
|
||||
<div class="arch-diagram reveal">Telegram / браузер
|
||||
│
|
||||
▼
|
||||
<span class="n">bot.py</span> (aiogram) ──┐
|
||||
<span class="n">api.py</span> (FastAPI) ──┼──▶ <span class="n">SQLite</span>
|
||||
│ │
|
||||
▼ ▼
|
||||
локальный <span class="a">Xray</span> SSH ──▶ <span class="a">Xray</span> на удалённой ноде
|
||||
(management-ключ, генерится сам)</div>
|
||||
<div class="how-steps reveal">
|
||||
<div class="how-step"><span>1</span>Устанавливаешь на чистый сервер — скрипт сам ставит Xray, nginx, certbot, генерит Reality-ключи, поднимает бота и API systemd-юнитами.</div>
|
||||
<div class="how-step"><span>2</span>Бот — точка входа для юзеров: тарифы, гифт-коды, оплата (если включена). Подписка выдаётся сразу и добавляется в клиент по одной ссылке.</div>
|
||||
<div class="how-step"><span>3</span>Админка — точка входа для тебя: ноды, юзеры, устройства, трафик по Stats API самого Xray. Всё через браузер, ничего руками на сервере.</div>
|
||||
<div class="how-step"><span>4</span>Новую ноду добавляешь в панели — получаешь одну bash-команду. Вставляешь на чистый сервер, она сама ставит Xray и регистрируется — SSH-ключ панель добавляет туда сама, пароль не спрашивает ни разу.</div>
|
||||
</div>
|
||||
<div class="stack reveal" style="margin-top:28px">
|
||||
<span>Python 3.10+</span><span>FastAPI</span><span>aiogram 3</span><span>SQLite (WAL)</span><span>paramiko</span><span>Xray-core</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="wrap">
|
||||
<section id="install">
|
||||
<div class="section-label reveal">Как скачать и установить</div>
|
||||
<h2 class="reveal">Чистый сервер → готовая панель за одну команду</h2>
|
||||
<p class="section-sub reveal">Код открыт, MIT — клонируешь и ставишь на свой сервер, ничего не покупаешь и никуда не регистрируешься.</p>
|
||||
<div class="how-steps reveal">
|
||||
<div class="how-step"><span>1</span>Подними чистый сервер — <b>Ubuntu 22.04/24.04</b> или <b>Debian 11/12</b>, root-доступ — и три DNS A-записи на его IP: под панель, под подписку и под первую VPN-ноду.</div>
|
||||
<div class="how-step"><span>2</span>Запусти установочный скрипт от root — одна команда ниже, сама подтянет исходники.</div>
|
||||
<div class="how-step"><span>3</span>Скрипт спросит домены и пароль админки, сам поставит Xray/nginx/certbot, сгенерит ключи и выпустит сертификаты.</div>
|
||||
<div class="how-step"><span>4</span>Готово: бот отвечает в Telegram, сайт с подпиской и админка — на своих доменах. Обновления потом — командой <code class="inline">mbs update</code>.</div>
|
||||
</div>
|
||||
<div class="install reveal" style="max-width:none;margin-top:8px">
|
||||
<code><span class="dim">$</span> bash <(curl -Ls https://mbs.savsis.xyz/install.sh)</code>
|
||||
<button class="copy-btn" onclick="copyInstall(this)">Копировать</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="cta reveal">
|
||||
<h2>Открытый исходник, MIT</h2>
|
||||
<p class="section-sub">Изначально писалось под конкретный проект — получилось достаточно универсально, чтобы выложить как есть. Issues и PR приветствуются.</p>
|
||||
<div class="cta-row">
|
||||
<a class="btn" href="https://github.com/savsisbtw/mbs-panel" target="_blank">github.com/savsisbtw/mbs-panel</a>
|
||||
<a class="btn ghost" href="https://github.com/savsisbtw/mbs-panel#readme" target="_blank">Читать README</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<div>MBS Panel — made by <a href="https://github.com/savsisbtw" target="_blank">savsis</a></div>
|
||||
<div><a href="https://github.com/savsisbtw/mbs-panel/blob/main/LICENSE" target="_blank">MIT License</a></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
entries.forEach((e, i) => {
|
||||
if (e.isIntersecting) {
|
||||
e.target.style.transitionDelay = (i % 4) * 0.07 + "s";
|
||||
e.target.classList.add("in");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.12 });
|
||||
document.querySelectorAll(".reveal").forEach((el) => io.observe(el));
|
||||
|
||||
function copyInstall(btn) {
|
||||
const text = "bash <(curl -Ls https://mbs.savsis.xyz/install.sh)";
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = "Скопировано";
|
||||
btn.classList.add("copied");
|
||||
setTimeout(() => { btn.textContent = orig; btn.classList.remove("copied"); }, 1600);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
108
legal.py
Normal file
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
|
||||
23
links.py
23
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
|
||||
|
||||
|
|
@ -98,9 +109,14 @@ def build_subscription_text(subs: list[dict]) -> str:
|
|||
if cur is None or s["expires_at"] > cur["expires_at"]:
|
||||
best_by_node[s["node"]] = s
|
||||
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
chains_by_entry = {}
|
||||
for chain in db.list_chains(enabled_only=True):
|
||||
chains_by_entry.setdefault(chain["entry_node"], []).append(chain)
|
||||
|
||||
lines = []
|
||||
for node_code, s in best_by_node.items():
|
||||
node = db.get_node(node_code)
|
||||
node = nodes_by_code.get(node_code)
|
||||
if not node:
|
||||
continue
|
||||
base_name = display_name(node["label"])
|
||||
|
|
@ -108,5 +124,10 @@ def build_subscription_text(subs: list[dict]) -> str:
|
|||
hy = hysteria_uri_for_node(node, base_name)
|
||||
if hy:
|
||||
lines.append(hy)
|
||||
for chain in chains_by_entry.get(node_code, []):
|
||||
exit_node = nodes_by_code.get(chain["exit_node"])
|
||||
if not node["enabled"] or not exit_node or not exit_node["enabled"]:
|
||||
continue
|
||||
lines.append(chain_uri(s["uuid"], node, chain, chain_remark(node, exit_node)))
|
||||
raw = "\n".join(lines)
|
||||
return base64.b64encode(raw.encode()).decode()
|
||||
|
|
|
|||
262
mbs
262
mbs
|
|
@ -10,9 +10,14 @@ mbs — управление MBS Panel
|
|||
|
||||
mbs pass [новый_пароль] сменить пароль админ-панели (без аргумента — сгенерировать случайный)
|
||||
mbs status статус всех сервисов (bot, api, xray, nginx)
|
||||
mbs restart перезапустить bot + api
|
||||
mbs restart перезапустить всё (bot, api, xray, reload nginx)
|
||||
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
|
||||
mbs domain показать текущий домен панели
|
||||
mbs backup полная копия панели (база, .env, ручные правки, конфиг Xray) в /root/mbs-backups
|
||||
mbs update [ссылка] обновить код и перезапустить (не трогает .env и базу, перед этим сам делает копию). Без ссылки: своё зеркало -> lab.savsis.xyz -> api.savsis.xyz -> GitHub.
|
||||
Со ссылкой на git-репозиторий (зеркало) — берёт обновление оттуда, один раз
|
||||
mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, которое mbs update проверяет первым
|
||||
mbs autoupdate [on|off] включить / выключить ежедневное автообновление (04:00, перед ним всегда копия), без аргумента — показать состояние
|
||||
EOF
|
||||
}
|
||||
|
||||
|
|
@ -35,8 +40,9 @@ cmd_status() {
|
|||
}
|
||||
|
||||
cmd_restart() {
|
||||
systemctl restart mbs-bot mbs-api
|
||||
echo "Перезапущено."
|
||||
systemctl restart mbs-bot mbs-api xray
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
echo "Перезапущено: bot, api, xray (+ reload nginx)."
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
|
|
@ -53,11 +59,261 @@ cmd_domain() {
|
|||
grep "^PANEL_DOMAIN=" "$ENV_FILE"
|
||||
}
|
||||
|
||||
MIRROR_FILE="$APP_DIR/.update_mirror"
|
||||
BACKUP_DIR="${MBS_BACKUP_DIR:-/root/mbs-backups}"
|
||||
XRAY_CONFIG="/usr/local/etc/xray/config.json"
|
||||
BACKUP_FILE=""
|
||||
UPDATE_STASHED=0
|
||||
|
||||
snapshot_backup() {
|
||||
local stamp dbsnap file
|
||||
local -a targs=(--exclude=venv --exclude=__pycache__)
|
||||
stamp=$(date +%Y%m%d-%H%M%S)
|
||||
file="$BACKUP_DIR/mbs-$1-$stamp.tar.gz"
|
||||
dbsnap="$APP_DIR/.mbs.db.snapshot"
|
||||
mkdir -p "$BACKUP_DIR" || return 1
|
||||
chmod 700 "$BACKUP_DIR"
|
||||
rm -f "$dbsnap"
|
||||
if [ -f "$APP_DIR/mbs.db" ] && [ -x "$APP_DIR/venv/bin/python" ] && \
|
||||
"$APP_DIR/venv/bin/python" -c "import sqlite3,sys; s=sqlite3.connect(sys.argv[1]); d=sqlite3.connect(sys.argv[2]); s.backup(d); d.close(); s.close()" "$APP_DIR/mbs.db" "$dbsnap" 2>/dev/null; then
|
||||
targs+=(--exclude=mbs.db --exclude=mbs.db-wal --exclude=mbs.db-shm "--transform=s#\.mbs\.db\.snapshot#mbs.db#")
|
||||
fi
|
||||
if [ -f "$XRAY_CONFIG" ]; then
|
||||
tar czf "$file" "${targs[@]}" "$APP_DIR" "$XRAY_CONFIG" 2>/dev/null
|
||||
else
|
||||
tar czf "$file" "${targs[@]}" "$APP_DIR" 2>/dev/null
|
||||
fi
|
||||
local rc=$?
|
||||
rm -f "$dbsnap"
|
||||
if [ $rc -ne 0 ] || [ ! -s "$file" ]; then
|
||||
rm -f "$file"
|
||||
return 1
|
||||
fi
|
||||
chmod 600 "$file"
|
||||
ls -1t "$BACKUP_DIR"/mbs-*.tar.gz 2>/dev/null | tail -n +6 | while read -r old; do rm -f "$old"; done
|
||||
BACKUP_FILE="$file"
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_backup() {
|
||||
if snapshot_backup manual; then
|
||||
echo "копия сохранена: $BACKUP_FILE"
|
||||
echo "внутри: база (консистентный снапшот), .env, код с твоими правками, конфиг Xray. Хранится 5 последних."
|
||||
else
|
||||
echo "не удалось сделать копию в $BACKUP_DIR (место на диске?)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
valid_url() {
|
||||
case "$1" in
|
||||
https://*|http://*|ssh://*|git@*:*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
case "$1" in
|
||||
*[[:space:]]*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
fetch_url() {
|
||||
git -c protocol.ext.allow=never fetch --quiet -- "$1" main 2>/dev/null
|
||||
}
|
||||
|
||||
restore_stash() {
|
||||
if [ "$UPDATE_STASHED" = "1" ]; then
|
||||
UPDATE_STASHED=0
|
||||
if git stash pop --quiet 2>/dev/null; then
|
||||
echo "ручные правки вернул на место"
|
||||
else
|
||||
echo "ручные правки остались в git stash (git stash list)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_mirror() {
|
||||
local url="$1"
|
||||
case "$url" in
|
||||
"")
|
||||
if [ -f "$MIRROR_FILE" ]; then
|
||||
echo "своё зеркало для обновлений: $(head -n 1 "$MIRROR_FILE")"
|
||||
else
|
||||
echo "своё зеркало не задано — mbs update берёт api.savsis.xyz, потом GitHub"
|
||||
fi
|
||||
;;
|
||||
off|clear)
|
||||
rm -f "$MIRROR_FILE"
|
||||
echo "своё зеркало убрано"
|
||||
;;
|
||||
*)
|
||||
if ! valid_url "$url"; then
|
||||
echo "это не похоже на ссылку на git-репозиторий (нужна https://..., ssh://... или git@хост:путь)"
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$url" > "$MIRROR_FILE"
|
||||
echo "зеркало запомнил: $url — mbs update теперь проверяет его первым"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_update() {
|
||||
local arg_url="$1" target="" saved="" before="" stamp="" patch=""
|
||||
cd "$APP_DIR"
|
||||
echo "проверяю обновления..."
|
||||
if [ -n "$arg_url" ]; then
|
||||
if ! valid_url "$arg_url"; then
|
||||
echo "это не похоже на ссылку на git-репозиторий (нужна https://..., ssh://... или git@хост:путь)"
|
||||
return 1
|
||||
fi
|
||||
if ! fetch_url "$arg_url"; then
|
||||
echo "не удалось получить обновления по ссылке: $arg_url"
|
||||
return 1
|
||||
fi
|
||||
target=$(git rev-parse FETCH_HEAD)
|
||||
echo "источник: $arg_url"
|
||||
else
|
||||
if [ -f "$MIRROR_FILE" ]; then
|
||||
saved=$(head -n 1 "$MIRROR_FILE" | tr -d '[:space:]')
|
||||
fi
|
||||
if [ -n "$saved" ] && valid_url "$saved" && fetch_url "$saved"; then
|
||||
target=$(git rev-parse FETCH_HEAD)
|
||||
echo "источник: своё зеркало $saved"
|
||||
elif git fetch --quiet origin main 2>/dev/null; then
|
||||
target=$(git rev-parse origin/main)
|
||||
elif git remote | grep -q '^mirror$' && git fetch --quiet mirror main 2>/dev/null; then
|
||||
echo "lab.savsis.xyz недоступен, взял с зеркала..."
|
||||
target=$(git rev-parse mirror/main)
|
||||
elif git remote | grep -q '^github$' && git fetch --quiet github main 2>/dev/null; then
|
||||
echo "зеркало недоступно, взял с github..."
|
||||
target=$(git rev-parse github/main)
|
||||
else
|
||||
echo "не удалось получить обновления ни с зеркала, ни с github"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
before=$(git rev-parse HEAD)
|
||||
if [ "$before" = "$target" ]; then
|
||||
echo "уже последняя версия ($before)."
|
||||
return 0
|
||||
fi
|
||||
if git merge-base --is-ancestor "$target" "$before" 2>/dev/null; then
|
||||
echo "на сервере версия новее, чем в источнике ($before) — ничего не делаю."
|
||||
return 0
|
||||
fi
|
||||
echo "текущая: $before"
|
||||
echo "новая: $target"
|
||||
|
||||
if ! snapshot_backup before-update; then
|
||||
echo "не вышло сделать резервную копию в $BACKUP_DIR — обновление не начинаю, чтобы ничего не потерять (место на диске?)"
|
||||
return 1
|
||||
fi
|
||||
echo "резервная копия перед обновлением: $BACKUP_FILE"
|
||||
|
||||
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||
stamp=$(date +%Y%m%d-%H%M%S)
|
||||
mkdir -p "$APP_DIR/local-changes"
|
||||
patch="$APP_DIR/local-changes/local-changes-$stamp.patch"
|
||||
git diff HEAD > "$patch"
|
||||
if GIT_AUTHOR_NAME=mbs GIT_AUTHOR_EMAIL=mbs@localhost GIT_COMMITTER_NAME=mbs GIT_COMMITTER_EMAIL=mbs@localhost git stash push --quiet -m "mbs-update-$stamp"; then
|
||||
UPDATE_STASHED=1
|
||||
echo "на сервере были ручные правки — убрал в сторону, ничего не потеряно:"
|
||||
echo " патч: $patch"
|
||||
echo " stash: git stash list (вернуть обратно: git stash pop)"
|
||||
else
|
||||
echo "не вышло спрятать ручные правки, остановился — разберись руками: git status"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! git merge --ff-only "$target" --quiet 2>/dev/null; then
|
||||
echo "не вышло быстро обновиться (на сервере есть свои коммиты, которых нет в источнике) — разберись руками: git log"
|
||||
restore_stash
|
||||
return 1
|
||||
fi
|
||||
echo "обновляю зависимости..."
|
||||
venv/bin/pip install --quiet -r requirements.txt
|
||||
echo "проверяю код..."
|
||||
if ! venv/bin/python -m py_compile *.py; then
|
||||
echo "новый код не проходит проверку, откатываюсь на $before..."
|
||||
git reset --hard "$before" --quiet
|
||||
venv/bin/pip install --quiet -r requirements.txt
|
||||
restore_stash
|
||||
return 1
|
||||
fi
|
||||
echo "обновляю сам CLI..."
|
||||
cp "$APP_DIR/mbs" /usr/local/bin/mbs
|
||||
chmod +x /usr/local/bin/mbs
|
||||
|
||||
echo "обновляю systemd-юниты..."
|
||||
local cpu_count api_workers
|
||||
cpu_count=$(nproc 2>/dev/null || echo 1)
|
||||
if [ "$cpu_count" -lt 2 ]; then api_workers=1
|
||||
elif [ "$cpu_count" -gt 4 ]; then api_workers=4
|
||||
else api_workers=$cpu_count
|
||||
fi
|
||||
cp "$APP_DIR/systemd/mbs-bot.service" /etc/systemd/system/mbs-bot.service
|
||||
sed "s/__WORKERS__/$api_workers/" "$APP_DIR/systemd/mbs-api.service" > /etc/systemd/system/mbs-api.service
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "перезапускаю..."
|
||||
systemctl restart mbs-bot mbs-api xray
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
sleep 2
|
||||
if systemctl is-active --quiet mbs-bot && systemctl is-active --quiet mbs-api; then
|
||||
echo "обновлено: $before -> $(git rev-parse --short HEAD)"
|
||||
if [ "$UPDATE_STASHED" = "1" ]; then
|
||||
echo "твои ручные правки лежат в git stash и в $patch — если они нужны, посмотри git stash show -p"
|
||||
fi
|
||||
echo "если что-то пошло не так: копия $BACKUP_FILE (распаковать: tar xzf файл -C /)"
|
||||
if [ -n "$arg_url" ]; then
|
||||
echo "чтобы всегда обновляться с этого зеркала: mbs mirror $arg_url"
|
||||
fi
|
||||
else
|
||||
echo "сервисы не поднялись после обновления, откатываюсь на $before..."
|
||||
git reset --hard "$before" --quiet
|
||||
venv/bin/pip install --quiet -r requirements.txt
|
||||
systemctl restart mbs-bot mbs-api
|
||||
restore_stash
|
||||
echo "откачено обратно на $before"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_autoupdate() {
|
||||
case "$1" in
|
||||
on)
|
||||
systemctl enable --now mbs-autoupdate.timer
|
||||
echo "автообновление включено: каждый день около 04:00, перед обновлением делается копия"
|
||||
;;
|
||||
off)
|
||||
systemctl disable --now mbs-autoupdate.timer
|
||||
echo "автообновление выключено"
|
||||
;;
|
||||
"")
|
||||
if systemctl is-enabled --quiet mbs-autoupdate.timer 2>/dev/null; then
|
||||
echo "автообновление включено"
|
||||
systemctl list-timers mbs-autoupdate.timer --no-pager 2>/dev/null | head -n 2
|
||||
else
|
||||
echo "автообновление выключено (mbs autoupdate on — включить)"
|
||||
fi
|
||||
;;
|
||||
*) echo "mbs autoupdate [on|off]"; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main() {
|
||||
case "$1" in
|
||||
pass) cmd_pass "$2" ;;
|
||||
status) cmd_status ;;
|
||||
restart) cmd_restart ;;
|
||||
logs) cmd_logs "$2" ;;
|
||||
domain) cmd_domain ;;
|
||||
update) cmd_update "$2" ;;
|
||||
mirror) cmd_mirror "$2" ;;
|
||||
backup) cmd_backup ;;
|
||||
autoupdate) cmd_autoupdate "$2" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"; exit $?
|
||||
|
|
|
|||
213
nodeprov.py
213
nodeprov.py
|
|
@ -1,3 +1,6 @@
|
|||
import contextlib
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
|
|
@ -5,11 +8,12 @@ import subprocess
|
|||
|
||||
import paramiko
|
||||
|
||||
import chains
|
||||
from config import PANEL_DOMAIN
|
||||
|
||||
MGMT_KEY_PATH = "/root/.ssh/mbs_nodes_ed25519"
|
||||
LOCAL_TAGS = {"vless-tcp-reality", "vless-grpc-reality", "vless-xhttp-reality", "vless-ws-tls"}
|
||||
TAG_FLOW = {"vless-tcp-reality": "xtls-rprx-vision"}
|
||||
MGMT_KNOWN_HOSTS_PATH = "/root/.ssh/mbs_nodes_known_hosts"
|
||||
REMOTE_CONFIG_PATH = "/usr/local/etc/xray/config.json"
|
||||
|
||||
ONE_COMMAND_TEMPLATE = "bash <(curl -Ls https://{panel}/install/{token}.sh)"
|
||||
|
||||
|
|
@ -17,9 +21,18 @@ CERTBOT_SNIPPET = """echo "issuing a real TLS cert for {address} (needed for WS+
|
|||
command -v certbot >/dev/null 2>&1 || apt-get install -y certbot
|
||||
ss -ltnp | grep -q ':80 ' && {{ echo "something is already on port 80, stop it first"; exit 1; }}
|
||||
certbot certonly --standalone --non-interactive --agree-tos --register-unsafely-without-email -d {address}
|
||||
mkdir -p /etc/xray/certs
|
||||
cp /etc/letsencrypt/live/{address}/fullchain.pem /etc/xray/certs/node.crt
|
||||
cp /etc/letsencrypt/live/{address}/privkey.pem /etc/xray/certs/node.key
|
||||
chmod 644 /etc/xray/certs/node.crt /etc/xray/certs/node.key
|
||||
chown nobody:nogroup /etc/xray/certs/node.crt /etc/xray/certs/node.key
|
||||
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
|
||||
cat > /etc/letsencrypt/renewal-hooks/deploy/mbs-restart-xray.sh << 'HOOK'
|
||||
cat > /etc/letsencrypt/renewal-hooks/deploy/mbs-restart-xray.sh << HOOK
|
||||
#!/bin/bash
|
||||
cp /etc/letsencrypt/live/{address}/fullchain.pem /etc/xray/certs/node.crt
|
||||
cp /etc/letsencrypt/live/{address}/privkey.pem /etc/xray/certs/node.key
|
||||
chmod 644 /etc/xray/certs/node.crt /etc/xray/certs/node.key
|
||||
chown nobody:nogroup /etc/xray/certs/node.crt /etc/xray/certs/node.key
|
||||
systemctl restart xray || true
|
||||
HOOK
|
||||
chmod +x /etc/letsencrypt/renewal-hooks/deploy/mbs-restart-xray.sh
|
||||
|
|
@ -58,6 +71,28 @@ set -e
|
|||
echo "== MBS Panel node install =="
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
PREFLIGHT_FAIL=0
|
||||
if ! {{ [ -f /usr/local/etc/xray/config.json ] && grep -q mbs-grpc /usr/local/etc/xray/config.json; }}; then
|
||||
if [ -d /usr/local/bin/xray ]; then
|
||||
echo "СТОП: /usr/local/bin/xray это каталог, на сервере уже стоит чужой прокси (Marzban-node и подобное)"
|
||||
PREFLIGHT_FAIL=1
|
||||
fi
|
||||
if command -v docker >/dev/null 2>&1 && docker ps --format '{{{{.Names}}}}' 2>/dev/null | grep -qiE 'marzban|xray|remnawave|v2ray|hysteria'; then
|
||||
echo "СТОП: в Docker на этом сервере уже крутится прокси"
|
||||
PREFLIGHT_FAIL=1
|
||||
fi
|
||||
for p in {ports}; do
|
||||
if ss -ltn 2>/dev/null | awk '{{print $4}}' | grep -qE "[:.]$p$"; then
|
||||
echo "СТОП: порт $p уже занят другим процессом"
|
||||
PREFLIGHT_FAIL=1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "$PREFLIGHT_FAIL" = "1" ]; then
|
||||
echo "Нужен чистый сервер. Ничего не установлено и не изменено, ключ панели не добавлен."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p /root/.ssh
|
||||
chmod 700 /root/.ssh
|
||||
curl -Ls https://{panel}/mgmt-pubkey.txt >> /root/.ssh/authorized_keys
|
||||
|
|
@ -78,9 +113,19 @@ XRAYCFG
|
|||
command -v ufw >/dev/null 2>&1 && {{ ufw allow 22/tcp || true; {ufw_rules} }}
|
||||
systemctl enable xray >/dev/null 2>&1 || true
|
||||
systemctl restart xray
|
||||
sleep 1
|
||||
STATUS=$(systemctl is-active xray)
|
||||
OK=0
|
||||
for i in 1 2 3 4 5 6 7 8; do
|
||||
sleep 1
|
||||
if systemctl is-active --quiet xray && ss -ltn 2>/dev/null | awk '{{print $4}}' | grep -qE "[:.]{first_port}$"; then
|
||||
OK=$((OK+1))
|
||||
else
|
||||
OK=0
|
||||
fi
|
||||
if [ "$OK" -ge 3 ]; then break; fi
|
||||
done
|
||||
if [ "$OK" -ge 3 ]; then STATUS=active; else STATUS=failed; fi
|
||||
echo "xray status: $STATUS"
|
||||
if [ "$STATUS" != "active" ]; then journalctl -u xray -n 15 --no-pager 2>/dev/null || true; fi
|
||||
|
||||
{hysteria_block}
|
||||
MY_IP=$(curl -s https://api.ipify.org || echo unknown)
|
||||
|
|
@ -185,8 +230,8 @@ def _build_config_json(transports, private_key, address):
|
|||
elif t["security"] == "tls":
|
||||
ib["streamSettings"] = {"network": "ws", "security": "tls", "wsSettings": {"path": t["path"]},
|
||||
"tlsSettings": {"certificates": [{
|
||||
"certificateFile": f"/etc/letsencrypt/live/{address}/fullchain.pem",
|
||||
"keyFile": f"/etc/letsencrypt/live/{address}/privkey.pem",
|
||||
"certificateFile": "/etc/xray/certs/node.crt",
|
||||
"keyFile": "/etc/xray/certs/node.key",
|
||||
}]}}
|
||||
inbounds.append(ib)
|
||||
|
||||
|
|
@ -221,45 +266,108 @@ def render_install_script(node: dict) -> str:
|
|||
sni=node["sni"],
|
||||
)
|
||||
|
||||
ports = " ".join(str(t["port"]) for t in transports)
|
||||
return SELF_INSTALL_SCRIPT.format(
|
||||
panel=PANEL_DOMAIN, token=node["provision_token"], config_json=config_json,
|
||||
certbot_block=certbot_block, hysteria_block=hysteria_block, ufw_rules=ufw_rules,
|
||||
ports=ports, first_port=transports[0]["port"],
|
||||
)
|
||||
|
||||
|
||||
def _mgmt_connect(address: str, ssh_port: int = 22) -> paramiko.SSHClient:
|
||||
client = paramiko.SSHClient()
|
||||
try:
|
||||
client.load_host_keys(MGMT_KNOWN_HOSTS_PATH)
|
||||
except IOError:
|
||||
pass
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
key = paramiko.Ed25519Key.from_private_key_file(MGMT_KEY_PATH)
|
||||
client.connect(address, port=ssh_port, username="root", pkey=key, timeout=15, banner_timeout=15, auth_timeout=15)
|
||||
client.save_host_keys(MGMT_KNOWN_HOSTS_PATH)
|
||||
return client
|
||||
|
||||
|
||||
def _remote_edit_clients(node: dict, mutate_fn):
|
||||
class RemoteConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _busy_ports(client) -> set:
|
||||
_, stdout, _ = client.exec_command("ss -ltnH 2>/dev/null | awk '{print $4}'", timeout=10)
|
||||
busy = set()
|
||||
for line in stdout.read().decode(errors="replace").splitlines():
|
||||
tail = line.rsplit(":", 1)[-1]
|
||||
if tail.isdigit():
|
||||
busy.add(int(tail))
|
||||
return busy
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _node_lock(address: str):
|
||||
key = hashlib.sha1(address.encode()).hexdigest()[:12]
|
||||
with open(f"/tmp/mbs-node-{key}.lock", "w") as lock_file:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _remote_edit_config(node: dict, mutate_fn):
|
||||
with _node_lock(node["address"]):
|
||||
return _remote_edit_config_locked(node, mutate_fn)
|
||||
|
||||
|
||||
def _remote_edit_config_locked(node: dict, mutate_fn):
|
||||
client = _mgmt_connect(node["address"])
|
||||
try:
|
||||
sftp = client.open_sftp()
|
||||
with sftp.open("/usr/local/etc/xray/config.json") as f:
|
||||
with sftp.open(REMOTE_CONFIG_PATH) as f:
|
||||
cfg = json.loads(f.read().decode())
|
||||
result = mutate_fn(cfg, client)
|
||||
if not result["changed"]:
|
||||
sftp.close()
|
||||
return result
|
||||
data = json.dumps(cfg, indent=2).encode()
|
||||
tmp_path = REMOTE_CONFIG_PATH + ".validate.tmp"
|
||||
prev_path = REMOTE_CONFIG_PATH + ".mbs-prev"
|
||||
with sftp.open(tmp_path, "wb") as f:
|
||||
f.write(data)
|
||||
_, stdout, stderr = client.exec_command(f"/usr/local/bin/xray run -test -format=json -config {tmp_path}", timeout=15)
|
||||
test_exit = stdout.channel.recv_exit_status()
|
||||
test_out = (stdout.read().decode(errors="replace") + stderr.read().decode(errors="replace")).strip()
|
||||
if test_exit != 0:
|
||||
client.exec_command(f"rm -f {tmp_path}")
|
||||
sftp.close()
|
||||
raise RemoteConfigError(f"config test failed on {node['address']}: {test_out}")
|
||||
client.exec_command(f"cp -p {REMOTE_CONFIG_PATH} {prev_path}")[1].channel.recv_exit_status()
|
||||
client.exec_command(f"mv {tmp_path} {REMOTE_CONFIG_PATH}")[1].channel.recv_exit_status()
|
||||
sftp.close()
|
||||
_, stdout, stderr = client.exec_command("systemctl restart xray && sleep 1 && systemctl is-active xray", timeout=30)
|
||||
restart_exit = stdout.channel.recv_exit_status()
|
||||
if restart_exit != 0:
|
||||
err = stderr.read().decode(errors="replace").strip()
|
||||
client.exec_command(f"cp -p {prev_path} {REMOTE_CONFIG_PATH} && systemctl restart xray")[1].channel.recv_exit_status()
|
||||
raise RemoteConfigError(f"xray не поднялся на {node['address']}, конфиг откатили назад: {err}")
|
||||
for port in result.get("new_ports") or []:
|
||||
client.exec_command(f"command -v ufw >/dev/null 2>&1 && ufw allow {int(port)}/tcp || true")[1].channel.recv_exit_status()
|
||||
return result
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _remote_edit_clients(node: dict, mutate_fn):
|
||||
def mutate(cfg, client):
|
||||
changed = False
|
||||
for ib in cfg["inbounds"]:
|
||||
if ib.get("tag") not in LOCAL_TAGS:
|
||||
tag = ib.get("tag")
|
||||
if not chains.is_user_tag(tag):
|
||||
continue
|
||||
clients = ib["settings"]["clients"]
|
||||
new_clients = mutate_fn(clients, ib["tag"])
|
||||
new_clients = mutate_fn(ib["settings"]["clients"], tag)
|
||||
if new_clients is not None:
|
||||
ib["settings"]["clients"] = new_clients
|
||||
changed = True
|
||||
if changed:
|
||||
data = json.dumps(cfg, indent=2).encode()
|
||||
with sftp.open("/usr/local/etc/xray/config.json", "wb") as f:
|
||||
f.write(data)
|
||||
sftp.close()
|
||||
client.exec_command("systemctl restart xray")[1].channel.recv_exit_status()
|
||||
else:
|
||||
sftp.close()
|
||||
finally:
|
||||
client.close()
|
||||
return {"changed": changed}
|
||||
_remote_edit_config(node, mutate)
|
||||
|
||||
|
||||
def remote_add_client(node: dict, client_uuid: str, email: str):
|
||||
|
|
@ -267,7 +375,7 @@ def remote_add_client(node: dict, client_uuid: str, email: str):
|
|||
if any(c["id"] == client_uuid for c in clients):
|
||||
return None
|
||||
entry = {"id": client_uuid, "email": email}
|
||||
flow = TAG_FLOW.get(tag)
|
||||
flow = chains.flow_for_tag(tag)
|
||||
if flow:
|
||||
entry["flow"] = flow
|
||||
clients.append(entry)
|
||||
|
|
@ -282,24 +390,49 @@ def remote_remove_client(node: dict, client_uuid: str):
|
|||
_remote_edit_clients(node, mutate)
|
||||
|
||||
|
||||
def remote_sync(node: dict, active_subs: list[dict]):
|
||||
active_by_id = {s["uuid"]: s for s in active_subs}
|
||||
def remote_reconcile(node: dict, wanted: dict, relay_wanted: dict, entry_chains: list, exit_nodes: dict, apply_chains: bool = True):
|
||||
def mutate(cfg, client):
|
||||
usable = entry_chains
|
||||
skipped = []
|
||||
if apply_chains:
|
||||
usable, skipped = chains.split_busy_chains(cfg, entry_chains, _busy_ports(client))
|
||||
new_ports = chains.new_ports_needed(cfg, usable) if apply_chains else []
|
||||
changed, problems = chains.sync_config(cfg, wanted, relay_wanted, usable, exit_nodes, apply_chains=apply_chains)
|
||||
return {"changed": changed, "new_ports": new_ports, "problems": skipped + problems}
|
||||
return _remote_edit_config(node, mutate)
|
||||
|
||||
def mutate(clients, tag):
|
||||
current_ids = {c["id"] for c in clients}
|
||||
if current_ids == set(active_by_id.keys()):
|
||||
return None
|
||||
new_clients = [c for c in clients if c["id"] in active_by_id]
|
||||
existing_ids = {c["id"] for c in new_clients}
|
||||
flow = TAG_FLOW.get(tag)
|
||||
for cid in active_by_id:
|
||||
if cid not in existing_ids:
|
||||
entry = {"id": cid, "email": cid}
|
||||
if flow:
|
||||
entry["flow"] = flow
|
||||
new_clients.append(entry)
|
||||
return new_clients
|
||||
_remote_edit_clients(node, mutate)
|
||||
|
||||
PROBE_SCRIPT = """for i in 1 2 3; do
|
||||
s=$(date +%s%N)
|
||||
if timeout 3 bash -c 'exec 3<>/dev/tcp/{host}/{port}' 2>/dev/null; then
|
||||
e=$(date +%s%N)
|
||||
echo $(( (e - s) / 1000000 ))
|
||||
else
|
||||
echo -1
|
||||
fi
|
||||
done
|
||||
"""
|
||||
|
||||
|
||||
def remote_probe(node: dict, host: str, port: int) -> list:
|
||||
if not chains.HOST_RE.match(host or ""):
|
||||
raise ValueError("bad host")
|
||||
port = int(port)
|
||||
client = _mgmt_connect(node["address"])
|
||||
try:
|
||||
stdin, stdout, _ = client.exec_command("bash -s", timeout=30)
|
||||
stdin.write(PROBE_SCRIPT.format(host=host, port=port))
|
||||
stdin.channel.shutdown_write()
|
||||
out = stdout.read().decode(errors="replace")
|
||||
finally:
|
||||
client.close()
|
||||
samples = []
|
||||
for line in out.split():
|
||||
try:
|
||||
samples.append(int(line))
|
||||
except ValueError:
|
||||
continue
|
||||
return samples
|
||||
|
||||
|
||||
def remote_query_stats(node: dict) -> dict:
|
||||
|
|
|
|||
65
payments.py
65
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
|
||||
|
||||
|
|
@ -34,8 +32,15 @@ def _post_json(url: str, body: dict, headers: dict, timeout: int = 15) -> dict:
|
|||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def _get_json(url: str, headers: dict, timeout: int = 15) -> dict:
|
||||
req = urllib.request.Request(url, method="GET", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def create_yookassa_payment(payment_id: str, amount_rub: int, description: str) -> str:
|
||||
auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode()
|
||||
shop_id, secret_key = settings.yookassa_credentials()
|
||||
auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode()
|
||||
data = _post_json(
|
||||
"https://api.yookassa.ru/v3/payments",
|
||||
{
|
||||
|
|
@ -56,11 +61,27 @@ def create_yookassa_payment(payment_id: str, amount_rub: int, description: str)
|
|||
return external_id, pay_url
|
||||
|
||||
|
||||
def validate_yookassa_credentials(shop_id: str, secret_key: str) -> dict:
|
||||
auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode()
|
||||
return _get_json("https://api.yookassa.ru/v3/me", {"Authorization": f"Basic {auth}"})
|
||||
|
||||
|
||||
def verify_yookassa_notification(body: dict) -> bool:
|
||||
return body.get("event") == "payment.succeeded" and "object" in body
|
||||
|
||||
|
||||
def check_yookassa_payment(external_id: str) -> str:
|
||||
shop_id, secret_key = settings.yookassa_credentials()
|
||||
auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode()
|
||||
data = _get_json(
|
||||
f"https://api.yookassa.ru/v3/payments/{external_id}",
|
||||
{"Authorization": f"Basic {auth}"},
|
||||
)
|
||||
return data.get("status", "")
|
||||
|
||||
|
||||
def create_platega_payment(payment_id: str, amount_rub: int, description: str) -> str:
|
||||
merchant_id, secret = settings.platega_credentials()
|
||||
data = _post_json(
|
||||
"https://app.platega.io/transaction/process",
|
||||
{
|
||||
|
|
@ -72,8 +93,8 @@ def create_platega_payment(payment_id: str, amount_rub: int, description: str) -
|
|||
},
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"X-MerchantId": PLATEGA_MERCHANT_ID,
|
||||
"X-Secret": PLATEGA_SECRET,
|
||||
"X-MerchantId": merchant_id,
|
||||
"X-Secret": secret,
|
||||
},
|
||||
)
|
||||
external_id = data.get("id") or data.get("transactionId")
|
||||
|
|
@ -84,13 +105,35 @@ def create_platega_payment(payment_id: str, amount_rub: int, description: str) -
|
|||
def verify_platega_signature(raw_body: bytes, signature: str) -> bool:
|
||||
if not signature:
|
||||
return False
|
||||
expected = hmac.new(PLATEGA_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
_, secret = settings.platega_credentials()
|
||||
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def check_platega_payment(external_id: str) -> str:
|
||||
merchant_id, secret = settings.platega_credentials()
|
||||
data = _get_json(
|
||||
f"https://app.platega.io/transaction/{external_id}",
|
||||
{"X-MerchantId": merchant_id, "X-Secret": secret},
|
||||
)
|
||||
return data.get("status", "")
|
||||
|
||||
|
||||
PAID_STATUSES = {"succeeded", "CONFIRMED"}
|
||||
FAILED_STATUSES = {"canceled", "CANCELED", "CHARGEBACKED"}
|
||||
|
||||
|
||||
def create_payment_link(provider: str, payment_id: str, amount_rub: int, description: str):
|
||||
if provider == "yookassa":
|
||||
return create_yookassa_payment(payment_id, amount_rub, description)
|
||||
if provider == "platega":
|
||||
return create_platega_payment(payment_id, amount_rub, description)
|
||||
raise ValueError(f"unknown provider: {provider}")
|
||||
|
||||
|
||||
def check_payment_status(provider: str, external_id: str) -> str:
|
||||
if provider == "yookassa":
|
||||
return check_yookassa_payment(external_id)
|
||||
if provider == "platega":
|
||||
return check_platega_payment(external_id)
|
||||
raise ValueError(f"unknown provider: {provider}")
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ aiogram==3.15.0
|
|||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
paramiko==3.5.0
|
||||
python-multipart==0.0.20
|
||||
|
|
|
|||
142
settings.py
Normal file
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>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ After=network.target
|
|||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/mbs-panel
|
||||
ExecStart=/opt/mbs-panel/venv/bin/uvicorn api:app --host 127.0.0.1 --port 8001
|
||||
ExecStart=/opt/mbs-panel/venv/bin/uvicorn api:app --host 127.0.0.1 --port 8001 --workers __WORKERS__
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
User=root
|
||||
|
|
|
|||
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 ]
|
||||
45
totp.py
Normal file
45
totp.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import struct
|
||||
import time as timemod
|
||||
import urllib.parse
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
return base64.b32encode(os.urandom(20)).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _hotp(secret_b32: str, counter: int) -> str:
|
||||
padded = secret_b32 + "=" * ((8 - len(secret_b32) % 8) % 8)
|
||||
key = base64.b32decode(padded.upper())
|
||||
msg = struct.pack(">Q", counter)
|
||||
h = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
offset = h[-1] & 0x0F
|
||||
code = (struct.unpack(">I", h[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000
|
||||
return f"{code:06d}"
|
||||
|
||||
|
||||
def now_code(secret_b32: str, for_time: float | None = None) -> str:
|
||||
t = for_time if for_time is not None else timemod.time()
|
||||
counter = int(t // 30)
|
||||
return _hotp(secret_b32, counter)
|
||||
|
||||
|
||||
def verify(secret_b32: str, code: str, window: int = 1) -> bool:
|
||||
if not code or not code.isdigit() or len(code) != 6:
|
||||
return False
|
||||
counter = int(timemod.time() // 30)
|
||||
for offset in range(-window, window + 1):
|
||||
if hmac.compare_digest(_hotp(secret_b32, counter + offset), code):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def uri(secret_b32: str, username: str, issuer: str = "MBS Panel") -> str:
|
||||
label = urllib.parse.quote(f"{issuer}:{username}")
|
||||
return (
|
||||
f"otpauth://totp/{label}?secret={secret_b32}"
|
||||
f"&issuer={urllib.parse.quote(issuer)}&algorithm=SHA1&digits=6&period=30"
|
||||
)
|
||||
23
webhooks.py
Normal file
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
|
||||
243
xray_manager.py
243
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
|
||||
|
|
@ -26,7 +27,70 @@ def _load():
|
|||
return json.load(f)
|
||||
|
||||
|
||||
class ConfigValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _readable_by(path, uid, gid) -> bool:
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return False
|
||||
mode = st.st_mode
|
||||
if st.st_uid == uid and mode & 0o400:
|
||||
return True
|
||||
if st.st_gid == gid and mode & 0o040:
|
||||
return True
|
||||
if mode & 0o004:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def check_cert_permissions(cfg, uid=65534, gid=65534) -> list:
|
||||
problems = []
|
||||
for ib in cfg.get("inbounds", []):
|
||||
stream = ib.get("streamSettings") or {}
|
||||
tls = stream.get("tlsSettings")
|
||||
if not tls:
|
||||
continue
|
||||
for cert in tls.get("certificates") or []:
|
||||
for key in ("certificateFile", "keyFile"):
|
||||
path = cert.get(key)
|
||||
if path and not _readable_by(path, uid, gid):
|
||||
problems.append(f"{ib.get('tag', '?')}: {key}={path} not readable by the xray service user")
|
||||
return problems
|
||||
|
||||
|
||||
def validate_config(cfg, xray_bin="/usr/local/bin/xray") -> tuple:
|
||||
tmp = XRAY_CONFIG_PATH + ".validate.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[xray_bin, "run", "-test", "-format=json", "-config", tmp],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
ok = result.returncode == 0
|
||||
detail = (result.stdout + result.stderr).strip()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
if not ok:
|
||||
return False, detail
|
||||
perm_problems = check_cert_permissions(cfg)
|
||||
if perm_problems:
|
||||
return False, "cert permission problem(s): " + "; ".join(perm_problems)
|
||||
return True, detail
|
||||
|
||||
|
||||
def _save(cfg):
|
||||
ok, detail = validate_config(cfg)
|
||||
if not ok:
|
||||
raise ConfigValidationError(detail)
|
||||
tmp = XRAY_CONFIG_PATH + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
|
|
@ -38,7 +102,7 @@ def _reload_xray():
|
|||
|
||||
|
||||
def _local_inbounds(cfg):
|
||||
return [ib for ib in cfg["inbounds"] if ib.get("tag") in _LOCAL_TAGS]
|
||||
return [ib for ib in cfg["inbounds"] if chains.is_user_tag(ib.get("tag"))]
|
||||
|
||||
|
||||
def add_client(client_uuid: str, email: str):
|
||||
|
|
@ -50,7 +114,7 @@ def add_client(client_uuid: str, email: str):
|
|||
if any(c["id"] == client_uuid for c in clients):
|
||||
continue
|
||||
entry = {"id": client_uuid, "email": email}
|
||||
flow = _TAG_FLOW.get(ib["tag"])
|
||||
flow = chains.flow_for_tag(ib["tag"])
|
||||
if flow:
|
||||
entry["flow"] = flow
|
||||
clients.append(entry)
|
||||
|
|
@ -75,47 +139,138 @@ def remove_client(client_uuid: str):
|
|||
_reload_xray()
|
||||
|
||||
|
||||
def list_client_ids():
|
||||
def _node_usable(node):
|
||||
return bool(node["enabled"]) and node["status"] == "active"
|
||||
|
||||
|
||||
def desired_state(node):
|
||||
import db as dbmod
|
||||
|
||||
active = dbmod.list_active_subscriptions(node=node["code"])
|
||||
wanted = {s["uuid"]: s["uuid"] for s in active}
|
||||
nodes_by_code = {n["code"]: n for n in dbmod.list_nodes()}
|
||||
entry_chains = []
|
||||
exit_nodes = {}
|
||||
relay_wanted = {}
|
||||
for chain in dbmod.list_chains(enabled_only=True):
|
||||
entry = nodes_by_code.get(chain["entry_node"])
|
||||
exit_node = nodes_by_code.get(chain["exit_node"])
|
||||
if not entry or not exit_node:
|
||||
continue
|
||||
if not _node_usable(entry) or not _node_usable(exit_node):
|
||||
continue
|
||||
if chain["entry_node"] == node["code"]:
|
||||
entry_chains.append(chain)
|
||||
exit_nodes[chain["exit_node"]] = exit_node
|
||||
if chain["exit_node"] == node["code"] and node["kind"] in ("local", "managed") and chain.get("relay_uuid"):
|
||||
relay_wanted[chain["relay_uuid"]] = chains.relay_email(chain["code"])
|
||||
return wanted, relay_wanted, entry_chains, exit_nodes
|
||||
|
||||
|
||||
def _read_config_text():
|
||||
with open(XRAY_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _restore_config_text(text):
|
||||
tmp = XRAY_CONFIG_PATH + ".restore.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
os.replace(tmp, XRAY_CONFIG_PATH)
|
||||
subprocess.run(["systemctl", "restart", "xray"], timeout=20)
|
||||
|
||||
|
||||
def _reload_and_verify():
|
||||
subprocess.run(["systemctl", "restart", "xray"], check=True, timeout=20)
|
||||
time.sleep(1)
|
||||
state = subprocess.run(["systemctl", "is-active", "xray"], capture_output=True, text=True).stdout.strip()
|
||||
if state != "active":
|
||||
raise ConfigValidationError("xray не поднялся после применения конфига, вернули старый")
|
||||
|
||||
|
||||
def _port_busy(port):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.bind(("0.0.0.0", int(port)))
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def _open_firewall(port):
|
||||
subprocess.run(
|
||||
["sh", "-c", f"command -v ufw >/dev/null 2>&1 && ufw allow {int(port)}/tcp || true"],
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
|
||||
def _reconcile_local(wanted, relay_wanted, entry_chains, exit_nodes, apply_chains=True):
|
||||
with _locked():
|
||||
cfg = _load()
|
||||
ids = set()
|
||||
for ib in _local_inbounds(cfg):
|
||||
ids |= {c["id"] for c in ib["settings"]["clients"]}
|
||||
return ids
|
||||
before_text = _read_config_text()
|
||||
cfg = json.loads(before_text)
|
||||
usable = entry_chains
|
||||
skipped = []
|
||||
new_ports = []
|
||||
if apply_chains:
|
||||
busy = {c["port"] for c in entry_chains if _port_busy(c["port"])}
|
||||
usable, skipped = chains.split_busy_chains(cfg, entry_chains, busy)
|
||||
new_ports = chains.new_ports_needed(cfg, usable)
|
||||
changed, problems = chains.sync_config(cfg, wanted, relay_wanted, usable, exit_nodes, apply_chains=apply_chains)
|
||||
if changed:
|
||||
_save(cfg)
|
||||
try:
|
||||
_reload_and_verify()
|
||||
except Exception:
|
||||
_restore_config_text(before_text)
|
||||
raise
|
||||
for port in new_ports:
|
||||
_open_firewall(port)
|
||||
return {"changed": changed, "new_ports": new_ports, "problems": skipped + problems}
|
||||
|
||||
|
||||
def sync_node(node):
|
||||
wanted, relay_wanted, entry_chains, exit_nodes = desired_state(node)
|
||||
if node["kind"] == "managed":
|
||||
import nodeprov
|
||||
reconcile = nodeprov.remote_reconcile
|
||||
args = (node, wanted, relay_wanted, entry_chains, exit_nodes)
|
||||
elif node["kind"] == "local":
|
||||
reconcile = _reconcile_local
|
||||
args = (wanted, relay_wanted, entry_chains, exit_nodes)
|
||||
else:
|
||||
return {"changed": False, "new_ports": [], "problems": []}
|
||||
try:
|
||||
return reconcile(*args)
|
||||
except Exception as first_error:
|
||||
if not entry_chains:
|
||||
raise
|
||||
result = reconcile(*args, apply_chains=False)
|
||||
result["problems"].append(f"цепочки не применились, клиенты синхронизированы: {first_error}")
|
||||
return result
|
||||
|
||||
|
||||
def sync_from_db():
|
||||
import db as dbmod
|
||||
|
||||
expired = dbmod.deactivate_expired()
|
||||
active = dbmod.list_active_subscriptions(node="de1")
|
||||
active_by_id = {s["uuid"]: s for s in active}
|
||||
node = dbmod.get_node("de1")
|
||||
result = sync_node(node)
|
||||
wanted = desired_state(node)[0]
|
||||
return {
|
||||
"removed_expired": len(expired), "active_now": len(wanted),
|
||||
"reloaded": result["changed"], "problems": result["problems"],
|
||||
}
|
||||
|
||||
with _locked():
|
||||
cfg = _load()
|
||||
changed = False
|
||||
for ib in _local_inbounds(cfg):
|
||||
clients = ib["settings"]["clients"]
|
||||
current_ids = {c["id"] for c in clients}
|
||||
if current_ids == set(active_by_id.keys()):
|
||||
continue
|
||||
new_clients = [c for c in clients if c["id"] in active_by_id]
|
||||
existing_ids = {c["id"] for c in new_clients}
|
||||
flow = _TAG_FLOW.get(ib["tag"])
|
||||
for cid, sub in active_by_id.items():
|
||||
if cid not in existing_ids:
|
||||
entry = {"id": cid, "email": cid}
|
||||
if flow:
|
||||
entry["flow"] = flow
|
||||
new_clients.append(entry)
|
||||
ib["settings"]["clients"] = new_clients
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
_save(cfg)
|
||||
_reload_xray()
|
||||
|
||||
return {"removed_expired": len(expired), "active_now": len(active_by_id), "reloaded": changed}
|
||||
def probe_from_node(node: dict, host: str, port: int):
|
||||
if node["kind"] == "local":
|
||||
return chains.tcp_connect_ms(host, port)
|
||||
if node["kind"] == "managed":
|
||||
import nodeprov
|
||||
return nodeprov.remote_probe(node, host, port)
|
||||
raise ValueError("нода не под управлением панели, замерить с неё нельзя")
|
||||
|
||||
|
||||
def add_client_to_node(node: dict, client_uuid: str, email: str):
|
||||
|
|
@ -212,7 +367,6 @@ def local_node_status() -> dict:
|
|||
|
||||
def sync_all():
|
||||
import db as dbmod
|
||||
import nodeprov
|
||||
|
||||
expired = dbmod.deactivate_expired()
|
||||
results = {}
|
||||
|
|
@ -222,8 +376,15 @@ def sync_all():
|
|||
elif node["kind"] == "managed":
|
||||
active = dbmod.list_active_subscriptions(node=node["code"])
|
||||
try:
|
||||
nodeprov.remote_sync(node, active)
|
||||
results[node["code"]] = {"active_now": len(active), "ok": True}
|
||||
res = sync_node(node)
|
||||
results[node["code"]] = {
|
||||
"active_now": len(active), "ok": True,
|
||||
"changed": res["changed"], "problems": res["problems"],
|
||||
}
|
||||
except Exception as e:
|
||||
results[node["code"]] = {"active_now": len(active), "ok": False, "error": str(e)}
|
||||
return {"removed_expired": len(expired), "nodes": results}
|
||||
reloaded = any(r.get("changed") or r.get("reloaded") for r in results.values())
|
||||
return {
|
||||
"removed_expired": len(expired), "active_now": len(dbmod.list_active_subscriptions()),
|
||||
"reloaded": reloaded, "nodes": results,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue