diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef440a9..4bf5f92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,10 @@ 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: Smoke test install-script rendering env: @@ -57,6 +61,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 +317,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 +340,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 +391,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 diff --git a/.gitignore b/.gitignore index 5516894..1119900 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ __pycache__/ *.pyc venv/ .claude/ +.update_mirror +local-changes/ diff --git a/README.md b/README.md index 14b9b79..2a94046 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # MBS Panel -[![CI](https://github.com/devsavsis/mbs-panel/actions/workflows/ci.yml/badge.svg)](https://github.com/devsavsis/mbs-panel/actions/workflows/ci.yml) +[![CI](https://github.com/savsisbtw/mbs-panel/actions/workflows/ci.yml/badge.svg)](https://github.com/savsisbtw/mbs-panel/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Release](https://img.shields.io/github/v/release/devsavsis/mbs-panel?include_prereleases)](https://github.com/devsavsis/mbs-panel/releases) +[![Release](https://img.shields.io/github/v/release/savsisbtw/mbs-panel?include_prereleases)](https://github.com/savsisbtw/mbs-panel/releases) [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/) [![Xray-core](https://img.shields.io/badge/xray--core-latest-red)](https://github.com/XTLS/Xray-core) @@ -30,8 +30,12 @@ - **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код. - **Свой путь входа** — страницу логина можно увести с дефолтного `/admin` на любой другой (`ADMIN_PATH` в `.env`), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще. - **Пауза подписки** — временно отключить доступ без потери оплаченных дней (Marzban это умеет, Remnawave — нет): при возобновлении срок сдвигается ровно на длительность паузы. -- **Исходящие вебхуки** — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса. -- **Поиск и фильтр по подпискам** — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице. +- **Исходящие вебхуки** — на оплату, выдачу/отзыв/паузу/возобновление подписки и на добавление/удаление/вкл-выкл ноды и создание/удаление/вкл-выкл цепочки, с HMAC-подписью тела. У Remnawave это события по юзерам и нодам, у Marzban — только по юзерам; мы покрываем оба класса. +- **Поиск и фильтр по подпискам** — по юзернейму/tg id/ноде/тарифу и по статусу, прямо в таблице. Плюс экспорт всех подписок в CSV одной кнопкой. +- **Цепочки серверов (v1.6)** — `клиент → нода A → нода B → интернет`: собираются в админке мышкой, зажал ЛКМ на клиенте и протянул провод через серверы к интернету. Больше двух серверов нельзя специально, на двух панель предупреждает про задержку и сама меряет RTT между нодами. Подробности ниже в разделе «Цепочки серверов». +- **Юзеры и выдача подписок (v1.6)** — отдельная страница со всеми, кто уже есть в системе, даже если подписок у них ни разу не было (в «Подписках» таких не видно): поиск по юзернейму и Telegram ID, счётчики активных подписок и устройств, кнопка «Выдать подписку». Можно выдать и по Telegram ID тому, кого в базе ещё нет, подписка дождётся, пока он зайдёт в бота. +- **Журнал действий (v1.6)** — кто из админов и когда менял ноды, цепочки, подписки, настройки, качал бэкап и входил (включая неудачные входы с IP). Пароли и ключи в журнал не попадают, только факт действия. +- **Пинг нод и палитра команд (v1.6)** — живая задержка от панели до каждой ноды на дашборде и в списке нод; `Ctrl K` открывает поиск по страницам, нодам, цепочкам и действиям. Акцентный цвет панели меняется кружками сверху. ## Архитектура @@ -111,7 +115,7 @@ sequenceDiagram bash <(curl -Ls https://mbs.savsis.xyz/install.sh) ``` -(или напрямую с GitHub, если так удобнее: `git clone https://github.com/devsavsis/mbs-panel.git && cd mbs-panel && sudo bash install.sh` — скрипт один и тот же, `mbs.savsis.xyz` просто зеркало с автосинком) +(или напрямую с GitHub, если так удобнее: `git clone https://github.com/savsisbtw/mbs-panel.git && cd mbs-panel && sudo bash install.sh` — скрипт один и тот же, `mbs.savsis.xyz` просто зеркало с автосинком) Скрипт спросит домен панели, домен подписки, токен бота от [@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`), потом `api.savsis.xyz`, потом GitHub. Появилось новое зеркало или 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 ## Лицензия diff --git a/admin.html b/admin.html index ce75d1b..75a44b4 100644 --- a/admin.html +++ b/admin.html @@ -4,272 +4,580 @@ MBS Panel + - + @@ -301,40 +609,80 @@
+
+
MBS Panel/Дашборд
+
+
Поиск и командыCtrl K
+
+
онлайн
+
+
Дашборд
-
Общая статистика по сервису
+
Что происходит с сервисом прямо сейчас
+
+
+

Сеть

+
+
+
+

Последние действия

+
+
+
-

Последние подписки

+

Последние подписки

ПользовательСерверТарифИстекаетСтатус
+
+
Юзеры
+
Все, кто уже есть в системе, в том числе те, у кого ни разу не было подписки (в «Подписках» их не видно). Открой карточку и выдай подписку на любую ноду и срок.
+
+
+
+
+
+
+
+ +
ПользовательПодпискиАктивна доУстройствЗарегистрирован
+
+
Подписки
Все выданные подписки
@@ -363,7 +711,7 @@
ПользовательСерверТарифВыданаИстекаетСтатус
- +
ЛокацияАдресТипСтатусLiveЛокацияАдресТипСтатусПингLive
@@ -421,6 +769,62 @@
+
+
Цепочки
+
Собери маршрут клиент → сервер → сервер → интернет. Зажми левую кнопку мыши на «Клиенте» и протяни провод через серверы из пула к «Интернету». Один сервер — это обычное подключение, два — цепочка. Больше двух нельзя: каждый прыжок добавляет задержку.
+
+
+
+ +
+ +
+
Клиент
+
зажми ЛКМ и тяни
+
+
+
Пул серверов
+
+
+
+ +
+
Интернет
+
+
+
+
зажми ЛКМ на клиенте, веди провод через серверы, отпусти на интернете · клик по серверу тоже добавляет его · Esc — сбросить
+
+

Маршрут

+
+
+
+
+ + + +
+
+
+
+
+

Мои цепочки

+
+
+
+ +
+
Журнал
+
Кто и что менял в панели — последние 300 действий. Пароли и ключи сюда не попадают, только факт действия.
+
+
+
+
+
+ +
ВремяАдминДействиеДеталиIP
+
+
+
+
↑↓ выбратьEnter открытьEsc закрыть
+