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