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
-[](https://github.com/devsavsis/mbs-panel/actions/workflows/ci.yml)
+[](https://github.com/savsisbtw/mbs-panel/actions/workflows/ci.yml)
[](LICENSE)
-[](https://github.com/devsavsis/mbs-panel/releases)
+[](https://github.com/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` открывает поиск по страницам, нодам, цепочкам и действиям. Акцентный цвет панели меняется кружками сверху.
## Архитектура
@@ -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
+
Обзор
Дашборд
+
Сеть
+
Ноды
+
Цепочки
+
Трафик
+
Продажи
+
Юзеры
Подписки
Гифт-коды
-
Ноды
-
Трафик
Платежи
+
Система
+
Журнал
Документация
Настройки
+
+
MBS Panel/Дашборд
+
+
Поиск и командыCtrl K
+
+
онлайн
+
+
Дашборд
-
Общая статистика по сервису
+
Что происходит с сервисом прямо сейчас
+
+
+
Сеть
+
+
+
+
Последние действия
+
+
+
-
Последние подписки
+
Последние подписки
Пользователь
Сервер
Тариф
Истекает
Статус
+
+
Юзеры
+
Все, кто уже есть в системе, в том числе те, у кого ни разу не было подписки (в «Подписках» их не видно). Открой карточку и выдай подписку на любую ноду и срок.
Собери маршрут клиент → сервер → сервер → интернет. Зажми левую кнопку мыши на «Клиенте» и протяни провод через серверы из пула к «Интернету». Один сервер — это обычное подключение, два — цепочка. Больше двух нельзя: каждый прыжок добавляет задержку.
+
+
+
+
+
+
+
+
Клиент
+
зажми ЛКМ и тяни
+
+
+
Пул серверов
+
+
+
+
+
+
Интернет
+
+
+
+
зажми ЛКМ на клиенте, веди провод через серверы, отпусти на интернете · клик по серверу тоже добавляет его · Esc — сбросить
+
+
Маршрут
+
+
+
+
+
+
+
+
+
+
+
+
+
Мои цепочки
+
+
+
+
+
+
Журнал
+
Кто и что менял в панели — последние 300 действий. Пароли и ключи сюда не попадают, только факт действия.
+
+
+
+
+
+
Время
Админ
Действие
Детали
IP
+
+
+
@@ -599,6 +1003,31 @@
Порядок нод в списке (в каком порядке юзеры видят сервера в клиенте) — перетаскиванием за ⠿ слева от строки, сохраняется сразу без отдельной кнопки. Перед каждым рестартом Xray на ноде панель сама прогоняет xray run -test и проверяет, что TLS-сертификаты реально читаемы тем юзером, под которым крутится Xray — невалидный конфиг или неверные права на серт не применяются, а откатываются с понятной ошибкой вместо падения сервиса.
+
+
Цепочки серверов
+
Страница «Цепочки» — конструктор маршрута клиент → сервер A → сервер B → интернет. По умолчанию клиент ходит через одну ноду; цепочка добавляет второй прыжок: трафик заходит на сервер A, оттуда уходит на сервер B и только он выпускает его в интернет. Для сайтов виден IP сервера B, а клиент подключается к A — удобно, когда вход надо держать в «чистом» регионе, а выход — в нужной стране. Больше двух серверов нельзя намеренно: каждый лишний прыжок добавляет задержку.
+
Как собрать: зажми левую кнопку мыши на «Клиенте» и тяни провод — когда проходишь над сервером из пула, он цепляется в маршрут (цифра 1 — вход, 2 — выход). Отпусти провод на «Интернете», чтобы замкнуть. Можно и кликами: клик по серверу добавляет его, клик по «Интернету» замыкает. Esc или «Сбросить» — начать заново, крестик на сервере убирает его и всё, что после.
+
Что происходит под капотом: на входной ноде панель добавляет Xray-inbound chain-код (тот же ключ Reality, что у ноды, но отдельный порт из диапазона 10443–10999 и свой short ID) и outbound до выходной ноды, а в routing — правило «всё из этого inbound уходит в этот outbound». На выходной ноде заводится служебный клиент relay-код — под ним входная нода ходит на выход. Порт открывается в ufw сам. Пользователям входной ноды в подписку добавляется ещё одна ссылка «Вход → Выход». Подписчикам выходной ноды цепочка не выдаётся.
+
Перед применением панель прогоняет xray run -test, проверяет, что порт не занят, и после рестарта убеждается, что Xray поднялся — если нет, конфиг откатывается сам. Любая ошибка цепочки не блокирует синхронизацию подписчиков: клиенты всё равно применятся, а проблема покажется в ответе.
+
Выходом может быть и внешняя нода с общим (shared) UUID — тогда служебный клиент не нужен. Входом — только локальная или управляемая нода, потому что панель правит её конфиг. Ноду, которая участвует в цепочке, нельзя удалить — сначала удали цепочку.
+
Важно для Reality: SNI-маскировка (dest) не должна быть сайтом с пост-квантовым обменом ключами (например, www.microsoft.com) — на таком Reality не работает ни в цепочке, ни без неё. Дефолтный www.wildberries.ru подходит.
+
+
+
+
Юзеры и выдача подписок
+
Страница «Юзеры» — все, кто уже есть в базе, включая тех, у кого подписок не было ни разу (в «Подписках» они не появляются, потому что там только выданное). Поиск идёт по юзернейму и Telegram ID. «Выдать подписку» открывает карточку юзера: выбираешь ноду и срок, подписка создаётся сразу, а клиент добавляется в Xray. Поле «Выдать по Telegram ID» работает и для тех, кого в базе ещё нет: запись создастся при выдаче, подписка будет ждать, пока человек зайдёт в бота. Каждая выдача попадает в журнал.
+
+
+
+
Журнал действий
+
Страница «Журнал» — кто из админов и когда менял ноды, цепочки, подписки, настройки, скачивал бэкап и входил в панель (включая неудачные входы и неверные коды 2FA, с IP). Хранится последние 5000 записей. Содержимое запросов (пароли, ключи, токены) в журнал не пишется — только факт и адрес действия.
+
+
+
+
Поиск и команды
+
Ctrl K (или ⌘ K) открывает палитру: быстрый переход на любую страницу, к любой ноде или цепочке, экспорт подписок в CSV, бэкап, смена акцентного цвета панели. Выбранный акцент хранится в браузере.
+
+
Пароль и безопасность
Пароль админ-панели меняется командой mbs pass на сервере (без аргумента — сгенерит случайный). Панель физически откажется стартовать, если в .env стоит "change-me"/"admin"/что-то короче 8 символов — так что пропустить это не выйдет по-тихому.
@@ -654,7 +1083,7 @@
Telegram-бот
-
Сейчас: — (токен: —)
+
Сейчас: — (токен: —)
@@ -723,7 +1152,7 @@
- События: payment.paid, subscription.granted_by_admin, subscription.revoked, subscription.held, subscription.resumed, node.added, node.deleted, node.enabled, node.disabled. Тело — JSON {"event": "...", "data": {...}}, подписано заголовком X-Signature (HMAC-SHA256 от тела запроса на секрете ниже) — так получатель проверяет, что запрос реально от панели.
+ События: payment.paid, subscription.granted_by_admin, subscription.revoked, subscription.held, subscription.resumed, node.added, node.deleted, node.enabled, node.disabled, chain.created, chain.deleted, chain.enabled, chain.disabled. Тело — JSON {"event": "...", "data": {...}}, подписано заголовком X-Signature (HMAC-SHA256 от тела запроса на секрете ниже) — так получатель проверяет, что запрос реально от панели.
Секрет для проверки: —