Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82f83eede7 | |||
| ebdea51221 | |||
| 181bf69778 | |||
| 990bdc403a | |||
| 6372d649da | |||
| 4791c5ca2b | |||
| a701d55e3b | |||
| e3bd279407 | |||
| 264991593a | |||
| 2c5ec8b06c | |||
| da6200ae4a | |||
| 6546d5bed1 | |||
| 695771c79a | |||
| 2b34b11391 | |||
| 02ff43095c |
23 changed files with 4991 additions and 335 deletions
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
|
||||||
224
.github/workflows/ci.yml
vendored
224
.github/workflows/ci.yml
vendored
|
|
@ -24,6 +24,13 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
bash -n install.sh
|
bash -n install.sh
|
||||||
bash -n mbs
|
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
|
- name: Smoke test install-script rendering
|
||||||
env:
|
env:
|
||||||
|
|
@ -57,6 +64,9 @@ jobs:
|
||||||
}
|
}
|
||||||
script = nodeprov.render_install_script(node)
|
script = nodeprov.render_install_script(node)
|
||||||
assert "PRIVKEY" in script
|
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
|
assert len(script) > 500
|
||||||
print("node install script rendered OK,", len(script), "bytes")
|
print("node install script rendered OK,", len(script), "bytes")
|
||||||
PYEOF
|
PYEOF
|
||||||
|
|
@ -310,10 +320,10 @@ jobs:
|
||||||
import settings
|
import settings
|
||||||
|
|
||||||
db.init_db()
|
db.init_db()
|
||||||
db.create_node("n1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
|
db.create_node("bk1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
|
||||||
|
|
||||||
sub_a = db.create_subscription(111, "n1", 30, "1m", source="bot")
|
sub_a = db.create_subscription(111, "bk1", 30, "1m", source="bot")
|
||||||
sub_b = db.create_subscription(222, "n1", 30, "1m", source="bot")
|
sub_b = db.create_subscription(222, "bk1", 30, "1m", source="bot")
|
||||||
|
|
||||||
legal.update_env_var("BRAND_NAME", "SnapshotBrand")
|
legal.update_env_var("BRAND_NAME", "SnapshotBrand")
|
||||||
settings.set_plan_prices({"1m": 555})
|
settings.set_plan_prices({"1m": 555})
|
||||||
|
|
@ -333,7 +343,7 @@ jobs:
|
||||||
settings.set_plan_prices({"1m": 999})
|
settings.set_plan_prices({"1m": 999})
|
||||||
legal.update_env_var("HWID_LIMIT_ENABLED", "false")
|
legal.update_env_var("HWID_LIMIT_ENABLED", "false")
|
||||||
assert db.resume_subscription(sub_a["uuid"])["held_at"] is None
|
assert db.resume_subscription(sub_a["uuid"])["held_at"] is None
|
||||||
sub_c = db.create_subscription(333, "n1", 30, "1m", source="bot")
|
sub_c = db.create_subscription(333, "bk1", 30, "1m", source="bot")
|
||||||
assert settings.get_brand_name() == "MutatedAfterBackup"
|
assert settings.get_brand_name() == "MutatedAfterBackup"
|
||||||
assert len(db.list_active_subscriptions(tg_id=111)) == 1
|
assert len(db.list_active_subscriptions(tg_id=111)) == 1
|
||||||
|
|
||||||
|
|
@ -384,3 +394,209 @@ jobs:
|
||||||
|
|
||||||
print("custom ADMIN_PATH: old /admin route gone, new path registered, root() no longer leaks the panel OK")
|
print("custom ADMIN_PATH: old /admin route gone, new path registered, root() no longer leaks the panel OK")
|
||||||
PYEOF
|
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
|
*.pyc
|
||||||
venv/
|
venv/
|
||||||
.claude/
|
.claude/
|
||||||
|
.update_mirror
|
||||||
|
local-changes/
|
||||||
|
|
|
||||||
46
README.md
46
README.md
|
|
@ -1,8 +1,8 @@
|
||||||
# MBS Panel
|
# MBS Panel
|
||||||
|
|
||||||
[](https://github.com/devsavsis/mbs-panel/actions/workflows/ci.yml)
|
[](https://lab.savsis.xyz/savsisbtw/mbs-panel/actions)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/devsavsis/mbs-panel/releases)
|
[](https://lab.savsis.xyz/savsisbtw/mbs-panel/releases)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
[](https://github.com/XTLS/Xray-core)
|
[](https://github.com/XTLS/Xray-core)
|
||||||
|
|
||||||
|
|
@ -30,8 +30,12 @@
|
||||||
- **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код.
|
- **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код.
|
||||||
- **Свой путь входа** — страницу логина можно увести с дефолтного `/admin` на любой другой (`ADMIN_PATH` в `.env`), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще.
|
- **Свой путь входа** — страницу логина можно увести с дефолтного `/admin` на любой другой (`ADMIN_PATH` в `.env`), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще.
|
||||||
- **Пауза подписки** — временно отключить доступ без потери оплаченных дней (Marzban это умеет, Remnawave — нет): при возобновлении срок сдвигается ровно на длительность паузы.
|
- **Пауза подписки** — временно отключить доступ без потери оплаченных дней (Marzban это умеет, Remnawave — нет): при возобновлении срок сдвигается ровно на длительность паузы.
|
||||||
- **Исходящие вебхуки** — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса.
|
- **Исходящие вебхуки** — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды и создание/удаление/вкл-выкл цепочки, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса.
|
||||||
- **Поиск и фильтр по подпискам** — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице.
|
- **Поиск и фильтр по подпискам** — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице. Плюс экспорт всех подписок в CSV одной кнопкой.
|
||||||
|
- **Цепочки серверов (v1.6)** — `клиент → нода A → нода B → интернет`: собираются в админке мышкой, зажал ЛКМ на клиенте и протянул провод через серверы к интернету. Больше двух серверов нельзя специально, на двух панель предупреждает про задержку и сама меряет RTT между нодами. Подробности ниже в разделе «Цепочки серверов».
|
||||||
|
- **Юзеры и выдача подписок (v1.6)** — отдельная страница со всеми, кто уже есть в системе, даже если подписок у них ни разу не было (в «Подписках» таких не видно): поиск по юзернейму и Telegram ID, счётчики активных подписок и устройств, кнопка «Выдать подписку». Можно выдать и по Telegram ID тому, кого в базе ещё нет, подписка дождётся, пока он зайдёт в бота.
|
||||||
|
- **Журнал действий (v1.6)** — кто из админов и когда менял ноды, цепочки, подписки, настройки, качал бэкап и входил (включая неудачные входы с IP). Пароли и ключи в журнал не попадают, только факт действия.
|
||||||
|
- **Пинг нод и палитра команд (v1.6)** — живая задержка от панели до каждой ноды на дашборде и в списке нод; `Ctrl K` открывает поиск по страницам, нодам, цепочкам и действиям. Акцентный цвет панели меняется кружками сверху.
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
|
|
@ -108,10 +112,10 @@ sequenceDiagram
|
||||||
Нужен чистый сервер на **Ubuntu 22.04/24.04** или **Debian 11/12**, root-доступ и три поднятых DNS A-записи (см. таблицу ниже).
|
Нужен чистый сервер на **Ubuntu 22.04/24.04** или **Debian 11/12**, root-доступ и три поднятых DNS A-записи (см. таблицу ниже).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -Ls https://mbs.savsis.xyz/install.sh)
|
bash <(curl -Ls https://lab.savsis.xyz/savsisbtw/mbs-panel/raw/branch/main/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
(или напрямую с GitHub, если так удобнее: `git clone https://github.com/devsavsis/mbs-panel.git && cd mbs-panel && sudo bash install.sh` — скрипт один и тот же, `mbs.savsis.xyz` просто зеркало с автосинком)
|
(или так: `git clone https://lab.savsis.xyz/savsisbtw/mbs-panel.git && cd mbs-panel && sudo bash install.sh`. Основной репозиторий лежит на lab.savsis.xyz, GitHub и api.savsis.xyz остаются зеркалами на случай, если lab недоступен, установщик и `mbs update` сами переключаются на них)
|
||||||
|
|
||||||
Скрипт спросит домен панели, домен подписки, токен бота от [@BotFather](https://t.me/BotFather) и список Telegram ID админов — и дальше всё сам: ставит зависимости, Xray, nginx, выпускает сертификаты Let's Encrypt, генерирует Reality-ключи, поднимает systemd-сервисы, настраивает firewall (ufw) и fail2ban. В конце покажет пароль от админки и ссылку на панель.
|
Скрипт спросит домен панели, домен подписки, токен бота от [@BotFather](https://t.me/BotFather) и список Telegram ID админов — и дальше всё сам: ставит зависимости, Xray, nginx, выпускает сертификаты Let's Encrypt, генерирует Reality-ключи, поднимает systemd-сервисы, настраивает firewall (ufw) и fail2ban. В конце покажет пароль от админки и ссылку на панель.
|
||||||
|
|
||||||
|
|
@ -148,10 +152,18 @@ mbs status статус bot / api / xray / nginx
|
||||||
mbs restart перезапустить bot + api
|
mbs restart перезапустить bot + api
|
||||||
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
|
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
|
||||||
mbs domain текущий домен панели
|
mbs domain текущий домен панели
|
||||||
mbs update обновить код с GitHub и перезапустить
|
mbs backup полная копия панели в /root/mbs-backups (база, .env, твои правки, конфиг Xray)
|
||||||
|
mbs update [ссылка] обновить код и перезапустить; со ссылкой на git-зеркало берёт обновление оттуда
|
||||||
|
mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, его mbs update проверяет первым
|
||||||
```
|
```
|
||||||
|
|
||||||
`mbs update` тянет `git pull` (только fast-forward — если на сервере что-то правили руками, честно откажется и не полезет мержить), ставит зависимости, **проверяет, что новый код вообще компилируется**, и только потом перезапускает. Если после рестарта `mbs-bot`/`mbs-api` не поднялись — сам откатывает на предыдущий коммит и поднимает его. `.env` и база (`mbs.db`) не в гите — их не тронет ни при каком раскладе.
|
`mbs update` тянет обновление (только fast-forward, чужую историю на сервере не мержит), ставит зависимости, **проверяет, что новый код вообще компилируется**, и только потом перезапускает. Если после рестарта `mbs-bot`/`mbs-api` не поднялись — сам откатывает на предыдущий коммит и поднимает его. `.env` и база (`mbs.db`) не в гите — их не тронет ни при каком раскладе.
|
||||||
|
|
||||||
|
Перед каждым обновлением `mbs update` сам снимает полную копию в `/root/mbs-backups/` (консистентный снапшот базы через backup API SQLite, `.env`, код вместе с твоими ручными правками, конфиг Xray, без `venv`), хранит 5 последних, права `600`. Не получилось сделать копию (нет места на диске), обновление даже не начнётся. То же вручную: `mbs backup`. Вернуть всё как было: `tar xzf /root/mbs-backups/mbs-before-update-<время>.tar.gz -C /` и `mbs restart`.
|
||||||
|
|
||||||
|
Если на сервере правили файлы руками (бывает, `bot.py`/`config.py`/`db.py` под себя), обновление больше на этом не падает: правки откладываются в `git stash` и сохраняются патчем в `local-changes/local-changes-<время>.patch`, потом подтягивается новая версия. Вернуть своё поверх новой: `git stash pop` (может быть конфликт, если новая версия правила те же строки, тогда смотри патч). Если новый код не прошёл проверку или сервисы не поднялись, откат на старый коммит возвращает и твои правки.
|
||||||
|
|
||||||
|
Источники по порядку: своё зеркало (если задано через `mbs mirror`), потом `lab.savsis.xyz`, потом `api.savsis.xyz`, потом GitHub. Установщик включает автообновление: каждый день около 04:00 сервер сам делает `mbs update` (перед ним всегда резервная копия). Выключить: `mbs autoupdate off`, включить обратно: `mbs autoupdate on`. Появилось новое зеркало или GitHub недоступен, а ссылка на репо есть: `mbs update https://example.com/путь/mbs-panel.git` возьмёт обновление именно оттуда, один раз. Чтобы всегда обновляться с него: `mbs mirror https://example.com/путь/mbs-panel.git` (убрать: `mbs mirror off`). Принимаются только `https://`, `http://`, `ssh://` и `git@хост:путь`, всё остальное (в том числе `file://` и хитрые транспорты типа `ext::`) отбрасывается, ветка берётся `main`.
|
||||||
|
|
||||||
## Добавление ноды
|
## Добавление ноды
|
||||||
|
|
||||||
|
|
@ -178,6 +190,22 @@ sequenceDiagram
|
||||||
Panel->>Panel: нода активна, доступна в боте
|
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`). Чтобы продавать доступ:
|
По умолчанию бот выдаёт подписки бесплатно по кнопке — платежи выключены (`PAYMENTS_ENABLED=false`). Чтобы продавать доступ:
|
||||||
|
|
@ -215,7 +243,7 @@ sequenceDiagram
|
||||||
PR и issues welcome. CI на каждый пуш гоняет compile-check по питону, синтаксис-проверку шелл-скриптов и smoke-тест генерации install-скрипта ноды.
|
PR и issues welcome. CI на каждый пуш гоняет compile-check по питону, синтаксис-проверку шелл-скриптов и smoke-тест генерации install-скрипта ноды.
|
||||||
|
|
||||||
## Авторы:
|
## Авторы:
|
||||||
github.com/devsavsis
|
github.com/savsisbtw
|
||||||
github.com/welfizx
|
github.com/welfizx
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
|
||||||
1935
admin.html
1935
admin.html
File diff suppressed because it is too large
Load diff
387
api.py
387
api.py
|
|
@ -1,17 +1,22 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import csv
|
||||||
import datetime
|
import datetime
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import uuid as uuidlib
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response, File, UploadFile
|
from fastapi import FastAPI, HTTPException, Request, Response, File, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi import Body
|
from fastapi import Body
|
||||||
from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse
|
from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse
|
||||||
|
|
||||||
import backup
|
import backup
|
||||||
|
import chains
|
||||||
import db
|
import db
|
||||||
import legal
|
import legal
|
||||||
import links
|
import links
|
||||||
|
|
@ -52,6 +57,64 @@ def require_admin(request: Request):
|
||||||
raise HTTPException(401, "unauthorized")
|
raise HTTPException(401, "unauthorized")
|
||||||
|
|
||||||
|
|
||||||
|
AUDIT_RULES = [
|
||||||
|
("POST", r"^/admin/api/nodes$", "node.add"),
|
||||||
|
("POST", r"^/admin/api/nodes/provision-guide$", "node.provision"),
|
||||||
|
("POST", r"^/admin/api/nodes/reorder$", "node.reorder"),
|
||||||
|
("PATCH", r"^/admin/api/nodes/[^/]+$", "node.edit"),
|
||||||
|
("DELETE", r"^/admin/api/nodes/[^/]+$", "node.delete"),
|
||||||
|
("POST", r"^/admin/api/chains$", "chain.create"),
|
||||||
|
("PATCH", r"^/admin/api/chains/[^/]+$", "chain.edit"),
|
||||||
|
("DELETE", r"^/admin/api/chains/[^/]+$", "chain.delete"),
|
||||||
|
("POST", r"^/admin/api/subscriptions/[^/]+/revoke$", "sub.revoke"),
|
||||||
|
("POST", r"^/admin/api/subscriptions/[^/]+/hold$", "sub.hold"),
|
||||||
|
("POST", r"^/admin/api/subscriptions/[^/]+/resume$", "sub.resume"),
|
||||||
|
("POST", r"^/admin/api/subscriptions/[^/]+/reset-traffic$", "sub.reset_traffic"),
|
||||||
|
("POST", r"^/admin/api/users/-?\d+/grant$", "user.grant"),
|
||||||
|
("POST", r"^/admin/api/users/-?\d+/hwid-limit$", "user.hwid_limit"),
|
||||||
|
("DELETE", r"^/admin/api/users/-?\d+/devices/\d+$", "user.device_delete"),
|
||||||
|
("POST", r"^/admin/api/gift-codes$", "gift.create"),
|
||||||
|
("POST", r"^/admin/api/admins$", "admin.add"),
|
||||||
|
("DELETE", r"^/admin/api/admins/\d+$", "admin.delete"),
|
||||||
|
("POST", r"^/admin/api/2fa/enable$", "2fa.enable"),
|
||||||
|
("POST", r"^/admin/api/2fa/disable$", "2fa.disable"),
|
||||||
|
("GET", r"^/admin/api/backup$", "backup.download"),
|
||||||
|
("POST", r"^/admin/api/backup/restore$", "backup.restore"),
|
||||||
|
("POST", r"^/admin/api/settings/bot$", "settings.bot"),
|
||||||
|
("POST", r"^/admin/api/branding$", "settings.brand"),
|
||||||
|
("POST", r"^/admin/api/webhook-settings$", "settings.webhook"),
|
||||||
|
("POST", r"^/admin/api/hwid-settings$", "settings.hwid"),
|
||||||
|
("POST", r"^/admin/api/features$", "settings.features"),
|
||||||
|
("POST", r"^/admin/api/promos$", "promo.create"),
|
||||||
|
("PATCH", r"^/admin/api/promos/[^/]+$", "promo.edit"),
|
||||||
|
("DELETE", r"^/admin/api/promos/[^/]+$", "promo.delete"),
|
||||||
|
("POST", r"^/admin/api/subscriptions/[^/]+/traffic-limit$", "sub.traffic_limit"),
|
||||||
|
("POST", r"^/admin/api/payments/(yookassa-settings|platega-settings|plan-settings|legal-settings)$", "settings.payments"),
|
||||||
|
]
|
||||||
|
AUDIT_COMPILED = [(method, re.compile(pattern), action) for method, pattern, action in AUDIT_RULES]
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_action(method: str, path: str):
|
||||||
|
for rule_method, pattern, action in AUDIT_COMPILED:
|
||||||
|
if rule_method == method and pattern.match(path):
|
||||||
|
return action
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def audit_middleware(request: Request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
action = _audit_action(request.method, path) if path.startswith("/admin/api/") else None
|
||||||
|
admin_name = None
|
||||||
|
if action:
|
||||||
|
admin = await asyncio.to_thread(db.get_session_admin, request.cookies.get(ADMIN_COOKIE))
|
||||||
|
admin_name = admin["username"] if admin else None
|
||||||
|
response = await call_next(request)
|
||||||
|
if action and response.status_code < 400:
|
||||||
|
await asyncio.to_thread(db.add_audit, admin_name, action, path, _client_ip(request))
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _days_left(sub: dict) -> int:
|
def _days_left(sub: dict) -> int:
|
||||||
exp = datetime.datetime.fromisoformat(sub["expires_at"])
|
exp = datetime.datetime.fromisoformat(sub["expires_at"])
|
||||||
reference = datetime.datetime.fromisoformat(sub["held_at"]) if sub.get("held_at") else datetime.datetime.utcnow()
|
reference = datetime.datetime.fromisoformat(sub["held_at"]) if sub.get("held_at") else datetime.datetime.utcnow()
|
||||||
|
|
@ -311,7 +374,10 @@ def _grant_paid_subscription(payment_id: str):
|
||||||
payment = db.mark_payment_paid(payment_id)
|
payment = db.mark_payment_paid(payment_id)
|
||||||
if not payment:
|
if not payment:
|
||||||
return
|
return
|
||||||
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
|
sub = db.create_subscription(
|
||||||
|
payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment",
|
||||||
|
traffic_limit=settings.default_traffic_limit_bytes(),
|
||||||
|
)
|
||||||
xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
|
xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
|
||||||
user = db.get_or_create_user(payment["tg_id"], None)
|
user = db.get_or_create_user(payment["tg_id"], None)
|
||||||
_tg_send_message(
|
_tg_send_message(
|
||||||
|
|
@ -614,11 +680,13 @@ def admin_login(request: Request, response: Response, body: dict = Body(...)):
|
||||||
admin = db.verify_admin_login(username, password)
|
admin = db.verify_admin_login(username, password)
|
||||||
if not admin:
|
if not admin:
|
||||||
db.record_login_attempt(ip, "password")
|
db.record_login_attempt(ip, "password")
|
||||||
|
db.add_audit(username[:64] or None, "login.failed", "", ip)
|
||||||
raise HTTPException(401, "wrong username or password")
|
raise HTTPException(401, "wrong username or password")
|
||||||
db.clear_login_attempts(ip, "password")
|
db.clear_login_attempts(ip, "password")
|
||||||
if admin.get("totp_secret"):
|
if admin.get("totp_secret"):
|
||||||
pending_token = db.create_pending_totp(admin["id"])
|
pending_token = db.create_pending_totp(admin["id"])
|
||||||
return {"ok": True, "needs_totp": True, "pending_token": pending_token}
|
return {"ok": True, "needs_totp": True, "pending_token": pending_token}
|
||||||
|
db.add_audit(admin["username"], "login.ok", "", ip)
|
||||||
token = db.create_admin_session(admin["id"])
|
token = db.create_admin_session(admin["id"])
|
||||||
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
|
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
@ -637,9 +705,11 @@ def admin_login_totp(request: Request, response: Response, body: dict = Body(...
|
||||||
admin = db.get_admin_by_id(pending["admin_id"])
|
admin = db.get_admin_by_id(pending["admin_id"])
|
||||||
if not admin or not admin.get("totp_secret") or not totp.verify(admin["totp_secret"], code):
|
if not admin or not admin.get("totp_secret") or not totp.verify(admin["totp_secret"], code):
|
||||||
db.record_login_attempt(ip, "totp")
|
db.record_login_attempt(ip, "totp")
|
||||||
|
db.add_audit(admin["username"] if admin else None, "login.totp_failed", "", ip)
|
||||||
raise HTTPException(401, "wrong code")
|
raise HTTPException(401, "wrong code")
|
||||||
db.clear_login_attempts(ip, "totp")
|
db.clear_login_attempts(ip, "totp")
|
||||||
db.delete_pending_totp(pending_token)
|
db.delete_pending_totp(pending_token)
|
||||||
|
db.add_audit(admin["username"], "login.ok", "2fa", ip)
|
||||||
token = db.create_admin_session(admin["id"])
|
token = db.create_admin_session(admin["id"])
|
||||||
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
|
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
@ -939,7 +1009,88 @@ def admin_reset_traffic(uuid: str, request: Request):
|
||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(404, "node not found")
|
raise HTTPException(404, "node not found")
|
||||||
ok = xray_manager.reset_stats_for_node(node, uuid)
|
ok = xray_manager.reset_stats_for_node(node, uuid)
|
||||||
return {"ok": ok}
|
reactivated = db.reset_traffic_counter(uuid)
|
||||||
|
if reactivated:
|
||||||
|
xray_manager.add_client_to_node(node, uuid, email=uuid)
|
||||||
|
return {"ok": ok, "reactivated": reactivated}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/api/subscriptions/{uuid}/traffic-limit")
|
||||||
|
def admin_set_traffic_limit(uuid: str, request: Request, body: dict = Body(...)):
|
||||||
|
require_admin(request)
|
||||||
|
sub = db.get_subscription(uuid)
|
||||||
|
if not sub:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
try:
|
||||||
|
gb = float(body.get("gb", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "gb должен быть числом")
|
||||||
|
if gb < 0 or gb > 100000:
|
||||||
|
raise HTTPException(400, "лимит от 0 до 100000 ГБ, 0 значит без лимита")
|
||||||
|
db.set_traffic_limit(uuid, int(gb * settings.GB) if gb > 0 else None)
|
||||||
|
fresh = db.get_subscription(uuid)
|
||||||
|
node = db.get_node(fresh["node"])
|
||||||
|
if fresh["active"] and not sub["active"] and node:
|
||||||
|
xray_manager.add_client_to_node(node, uuid, email=uuid)
|
||||||
|
return {"ok": True, "traffic_limit": fresh["traffic_limit"], "active": bool(fresh["active"])}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/api/features")
|
||||||
|
def admin_get_features(request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
return settings.get_features()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/api/features")
|
||||||
|
def admin_set_features(request: Request, body: dict = Body(...)):
|
||||||
|
require_admin(request)
|
||||||
|
try:
|
||||||
|
settings.set_features(body)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "неверные значения настроек")
|
||||||
|
return settings.get_features()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/api/promos")
|
||||||
|
def admin_list_promos(request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
return db.list_promos()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/api/promos")
|
||||||
|
def admin_create_promo(request: Request, body: dict = Body(...)):
|
||||||
|
require_admin(request)
|
||||||
|
try:
|
||||||
|
return db.create_promo(
|
||||||
|
str(body.get("code", "")), str(body.get("kind", "")), int(body.get("value", 0)),
|
||||||
|
int(body["max_uses"]) if body.get("max_uses") else None,
|
||||||
|
str(body.get("expires_at") or "") or None,
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/admin/api/promos/{code}")
|
||||||
|
def admin_update_promo(code: str, request: Request, body: dict = Body(...)):
|
||||||
|
require_admin(request)
|
||||||
|
if not db.get_promo(code):
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
if "active" in body:
|
||||||
|
db.set_promo_active(code, bool(body["active"]))
|
||||||
|
return db.get_promo(code)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/admin/api/promos/{code}")
|
||||||
|
def admin_delete_promo(code: str, request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
db.delete_promo(code)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/api/users")
|
||||||
|
def admin_list_users(request: Request, q: str = "", limit: int = 200):
|
||||||
|
require_admin(request)
|
||||||
|
return db.list_users(q=q, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/api/users/{tg_id}")
|
@app.get("/admin/api/users/{tg_id}")
|
||||||
|
|
@ -947,7 +1098,11 @@ def admin_user_card(tg_id: int, request: Request):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
user = db.get_user(tg_id)
|
user = db.get_user(tg_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(404, "not found")
|
return {
|
||||||
|
"tg_id": tg_id, "username": None, "created_at": None, "token": None, "exists": False,
|
||||||
|
"subscriptions": [], "devices": [], "hwid_limit": None,
|
||||||
|
"hwid_fallback_limit": settings.get_hwid_settings()["fallback_limit"],
|
||||||
|
}
|
||||||
subs = db.list_subscriptions_for_user(tg_id)
|
subs = db.list_subscriptions_for_user(tg_id)
|
||||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||||
plans_by_code = settings.get_plans_by_code()
|
plans_by_code = settings.get_plans_by_code()
|
||||||
|
|
@ -966,6 +1121,7 @@ def admin_user_card(tg_id: int, request: Request):
|
||||||
"username": user["username"],
|
"username": user["username"],
|
||||||
"created_at": user["created_at"],
|
"created_at": user["created_at"],
|
||||||
"token": user["token"],
|
"token": user["token"],
|
||||||
|
"exists": True,
|
||||||
"subscriptions": out_subs,
|
"subscriptions": out_subs,
|
||||||
"devices": db.list_devices(tg_id),
|
"devices": db.list_devices(tg_id),
|
||||||
"hwid_limit": user.get("hwid_limit"),
|
"hwid_limit": user.get("hwid_limit"),
|
||||||
|
|
@ -983,7 +1139,14 @@ def admin_grant_subscription(tg_id: int, request: Request, body: dict = Body(...
|
||||||
if not node or not plan:
|
if not node or not plan:
|
||||||
raise HTTPException(400, "unknown node or plan")
|
raise HTTPException(400, "unknown node or plan")
|
||||||
db.get_or_create_user(tg_id, None)
|
db.get_or_create_user(tg_id, None)
|
||||||
sub = db.create_subscription(tg_id, node_code, plan["days"], plan_code, source="admin")
|
limit = settings.default_traffic_limit_bytes()
|
||||||
|
if body.get("traffic_gb") not in (None, ""):
|
||||||
|
try:
|
||||||
|
gb = float(body["traffic_gb"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "traffic_gb должен быть числом")
|
||||||
|
limit = int(gb * settings.GB) if gb > 0 else None
|
||||||
|
sub = db.create_subscription(tg_id, node_code, plan["days"], plan_code, source="admin", traffic_limit=limit)
|
||||||
xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
|
xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
|
||||||
webhooks.send("subscription.granted_by_admin", {
|
webhooks.send("subscription.granted_by_admin", {
|
||||||
"tg_id": tg_id, "node": node_code, "plan": plan_code,
|
"tg_id": tg_id, "node": node_code, "plan": plan_code,
|
||||||
|
|
@ -1231,6 +1394,222 @@ def admin_node_status(code: str, request: Request):
|
||||||
return {"status": node["status"], "enabled": bool(node["enabled"])}
|
return {"status": node["status"], "enabled": bool(node["enabled"])}
|
||||||
|
|
||||||
|
|
||||||
|
def _panel_latency(node: dict) -> dict:
|
||||||
|
samples = chains.tcp_connect_ms(node["address"], node["port"], samples=2, timeout=2.0)
|
||||||
|
return {"code": node["code"], "ms": chains.median_ms(samples)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/api/nodes/latency")
|
||||||
|
def admin_nodes_latency(request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
targets = [n for n in db.list_nodes() if n["address"] and n["status"] == "active" and n["enabled"]]
|
||||||
|
if not targets:
|
||||||
|
return []
|
||||||
|
with ThreadPoolExecutor(max_workers=min(8, len(targets))) as pool:
|
||||||
|
return list(pool.map(_panel_latency, targets))
|
||||||
|
|
||||||
|
|
||||||
|
def _chain_view(chain: dict, nodes_by_code: dict) -> dict:
|
||||||
|
entry = nodes_by_code.get(chain["entry_node"])
|
||||||
|
exit_node = nodes_by_code.get(chain["exit_node"])
|
||||||
|
return {
|
||||||
|
"code": chain["code"], "label": chain["label"],
|
||||||
|
"entry_node": chain["entry_node"], "exit_node": chain["exit_node"],
|
||||||
|
"entry_label": entry["label"] if entry else chain["entry_node"],
|
||||||
|
"exit_label": exit_node["label"] if exit_node else chain["exit_node"],
|
||||||
|
"entry_address": entry["address"] if entry else None,
|
||||||
|
"port": chain["port"], "enabled": bool(chain["enabled"]),
|
||||||
|
"created_at": chain["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_safe(value) -> str:
|
||||||
|
text = str(value)
|
||||||
|
if text and text[0] in "=+-@\t\r":
|
||||||
|
return "'" + text
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_chain_nodes(chain: dict) -> dict:
|
||||||
|
results = {}
|
||||||
|
for code in dict.fromkeys([chain["entry_node"], chain["exit_node"]]):
|
||||||
|
node = db.get_node(code)
|
||||||
|
if not node:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
res = xray_manager.sync_node(node)
|
||||||
|
results[code] = {"ok": not res["problems"], "problems": res["problems"]}
|
||||||
|
except Exception as e:
|
||||||
|
results[code] = {"ok": False, "problems": [str(e)]}
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _chain_failures(results: dict) -> list:
|
||||||
|
failures = []
|
||||||
|
for code, res in results.items():
|
||||||
|
for problem in res["problems"]:
|
||||||
|
failures.append(f"{code}: {problem}")
|
||||||
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/api/chains")
|
||||||
|
def admin_list_chains(request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||||
|
return [_chain_view(c, nodes_by_code) for c in db.list_chains()]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/api/chains")
|
||||||
|
def admin_create_chain(request: Request, body: dict = Body(...)):
|
||||||
|
require_admin(request)
|
||||||
|
entry_code = str(body.get("entry", "")).strip()
|
||||||
|
exit_code = str(body.get("exit", "")).strip()
|
||||||
|
entry = db.get_node(entry_code)
|
||||||
|
exit_node = db.get_node(exit_code)
|
||||||
|
if not entry or not exit_node:
|
||||||
|
raise HTTPException(400, "выбери входной и выходной серверы из списка нод")
|
||||||
|
if entry_code == exit_code:
|
||||||
|
raise HTTPException(400, "вход и выход цепочки должны быть разными серверами")
|
||||||
|
if entry["kind"] not in chains.CHAIN_KINDS_ENTRY:
|
||||||
|
raise HTTPException(400, "входной сервер должен быть под управлением панели (локальный или управляемый)")
|
||||||
|
if exit_node["kind"] not in chains.CHAIN_KINDS_EXIT:
|
||||||
|
raise HTTPException(400, "этот тип ноды нельзя использовать как выход цепочки")
|
||||||
|
for node in (entry, exit_node):
|
||||||
|
if not node["enabled"] or node["status"] != "active":
|
||||||
|
raise HTTPException(400, f"нода «{node['label']}» выключена или ещё не установлена")
|
||||||
|
relay_uuid = None
|
||||||
|
if exit_node["kind"] == "external":
|
||||||
|
if not exit_node.get("shared_uuid"):
|
||||||
|
raise HTTPException(400, "у внешней ноды не задан shared UUID — через неё цепочку не построить")
|
||||||
|
else:
|
||||||
|
relay_uuid = str(uuidlib.uuid4())
|
||||||
|
label = str(body.get("label", "")).strip()[:80] or f"{entry['label']} → {exit_node['label']}"
|
||||||
|
try:
|
||||||
|
chain = db.create_chain(label, entry_code, exit_code, relay_uuid)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
results = _sync_chain_nodes(chain)
|
||||||
|
failures = _chain_failures(results)
|
||||||
|
if failures:
|
||||||
|
db.delete_chain(chain["code"])
|
||||||
|
_sync_chain_nodes(chain)
|
||||||
|
raise HTTPException(502, "цепочка не применилась, всё откатили назад: " + "; ".join(failures))
|
||||||
|
webhooks.send("chain.created", {
|
||||||
|
"code": chain["code"], "label": chain["label"], "entry": entry_code, "exit": exit_code,
|
||||||
|
})
|
||||||
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||||
|
return _chain_view(chain, nodes_by_code)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/admin/api/chains/{code}")
|
||||||
|
def admin_update_chain(code: str, request: Request, body: dict = Body(...)):
|
||||||
|
require_admin(request)
|
||||||
|
chain = db.get_chain(code)
|
||||||
|
if not chain:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
fields = {}
|
||||||
|
if "label" in body:
|
||||||
|
label = str(body["label"]).strip()[:80]
|
||||||
|
if not label:
|
||||||
|
raise HTTPException(400, "название не может быть пустым")
|
||||||
|
fields["label"] = label
|
||||||
|
toggled = "enabled" in body and bool(body["enabled"]) != bool(chain["enabled"])
|
||||||
|
if "enabled" in body:
|
||||||
|
fields["enabled"] = 1 if body["enabled"] else 0
|
||||||
|
updated = db.update_chain(code, **fields)
|
||||||
|
if toggled:
|
||||||
|
failures = _chain_failures(_sync_chain_nodes(updated))
|
||||||
|
if failures:
|
||||||
|
db.update_chain(code, enabled=chain["enabled"])
|
||||||
|
_sync_chain_nodes(chain)
|
||||||
|
raise HTTPException(502, "не получилось применить: " + "; ".join(failures))
|
||||||
|
webhooks.send("chain.enabled" if updated["enabled"] else "chain.disabled", {
|
||||||
|
"code": code, "label": updated["label"],
|
||||||
|
})
|
||||||
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||||
|
return _chain_view(db.get_chain(code), nodes_by_code)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/admin/api/chains/{code}")
|
||||||
|
def admin_delete_chain(code: str, request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
chain = db.get_chain(code)
|
||||||
|
if not chain:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
db.delete_chain(code)
|
||||||
|
results = _sync_chain_nodes(chain)
|
||||||
|
webhooks.send("chain.deleted", {"code": code, "label": chain["label"]})
|
||||||
|
return {"ok": True, "warnings": _chain_failures(results)}
|
||||||
|
|
||||||
|
|
||||||
|
def _hop_probe(entry: dict, exit_node: dict) -> dict:
|
||||||
|
try:
|
||||||
|
samples = xray_manager.probe_from_node(entry, exit_node["address"], exit_node["port"])
|
||||||
|
except Exception as e:
|
||||||
|
return {"rtt_ms": None, "samples": [], "level": "unknown", "error": str(e)}
|
||||||
|
rtt = chains.median_ms(samples)
|
||||||
|
return {"rtt_ms": rtt, "samples": samples, "level": chains.latency_level(rtt)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/api/chains/probe")
|
||||||
|
def admin_probe_chain(entry: str, exit: str, request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
entry_node = db.get_node(entry)
|
||||||
|
exit_node = db.get_node(exit)
|
||||||
|
if not entry_node or not exit_node or entry == exit:
|
||||||
|
raise HTTPException(400, "нужны две разные ноды")
|
||||||
|
return _hop_probe(entry_node, exit_node)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/api/chains/{code}/check")
|
||||||
|
def admin_check_chain(code: str, request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
chain = db.get_chain(code)
|
||||||
|
if not chain:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
entry = db.get_node(chain["entry_node"])
|
||||||
|
exit_node = db.get_node(chain["exit_node"])
|
||||||
|
if not entry or not exit_node:
|
||||||
|
raise HTTPException(400, "одна из нод цепочки удалена")
|
||||||
|
entry_alive = nodeprov.check_node_alive(entry["address"], chain["port"])
|
||||||
|
hop = _hop_probe(entry, exit_node)
|
||||||
|
return {"entry_alive": entry_alive, **hop}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/api/audit")
|
||||||
|
def admin_audit(request: Request, limit: int = 100):
|
||||||
|
require_admin(request)
|
||||||
|
return db.list_audit(limit=max(1, min(limit, 500)))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/api/subscriptions/export.csv")
|
||||||
|
def admin_export_subscriptions(request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||||
|
plans_by_code = settings.get_plans_by_code()
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(["tg_id", "username", "node", "plan", "created_at", "expires_at", "days_left", "status", "uuid"])
|
||||||
|
for s in db.list_all_subscriptions(limit=100000):
|
||||||
|
node = nodes_by_code.get(s["node"])
|
||||||
|
plan = plans_by_code.get(s["plan"])
|
||||||
|
if s.get("held_at"):
|
||||||
|
status = "held"
|
||||||
|
elif not s["active"] or s["expires_at"] <= db.now_iso():
|
||||||
|
status = "expired"
|
||||||
|
else:
|
||||||
|
status = "active"
|
||||||
|
writer.writerow([_csv_safe(v) for v in [
|
||||||
|
s["tg_id"], s.get("username") or "", node["label"] if node else s["node"],
|
||||||
|
plan["label"] if plan else s["plan"], s["created_at"], s["expires_at"],
|
||||||
|
_days_left(s), status, s["uuid"],
|
||||||
|
]])
|
||||||
|
return Response(
|
||||||
|
content="" + buf.getvalue(), media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="subscriptions.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ADMIN_HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "admin.html")
|
ADMIN_HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "admin.html")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,5 +123,8 @@ def restore_backup(data: bytes) -> dict:
|
||||||
os.chmod(tmp_db_path, 0o600)
|
os.chmod(tmp_db_path, 0o600)
|
||||||
os.replace(tmp_db_path, DB_PATH)
|
os.replace(tmp_db_path, DB_PATH)
|
||||||
|
|
||||||
|
import db
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
_prune_old_safety_copies()
|
_prune_old_safety_copies()
|
||||||
return {"restored_env": restored_env, "safety_copy": safety_copy}
|
return {"restored_env": restored_env, "safety_copy": safety_copy}
|
||||||
|
|
|
||||||
248
bot.py
248
bot.py
|
|
@ -2,12 +2,14 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher, F
|
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.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from aiogram.enums import ParseMode
|
from aiogram.enums import ParseMode
|
||||||
|
|
||||||
|
import chains
|
||||||
import db
|
import db
|
||||||
|
import features
|
||||||
import payments
|
import payments
|
||||||
import settings
|
import settings
|
||||||
import webhooks
|
import webhooks
|
||||||
|
|
@ -30,9 +32,14 @@ def is_admin(tg_id: int) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def main_menu_kb(tg_id: int) -> InlineKeyboardMarkup:
|
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="Получить VPN", callback_data="menu:get")],
|
||||||
[InlineKeyboardButton(text="Моя подписка", callback_data="menu:mysub")],
|
[InlineKeyboardButton(text="Моя подписка", callback_data="menu:mysub")],
|
||||||
|
[InlineKeyboardButton(text="Промокод", callback_data="menu:promo")],
|
||||||
|
[InlineKeyboardButton(text="Пригласить друга", callback_data="menu:referral")],
|
||||||
[InlineKeyboardButton(text="О сервисе", callback_data="menu:about")],
|
[InlineKeyboardButton(text="О сервисе", callback_data="menu:about")],
|
||||||
]
|
]
|
||||||
if is_admin(tg_id):
|
if is_admin(tg_id):
|
||||||
|
|
@ -40,6 +47,14 @@ def main_menu_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
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:
|
def nodes_kb(prefix: str) -> InlineKeyboardMarkup:
|
||||||
rows = []
|
rows = []
|
||||||
for n in db.list_nodes(enabled_only=True):
|
for n in db.list_nodes(enabled_only=True):
|
||||||
|
|
@ -48,11 +63,16 @@ def nodes_kb(prefix: str) -> InlineKeyboardMarkup:
|
||||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
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"]
|
payments_enabled = settings.get_payment_settings()["payments_enabled"]
|
||||||
|
promo = db.get_pending_promo(tg_id) if tg_id else None
|
||||||
rows = []
|
rows = []
|
||||||
for p in settings.get_plans():
|
for p in settings.get_plans():
|
||||||
label = f"{p['label']} — {p['price']} ₽" if payments_enabled and p["price"] > 0 else p["label"]
|
if payments_enabled and p["price"] > 0:
|
||||||
|
final = db.discounted_price(p["price"], promo)
|
||||||
|
label = f"{p['label']} — {final} ₽" if final == p["price"] else f"{p['label']} — {final} ₽ (было {p['price']})"
|
||||||
|
else:
|
||||||
|
label = p["label"]
|
||||||
rows.append([InlineKeyboardButton(text=label, callback_data=f"{prefix}:{node_code}:{p['code']}")])
|
rows.append([InlineKeyboardButton(text=label, callback_data=f"{prefix}:{node_code}:{p['code']}")])
|
||||||
rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:get")])
|
rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:get")])
|
||||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||||
|
|
@ -90,6 +110,12 @@ async def send_main_menu(message: Message):
|
||||||
async def start_deeplink(message: Message, command: CommandObject):
|
async def start_deeplink(message: Message, command: CommandObject):
|
||||||
user = db.get_or_create_user(message.from_user.id, message.from_user.username)
|
user = db.get_or_create_user(message.from_user.id, message.from_user.username)
|
||||||
payload = command.args or ""
|
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-"):
|
if payload.startswith("gift_") or payload.startswith("gift-"):
|
||||||
code = payload[5:]
|
code = payload[5:]
|
||||||
gift, err = db.redeem_gift_code(code, message.from_user.id)
|
gift, err = db.redeem_gift_code(code, message.from_user.id)
|
||||||
|
|
@ -140,6 +166,32 @@ async def cb_about(cb: CallbackQuery):
|
||||||
await cb.answer()
|
await cb.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@dp.callback_query(F.data == "menu:referral")
|
||||||
|
async def cb_referral(cb: CallbackQuery):
|
||||||
|
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
|
||||||
|
ref_settings = settings.get_referral_settings()
|
||||||
|
if not ref_settings["enabled"]:
|
||||||
|
await cb.message.edit_text("Реферальная программа сейчас отключена.", reply_markup=kb)
|
||||||
|
return await cb.answer()
|
||||||
|
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||||
|
stats = db.referral_stats(cb.from_user.id)
|
||||||
|
username = await get_bot_username()
|
||||||
|
link = f"https://t.me/{username}?start=ref_{user['ref_code']}"
|
||||||
|
days = ref_settings["bonus_days"]
|
||||||
|
text = (
|
||||||
|
f"<b>Пригласи друга</b>\n\n"
|
||||||
|
f"За каждого друга, который активирует подписку по твоей ссылке, "
|
||||||
|
f"вы <b>оба</b> получаете +{days} дн. к подписке.\n\n"
|
||||||
|
f"{DIVIDER}\n"
|
||||||
|
f"Твоя ссылка:\n<code>{link}</code>\n\n"
|
||||||
|
f"Приглашено: {stats['referred_count']}\n"
|
||||||
|
)
|
||||||
|
if stats["bonus_days_pending"]:
|
||||||
|
text += f"Накоплено бонусных дней (зачислятся при следующей подписке): {stats['bonus_days_pending']}\n"
|
||||||
|
await cb.message.edit_text(text, reply_markup=kb)
|
||||||
|
await cb.answer()
|
||||||
|
|
||||||
|
|
||||||
@dp.callback_query(F.data == "menu:get")
|
@dp.callback_query(F.data == "menu:get")
|
||||||
async def cb_get(cb: CallbackQuery):
|
async def cb_get(cb: CallbackQuery):
|
||||||
await cb.message.edit_text("Выбери сервер:", reply_markup=nodes_kb("node"))
|
await cb.message.edit_text("Выбери сервер:", reply_markup=nodes_kb("node"))
|
||||||
|
|
@ -149,7 +201,7 @@ async def cb_get(cb: CallbackQuery):
|
||||||
@dp.callback_query(F.data.startswith("node:"))
|
@dp.callback_query(F.data.startswith("node:"))
|
||||||
async def cb_node(cb: CallbackQuery):
|
async def cb_node(cb: CallbackQuery):
|
||||||
node_code = cb.data.split(":")[1]
|
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()
|
await cb.answer()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -167,15 +219,24 @@ async def cb_plan(cb: CallbackQuery):
|
||||||
plan = settings.get_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)
|
db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||||
|
|
||||||
if settings.get_payment_settings()["payments_enabled"] and plan["price"] > 0 and payments.available_providers():
|
promo = db.get_pending_promo(cb.from_user.id)
|
||||||
|
final_price = db.discounted_price(plan["price"], promo)
|
||||||
|
if settings.get_payment_settings()["payments_enabled"] and final_price > 0 and payments.available_providers():
|
||||||
|
price_line = f"{final_price} ₽" if final_price == plan["price"] else f"{final_price} ₽ (скидка по промокоду {promo['code']})"
|
||||||
await cb.message.edit_text(
|
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),
|
reply_markup=providers_kb(node_code, plan_code),
|
||||||
)
|
)
|
||||||
return await cb.answer()
|
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)
|
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)
|
node_row = db.get_node(node_code)
|
||||||
await asyncio.to_thread(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=[
|
kb = connect_kb(user["token"], extra_rows=[
|
||||||
|
|
@ -199,10 +260,15 @@ async def cb_pay(cb: CallbackQuery):
|
||||||
plan = settings.get_plans_by_code()[plan_code]
|
plan = settings.get_plans_by_code()[plan_code]
|
||||||
node_row = db.get_node(node_code)
|
node_row = db.get_node(node_code)
|
||||||
payment_id = payments.new_payment_id()
|
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:
|
try:
|
||||||
external_id, pay_url = payments.create_payment_link(
|
external_id, pay_url = payments.create_payment_link(
|
||||||
provider, payment_id, plan["price"], f"{settings.get_brand_name()} — {node_row['label']}, {plan['label']}",
|
provider, payment_id, final_price, f"{settings.get_brand_name()} — {node_row['label']}, {plan['label']}",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("payment creation failed")
|
log.exception("payment creation failed")
|
||||||
|
|
@ -214,7 +280,7 @@ async def cb_pay(cb: CallbackQuery):
|
||||||
[InlineKeyboardButton(text="Назад", callback_data=f"plan:{node_code}:{plan_code}")],
|
[InlineKeyboardButton(text="Назад", callback_data=f"plan:{node_code}:{plan_code}")],
|
||||||
])
|
])
|
||||||
await cb.message.edit_text(
|
await cb.message.edit_text(
|
||||||
f"Счёт на {plan['price']} ₽ создан.\nПосле оплаты подписка выдастся автоматически.",
|
f"Счёт на {final_price} ₽ создан.\nПосле оплаты подписка выдастся автоматически.",
|
||||||
reply_markup=kb,
|
reply_markup=kb,
|
||||||
)
|
)
|
||||||
await cb.answer()
|
await cb.answer()
|
||||||
|
|
@ -235,7 +301,7 @@ async def cb_mysub(cb: CallbackQuery):
|
||||||
plan = plans_by_code.get(s["plan"], {}).get("label", s["plan"])
|
plan = plans_by_code.get(s["plan"], {}).get("label", s["plan"])
|
||||||
node_info = nodes_by_code.get(s["node"])
|
node_info = nodes_by_code.get(s["node"])
|
||||||
node = node_info["label"] if node_info else 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>")
|
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")]])
|
kb = connect_kb(user["token"], extra_rows=[[InlineKeyboardButton(text="В меню", callback_data="menu:main")]])
|
||||||
await cb.message.edit_text("\n".join(lines), reply_markup=kb)
|
await cb.message.edit_text("\n".join(lines), reply_markup=kb)
|
||||||
|
|
@ -327,6 +393,140 @@ async def cb_admin_sync(cb: CallbackQuery):
|
||||||
await cb.answer()
|
await cb.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@dp.callback_query(F.data == "trial:start")
|
||||||
|
async def cb_trial(cb: CallbackQuery):
|
||||||
|
feats = settings.get_features()
|
||||||
|
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||||
|
if not feats["trial_enabled"] or not db.trial_available(cb.from_user.id):
|
||||||
|
return await cb.answer("Пробный период недоступен", show_alert=True)
|
||||||
|
node_row = features.pick_trial_node()
|
||||||
|
if not node_row:
|
||||||
|
return await cb.answer("Сейчас нет доступных серверов", show_alert=True)
|
||||||
|
if not db.claim_trial(cb.from_user.id):
|
||||||
|
return await cb.answer("Пробный период уже использован", show_alert=True)
|
||||||
|
limit = feats["trial_traffic_gb"] * settings.GB if feats["trial_traffic_gb"] > 0 else None
|
||||||
|
sub = db.create_subscription(
|
||||||
|
cb.from_user.id, node_row["code"], feats["trial_days"], "trial", source="trial", traffic_limit=limit,
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
|
||||||
|
traffic_line = f"\nТрафик: до {feats['trial_traffic_gb']} ГБ" if limit else ""
|
||||||
|
kb = connect_kb(user["token"], extra_rows=[[InlineKeyboardButton(text="В меню", callback_data="menu:main")]])
|
||||||
|
await cb.message.edit_text(
|
||||||
|
f"<b>Пробный период активен</b>\n\n"
|
||||||
|
f"Сервер: {node_row['label']}\n"
|
||||||
|
f"Срок: до {sub['expires_at'][:10]}{traffic_line}\n\n"
|
||||||
|
f"{DIVIDER}\n"
|
||||||
|
f"Ссылка-подписка:\n<code>{sub_url_for(user['token'])}</code>",
|
||||||
|
reply_markup=kb,
|
||||||
|
)
|
||||||
|
await cb.answer("Пробный период выдан")
|
||||||
|
await asyncio.to_thread(webhooks.send, "subscription.trial", {
|
||||||
|
"tg_id": cb.from_user.id, "node": node_row["code"], "subscription_uuid": sub["uuid"],
|
||||||
|
"expires_at": sub["expires_at"],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
PROMO_ERRORS = {
|
||||||
|
"not_found": "Такого промокода нет.",
|
||||||
|
"expired": "Срок действия промокода закончился.",
|
||||||
|
"exhausted": "Этот промокод уже использован максимальное число раз.",
|
||||||
|
"already_used": "Ты уже использовал этот промокод.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_promo_code(message: Message, raw_code: str):
|
||||||
|
code = (raw_code or "").strip()
|
||||||
|
if not code:
|
||||||
|
return await message.answer("Напиши промокод так: /promo КОД")
|
||||||
|
db.get_or_create_user(message.from_user.id, message.from_user.username)
|
||||||
|
promo, err = db.validate_promo(code, message.from_user.id)
|
||||||
|
if err:
|
||||||
|
return await message.answer(PROMO_ERRORS.get(err, "Промокод не подошёл."))
|
||||||
|
if promo["kind"] == "days":
|
||||||
|
redeemed, err = db.redeem_days_promo(code, message.from_user.id)
|
||||||
|
if err:
|
||||||
|
return await message.answer(PROMO_ERRORS.get(err, "Промокод не подошёл."))
|
||||||
|
return await message.answer(f"Промокод принят: +{promo['value']} дн. к подписке.")
|
||||||
|
db.set_promo_pending(message.from_user.id, promo["code"])
|
||||||
|
what = f"{promo['value']}%" if promo["kind"] == "percent" else f"{promo['value']} ₽"
|
||||||
|
await message.answer(f"Промокод принят: скидка {what}. Она применится на следующей оплате, выбери срок в меню.")
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(Command("promo"))
|
||||||
|
async def cmd_promo(message: Message, command: CommandObject):
|
||||||
|
await apply_promo_code(message, command.args or "")
|
||||||
|
|
||||||
|
|
||||||
|
@dp.callback_query(F.data == "menu:promo")
|
||||||
|
async def cb_promo_hint(cb: CallbackQuery):
|
||||||
|
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
|
||||||
|
await cb.message.edit_text("Отправь команду с кодом, например:\n<code>/promo КОД</code>", reply_markup=kb)
|
||||||
|
await cb.answer()
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_limit_reached(subs: list):
|
||||||
|
for sub in subs:
|
||||||
|
try:
|
||||||
|
await bot.send_message(
|
||||||
|
sub["tg_id"],
|
||||||
|
"<b>Лимит трафика исчерпан</b>\n\nДоступ приостановлен. Продли подписку или напиши в поддержку, "
|
||||||
|
"чтобы получить ещё трафик.",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception("failed to notify about traffic limit")
|
||||||
|
await asyncio.to_thread(webhooks.send, "subscription.limit_reached", {
|
||||||
|
"tg_id": sub["tg_id"], "subscription_uuid": sub["uuid"], "node": sub["node"],
|
||||||
|
"limit": sub["traffic_limit"], "used": sub["traffic_used"],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def send_expiry_reminders():
|
||||||
|
if not settings.get_features()["reminders_enabled"]:
|
||||||
|
return
|
||||||
|
for sub, kind, stage in features.reminders_due():
|
||||||
|
left = "3 дня" if stage == "3d" else "сутки"
|
||||||
|
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Продлить", callback_data="menu:get")]])
|
||||||
|
try:
|
||||||
|
await bot.send_message(
|
||||||
|
sub["tg_id"],
|
||||||
|
f"<b>Подписка скоро закончится</b>\n\nДо конца осталось меньше чем {left} "
|
||||||
|
f"(до {sub['expires_at'][:10]}). Продли заранее, чтобы доступ не прерывался.",
|
||||||
|
reply_markup=kb,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception("failed to send expiry reminder")
|
||||||
|
features.mark_stage_sent(sub["uuid"], sub["expires_at"])
|
||||||
|
|
||||||
|
|
||||||
|
_node_state: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def check_nodes_and_alert():
|
||||||
|
if not settings.get_features()["node_alerts_enabled"]:
|
||||||
|
return
|
||||||
|
for node in db.list_nodes(enabled_only=True):
|
||||||
|
if node["kind"] == "local" or node["status"] != "active" or not node.get("address"):
|
||||||
|
continue
|
||||||
|
samples = await asyncio.to_thread(chains.tcp_connect_ms, node["address"], node["port"], 2, 3.0)
|
||||||
|
alive = chains.median_ms(samples) is not None
|
||||||
|
previous = _node_state.get(node["code"])
|
||||||
|
_node_state[node["code"]] = alive
|
||||||
|
if previous is None or previous == alive:
|
||||||
|
continue
|
||||||
|
text = (
|
||||||
|
f"Нода «{node['label']}» ({node['address']}) снова доступна."
|
||||||
|
if alive else f"Нода «{node['label']}» ({node['address']}) не отвечает."
|
||||||
|
)
|
||||||
|
for admin_id in ADMIN_IDS:
|
||||||
|
try:
|
||||||
|
await bot.send_message(admin_id, text)
|
||||||
|
except Exception:
|
||||||
|
log.exception("failed to send node alert")
|
||||||
|
await asyncio.to_thread(webhooks.send, "node.up" if alive else "node.down", {
|
||||||
|
"node": node["code"], "label": node["label"], "address": node["address"],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
async def reconcile_pending_payments():
|
async def reconcile_pending_payments():
|
||||||
if not settings.get_payment_settings()["payments_enabled"]:
|
if not settings.get_payment_settings()["payments_enabled"]:
|
||||||
return
|
return
|
||||||
|
|
@ -347,7 +547,10 @@ async def reconcile_pending_payments():
|
||||||
granted = db.mark_payment_paid(payment["id"])
|
granted = db.mark_payment_paid(payment["id"])
|
||||||
if not granted:
|
if not granted:
|
||||||
continue
|
continue
|
||||||
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
|
sub = db.create_subscription(
|
||||||
|
payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment",
|
||||||
|
traffic_limit=settings.default_traffic_limit_bytes(),
|
||||||
|
)
|
||||||
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
|
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)
|
user = db.get_or_create_user(payment["tg_id"], None)
|
||||||
try:
|
try:
|
||||||
|
|
@ -374,11 +577,30 @@ async def reconcile_pending_payments():
|
||||||
|
|
||||||
|
|
||||||
async def periodic_sync():
|
async def periodic_sync():
|
||||||
|
tick = 0
|
||||||
while True:
|
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:
|
try:
|
||||||
await asyncio.to_thread(xray_manager.sync_all)
|
await asyncio.to_thread(xray_manager.sync_all)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("periodic sync failed")
|
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:
|
try:
|
||||||
await reconcile_pending_payments()
|
await reconcile_pending_payments()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
233
chains.py
Normal file
233
chains.py
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
BASE_TAGS = ("vless-tcp-reality", "vless-grpc-reality", "vless-xhttp-reality", "vless-ws-tls")
|
||||||
|
TCP_TAG = "vless-tcp-reality"
|
||||||
|
VISION = "xtls-rprx-vision"
|
||||||
|
CHAIN_PREFIX = "chain-"
|
||||||
|
RELAY_EMAIL_PREFIX = "relay-"
|
||||||
|
MAX_SERVERS = 2
|
||||||
|
PORT_MIN = 10443
|
||||||
|
PORT_MAX = 10999
|
||||||
|
CODE_RE = re.compile(r"^[a-z0-9]{1,16}$")
|
||||||
|
HOST_RE = re.compile(r"^[A-Za-z0-9.-]{1,253}$")
|
||||||
|
CHAIN_KINDS_ENTRY = ("local", "managed")
|
||||||
|
CHAIN_KINDS_EXIT = ("local", "managed", "external")
|
||||||
|
|
||||||
|
|
||||||
|
class ChainConfigError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def inbound_tag(code):
|
||||||
|
return CHAIN_PREFIX + code
|
||||||
|
|
||||||
|
|
||||||
|
def outbound_tag(code):
|
||||||
|
return CHAIN_PREFIX + code + "-out"
|
||||||
|
|
||||||
|
|
||||||
|
def relay_email(code):
|
||||||
|
return RELAY_EMAIL_PREFIX + code
|
||||||
|
|
||||||
|
|
||||||
|
def is_chain_inbound_tag(tag):
|
||||||
|
return bool(tag) and tag.startswith(CHAIN_PREFIX) and not tag.endswith("-out")
|
||||||
|
|
||||||
|
|
||||||
|
def is_user_tag(tag):
|
||||||
|
return tag in BASE_TAGS or is_chain_inbound_tag(tag)
|
||||||
|
|
||||||
|
|
||||||
|
def flow_for_tag(tag):
|
||||||
|
if tag == TCP_TAG or is_chain_inbound_tag(tag):
|
||||||
|
return VISION
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sync_clients(clients, wanted, flow):
|
||||||
|
kept = []
|
||||||
|
seen = set()
|
||||||
|
for c in clients:
|
||||||
|
cid = c.get("id")
|
||||||
|
if cid in wanted and cid not in seen:
|
||||||
|
kept.append(c)
|
||||||
|
seen.add(cid)
|
||||||
|
for cid in wanted:
|
||||||
|
if cid in seen:
|
||||||
|
continue
|
||||||
|
entry = {"id": cid, "email": wanted[cid]}
|
||||||
|
if flow:
|
||||||
|
entry["flow"] = flow
|
||||||
|
kept.append(entry)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def find_inbound(cfg, tag):
|
||||||
|
for ib in cfg.get("inbounds", []):
|
||||||
|
if ib.get("tag") == tag:
|
||||||
|
return ib
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_chain_inbound(template, chain, wanted, old_clients):
|
||||||
|
reality = (template.get("streamSettings") or {}).get("realitySettings")
|
||||||
|
if not reality:
|
||||||
|
raise ChainConfigError("у входной ноды нет TCP+Reality inbound — цепочку строить не из чего")
|
||||||
|
ib = copy.deepcopy(template)
|
||||||
|
ib["tag"] = inbound_tag(chain["code"])
|
||||||
|
ib["port"] = chain["port"]
|
||||||
|
ib["streamSettings"]["realitySettings"]["shortIds"] = [chain["short_id"]]
|
||||||
|
ib["settings"]["clients"] = sync_clients(old_clients, wanted, VISION)
|
||||||
|
return ib
|
||||||
|
|
||||||
|
|
||||||
|
def build_chain_outbound(chain, exit_node, relay_uuid):
|
||||||
|
return {
|
||||||
|
"tag": outbound_tag(chain["code"]),
|
||||||
|
"protocol": "vless",
|
||||||
|
"settings": {
|
||||||
|
"vnext": [{
|
||||||
|
"address": exit_node["address"],
|
||||||
|
"port": int(exit_node["port"]),
|
||||||
|
"users": [{"id": relay_uuid, "encryption": "none", "flow": VISION}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "tcp",
|
||||||
|
"security": "reality",
|
||||||
|
"realitySettings": {
|
||||||
|
"serverName": exit_node["sni"],
|
||||||
|
"fingerprint": "chrome",
|
||||||
|
"publicKey": exit_node["public_key"],
|
||||||
|
"shortId": exit_node["short_id"],
|
||||||
|
"spiderX": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_chain_rule(chain):
|
||||||
|
return {
|
||||||
|
"type": "field",
|
||||||
|
"inboundTag": [inbound_tag(chain["code"])],
|
||||||
|
"outboundTag": outbound_tag(chain["code"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def relay_for_chain(chain, exit_node):
|
||||||
|
if exit_node["kind"] == "external":
|
||||||
|
return exit_node.get("shared_uuid")
|
||||||
|
return chain.get("relay_uuid")
|
||||||
|
|
||||||
|
|
||||||
|
def split_busy_chains(cfg, entry_chains, busy_ports):
|
||||||
|
new_ports = set(new_ports_needed(cfg, entry_chains))
|
||||||
|
usable = []
|
||||||
|
problems = []
|
||||||
|
for chain in entry_chains:
|
||||||
|
if chain["port"] in new_ports and chain["port"] in busy_ports:
|
||||||
|
problems.append(f"{chain['code']}: порт {chain['port']} уже занят другим процессом, цепочка не применена")
|
||||||
|
continue
|
||||||
|
usable.append(chain)
|
||||||
|
return usable, problems
|
||||||
|
|
||||||
|
|
||||||
|
def sync_config(cfg, wanted, relay_wanted, entry_chains, exit_nodes, apply_chains=True):
|
||||||
|
before = json.dumps(cfg, sort_keys=True)
|
||||||
|
problems = []
|
||||||
|
|
||||||
|
template = find_inbound(cfg, TCP_TAG)
|
||||||
|
|
||||||
|
for ib in cfg["inbounds"]:
|
||||||
|
tag = ib.get("tag")
|
||||||
|
if not is_user_tag(tag):
|
||||||
|
continue
|
||||||
|
want = dict(wanted)
|
||||||
|
if tag == TCP_TAG:
|
||||||
|
want.update(relay_wanted)
|
||||||
|
ib["settings"]["clients"] = sync_clients(ib["settings"]["clients"], want, flow_for_tag(tag))
|
||||||
|
|
||||||
|
if not apply_chains:
|
||||||
|
changed = json.dumps(cfg, sort_keys=True) != before
|
||||||
|
return changed, problems
|
||||||
|
|
||||||
|
old_chain_inbounds = {}
|
||||||
|
for ib in cfg["inbounds"]:
|
||||||
|
if is_chain_inbound_tag(ib.get("tag")):
|
||||||
|
old_chain_inbounds[ib["tag"]] = ib
|
||||||
|
|
||||||
|
kept_inbounds = [ib for ib in cfg["inbounds"] if not is_chain_inbound_tag(ib.get("tag"))]
|
||||||
|
kept_outbounds = [ob for ob in cfg.get("outbounds", []) if not (ob.get("tag") or "").startswith(CHAIN_PREFIX)]
|
||||||
|
routing = cfg.setdefault("routing", {})
|
||||||
|
kept_rules = [r for r in routing.get("rules", []) if not (r.get("outboundTag") or "").startswith(CHAIN_PREFIX)]
|
||||||
|
|
||||||
|
for chain in entry_chains:
|
||||||
|
exit_node = exit_nodes.get(chain["exit_node"])
|
||||||
|
if template is None:
|
||||||
|
problems.append(f"{chain['code']}: нет TCP+Reality inbound на входной ноде")
|
||||||
|
continue
|
||||||
|
if not exit_node:
|
||||||
|
problems.append(f"{chain['code']}: выходная нода не найдена")
|
||||||
|
continue
|
||||||
|
relay_uuid = relay_for_chain(chain, exit_node)
|
||||||
|
if not relay_uuid:
|
||||||
|
problems.append(f"{chain['code']}: у выходной ноды нет ключа для цепочки")
|
||||||
|
continue
|
||||||
|
old = old_chain_inbounds.get(inbound_tag(chain["code"]))
|
||||||
|
old_clients = old["settings"]["clients"] if old else []
|
||||||
|
kept_inbounds.append(build_chain_inbound(template, chain, wanted, old_clients))
|
||||||
|
kept_outbounds.append(build_chain_outbound(chain, exit_node, relay_uuid))
|
||||||
|
kept_rules.append(build_chain_rule(chain))
|
||||||
|
|
||||||
|
cfg["inbounds"] = kept_inbounds
|
||||||
|
cfg["outbounds"] = kept_outbounds
|
||||||
|
routing["rules"] = kept_rules
|
||||||
|
|
||||||
|
changed = json.dumps(cfg, sort_keys=True) != before
|
||||||
|
return changed, problems
|
||||||
|
|
||||||
|
|
||||||
|
def new_ports_needed(cfg, entry_chains):
|
||||||
|
existing = set()
|
||||||
|
for ib in cfg.get("inbounds", []):
|
||||||
|
if is_chain_inbound_tag(ib.get("tag")):
|
||||||
|
existing.add(ib["tag"])
|
||||||
|
ports = []
|
||||||
|
for chain in entry_chains:
|
||||||
|
if inbound_tag(chain["code"]) not in existing:
|
||||||
|
ports.append(chain["port"])
|
||||||
|
return ports
|
||||||
|
|
||||||
|
|
||||||
|
def tcp_connect_ms(host, port, samples=3, timeout=3.0):
|
||||||
|
results = []
|
||||||
|
for _ in range(samples):
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, int(port)), timeout=timeout):
|
||||||
|
pass
|
||||||
|
results.append(round((time.perf_counter() - start) * 1000))
|
||||||
|
except OSError:
|
||||||
|
results.append(-1)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def median_ms(samples):
|
||||||
|
good = sorted(s for s in samples if s >= 0)
|
||||||
|
if not good:
|
||||||
|
return None
|
||||||
|
return good[len(good) // 2]
|
||||||
|
|
||||||
|
|
||||||
|
def latency_level(rtt_ms):
|
||||||
|
if rtt_ms is None:
|
||||||
|
return "unknown"
|
||||||
|
if rtt_ms < 40:
|
||||||
|
return "low"
|
||||||
|
if rtt_ms < 120:
|
||||||
|
return "medium"
|
||||||
|
return "high"
|
||||||
|
|
@ -120,3 +120,6 @@ PLATEGA_SECRET = env("PLATEGA_SECRET", "")
|
||||||
|
|
||||||
HWID_LIMIT_ENABLED = env("HWID_LIMIT_ENABLED", "false").lower() == "true"
|
HWID_LIMIT_ENABLED = env("HWID_LIMIT_ENABLED", "false").lower() == "true"
|
||||||
HWID_FALLBACK_LIMIT = int(env("HWID_FALLBACK_LIMIT", "3"))
|
HWID_FALLBACK_LIMIT = int(env("HWID_FALLBACK_LIMIT", "3"))
|
||||||
|
|
||||||
|
REFERRAL_ENABLED = env("REFERRAL_ENABLED", "true").lower() == "true"
|
||||||
|
REFERRAL_BONUS_DAYS = int(env("REFERRAL_BONUS_DAYS", "3"))
|
||||||
|
|
|
||||||
509
db.py
509
db.py
|
|
@ -6,6 +6,7 @@ import secrets
|
||||||
import datetime
|
import datetime
|
||||||
import contextlib
|
import contextlib
|
||||||
|
|
||||||
|
import chains as chainsmod
|
||||||
from config import DB_PATH
|
from config import DB_PATH
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
|
|
@ -113,6 +114,56 @@ CREATE TABLE IF NOT EXISTS nodes (
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chains (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
entry_node TEXT NOT NULL,
|
||||||
|
exit_node TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
short_id TEXT NOT NULL,
|
||||||
|
relay_uuid TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
admin TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
detail TEXT,
|
||||||
|
ip TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS promo_codes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
value INTEGER NOT NULL,
|
||||||
|
max_uses INTEGER,
|
||||||
|
used_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
expires_at TEXT,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS promo_uses (
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
tg_id INTEGER NOT NULL,
|
||||||
|
used_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (code, tg_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sub_notices (
|
||||||
|
sub_uuid TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
sent_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (sub_uuid, kind)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_chains_pair ON chains (entry_node, exit_node);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_chains_entry_port ON chains (entry_node, port);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log (ts);
|
||||||
CREATE INDEX IF NOT EXISTS idx_subs_active_expires ON subscriptions (active, expires_at);
|
CREATE INDEX IF NOT EXISTS idx_subs_active_expires ON subscriptions (active, expires_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_subs_tg_id ON subscriptions (tg_id);
|
CREATE INDEX IF NOT EXISTS idx_subs_tg_id ON subscriptions (tg_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_subs_node ON subscriptions (node);
|
CREATE INDEX IF NOT EXISTS idx_subs_node ON subscriptions (node);
|
||||||
|
|
@ -133,6 +184,12 @@ _NEW_NODE_COLUMNS = {
|
||||||
|
|
||||||
_NEW_USER_COLUMNS = {
|
_NEW_USER_COLUMNS = {
|
||||||
"hwid_limit": "INTEGER",
|
"hwid_limit": "INTEGER",
|
||||||
|
"ref_code": "TEXT",
|
||||||
|
"referred_by": "INTEGER",
|
||||||
|
"referral_rewarded": "INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"bonus_days_pending": "INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"trial_used": "INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"promo_pending": "TEXT",
|
||||||
}
|
}
|
||||||
|
|
||||||
_NEW_ADMIN_SESSION_COLUMNS = {
|
_NEW_ADMIN_SESSION_COLUMNS = {
|
||||||
|
|
@ -145,6 +202,15 @@ _NEW_ADMIN_COLUMNS = {
|
||||||
|
|
||||||
_NEW_SUBSCRIPTION_COLUMNS = {
|
_NEW_SUBSCRIPTION_COLUMNS = {
|
||||||
"held_at": "TEXT",
|
"held_at": "TEXT",
|
||||||
|
"traffic_limit": "INTEGER",
|
||||||
|
"traffic_used": "INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"traffic_last_raw": "INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"limit_hit_at": "TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
_NEW_PAYMENT_COLUMNS = {
|
||||||
|
"promo_code": "TEXT",
|
||||||
|
"original_amount": "INTEGER",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -159,6 +225,7 @@ def _migrate():
|
||||||
for name, decl in _NEW_USER_COLUMNS.items():
|
for name, decl in _NEW_USER_COLUMNS.items():
|
||||||
if name not in ucols:
|
if name not in ucols:
|
||||||
conn.execute(f"ALTER TABLE users ADD COLUMN {name} {decl}")
|
conn.execute(f"ALTER TABLE users ADD COLUMN {name} {decl}")
|
||||||
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ref_code ON users(ref_code)")
|
||||||
scols = {r["name"] for r in conn.execute("PRAGMA table_info(admin_sessions)").fetchall()}
|
scols = {r["name"] for r in conn.execute("PRAGMA table_info(admin_sessions)").fetchall()}
|
||||||
for name, decl in _NEW_ADMIN_SESSION_COLUMNS.items():
|
for name, decl in _NEW_ADMIN_SESSION_COLUMNS.items():
|
||||||
if name not in scols:
|
if name not in scols:
|
||||||
|
|
@ -171,6 +238,10 @@ def _migrate():
|
||||||
for name, decl in _NEW_SUBSCRIPTION_COLUMNS.items():
|
for name, decl in _NEW_SUBSCRIPTION_COLUMNS.items():
|
||||||
if name not in subcols:
|
if name not in subcols:
|
||||||
conn.execute(f"ALTER TABLE subscriptions ADD COLUMN {name} {decl}")
|
conn.execute(f"ALTER TABLE subscriptions ADD COLUMN {name} {decl}")
|
||||||
|
pcols = {r["name"] for r in conn.execute("PRAGMA table_info(payments)").fetchall()}
|
||||||
|
for name, decl in _NEW_PAYMENT_COLUMNS.items():
|
||||||
|
if name not in pcols:
|
||||||
|
conn.execute(f"ALTER TABLE payments ADD COLUMN {name} {decl}")
|
||||||
if needs_sort_order_backfill:
|
if needs_sort_order_backfill:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT code FROM nodes ORDER BY (code='de1') DESC, created_at ASC"
|
"SELECT code FROM nodes ORDER BY (code='de1') DESC, created_at ASC"
|
||||||
|
|
@ -350,44 +421,212 @@ def delete_node(code: str):
|
||||||
).fetchone()["c"]
|
).fetchone()["c"]
|
||||||
if active:
|
if active:
|
||||||
raise ValueError(f"node has {active} active subscriptions, revoke them first")
|
raise ValueError(f"node has {active} active subscriptions, revoke them first")
|
||||||
|
in_chains = conn.execute(
|
||||||
|
"SELECT COUNT(*) c FROM chains WHERE entry_node=? OR exit_node=?", (code, code)
|
||||||
|
).fetchone()["c"]
|
||||||
|
if in_chains:
|
||||||
|
raise ValueError(f"node is used in {in_chains} chain(s), delete them first")
|
||||||
conn.execute("DELETE FROM nodes WHERE code=?", (code,))
|
conn.execute("DELETE FROM nodes WHERE code=?", (code,))
|
||||||
|
|
||||||
|
|
||||||
|
def list_chains(enabled_only: bool = False):
|
||||||
|
q = "SELECT * FROM chains"
|
||||||
|
if enabled_only:
|
||||||
|
q += " WHERE enabled=1"
|
||||||
|
q += " ORDER BY sort_order ASC, created_at ASC"
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(q).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_chain(code: str):
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM chains WHERE code=?", (code,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_chain(label: str, entry_node: str, exit_node: str, relay_uuid: str | None):
|
||||||
|
with get_conn() as conn:
|
||||||
|
dup = conn.execute(
|
||||||
|
"SELECT 1 FROM chains WHERE entry_node=? AND exit_node=?", (entry_node, exit_node)
|
||||||
|
).fetchone()
|
||||||
|
if dup:
|
||||||
|
raise ValueError("такая цепочка уже есть")
|
||||||
|
used = {r["port"] for r in conn.execute(
|
||||||
|
"SELECT port FROM chains WHERE entry_node=?", (entry_node,)
|
||||||
|
).fetchall()}
|
||||||
|
port = None
|
||||||
|
for candidate in range(chainsmod.PORT_MIN, chainsmod.PORT_MAX + 1):
|
||||||
|
if candidate not in used:
|
||||||
|
port = candidate
|
||||||
|
break
|
||||||
|
if port is None:
|
||||||
|
raise ValueError("закончились свободные порты под цепочки на этой ноде")
|
||||||
|
row = conn.execute("SELECT MAX(sort_order) m FROM chains").fetchone()
|
||||||
|
next_order = (row["m"] or 0) + 1
|
||||||
|
code = "c" + secrets.token_hex(3)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO chains (code, label, entry_node, exit_node, port, short_id, relay_uuid, enabled, sort_order, created_at) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,1,?,?)",
|
||||||
|
(code, label, entry_node, exit_node, port, secrets.token_hex(8), relay_uuid, next_order, now_iso()),
|
||||||
|
)
|
||||||
|
return get_chain(code)
|
||||||
|
|
||||||
|
|
||||||
|
def update_chain(code: str, **fields):
|
||||||
|
if not fields:
|
||||||
|
return get_chain(code)
|
||||||
|
cols = ", ".join(f"{k}=?" for k in fields)
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(f"UPDATE chains SET {cols} WHERE code=?", (*fields.values(), code))
|
||||||
|
return get_chain(code)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_chain(code: str):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM chains WHERE code=?", (code,))
|
||||||
|
|
||||||
|
|
||||||
|
def add_audit(admin: str | None, action: str, detail: str = "", ip: str | None = None):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO audit_log (ts, admin, action, detail, ip) VALUES (?,?,?,?,?)",
|
||||||
|
(now_iso(), admin, action, detail[:500], ip),
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM audit_log WHERE id <= (SELECT MAX(id) FROM audit_log) - 5000")
|
||||||
|
|
||||||
|
|
||||||
|
def list_audit(limit: int = 100):
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM audit_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_ref_code(conn) -> str:
|
||||||
|
for _ in range(20):
|
||||||
|
code = secrets.token_hex(4)
|
||||||
|
if not conn.execute("SELECT 1 FROM users WHERE ref_code=?", (code,)).fetchone():
|
||||||
|
return code
|
||||||
|
raise RuntimeError("could not generate a unique ref_code")
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_user(tg_id: int, username: str | None):
|
def get_or_create_user(tg_id: int, username: str | None):
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||||
if row:
|
if row:
|
||||||
if username and row["username"] != username:
|
if username and row["username"] != username:
|
||||||
conn.execute("UPDATE users SET username=? WHERE tg_id=?", (username, tg_id))
|
conn.execute("UPDATE users SET username=? WHERE tg_id=?", (username, tg_id))
|
||||||
|
if not row["ref_code"]:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET ref_code=? WHERE tg_id=?", (_generate_ref_code(conn), tg_id)
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||||
return dict(row)
|
return dict(row)
|
||||||
token = secrets.token_hex(16)
|
token = secrets.token_hex(16)
|
||||||
|
ref_code = _generate_ref_code(conn)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (tg_id, token, username, created_at) VALUES (?,?,?,?)",
|
"INSERT INTO users (tg_id, token, username, ref_code, created_at) VALUES (?,?,?,?,?)",
|
||||||
(tg_id, token, username, now_iso()),
|
(tg_id, token, username, ref_code, now_iso()),
|
||||||
)
|
)
|
||||||
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_ref_code(ref_code: str):
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM users WHERE ref_code=?", (ref_code,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_referred_by(tg_id: int, referrer_tg_id: int) -> bool:
|
||||||
|
"""First-touch attribution: only takes effect for a brand-new account
|
||||||
|
(no subscriptions yet) that isn't already attributed, and never to self."""
|
||||||
|
if tg_id == referrer_tg_id:
|
||||||
|
return False
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT referred_by FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||||
|
if not row or row["referred_by"] is not None:
|
||||||
|
return False
|
||||||
|
has_sub = conn.execute("SELECT 1 FROM subscriptions WHERE tg_id=?", (tg_id,)).fetchone()
|
||||||
|
if has_sub:
|
||||||
|
return False
|
||||||
|
referrer = conn.execute("SELECT 1 FROM users WHERE tg_id=?", (referrer_tg_id,)).fetchone()
|
||||||
|
if not referrer:
|
||||||
|
return False
|
||||||
|
conn.execute("UPDATE users SET referred_by=? WHERE tg_id=?", (referrer_tg_id, tg_id))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_bonus_days(conn, tg_id: int, days: int):
|
||||||
|
if days <= 0:
|
||||||
|
return
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT uuid, expires_at FROM subscriptions WHERE tg_id=? AND active=1 AND held_at IS NULL "
|
||||||
|
"ORDER BY expires_at DESC LIMIT 1",
|
||||||
|
(tg_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
new_expires = datetime.datetime.fromisoformat(row["expires_at"]) + datetime.timedelta(days=days)
|
||||||
|
conn.execute("UPDATE subscriptions SET expires_at=? WHERE uuid=?", (new_expires.isoformat(), row["uuid"]))
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET bonus_days_pending = COALESCE(bonus_days_pending, 0) + ? WHERE tg_id=?",
|
||||||
|
(days, tg_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def credit_bonus_days(tg_id: int, days: int):
|
||||||
|
with get_conn() as conn:
|
||||||
|
_apply_bonus_days(conn, tg_id, days)
|
||||||
|
|
||||||
|
|
||||||
|
def referral_stats(tg_id: int) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = conn.execute("SELECT ref_code, bonus_days_pending FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||||
|
count = conn.execute(
|
||||||
|
"SELECT COUNT(*) c FROM users WHERE referred_by=? AND referral_rewarded=1", (tg_id,)
|
||||||
|
).fetchone()["c"]
|
||||||
|
return {
|
||||||
|
"ref_code": user["ref_code"] if user else None,
|
||||||
|
"bonus_days_pending": user["bonus_days_pending"] if user else 0,
|
||||||
|
"referred_count": count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_token(token: str):
|
def get_user_by_token(token: str):
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM users WHERE token=?", (token,)).fetchone()
|
row = conn.execute("SELECT * FROM users WHERE token=?", (token,)).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
def create_subscription(tg_id: int, node: str, plan_days: int, plan_code: str, source: str = "bot", client_uuid: str | None = None):
|
def create_subscription(tg_id: int, node: str, plan_days: int, plan_code: str, source: str = "bot", client_uuid: str | None = None, traffic_limit: int | None = None):
|
||||||
import uuid as uuidlib
|
import uuid as uuidlib
|
||||||
|
import config
|
||||||
|
|
||||||
cid = client_uuid or str(uuidlib.uuid4())
|
cid = client_uuid or str(uuidlib.uuid4())
|
||||||
created = datetime.datetime.utcnow()
|
created = datetime.datetime.utcnow()
|
||||||
expires = created + datetime.timedelta(days=plan_days)
|
expires = created + datetime.timedelta(days=plan_days)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
is_first = conn.execute("SELECT 1 FROM subscriptions WHERE tg_id=?", (tg_id,)).fetchone() is None
|
||||||
|
urow = conn.execute(
|
||||||
|
"SELECT referred_by, referral_rewarded, bonus_days_pending FROM users WHERE tg_id=?", (tg_id,)
|
||||||
|
).fetchone()
|
||||||
|
pending = urow["bonus_days_pending"] if urow else 0
|
||||||
|
if pending:
|
||||||
|
expires += datetime.timedelta(days=pending)
|
||||||
|
conn.execute("UPDATE users SET bonus_days_pending=0 WHERE tg_id=?", (tg_id,))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO subscriptions (uuid, tg_id, node, plan, created_at, expires_at, active, source) "
|
"INSERT INTO subscriptions (uuid, tg_id, node, plan, created_at, expires_at, active, source, traffic_limit) "
|
||||||
"VALUES (?,?,?,?,?,?,1,?)",
|
"VALUES (?,?,?,?,?,?,1,?,?)",
|
||||||
(cid, tg_id, node, plan_code, created.isoformat(), expires.isoformat(), source),
|
(cid, tg_id, node, plan_code, created.isoformat(), expires.isoformat(), source, traffic_limit),
|
||||||
)
|
)
|
||||||
return {"uuid": cid, "tg_id": tg_id, "node": node, "plan": plan_code, "expires_at": expires.isoformat()}
|
if is_first and urow and urow["referred_by"] and not urow["referral_rewarded"] and config.REFERRAL_ENABLED:
|
||||||
|
conn.execute("UPDATE users SET referral_rewarded=1 WHERE tg_id=?", (tg_id,))
|
||||||
|
bonus = config.REFERRAL_BONUS_DAYS
|
||||||
|
_apply_bonus_days(conn, tg_id, bonus)
|
||||||
|
_apply_bonus_days(conn, urow["referred_by"], bonus)
|
||||||
|
expires_final = conn.execute("SELECT expires_at FROM subscriptions WHERE uuid=?", (cid,)).fetchone()["expires_at"]
|
||||||
|
return {"uuid": cid, "tg_id": tg_id, "node": node, "plan": plan_code, "expires_at": expires_final}
|
||||||
|
|
||||||
|
|
||||||
def list_active_subscriptions(tg_id: int | None = None, node: str | None = None):
|
def list_active_subscriptions(tg_id: int | None = None, node: str | None = None):
|
||||||
|
|
@ -645,6 +884,32 @@ def get_subscription(client_uuid: str):
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_users(q: str = "", limit: int = 200):
|
||||||
|
limit = max(1, min(int(limit), 1000))
|
||||||
|
q = (q or "").strip()
|
||||||
|
where = ""
|
||||||
|
params = [now_iso(), now_iso()]
|
||||||
|
if q:
|
||||||
|
if q.lstrip("-").isdigit():
|
||||||
|
where = "WHERE u.tg_id = ? OR instr(lower(COALESCE(u.username, '')), ?) > 0"
|
||||||
|
params += [int(q), q.lower()]
|
||||||
|
else:
|
||||||
|
where = "WHERE instr(lower(COALESCE(u.username, '')), ?) > 0"
|
||||||
|
params.append(q.lower().lstrip("@"))
|
||||||
|
params.append(limit)
|
||||||
|
query = (
|
||||||
|
"SELECT u.tg_id, u.username, u.created_at, "
|
||||||
|
"(SELECT COUNT(*) FROM subscriptions s WHERE s.tg_id=u.tg_id) AS subs_total, "
|
||||||
|
"(SELECT COUNT(*) FROM subscriptions s WHERE s.tg_id=u.tg_id AND s.active=1 AND s.held_at IS NULL AND s.expires_at > ?) AS subs_active, "
|
||||||
|
"(SELECT MAX(s.expires_at) FROM subscriptions s WHERE s.tg_id=u.tg_id AND s.active=1 AND s.held_at IS NULL AND s.expires_at > ?) AS active_until, "
|
||||||
|
"(SELECT COUNT(*) FROM devices d WHERE d.tg_id=u.tg_id) AS devices "
|
||||||
|
"FROM users u " + where + " ORDER BY u.created_at DESC LIMIT ?"
|
||||||
|
)
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def get_user(tg_id: int):
|
def get_user(tg_id: int):
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||||
|
|
@ -676,12 +941,16 @@ def stats():
|
||||||
total_subs = conn.execute("SELECT COUNT(*) c FROM subscriptions").fetchone()["c"]
|
total_subs = conn.execute("SELECT COUNT(*) c FROM subscriptions").fetchone()["c"]
|
||||||
gifts_created = conn.execute("SELECT COUNT(*) c FROM gift_codes").fetchone()["c"]
|
gifts_created = conn.execute("SELECT COUNT(*) c FROM gift_codes").fetchone()["c"]
|
||||||
gifts_used = conn.execute("SELECT COUNT(*) c FROM gift_codes WHERE used_by IS NOT NULL").fetchone()["c"]
|
gifts_used = conn.execute("SELECT COUNT(*) c FROM gift_codes WHERE used_by IS NOT NULL").fetchone()["c"]
|
||||||
|
nodes_n = conn.execute("SELECT COUNT(*) c FROM nodes WHERE enabled=1").fetchone()["c"]
|
||||||
|
chains_n = conn.execute("SELECT COUNT(*) c FROM chains WHERE enabled=1").fetchone()["c"]
|
||||||
return {
|
return {
|
||||||
"users": users_n,
|
"users": users_n,
|
||||||
"active_subscriptions": active_n,
|
"active_subscriptions": active_n,
|
||||||
"total_subscriptions": total_subs,
|
"total_subscriptions": total_subs,
|
||||||
"gifts_created": gifts_created,
|
"gifts_created": gifts_created,
|
||||||
"gifts_used": gifts_used,
|
"gifts_used": gifts_used,
|
||||||
|
"nodes": nodes_n,
|
||||||
|
"chains": chains_n,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -718,6 +987,9 @@ def mark_payment_paid(payment_id: str):
|
||||||
)
|
)
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
return None
|
return None
|
||||||
|
row = conn.execute("SELECT tg_id, promo_code FROM payments WHERE id=?", (payment_id,)).fetchone()
|
||||||
|
if row and row["promo_code"]:
|
||||||
|
_consume_promo(conn, row["promo_code"], row["tg_id"])
|
||||||
return get_payment(payment_id)
|
return get_payment(payment_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -792,3 +1064,226 @@ def delete_device(device_id: int):
|
||||||
def set_user_hwid_limit(tg_id: int, limit: int | None):
|
def set_user_hwid_limit(tg_id: int, limit: int | None):
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("UPDATE users SET hwid_limit=? WHERE tg_id=?", (limit, tg_id))
|
conn.execute("UPDATE users SET hwid_limit=? WHERE tg_id=?", (limit, tg_id))
|
||||||
|
|
||||||
|
|
||||||
|
def add_traffic_sample(client_uuid: str, raw_total: int) -> int:
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT traffic_used, traffic_last_raw FROM subscriptions WHERE uuid=?", (client_uuid,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return 0
|
||||||
|
last = row["traffic_last_raw"]
|
||||||
|
delta = raw_total - last if raw_total >= last else raw_total
|
||||||
|
used = row["traffic_used"] + max(delta, 0)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE subscriptions SET traffic_used=?, traffic_last_raw=? WHERE uuid=?",
|
||||||
|
(used, raw_total, client_uuid),
|
||||||
|
)
|
||||||
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
def set_traffic_limit(client_uuid: str, limit_bytes: int | None):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("UPDATE subscriptions SET traffic_limit=? WHERE uuid=?", (limit_bytes, client_uuid))
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE subscriptions SET active=1, limit_hit_at=NULL "
|
||||||
|
"WHERE uuid=? AND limit_hit_at IS NOT NULL AND expires_at > ? "
|
||||||
|
"AND (traffic_limit IS NULL OR traffic_limit <= 0 OR traffic_used < traffic_limit)",
|
||||||
|
(client_uuid, now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_over_limit():
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM subscriptions WHERE active=1 AND traffic_limit IS NOT NULL AND traffic_limit > 0 "
|
||||||
|
"AND traffic_used >= traffic_limit"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def mark_limit_hit(client_uuid: str):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE subscriptions SET active=0, limit_hit_at=? WHERE uuid=? AND active=1",
|
||||||
|
(now_iso(), client_uuid),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_traffic_counter(client_uuid: str) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE subscriptions SET traffic_used=0, traffic_last_raw=0 WHERE uuid=?", (client_uuid,)
|
||||||
|
)
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE subscriptions SET active=1, limit_hit_at=NULL "
|
||||||
|
"WHERE uuid=? AND limit_hit_at IS NOT NULL AND expires_at > ?",
|
||||||
|
(client_uuid, now_iso()),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def list_subscriptions_expiring(within_hours: int):
|
||||||
|
now = datetime.datetime.utcnow()
|
||||||
|
until = (now + datetime.timedelta(hours=within_hours)).isoformat()
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM subscriptions WHERE active=1 AND held_at IS NULL AND expires_at > ? AND expires_at <= ?",
|
||||||
|
(now.isoformat(), until),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def notice_already_sent(sub_uuid: str, kind: str) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT 1 FROM sub_notices WHERE sub_uuid=? AND kind=?", (sub_uuid, kind)
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_notice_sent(sub_uuid: str, kind: str):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO sub_notices (sub_uuid, kind, sent_at) VALUES (?,?,?)",
|
||||||
|
(sub_uuid, kind, now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def claim_trial(tg_id: int) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute("UPDATE users SET trial_used=1 WHERE tg_id=? AND trial_used=0", (tg_id,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def trial_available(tg_id: int) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT trial_used FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||||
|
if not row or row["trial_used"]:
|
||||||
|
return False
|
||||||
|
has_sub = conn.execute("SELECT 1 FROM subscriptions WHERE tg_id=?", (tg_id,)).fetchone()
|
||||||
|
return has_sub is None
|
||||||
|
|
||||||
|
|
||||||
|
PROMO_KINDS = ("percent", "fixed", "days")
|
||||||
|
|
||||||
|
|
||||||
|
def create_promo(code: str, kind: str, value: int, max_uses: int | None = None, expires_at: str | None = None):
|
||||||
|
code = code.strip().upper()
|
||||||
|
if not code or len(code) > 40 or not all(c.isalnum() or c in "-_" for c in code):
|
||||||
|
raise ValueError("код: только буквы, цифры, - и _, до 40 символов")
|
||||||
|
if kind not in PROMO_KINDS:
|
||||||
|
raise ValueError("тип промокода: percent, fixed или days")
|
||||||
|
value = int(value)
|
||||||
|
if value <= 0 or (kind == "percent" and value > 100):
|
||||||
|
raise ValueError("значение должно быть больше нуля, для процентов не больше 100")
|
||||||
|
if max_uses is not None and int(max_uses) <= 0:
|
||||||
|
max_uses = None
|
||||||
|
with get_conn() as conn:
|
||||||
|
if conn.execute("SELECT 1 FROM promo_codes WHERE code=?", (code,)).fetchone():
|
||||||
|
raise ValueError("такой промокод уже есть")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO promo_codes (code, kind, value, max_uses, expires_at, created_at) VALUES (?,?,?,?,?,?)",
|
||||||
|
(code, kind, value, max_uses, expires_at or None, now_iso()),
|
||||||
|
)
|
||||||
|
return get_promo(code)
|
||||||
|
|
||||||
|
|
||||||
|
def get_promo(code: str):
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM promo_codes WHERE code=?", ((code or "").strip().upper(),)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_promos():
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM promo_codes ORDER BY created_at DESC").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def set_promo_active(code: str, active: bool):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("UPDATE promo_codes SET active=? WHERE code=?", (1 if active else 0, code.strip().upper()))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_promo(code: str):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM promo_codes WHERE code=?", (code.strip().upper(),))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_promo(code: str, tg_id: int):
|
||||||
|
promo = get_promo(code)
|
||||||
|
if not promo or not promo["active"]:
|
||||||
|
return None, "not_found"
|
||||||
|
if promo["expires_at"] and promo["expires_at"] <= now_iso():
|
||||||
|
return None, "expired"
|
||||||
|
if promo["max_uses"] is not None and promo["used_count"] >= promo["max_uses"]:
|
||||||
|
return None, "exhausted"
|
||||||
|
with get_conn() as conn:
|
||||||
|
used = conn.execute(
|
||||||
|
"SELECT 1 FROM promo_uses WHERE code=? AND tg_id=?", (promo["code"], tg_id)
|
||||||
|
).fetchone()
|
||||||
|
if used:
|
||||||
|
return None, "already_used"
|
||||||
|
return promo, None
|
||||||
|
|
||||||
|
|
||||||
|
def discounted_price(price: int, promo: dict | None) -> int:
|
||||||
|
if not promo or promo["kind"] == "days":
|
||||||
|
return price
|
||||||
|
if promo["kind"] == "percent":
|
||||||
|
return max(price - price * promo["value"] // 100, 0)
|
||||||
|
return max(price - promo["value"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_promo(conn, code: str, tg_id: int) -> bool:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO promo_uses (code, tg_id, used_at) VALUES (?,?,?)", (code, tg_id, now_iso())
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return False
|
||||||
|
conn.execute("UPDATE promo_codes SET used_count=used_count+1 WHERE code=?", (code,))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def redeem_days_promo(code: str, tg_id: int):
|
||||||
|
promo, err = validate_promo(code, tg_id)
|
||||||
|
if err:
|
||||||
|
return None, err
|
||||||
|
if promo["kind"] != "days":
|
||||||
|
return None, "not_days"
|
||||||
|
with get_conn() as conn:
|
||||||
|
if not _consume_promo(conn, promo["code"], tg_id):
|
||||||
|
return None, "already_used"
|
||||||
|
_apply_bonus_days(conn, tg_id, promo["value"])
|
||||||
|
return promo, None
|
||||||
|
|
||||||
|
|
||||||
|
def set_promo_pending(tg_id: int, code: str | None):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("UPDATE users SET promo_pending=? WHERE tg_id=?", (code, tg_id))
|
||||||
|
|
||||||
|
|
||||||
|
def get_pending_promo(tg_id: int):
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT promo_pending FROM users WHERE tg_id=?", (tg_id,)).fetchone()
|
||||||
|
if not row or not row["promo_pending"]:
|
||||||
|
return None
|
||||||
|
promo, err = validate_promo(row["promo_pending"], tg_id)
|
||||||
|
if err:
|
||||||
|
set_promo_pending(tg_id, None)
|
||||||
|
return None
|
||||||
|
return promo
|
||||||
|
|
||||||
|
|
||||||
|
def set_payment_promo(payment_id: str, promo_code: str | None, original_amount: int | None):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE payments SET promo_code=?, original_amount=? WHERE id=?",
|
||||||
|
(promo_code, original_amount, payment_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_promo(code: str, tg_id: int) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
return _consume_promo(conn, code, tg_id)
|
||||||
|
|
|
||||||
94
features.py
Normal file
94
features.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import db
|
||||||
|
import nodeprov
|
||||||
|
import settings
|
||||||
|
import xray_manager
|
||||||
|
from settings import GB
|
||||||
|
|
||||||
|
REMINDER_STAGES = (("3d", 72), ("1d", 24))
|
||||||
|
|
||||||
|
|
||||||
|
def format_bytes(n: int) -> str:
|
||||||
|
v = float(n)
|
||||||
|
for unit in ["Б", "КБ", "МБ", "ГБ", "ТБ"]:
|
||||||
|
if v < 1024 or unit == "ТБ":
|
||||||
|
return f"{int(v)} {unit}" if unit == "Б" else f"{v:.1f} {unit}"
|
||||||
|
v /= 1024
|
||||||
|
return f"{v:.1f} ТБ"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_all_stats() -> dict:
|
||||||
|
all_stats = dict(xray_manager.query_stats())
|
||||||
|
for node in db.list_nodes():
|
||||||
|
if node["kind"] != "managed" or node["status"] != "active":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
remote = nodeprov.remote_query_stats(node)
|
||||||
|
except Exception:
|
||||||
|
remote = {}
|
||||||
|
for key, value in remote.items():
|
||||||
|
if key in all_stats:
|
||||||
|
all_stats[key] = {
|
||||||
|
"up": all_stats[key]["up"] + value["up"],
|
||||||
|
"down": all_stats[key]["down"] + value["down"],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
all_stats[key] = value
|
||||||
|
return all_stats
|
||||||
|
|
||||||
|
|
||||||
|
def update_traffic_and_find_exceeded() -> list:
|
||||||
|
stats = collect_all_stats()
|
||||||
|
if not stats:
|
||||||
|
return []
|
||||||
|
for sub in db.list_active_subscriptions():
|
||||||
|
row = stats.get(sub["uuid"])
|
||||||
|
if row:
|
||||||
|
db.add_traffic_sample(sub["uuid"], row["up"] + row["down"])
|
||||||
|
exceeded = db.list_over_limit()
|
||||||
|
for sub in exceeded:
|
||||||
|
db.mark_limit_hit(sub["uuid"])
|
||||||
|
return exceeded
|
||||||
|
|
||||||
|
|
||||||
|
def reminders_due() -> list:
|
||||||
|
due = []
|
||||||
|
for stage, hours in REMINDER_STAGES:
|
||||||
|
for sub in db.list_subscriptions_expiring(hours):
|
||||||
|
kind = f"{stage}:{sub['expires_at'][:10]}"
|
||||||
|
if db.notice_already_sent(sub["uuid"], kind):
|
||||||
|
continue
|
||||||
|
due.append((sub, kind, stage))
|
||||||
|
seen = set()
|
||||||
|
result = []
|
||||||
|
for sub, kind, stage in sorted(due, key=lambda x: x[2]):
|
||||||
|
if sub["uuid"] in seen:
|
||||||
|
continue
|
||||||
|
seen.add(sub["uuid"])
|
||||||
|
result.append((sub, kind, stage))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def mark_stage_sent(sub_uuid: str, expires_at: str):
|
||||||
|
for stage, _ in REMINDER_STAGES:
|
||||||
|
db.mark_notice_sent(sub_uuid, f"{stage}:{expires_at[:10]}")
|
||||||
|
|
||||||
|
|
||||||
|
def pick_trial_node():
|
||||||
|
features = settings.get_features()
|
||||||
|
wanted = features["trial_node"]
|
||||||
|
if wanted:
|
||||||
|
node = db.get_node(wanted)
|
||||||
|
if node and node["enabled"] and node["status"] == "active":
|
||||||
|
return node
|
||||||
|
for node in db.list_nodes(enabled_only=True):
|
||||||
|
if node["status"] == "active":
|
||||||
|
return node
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def traffic_text(sub: dict) -> str:
|
||||||
|
used = sub.get("traffic_used") or 0
|
||||||
|
limit = sub.get("traffic_limit") or 0
|
||||||
|
if limit > 0:
|
||||||
|
return f"{format_bytes(used)} из {format_bytes(limit)}"
|
||||||
|
return f"{format_bytes(used)}, без лимита"
|
||||||
20
install.sh
20
install.sh
|
|
@ -2,8 +2,9 @@
|
||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
|
LAB_URL="https://lab.savsis.xyz/savsisbtw/mbs-panel.git"
|
||||||
MIRROR_URL="https://api.savsis.xyz/git/mbs-panel.git/"
|
MIRROR_URL="https://api.savsis.xyz/git/mbs-panel.git/"
|
||||||
REPO_URL="https://github.com/devsavsis/mbs-panel.git"
|
REPO_URL="https://github.com/savsisbtw/mbs-panel.git"
|
||||||
APP_DIR="/opt/mbs-panel"
|
APP_DIR="/opt/mbs-panel"
|
||||||
WEBROOT="/var/www/certbot"
|
WEBROOT="/var/www/certbot"
|
||||||
|
|
||||||
|
|
@ -84,11 +85,17 @@ echo "клонируем репозиторий в $APP_DIR..."
|
||||||
if [ -d "$APP_DIR/.git" ]; then
|
if [ -d "$APP_DIR/.git" ]; then
|
||||||
retry git -C "$APP_DIR" pull --quiet
|
retry git -C "$APP_DIR" pull --quiet
|
||||||
else
|
else
|
||||||
if ! git clone --quiet "$MIRROR_URL" "$APP_DIR" 2>/dev/null; then
|
if ! git clone --quiet "$LAB_URL" "$APP_DIR" 2>/dev/null; then
|
||||||
echo "зеркало недоступно, клонирую напрямую с GitHub..."
|
echo "lab.savsis.xyz недоступен, пробую зеркало..."
|
||||||
retry git clone --quiet "$REPO_URL" "$APP_DIR"
|
rm -rf "$APP_DIR"
|
||||||
git -C "$APP_DIR" remote set-url origin "$MIRROR_URL"
|
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
|
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
|
git -C "$APP_DIR" remote add github "$REPO_URL" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -386,6 +393,8 @@ else API_WORKERS=$CPU_COUNT
|
||||||
fi
|
fi
|
||||||
cp "$APP_DIR/systemd/mbs-bot.service" /etc/systemd/system/mbs-bot.service
|
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
|
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
|
systemctl daemon-reload
|
||||||
|
|
||||||
echo "ставим CLI mbs..."
|
echo "ставим CLI mbs..."
|
||||||
|
|
@ -407,6 +416,7 @@ systemctl reload nginx
|
||||||
systemctl enable --now xray
|
systemctl enable --now xray
|
||||||
systemctl enable --now mbs-bot
|
systemctl enable --now mbs-bot
|
||||||
systemctl enable --now mbs-api
|
systemctl enable --now mbs-api
|
||||||
|
systemctl enable --now mbs-autoupdate.timer
|
||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,20 +155,20 @@
|
||||||
<a href="#features">Возможности</a>
|
<a href="#features">Возможности</a>
|
||||||
<a href="#comparison">Сравнение</a>
|
<a href="#comparison">Сравнение</a>
|
||||||
<a href="#install">Установка</a>
|
<a href="#install">Установка</a>
|
||||||
<a href="https://github.com/devsavsis/mbs-panel" target="_blank">GitHub</a>
|
<a href="https://github.com/savsisbtw/mbs-panel" target="_blank">GitHub</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="hero" style="padding-bottom:0">
|
<section class="hero" style="padding-bottom:0">
|
||||||
<div class="badges reveal">
|
<div class="badges reveal">
|
||||||
<img src="https://img.shields.io/github/actions/workflow/status/devsavsis/mbs-panel/ci.yml?label=CI&style=flat-square&color=7c6cf0" alt="CI">
|
<img src="https://img.shields.io/github/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/devsavsis/mbs-panel?style=flat-square&color=7c6cf0" alt="License">
|
<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/devsavsis/mbs-panel?style=flat-square&color=7c6cf0" alt="Stars">
|
<img src="https://img.shields.io/github/stars/savsisbtw/mbs-panel?style=flat-square&color=7c6cf0" alt="Stars">
|
||||||
</div>
|
</div>
|
||||||
<h1 class="reveal">VPN-панель, которую<br>можно <span class="accent">понять за вечер</span></h1>
|
<h1 class="reveal">VPN-панель, которую<br>можно <span class="accent">понять за вечер</span></h1>
|
||||||
<p class="reveal">Xray-core, Reality, Hysteria2, Telegram-бот и платежи — в одном небольшом репозитории на Python. Без Docker, без чужой закрытой панели под капотом, без разбора чужого фреймворка перед первым коммитом.</p>
|
<p class="reveal">Xray-core, Reality, Hysteria2, Telegram-бот и платежи — в одном небольшом репозитории на Python. Без Docker, без чужой закрытой панели под капотом, без разбора чужого фреймворка перед первым коммитом.</p>
|
||||||
<div class="hero-ctas reveal">
|
<div class="hero-ctas reveal">
|
||||||
<a class="btn" href="https://github.com/devsavsis/mbs-panel" target="_blank">Смотреть на GitHub</a>
|
<a class="btn" href="https://github.com/savsisbtw/mbs-panel" target="_blank">Смотреть на GitHub</a>
|
||||||
<a class="btn ghost" href="#install">Установка</a>
|
<a class="btn ghost" href="#install">Установка</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="install reveal">
|
<div class="install reveal">
|
||||||
|
|
@ -233,6 +233,11 @@
|
||||||
<tr><td>Мультиадминство (раздельные логины)</td><td class="us yes">✓</td><td class="no">—</td><td class="no">в разработке</td></tr>
|
<tr><td>Мультиадминство (раздельные логины)</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>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>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>
|
<tr><td>Лицензия</td><td class="us">MIT</td><td>AGPL-3.0</td><td>AGPL-3.0</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -292,16 +297,16 @@
|
||||||
<h2>Открытый исходник, MIT</h2>
|
<h2>Открытый исходник, MIT</h2>
|
||||||
<p class="section-sub">Изначально писалось под конкретный проект — получилось достаточно универсально, чтобы выложить как есть. Issues и PR приветствуются.</p>
|
<p class="section-sub">Изначально писалось под конкретный проект — получилось достаточно универсально, чтобы выложить как есть. Issues и PR приветствуются.</p>
|
||||||
<div class="cta-row">
|
<div class="cta-row">
|
||||||
<a class="btn" href="https://github.com/devsavsis/mbs-panel" target="_blank">github.com/devsavsis/mbs-panel</a>
|
<a class="btn" href="https://github.com/savsisbtw/mbs-panel" target="_blank">github.com/savsisbtw/mbs-panel</a>
|
||||||
<a class="btn ghost" href="https://github.com/devsavsis/mbs-panel#readme" target="_blank">Читать README</a>
|
<a class="btn ghost" href="https://github.com/savsisbtw/mbs-panel#readme" target="_blank">Читать README</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div>MBS Panel — made by <a href="https://github.com/devsavsis" target="_blank">savsis</a></div>
|
<div>MBS Panel — made by <a href="https://github.com/savsisbtw" target="_blank">savsis</a></div>
|
||||||
<div><a href="https://github.com/devsavsis/mbs-panel/blob/main/LICENSE" target="_blank">MIT License</a></div>
|
<div><a href="https://github.com/savsisbtw/mbs-panel/blob/main/LICENSE" target="_blank">MIT License</a></div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|
|
||||||
20
links.py
20
links.py
|
|
@ -89,6 +89,17 @@ def vless_uris_for_node(client_uuid: str, node: dict, base_name: str) -> list[st
|
||||||
)]
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
def chain_remark(entry_node: dict, exit_node: dict) -> str:
|
||||||
|
return f"{display_name(entry_node['label'])} → {display_name(exit_node['label'])}"
|
||||||
|
|
||||||
|
|
||||||
|
def chain_uri(client_uuid: str, entry_node: dict, chain: dict, remark: str) -> str:
|
||||||
|
return _tcp_reality_uri(
|
||||||
|
client_uuid, entry_node["address"], chain["port"], entry_node["public_key"],
|
||||||
|
chain["short_id"], entry_node["sni"], "xtls-rprx-vision", remark,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_subscription_text(subs: list[dict]) -> str:
|
def build_subscription_text(subs: list[dict]) -> str:
|
||||||
import db
|
import db
|
||||||
|
|
||||||
|
|
@ -99,6 +110,10 @@ def build_subscription_text(subs: list[dict]) -> str:
|
||||||
best_by_node[s["node"]] = s
|
best_by_node[s["node"]] = s
|
||||||
|
|
||||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
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 = []
|
lines = []
|
||||||
for node_code, s in best_by_node.items():
|
for node_code, s in best_by_node.items():
|
||||||
node = nodes_by_code.get(node_code)
|
node = nodes_by_code.get(node_code)
|
||||||
|
|
@ -109,5 +124,10 @@ def build_subscription_text(subs: list[dict]) -> str:
|
||||||
hy = hysteria_uri_for_node(node, base_name)
|
hy = hysteria_uri_for_node(node, base_name)
|
||||||
if hy:
|
if hy:
|
||||||
lines.append(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)
|
raw = "\n".join(lines)
|
||||||
return base64.b64encode(raw.encode()).decode()
|
return base64.b64encode(raw.encode()).decode()
|
||||||
|
|
|
||||||
221
mbs
221
mbs
|
|
@ -13,7 +13,11 @@ mbs — управление MBS Panel
|
||||||
mbs restart перезапустить всё (bot, api, xray, reload nginx)
|
mbs restart перезапустить всё (bot, api, xray, reload nginx)
|
||||||
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
|
mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
|
||||||
mbs domain показать текущий домен панели
|
mbs domain показать текущий домен панели
|
||||||
mbs update обновить код (зеркало api.savsis.xyz, потом GitHub) и перезапустить (не трогает .env и базу)
|
mbs backup полная копия панели (база, .env, ручные правки, конфиг Xray) в /root/mbs-backups
|
||||||
|
mbs update [ссылка] обновить код и перезапустить (не трогает .env и базу, перед этим сам делает копию). Без ссылки: своё зеркало -> lab.savsis.xyz -> api.savsis.xyz -> GitHub.
|
||||||
|
Со ссылкой на git-репозиторий (зеркало) — берёт обновление оттуда, один раз
|
||||||
|
mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, которое mbs update проверяет первым
|
||||||
|
mbs autoupdate [on|off] включить / выключить ежедневное автообновление (04:00, перед ним всегда копия), без аргумента — показать состояние
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,34 +59,175 @@ cmd_domain() {
|
||||||
grep "^PANEL_DOMAIN=" "$ENV_FILE"
|
grep "^PANEL_DOMAIN=" "$ENV_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_update() {
|
MIRROR_FILE="$APP_DIR/.update_mirror"
|
||||||
cd "$APP_DIR"
|
BACKUP_DIR="${MBS_BACKUP_DIR:-/root/mbs-backups}"
|
||||||
echo "проверяю обновления..."
|
XRAY_CONFIG="/usr/local/etc/xray/config.json"
|
||||||
local remote="origin"
|
BACKUP_FILE=""
|
||||||
if ! git fetch --quiet origin main 2>/dev/null; then
|
UPDATE_STASHED=0
|
||||||
if git remote | grep -q '^github$'; then
|
|
||||||
echo "зеркало недоступно, пробую github..."
|
snapshot_backup() {
|
||||||
if ! git fetch --quiet github main 2>/dev/null; then
|
local stamp dbsnap file
|
||||||
echo "не удалось получить обновления ни с зеркала, ни с github"
|
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
|
return 1
|
||||||
fi
|
fi
|
||||||
remote="github"
|
printf '%s\n' "$url" > "$MIRROR_FILE"
|
||||||
|
echo "зеркало запомнил: $url — mbs update теперь проверяет его первым"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_update() {
|
||||||
|
local arg_url="$1" target="" saved="" before="" stamp="" patch=""
|
||||||
|
cd "$APP_DIR"
|
||||||
|
echo "проверяю обновления..."
|
||||||
|
if [ -n "$arg_url" ]; then
|
||||||
|
if ! valid_url "$arg_url"; then
|
||||||
|
echo "это не похоже на ссылку на git-репозиторий (нужна https://..., ssh://... или git@хост:путь)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! fetch_url "$arg_url"; then
|
||||||
|
echo "не удалось получить обновления по ссылке: $arg_url"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
target=$(git rev-parse FETCH_HEAD)
|
||||||
|
echo "источник: $arg_url"
|
||||||
|
else
|
||||||
|
if [ -f "$MIRROR_FILE" ]; then
|
||||||
|
saved=$(head -n 1 "$MIRROR_FILE" | tr -d '[:space:]')
|
||||||
|
fi
|
||||||
|
if [ -n "$saved" ] && valid_url "$saved" && fetch_url "$saved"; then
|
||||||
|
target=$(git rev-parse FETCH_HEAD)
|
||||||
|
echo "источник: своё зеркало $saved"
|
||||||
|
elif git fetch --quiet origin main 2>/dev/null; then
|
||||||
|
target=$(git rev-parse origin/main)
|
||||||
|
elif git remote | grep -q '^mirror$' && git fetch --quiet mirror main 2>/dev/null; then
|
||||||
|
echo "lab.savsis.xyz недоступен, взял с зеркала..."
|
||||||
|
target=$(git rev-parse mirror/main)
|
||||||
|
elif git remote | grep -q '^github$' && git fetch --quiet github main 2>/dev/null; then
|
||||||
|
echo "зеркало недоступно, взял с github..."
|
||||||
|
target=$(git rev-parse github/main)
|
||||||
else
|
else
|
||||||
echo "не удалось получить обновления"
|
echo "не удалось получить обновления ни с зеркала, ни с github"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
local before after
|
|
||||||
before=$(git rev-parse HEAD)
|
before=$(git rev-parse HEAD)
|
||||||
after=$(git rev-parse "$remote/main")
|
if [ "$before" = "$target" ]; then
|
||||||
if [ "$before" = "$after" ]; then
|
|
||||||
echo "уже последняя версия ($before)."
|
echo "уже последняя версия ($before)."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
if git merge-base --is-ancestor "$target" "$before" 2>/dev/null; then
|
||||||
|
echo "на сервере версия новее, чем в источнике ($before) — ничего не делаю."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
echo "текущая: $before"
|
echo "текущая: $before"
|
||||||
echo "новая: $after"
|
echo "новая: $target"
|
||||||
if ! git merge --ff-only "$remote/main" --quiet; then
|
|
||||||
echo "не вышло быстро обновиться (похоже, файлы правились вручную на сервере) — разберись руками: git status"
|
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
|
return 1
|
||||||
fi
|
fi
|
||||||
echo "обновляю зависимости..."
|
echo "обновляю зависимости..."
|
||||||
|
|
@ -92,6 +237,7 @@ cmd_update() {
|
||||||
echo "новый код не проходит проверку, откатываюсь на $before..."
|
echo "новый код не проходит проверку, откатываюсь на $before..."
|
||||||
git reset --hard "$before" --quiet
|
git reset --hard "$before" --quiet
|
||||||
venv/bin/pip install --quiet -r requirements.txt
|
venv/bin/pip install --quiet -r requirements.txt
|
||||||
|
restore_stash
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
echo "обновляю сам CLI..."
|
echo "обновляю сам CLI..."
|
||||||
|
|
@ -115,22 +261,59 @@ cmd_update() {
|
||||||
sleep 2
|
sleep 2
|
||||||
if systemctl is-active --quiet mbs-bot && systemctl is-active --quiet mbs-api; then
|
if systemctl is-active --quiet mbs-bot && systemctl is-active --quiet mbs-api; then
|
||||||
echo "обновлено: $before -> $(git rev-parse --short HEAD)"
|
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
|
else
|
||||||
echo "сервисы не поднялись после обновления, откатываюсь на $before..."
|
echo "сервисы не поднялись после обновления, откатываюсь на $before..."
|
||||||
git reset --hard "$before" --quiet
|
git reset --hard "$before" --quiet
|
||||||
venv/bin/pip install --quiet -r requirements.txt
|
venv/bin/pip install --quiet -r requirements.txt
|
||||||
systemctl restart mbs-bot mbs-api
|
systemctl restart mbs-bot mbs-api
|
||||||
|
restore_stash
|
||||||
echo "откачено обратно на $before"
|
echo "откачено обратно на $before"
|
||||||
return 1
|
return 1
|
||||||
fi
|
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
|
case "$1" in
|
||||||
pass) cmd_pass "$2" ;;
|
pass) cmd_pass "$2" ;;
|
||||||
status) cmd_status ;;
|
status) cmd_status ;;
|
||||||
restart) cmd_restart ;;
|
restart) cmd_restart ;;
|
||||||
logs) cmd_logs "$2" ;;
|
logs) cmd_logs "$2" ;;
|
||||||
domain) cmd_domain ;;
|
domain) cmd_domain ;;
|
||||||
update) cmd_update ;;
|
update) cmd_update "$2" ;;
|
||||||
|
mirror) cmd_mirror "$2" ;;
|
||||||
|
backup) cmd_backup ;;
|
||||||
|
autoupdate) cmd_autoupdate "$2" ;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"; exit $?
|
||||||
|
|
|
||||||
179
nodeprov.py
179
nodeprov.py
|
|
@ -1,3 +1,6 @@
|
||||||
|
import contextlib
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import socket
|
import socket
|
||||||
|
|
@ -5,12 +8,12 @@ import subprocess
|
||||||
|
|
||||||
import paramiko
|
import paramiko
|
||||||
|
|
||||||
|
import chains
|
||||||
from config import PANEL_DOMAIN
|
from config import PANEL_DOMAIN
|
||||||
|
|
||||||
MGMT_KEY_PATH = "/root/.ssh/mbs_nodes_ed25519"
|
MGMT_KEY_PATH = "/root/.ssh/mbs_nodes_ed25519"
|
||||||
MGMT_KNOWN_HOSTS_PATH = "/root/.ssh/mbs_nodes_known_hosts"
|
MGMT_KNOWN_HOSTS_PATH = "/root/.ssh/mbs_nodes_known_hosts"
|
||||||
LOCAL_TAGS = {"vless-tcp-reality", "vless-grpc-reality", "vless-xhttp-reality", "vless-ws-tls"}
|
REMOTE_CONFIG_PATH = "/usr/local/etc/xray/config.json"
|
||||||
TAG_FLOW = {"vless-tcp-reality": "xtls-rprx-vision"}
|
|
||||||
|
|
||||||
ONE_COMMAND_TEMPLATE = "bash <(curl -Ls https://{panel}/install/{token}.sh)"
|
ONE_COMMAND_TEMPLATE = "bash <(curl -Ls https://{panel}/install/{token}.sh)"
|
||||||
|
|
||||||
|
|
@ -68,6 +71,28 @@ set -e
|
||||||
echo "== MBS Panel node install =="
|
echo "== MBS Panel node install =="
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
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
|
mkdir -p /root/.ssh
|
||||||
chmod 700 /root/.ssh
|
chmod 700 /root/.ssh
|
||||||
curl -Ls https://{panel}/mgmt-pubkey.txt >> /root/.ssh/authorized_keys
|
curl -Ls https://{panel}/mgmt-pubkey.txt >> /root/.ssh/authorized_keys
|
||||||
|
|
@ -88,9 +113,19 @@ XRAYCFG
|
||||||
command -v ufw >/dev/null 2>&1 && {{ ufw allow 22/tcp || true; {ufw_rules} }}
|
command -v ufw >/dev/null 2>&1 && {{ ufw allow 22/tcp || true; {ufw_rules} }}
|
||||||
systemctl enable xray >/dev/null 2>&1 || true
|
systemctl enable xray >/dev/null 2>&1 || true
|
||||||
systemctl restart xray
|
systemctl restart xray
|
||||||
sleep 1
|
OK=0
|
||||||
STATUS=$(systemctl is-active xray)
|
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"
|
echo "xray status: $STATUS"
|
||||||
|
if [ "$STATUS" != "active" ]; then journalctl -u xray -n 15 --no-pager 2>/dev/null || true; fi
|
||||||
|
|
||||||
{hysteria_block}
|
{hysteria_block}
|
||||||
MY_IP=$(curl -s https://api.ipify.org || echo unknown)
|
MY_IP=$(curl -s https://api.ipify.org || echo unknown)
|
||||||
|
|
@ -231,9 +266,11 @@ def render_install_script(node: dict) -> str:
|
||||||
sni=node["sni"],
|
sni=node["sni"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ports = " ".join(str(t["port"]) for t in transports)
|
||||||
return SELF_INSTALL_SCRIPT.format(
|
return SELF_INSTALL_SCRIPT.format(
|
||||||
panel=PANEL_DOMAIN, token=node["provision_token"], config_json=config_json,
|
panel=PANEL_DOMAIN, token=node["provision_token"], config_json=config_json,
|
||||||
certbot_block=certbot_block, hysteria_block=hysteria_block, ufw_rules=ufw_rules,
|
certbot_block=certbot_block, hysteria_block=hysteria_block, ufw_rules=ufw_rules,
|
||||||
|
ports=ports, first_port=transports[0]["port"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -254,26 +291,45 @@ class RemoteConfigError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _remote_edit_clients(node: dict, mutate_fn):
|
def _busy_ports(client) -> set:
|
||||||
|
_, stdout, _ = client.exec_command("ss -ltnH 2>/dev/null | awk '{print $4}'", timeout=10)
|
||||||
|
busy = set()
|
||||||
|
for line in stdout.read().decode(errors="replace").splitlines():
|
||||||
|
tail = line.rsplit(":", 1)[-1]
|
||||||
|
if tail.isdigit():
|
||||||
|
busy.add(int(tail))
|
||||||
|
return busy
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _node_lock(address: str):
|
||||||
|
key = hashlib.sha1(address.encode()).hexdigest()[:12]
|
||||||
|
with open(f"/tmp/mbs-node-{key}.lock", "w") as lock_file:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_edit_config(node: dict, mutate_fn):
|
||||||
|
with _node_lock(node["address"]):
|
||||||
|
return _remote_edit_config_locked(node, mutate_fn)
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_edit_config_locked(node: dict, mutate_fn):
|
||||||
client = _mgmt_connect(node["address"])
|
client = _mgmt_connect(node["address"])
|
||||||
try:
|
try:
|
||||||
sftp = client.open_sftp()
|
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())
|
cfg = json.loads(f.read().decode())
|
||||||
changed = False
|
result = mutate_fn(cfg, client)
|
||||||
for ib in cfg["inbounds"]:
|
if not result["changed"]:
|
||||||
if ib.get("tag") not in LOCAL_TAGS:
|
|
||||||
continue
|
|
||||||
clients = ib["settings"]["clients"]
|
|
||||||
new_clients = mutate_fn(clients, ib["tag"])
|
|
||||||
if new_clients is not None:
|
|
||||||
ib["settings"]["clients"] = new_clients
|
|
||||||
changed = True
|
|
||||||
if not changed:
|
|
||||||
sftp.close()
|
sftp.close()
|
||||||
return
|
return result
|
||||||
data = json.dumps(cfg, indent=2).encode()
|
data = json.dumps(cfg, indent=2).encode()
|
||||||
tmp_path = "/usr/local/etc/xray/config.json.validate.tmp"
|
tmp_path = REMOTE_CONFIG_PATH + ".validate.tmp"
|
||||||
|
prev_path = REMOTE_CONFIG_PATH + ".mbs-prev"
|
||||||
with sftp.open(tmp_path, "wb") as f:
|
with sftp.open(tmp_path, "wb") as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
_, stdout, stderr = client.exec_command(f"/usr/local/bin/xray run -test -format=json -config {tmp_path}", timeout=15)
|
_, stdout, stderr = client.exec_command(f"/usr/local/bin/xray run -test -format=json -config {tmp_path}", timeout=15)
|
||||||
|
|
@ -283,23 +339,43 @@ def _remote_edit_clients(node: dict, mutate_fn):
|
||||||
client.exec_command(f"rm -f {tmp_path}")
|
client.exec_command(f"rm -f {tmp_path}")
|
||||||
sftp.close()
|
sftp.close()
|
||||||
raise RemoteConfigError(f"config test failed on {node['address']}: {test_out}")
|
raise RemoteConfigError(f"config test failed on {node['address']}: {test_out}")
|
||||||
client.exec_command(f"mv {tmp_path} /usr/local/etc/xray/config.json")[1].channel.recv_exit_status()
|
client.exec_command(f"cp -p {REMOTE_CONFIG_PATH} {prev_path}")[1].channel.recv_exit_status()
|
||||||
|
client.exec_command(f"mv {tmp_path} {REMOTE_CONFIG_PATH}")[1].channel.recv_exit_status()
|
||||||
sftp.close()
|
sftp.close()
|
||||||
_, stdout, stderr = client.exec_command("systemctl restart xray", timeout=20)
|
_, stdout, stderr = client.exec_command("systemctl restart xray && sleep 1 && systemctl is-active xray", timeout=30)
|
||||||
restart_exit = stdout.channel.recv_exit_status()
|
restart_exit = stdout.channel.recv_exit_status()
|
||||||
if restart_exit != 0:
|
if restart_exit != 0:
|
||||||
err = stderr.read().decode(errors="replace").strip()
|
err = stderr.read().decode(errors="replace").strip()
|
||||||
raise RemoteConfigError(f"xray restart failed on {node['address']}: {err}")
|
client.exec_command(f"cp -p {prev_path} {REMOTE_CONFIG_PATH} && systemctl restart xray")[1].channel.recv_exit_status()
|
||||||
|
raise RemoteConfigError(f"xray не поднялся на {node['address']}, конфиг откатили назад: {err}")
|
||||||
|
for port in result.get("new_ports") or []:
|
||||||
|
client.exec_command(f"command -v ufw >/dev/null 2>&1 && ufw allow {int(port)}/tcp || true")[1].channel.recv_exit_status()
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_edit_clients(node: dict, mutate_fn):
|
||||||
|
def mutate(cfg, client):
|
||||||
|
changed = False
|
||||||
|
for ib in cfg["inbounds"]:
|
||||||
|
tag = ib.get("tag")
|
||||||
|
if not chains.is_user_tag(tag):
|
||||||
|
continue
|
||||||
|
new_clients = mutate_fn(ib["settings"]["clients"], tag)
|
||||||
|
if new_clients is not None:
|
||||||
|
ib["settings"]["clients"] = new_clients
|
||||||
|
changed = True
|
||||||
|
return {"changed": changed}
|
||||||
|
_remote_edit_config(node, mutate)
|
||||||
|
|
||||||
|
|
||||||
def remote_add_client(node: dict, client_uuid: str, email: str):
|
def remote_add_client(node: dict, client_uuid: str, email: str):
|
||||||
def mutate(clients, tag):
|
def mutate(clients, tag):
|
||||||
if any(c["id"] == client_uuid for c in clients):
|
if any(c["id"] == client_uuid for c in clients):
|
||||||
return None
|
return None
|
||||||
entry = {"id": client_uuid, "email": email}
|
entry = {"id": client_uuid, "email": email}
|
||||||
flow = TAG_FLOW.get(tag)
|
flow = chains.flow_for_tag(tag)
|
||||||
if flow:
|
if flow:
|
||||||
entry["flow"] = flow
|
entry["flow"] = flow
|
||||||
clients.append(entry)
|
clients.append(entry)
|
||||||
|
|
@ -314,24 +390,49 @@ def remote_remove_client(node: dict, client_uuid: str):
|
||||||
_remote_edit_clients(node, mutate)
|
_remote_edit_clients(node, mutate)
|
||||||
|
|
||||||
|
|
||||||
def remote_sync(node: dict, active_subs: list[dict]):
|
def remote_reconcile(node: dict, wanted: dict, relay_wanted: dict, entry_chains: list, exit_nodes: dict, apply_chains: bool = True):
|
||||||
active_by_id = {s["uuid"]: s for s in active_subs}
|
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}
|
PROBE_SCRIPT = """for i in 1 2 3; do
|
||||||
if current_ids == set(active_by_id.keys()):
|
s=$(date +%s%N)
|
||||||
return None
|
if timeout 3 bash -c 'exec 3<>/dev/tcp/{host}/{port}' 2>/dev/null; then
|
||||||
new_clients = [c for c in clients if c["id"] in active_by_id]
|
e=$(date +%s%N)
|
||||||
existing_ids = {c["id"] for c in new_clients}
|
echo $(( (e - s) / 1000000 ))
|
||||||
flow = TAG_FLOW.get(tag)
|
else
|
||||||
for cid in active_by_id:
|
echo -1
|
||||||
if cid not in existing_ids:
|
fi
|
||||||
entry = {"id": cid, "email": cid}
|
done
|
||||||
if flow:
|
"""
|
||||||
entry["flow"] = flow
|
|
||||||
new_clients.append(entry)
|
|
||||||
return new_clients
|
def remote_probe(node: dict, host: str, port: int) -> list:
|
||||||
_remote_edit_clients(node, mutate)
|
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:
|
def remote_query_stats(node: dict) -> dict:
|
||||||
|
|
|
||||||
53
settings.py
53
settings.py
|
|
@ -87,3 +87,56 @@ def get_hwid_settings() -> dict:
|
||||||
"enabled": _bool(raw.get("HWID_LIMIT_ENABLED"), config.HWID_LIMIT_ENABLED),
|
"enabled": _bool(raw.get("HWID_LIMIT_ENABLED"), config.HWID_LIMIT_ENABLED),
|
||||||
"fallback_limit": limit if limit > 0 else config.HWID_FALLBACK_LIMIT,
|
"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
|
||||||
|
|
|
||||||
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 ]
|
||||||
177
xray_manager.py
177
xray_manager.py
|
|
@ -1,14 +1,15 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import fcntl
|
import fcntl
|
||||||
import contextlib
|
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"
|
_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
|
@contextlib.contextmanager
|
||||||
|
|
@ -101,7 +102,7 @@ def _reload_xray():
|
||||||
|
|
||||||
|
|
||||||
def _local_inbounds(cfg):
|
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):
|
def add_client(client_uuid: str, email: str):
|
||||||
|
|
@ -113,7 +114,7 @@ def add_client(client_uuid: str, email: str):
|
||||||
if any(c["id"] == client_uuid for c in clients):
|
if any(c["id"] == client_uuid for c in clients):
|
||||||
continue
|
continue
|
||||||
entry = {"id": client_uuid, "email": email}
|
entry = {"id": client_uuid, "email": email}
|
||||||
flow = _TAG_FLOW.get(ib["tag"])
|
flow = chains.flow_for_tag(ib["tag"])
|
||||||
if flow:
|
if flow:
|
||||||
entry["flow"] = flow
|
entry["flow"] = flow
|
||||||
clients.append(entry)
|
clients.append(entry)
|
||||||
|
|
@ -138,38 +139,138 @@ def remove_client(client_uuid: str):
|
||||||
_reload_xray()
|
_reload_xray()
|
||||||
|
|
||||||
|
|
||||||
|
def _node_usable(node):
|
||||||
|
return bool(node["enabled"]) and node["status"] == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def desired_state(node):
|
||||||
|
import db as dbmod
|
||||||
|
|
||||||
|
active = dbmod.list_active_subscriptions(node=node["code"])
|
||||||
|
wanted = {s["uuid"]: s["uuid"] for s in active}
|
||||||
|
nodes_by_code = {n["code"]: n for n in dbmod.list_nodes()}
|
||||||
|
entry_chains = []
|
||||||
|
exit_nodes = {}
|
||||||
|
relay_wanted = {}
|
||||||
|
for chain in dbmod.list_chains(enabled_only=True):
|
||||||
|
entry = nodes_by_code.get(chain["entry_node"])
|
||||||
|
exit_node = nodes_by_code.get(chain["exit_node"])
|
||||||
|
if not entry or not exit_node:
|
||||||
|
continue
|
||||||
|
if not _node_usable(entry) or not _node_usable(exit_node):
|
||||||
|
continue
|
||||||
|
if chain["entry_node"] == node["code"]:
|
||||||
|
entry_chains.append(chain)
|
||||||
|
exit_nodes[chain["exit_node"]] = exit_node
|
||||||
|
if chain["exit_node"] == node["code"] and node["kind"] in ("local", "managed") and chain.get("relay_uuid"):
|
||||||
|
relay_wanted[chain["relay_uuid"]] = chains.relay_email(chain["code"])
|
||||||
|
return wanted, relay_wanted, entry_chains, exit_nodes
|
||||||
|
|
||||||
|
|
||||||
|
def _read_config_text():
|
||||||
|
with open(XRAY_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_config_text(text):
|
||||||
|
tmp = XRAY_CONFIG_PATH + ".restore.tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
os.replace(tmp, XRAY_CONFIG_PATH)
|
||||||
|
subprocess.run(["systemctl", "restart", "xray"], timeout=20)
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_and_verify():
|
||||||
|
subprocess.run(["systemctl", "restart", "xray"], check=True, timeout=20)
|
||||||
|
time.sleep(1)
|
||||||
|
state = subprocess.run(["systemctl", "is-active", "xray"], capture_output=True, text=True).stdout.strip()
|
||||||
|
if state != "active":
|
||||||
|
raise ConfigValidationError("xray не поднялся после применения конфига, вернули старый")
|
||||||
|
|
||||||
|
|
||||||
|
def _port_busy(port):
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
sock.bind(("0.0.0.0", int(port)))
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_firewall(port):
|
||||||
|
subprocess.run(
|
||||||
|
["sh", "-c", f"command -v ufw >/dev/null 2>&1 && ufw allow {int(port)}/tcp || true"],
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_local(wanted, relay_wanted, entry_chains, exit_nodes, apply_chains=True):
|
||||||
|
with _locked():
|
||||||
|
before_text = _read_config_text()
|
||||||
|
cfg = json.loads(before_text)
|
||||||
|
usable = entry_chains
|
||||||
|
skipped = []
|
||||||
|
new_ports = []
|
||||||
|
if apply_chains:
|
||||||
|
busy = {c["port"] for c in entry_chains if _port_busy(c["port"])}
|
||||||
|
usable, skipped = chains.split_busy_chains(cfg, entry_chains, busy)
|
||||||
|
new_ports = chains.new_ports_needed(cfg, usable)
|
||||||
|
changed, problems = chains.sync_config(cfg, wanted, relay_wanted, usable, exit_nodes, apply_chains=apply_chains)
|
||||||
|
if changed:
|
||||||
|
_save(cfg)
|
||||||
|
try:
|
||||||
|
_reload_and_verify()
|
||||||
|
except Exception:
|
||||||
|
_restore_config_text(before_text)
|
||||||
|
raise
|
||||||
|
for port in new_ports:
|
||||||
|
_open_firewall(port)
|
||||||
|
return {"changed": changed, "new_ports": new_ports, "problems": skipped + problems}
|
||||||
|
|
||||||
|
|
||||||
|
def sync_node(node):
|
||||||
|
wanted, relay_wanted, entry_chains, exit_nodes = desired_state(node)
|
||||||
|
if node["kind"] == "managed":
|
||||||
|
import nodeprov
|
||||||
|
reconcile = nodeprov.remote_reconcile
|
||||||
|
args = (node, wanted, relay_wanted, entry_chains, exit_nodes)
|
||||||
|
elif node["kind"] == "local":
|
||||||
|
reconcile = _reconcile_local
|
||||||
|
args = (wanted, relay_wanted, entry_chains, exit_nodes)
|
||||||
|
else:
|
||||||
|
return {"changed": False, "new_ports": [], "problems": []}
|
||||||
|
try:
|
||||||
|
return reconcile(*args)
|
||||||
|
except Exception as first_error:
|
||||||
|
if not entry_chains:
|
||||||
|
raise
|
||||||
|
result = reconcile(*args, apply_chains=False)
|
||||||
|
result["problems"].append(f"цепочки не применились, клиенты синхронизированы: {first_error}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def sync_from_db():
|
def sync_from_db():
|
||||||
import db as dbmod
|
import db as dbmod
|
||||||
|
|
||||||
expired = dbmod.deactivate_expired()
|
expired = dbmod.deactivate_expired()
|
||||||
active = dbmod.list_active_subscriptions(node="de1")
|
node = dbmod.get_node("de1")
|
||||||
active_by_id = {s["uuid"]: s for s in active}
|
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:
|
def probe_from_node(node: dict, host: str, port: int):
|
||||||
_save(cfg)
|
if node["kind"] == "local":
|
||||||
_reload_xray()
|
return chains.tcp_connect_ms(host, port)
|
||||||
|
if node["kind"] == "managed":
|
||||||
return {"removed_expired": len(expired), "active_now": len(active_by_id), "reloaded": changed}
|
import nodeprov
|
||||||
|
return nodeprov.remote_probe(node, host, port)
|
||||||
|
raise ValueError("нода не под управлением панели, замерить с неё нельзя")
|
||||||
|
|
||||||
|
|
||||||
def add_client_to_node(node: dict, client_uuid: str, email: str):
|
def add_client_to_node(node: dict, client_uuid: str, email: str):
|
||||||
|
|
@ -266,7 +367,6 @@ def local_node_status() -> dict:
|
||||||
|
|
||||||
def sync_all():
|
def sync_all():
|
||||||
import db as dbmod
|
import db as dbmod
|
||||||
import nodeprov
|
|
||||||
|
|
||||||
expired = dbmod.deactivate_expired()
|
expired = dbmod.deactivate_expired()
|
||||||
results = {}
|
results = {}
|
||||||
|
|
@ -276,8 +376,15 @@ def sync_all():
|
||||||
elif node["kind"] == "managed":
|
elif node["kind"] == "managed":
|
||||||
active = dbmod.list_active_subscriptions(node=node["code"])
|
active = dbmod.list_active_subscriptions(node=node["code"])
|
||||||
try:
|
try:
|
||||||
nodeprov.remote_sync(node, active)
|
res = sync_node(node)
|
||||||
results[node["code"]] = {"active_now": len(active), "ok": True}
|
results[node["code"]] = {
|
||||||
|
"active_now": len(active), "ok": True,
|
||||||
|
"changed": res["changed"], "problems": res["problems"],
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results[node["code"]] = {"active_now": len(active), "ok": False, "error": str(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