From 0e7a7df636a5b4a9c6a5d5bf1250712275a0b6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Fri, 15 May 2026 04:39:19 +0300 Subject: [PATCH] feat(mtproto): Telegram proxy panel and /api routes - Docker telegrammessenger/proxy: install/restart/remove from UI - GET/POST /api/mtproto/*, UI_HIDE_MTPROTO / UI_HIDE_SECTIONS=mtproto - install.sh vars MTPRO_*; /health returns version; docs Co-authored-by: Cursor --- README.md | 30 +++- docs/panel-guide.md | 12 +- package.json | 2 +- public/app.js | 275 ++++++++++++++++++++++++++++++++++- public/index.html | 44 +++++- public/styles.css | 87 ++++++++++- scripts/install.sh | 11 +- server.js | 341 +++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 779 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 2a6632f..bc541e7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # amnezia_web-PRO -Веб-панель на вашем VPS для управления клиентами **AmneziaWG**: вкл/выкл, удаление, переименование, дата отключения; при нескольких контейнерах — переключатель **«Инстанс»** (`AWG_PROFILES`); **экспорт .conf** при наличии `last_config`; **новый клиент под каскад** (свой Endpoint и ключи на сервере). **Cloudflare WARP** — *необязательное* дополнение: часть клиентов может выходить в интернет через интерфейс `warp` внутри контейнера AWG (ставится отдельно скриптом на хосте). Работает через Docker и `docker exec` в контейнер **Amnezia** (по умолчанию `amnezia-awg2`). +Веб-панель на вашем VPS для управления клиентами **AmneziaWG**: вкл/выкл, удаление, переименование, дата отключения; при нескольких контейнерах — переключатель **«Инстанс»** (`AWG_PROFILES`); **экспорт .conf** при наличии `last_config`; **новый клиент под каскад** (свой Endpoint и ключи на сервере); **Telegram MTProto‑прокси** (отдельный контейнер `telegrammessenger/proxy`, управление из панели). **Cloudflare WARP** — *необязательное* дополнение: часть клиентов может выходить в интернет через интерфейс `warp` внутри контейнера AWG (ставится отдельно скриптом на хосте). Работает через Docker и `docker exec` в контейнер **Amnezia** (по умолчанию `amnezia-awg2`). Справочник по интерфейсу, типичным сбоям и HTTP API: **[docs/panel-guide.md](docs/panel-guide.md)**. @@ -8,7 +8,7 @@ ### Редакции: открытая база и PRO -- **[amnezia_web](https://github.com/andrey271192/amnezia_web)** — открытый репозиторий **базовой** панели: **только просмотр** клиентов AmneziaWG и статусов. Нет включения/выключения peer в туннеле, правки дат отключения, переименования, удаления, экспорта `.conf`, блока «Новый клиент под каскад», Cloudflare WARP и синхронизации времени хоста по SSH. Редакция задаётся файлом **`.amnezia-panel-edition`** со значением `community` (его подставляет установщик этого форка) или переменной **`AMNEZIA_EDITION=community`**. Текст и кнопка «Разблокировать PRO» используют **`COMMUNITY_UPGRADE_URL`** и **`COMMUNITY_UPGRADE_PITCH`** (по умолчанию URL ведёт на страницу подписки уровня PRO на Boosty); полный код и приватный репозиторий **amnezia_web-PRO** подключаются подписчикам отдельно. +- **[amnezia_web](https://github.com/andrey271192/amnezia_web)** — открытый репозиторий **базовой** панели: **только просмотр** клиентов AmneziaWG и статусов. Нет включения/выключения peer в туннеле, правки дат отключения, переименования, удаления, экспорта `.conf`, блока «Новый клиент под каскад», Cloudflare WARP, Telegram MTProto‑прокси и синхронизации времени хоста по SSH. Редакция задаётся файлом **`.amnezia-panel-edition`** со значением `community` (его подставляет установщик этого форка) или переменной **`AMNEZIA_EDITION=community`**. Текст и кнопка «Разблокировать PRO» используют **`COMMUNITY_UPGRADE_URL`** и **`COMMUNITY_UPGRADE_PITCH`** (по умолчанию URL ведёт на страницу подписки уровня PRO на Boosty); полный код и приватный репозиторий **amnezia_web-PRO** подключаются подписчикам отдельно. - **Этот репозиторий (`amnezia_web-PRO`)** — **полная** панель (редакция **pro** по умолчанию): все функции из README ниже активны, если не задана **`AMNEZIA_EDITION=community`**. @@ -16,7 +16,7 @@ | Адрес | Назначение | |-------|------------| -| **`http://IP:HOST_PORT`** (по умолчанию **`:8080`**) | **Админ-панель:** клиенты AmneziaWG, опциональный блок **Cloudflare WARP**, каскад, экспорт `.conf`, смена пароля. Внизу страницы — футер со ссылками автора (GitHub, донат, Telegram и т.д.). | +| **`http://IP:HOST_PORT`** (по умолчанию **`:8080`**) | **Админ-панель:** клиенты AmneziaWG, опциональные блоки **Telegram MTProto‑прокси** и **Cloudflare WARP**, каскад, экспорт `.conf`, смена пароля. Внизу страницы — футер со ссылками автора (GitHub, донат, Telegram и т.д.). | | **`http://IP:LANDING_PORT`** (по умолчанию **`:80`**) | **Публичная страница** для приглашённых: инструкция, кнопка перехода в админку (`landing/admin-port.js` подставляет тот же `HOST_PORT`). Текст «проблемы — администратору сервера» и дисклеймер. **Без** блока доната и личных ссылок автора — они только в админке. | Если порт **80** занят другим сервисом, задайте **`LANDING_PORT`** (например `8081`) или **`SKIP_LANDING=1`**. На работу панели по **`HOST_PORT`** это не влияет. @@ -105,10 +105,17 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `LANDING_PORT` | `80` | Порт nginx-лендинга (если `80` занят — например `8081`) | | `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга | | `NO_CACHE` | `0` | `1` — `docker build --no-cache` при проблемах с обновлением образа | -| `UI_HIDE_SECTIONS` | _(нет)_ | Список через запятую: **`users`**, **`warp`**, **`cascade`** — скрыть блоки в веб-панели (см. ниже). При обновлении через `install.sh` значение подтягивается из старого контейнера, если не задано заново | +| `UI_HIDE_SECTIONS` | _(нет)_ | Список через запятую: **`users`**, **`warp`**, **`cascade`**, **`mtproto`** — скрыть блоки в веб-панели (см. ниже). При обновлении через `install.sh` значение подтягивается из старого контейнера, если не задано заново | | `UI_HIDE_USERS` | `0` | `1` или `true` — эквивалент `users` в `UI_HIDE_SECTIONS` | | `UI_HIDE_WARP` | `0` | `1` — эквивалент `warp` (также блокируются `POST /api/warp/*`) | | `UI_HIDE_CASCADE` | `0` | `1` — эквивалент `cascade` (блокируется `POST /api/clients/create-cascade`) | +| `UI_HIDE_MTPROTO` | `0` | `1` или **`mtproto`** в `UI_HIDE_SECTIONS` — скрыть блок установки MTProto; **`GET/POST /api/mtproto/*`** отвечают **403** | +| `MTPRO_PROXY_CONTAINER` | `mtproto-proxy` | Имя контейнера MTProto (образ **telegrammessenger/proxy**) | +| `MTPRO_PROXY_IMAGE` | `telegrammessenger/proxy:latest` | Образ **`docker pull`** при установке из панели | +| `MTPRO_INTERNAL_PORT` | `443` | Порт процесса **внутри** контейнера прокси | +| `MTPRO_PUBLISH_PORT` | `8443` | Порт **хоста**, проброшенный наружу (`-p`) | +| `MTPRO_PUBLISH_BIND` | `0.0.0.0` | Адрес bind на хосте для `-p` | +| `MTPRO_PUBLIC_HOST` | _(нет)_ | Публичный IP/DNS для ссылки **`tg://proxy`**; можно вместо этого задать **`CLIENT_CONFIG_ENDPOINT`** | Переменная **`AWG_PROFILES`** при установке автоматически сохраняется в **`/root/amnezia-admin.awg-profiles.json`** на VPS; при следующем запуске `install.sh` без `AWG_PROFILES` значение подставляется из этого файла или из **старого контейнера** `amnezia-admin` перед его удалением — так переключатель «Инстанс» не пропадает после обновления панели. @@ -136,6 +143,18 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/s Если на хосте уже запущено **несколько** контейнеров с именами вида **`amnezia-awg*`**, а в панели по-прежнему один инстанс — задайте **`AWG_PROFILES`** (или файл **`/root/amnezia-admin.awg-profiles.json`**) и перезапустите установщик; при запуске **`install.sh`** без профилей в этом случае выводится предупреждение в консоль. +### Telegram MTProto‑прокси + +**Не связано с AmneziaWG.** Отдельный контейнер образа **[telegrammessenger/proxy](https://hub.docker.com/r/telegrammessenger/proxy)** на том же хосте. После входа в панель: установка, перезапуск, удаление, выбор порта на хосте, просмотр логов и ссылки **`tg://proxy`** (хост из **`MTPRO_PUBLIC_HOST`**, **`CLIENT_CONFIG_ENDPOINT`** или из URL страницы панели). + +- **`GET /api/mtproto/status`** — состояние и ссылка; **`?withLogs=1`** добавляет хвост логов (**`logsFetched`**). +- **`GET /api/mtproto/logs`** и **`GET /api/mtproto/tail`** — хвост **`docker logs`** (отдельный путь **`/tail`**, если прокси обрезает путь **`…/logs`**). +- **`POST /api/mtproto/install`** | **`…/restart`** | **`…/remove`** + +За nginx/Caddy нужно проксировать **весь** префикс **`/api/`** на процесс панели. После сборки образа можно свериться: **`GET /health`** отдаёт **`version`** из `package.json`. + +Чтобы **скрыть** блок и отключить API: **`UI_HIDE_MTPROTO=1`** или токен **`mtproto`** в **`UI_HIDE_SECTIONS`**. + ### Cloudflare WARP (необязательно) **Устанавливать не обязательно.** Панель и обычный AmneziaWG работают без WARP. Статус **«Не установлен»** значит: в контейнере AWG ещё **нет** `warp.conf` (скрипт на VPS не запускали) — это не ошибка. @@ -183,6 +202,7 @@ cd /opt/amnezia-admin | `users` | Секция **«Пользователи»** (таблица) и отладочный вывод **awg show**. Запрос **`GET /api/clients`** по-прежнему нужен странице — API для вкл/выкл peer и экспорта **не отключается**, скрыта только таблица. | | `warp` | Блок **Cloudflare WARP**; запросы **`POST /api/warp/*`** отвечают **403**. | | `cascade` | Блок **«Новый клиент под каскад»**; **`POST /api/clients/create-cascade`** отвечает **403**. | +| `mtproto` | Блок **«Telegram MTProto‑прокси»**; **`GET`/`POST /api/mtproto/*`** отвечают **403**. | Пример установки / обновления с частично пустой панелью: @@ -190,7 +210,7 @@ cd /opt/amnezia-admin UI_HIDE_SECTIONS=warp,cascade curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo -E bash ``` -Отдельные флаги **`UI_HIDE_USERS`**, **`UI_HIDE_WARP`**, **`UI_HIDE_CASCADE`** (`1` или `true`) дублируют соответствующий токен. +Отдельные флаги **`UI_HIDE_USERS`**, **`UI_HIDE_WARP`**, **`UI_HIDE_CASCADE`**, **`UI_HIDE_MTPROTO`** (`1` или `true`) дублируют соответствующий токен. Итоговые URL после установки совпадают с таблицей **«Что открывается по какому порту»** в начале этого файла; установщик записывает выбранный `HOST_PORT` в **`landing/admin-port.js`**, чтобы кнопка на лендинге вела на нужную админку. diff --git a/docs/panel-guide.md b/docs/panel-guide.md index aa898ef..fa9ab03 100644 --- a/docs/panel-guide.md +++ b/docs/panel-guide.md @@ -9,6 +9,7 @@ | **Инстанс** | Переключение между профилями из `AWG_PROFILES` (разные контейнеры AmneziaWG / Legacy). Виден только если в контейнере панели задан JSON из **двух и более** профилей. | | **Время** | Отображение часов контейнера и браузера; синхронизация времени хоста VPS по SSH (если доступно). | | **Cloudflare WARP** | *Необязательно.* Вывод части клиентов в интернет через интерфейс `warp` в контейнере AWG. Если WARP не ставили — статус **«Не установлен»** нормален; панель и VPN без этого работают. Установка и удаление — скрипт `scripts/warp-amnezia.sh` на хосте (`install` / **`uninstall`**), подробности в основном [README](../README.md). | +| **Telegram MTProto‑прокси** | *Необязательно*, не часть AWG. Docker-контейнер официального образа **telegrammessenger/proxy**; установка из панели, ссылка **`tg://proxy`**, переменные **`MTPRO_*`** — см. основной [README](../README.md). | | **Новый клиент под каскад** | Создание нового peer на сервере с **вашим Endpoint** (промежуточный узел); выдача готового `.conf`. | | **Пользователи** | Вкл/выкл peer, удаление, переименование, даты отключения; экспорт `.conf` если в записи есть `userData.last_config`. | @@ -44,11 +45,12 @@ ## Скрытие разделов в панели (`UI_HIDE_*`) -Переменные контейнера **`amnezia-admin`**: **`UI_HIDE_SECTIONS`** (список `users`, `warp`, `cascade`) или отдельно **`UI_HIDE_USERS`**, **`UI_HIDE_WARP`**, **`UI_HIDE_CASCADE`** (`1` / `true`). Подробности и пример — в таблице установки и разделе README про **`UI_HIDE_*`**. +Переменные контейнера **`amnezia-admin`**: **`UI_HIDE_SECTIONS`** (список `users`, `warp`, `cascade`, **`mtproto`**) или отдельно **`UI_HIDE_USERS`**, **`UI_HIDE_WARP`**, **`UI_HIDE_CASCADE`**, **`UI_HIDE_MTPROTO`** (`1` / `true`). Подробности и пример — в таблице установки и разделе README про **`UI_HIDE_*`**. - **`warp`** — скрывает блок WARP; **`POST /api/warp/*`** → 403. - **`cascade`** — скрывает каскад; **`POST /api/clients/create-cascade`** → 403. - **`users`** — скрывает таблицу «Пользователи» и отладку **awg show**; **`GET /api/clients`** и остальные операции с клиентами **остаются** (скрыт только UI таблицы). +- **`mtproto`** или **`UI_HIDE_MTPROTO`** — скрывает блок MTProto; **`GET`/`POST /api/mtproto/*`** → 403. ## Конфигурации клиентов @@ -67,7 +69,7 @@ | Метод | Путь | Назначение | |-------|------|------------| -| GET | `/health` | Проверка живости | +| GET | `/health` | Проверка живости; JSON **`{ ok, version }`** — **`version`** из `package.json` образа | | GET | `/api/session` | Есть ли действующая сессия | | POST | `/api/login` | `{ "password": "…" }` | | POST | `/api/logout` | Выход | @@ -86,6 +88,12 @@ | POST | `/api/warp/start` | Поднять WARP | | POST | `/api/warp/stop` | Остановить WARP | | POST | `/api/warp/routing` | Политика по клиентам | +| GET | `/api/mtproto/status` | Состояние MTProto‑контейнера и **`tg://proxy`**; **`?withLogs=1`** — хвост логов (**`logsFetched`**) | +| GET | `/api/mtproto/logs` | Хвост **`docker logs`** прокси | +| GET | `/api/mtproto/tail` | То же для прокси, обрезающего путь **`…/logs`** | +| POST | `/api/mtproto/install` | Установить/обновить контейнер (тело `{}`) | +| POST | `/api/mtproto/restart` | Перезапустить контейнер | +| POST | `/api/mtproto/remove` | Удалить контейнер | | GET | `/api/server-time` | Время и подсказки по поясам | | GET | `/api/time-sync-capabilities` | Доступность синхронизации по SSH | | POST | `/api/sync-host-time` | Запись времени на хост через SSH | diff --git a/package.json b/package.json index a8c3b87..069ca18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amnezia-admin", - "version": "1.1.7", + "version": "1.2.0", "private": true, "description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)", "license": "MIT", diff --git a/public/app.js b/public/app.js index 5872d46..f2bf772 100644 --- a/public/app.js +++ b/public/app.js @@ -21,6 +21,19 @@ const warpActionsEl = document.querySelector("#warp-actions"); const warpClientListEl = document.querySelector("#warp-client-list"); const warpWgShowEl = document.querySelector("#warp-wg-show"); +const mtprotoPanel = document.querySelector("#mtproto-panel"); +const mtprotoStatusLine = document.querySelector("#mtproto-status-line"); +const mtprotoActionsEl = document.querySelector("#mtproto-actions"); +const mtprotoLogsTailEl = document.querySelector("#mtproto-logs-tail"); +const mtprotoBanner = document.querySelector("#mtproto-banner"); +const mtprotoHostPortInput = document.querySelector("#mtproto-host-port"); +const mtprotoSecretInput = document.querySelector("#mtproto-secret-opt"); +const mtprotoCalloutEl = document.querySelector("#mtproto-callout"); +const mtprotoLinkCardEl = document.querySelector("#mtproto-link-card"); +const mtprotoLinkAnchorEl = document.querySelector("#mtproto-link-anchor"); +const mtprotoLinkCopyBtn = document.querySelector("#mtproto-link-copy"); +const mtprotoLinkCopyState = document.querySelector("#mtproto-link-copy-state"); + const cascadePanel = document.querySelector("#cascade-panel"); const usersPanel = document.querySelector("#users-panel"); const wgRawDetails = document.querySelector("#wg-raw-details"); @@ -58,7 +71,7 @@ const warpSshErr = document.querySelector("#warp-ssh-err"); let warpSshPendingCmd = null; /** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */ -let uiHidden = { users: false, warp: false, cascade: false }; +let uiHidden = { users: false, warp: false, cascade: false, mtproto: false }; const editionBanner = document.querySelector("#edition-banner"); const DEFAULT_HEADER_SUB = document.querySelector(".top .sub")?.textContent?.trim() || ""; @@ -93,7 +106,7 @@ function applyEditionPayload(data) { if (subEl) { if (editionState.tier === "community") { subEl.textContent = - "Базовая панель amnezia_web: только просмотр клиентов AmneziaWG и статусов. Управление туннелем, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO."; + "Базовая панель amnezia_web: только просмотр клиентов AmneziaWG и статусов. Управление туннелем, экспорт .conf, каскад, Cloudflare WARP, MTProto‑прокси Telegram и синхронизация времени хоста — в версии PRO."; } else { subEl.textContent = DEFAULT_HEADER_SUB; } @@ -131,6 +144,200 @@ function applyEditionPayload(data) { if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg; } +/** @param {string} [tgLink] */ +function setMtprotoLinkCard(tgLink) { + const t = typeof tgLink === "string" ? tgLink.trim() : ""; + if (!mtprotoLinkCardEl) return; + if (!t) { + mtprotoLinkCardEl.classList.add("hidden"); + delete mtprotoLinkCardEl.dataset.tgLink; + if (mtprotoLinkAnchorEl) { + mtprotoLinkAnchorEl.removeAttribute("href"); + mtprotoLinkAnchorEl.textContent = ""; + } + return; + } + mtprotoLinkCardEl.classList.remove("hidden"); + mtprotoLinkCardEl.dataset.tgLink = t; + if (mtprotoLinkAnchorEl) { + mtprotoLinkAnchorEl.href = t; + mtprotoLinkAnchorEl.textContent = t; + } + if (mtprotoLinkCopyState) mtprotoLinkCopyState.textContent = ""; +} + +async function refreshMtprotoPanel() { + if (!mtprotoPanel || !mtprotoStatusLine || !mtprotoActionsEl) return; + if (uiHidden.mtproto) { + mtprotoPanel.hidden = true; + if (mtprotoCalloutEl) { + mtprotoCalloutEl.textContent = ""; + mtprotoCalloutEl.classList.add("hidden"); + } + setMtprotoLinkCard(""); + return; + } + mtprotoPanel.hidden = false; + mtprotoActionsEl.innerHTML = ""; + if (mtprotoBanner) { + mtprotoBanner.classList.add("hidden"); + mtprotoBanner.textContent = ""; + } + try { + if (mtprotoLogsTailEl) mtprotoLogsTailEl.textContent = "Загрузка логов…"; + const snap = await api("/api/mtproto/status?withLogs=1"); + if (snap.logsFetched !== true && snap.exists === true && snap.running === true) { + void (async () => { + try { + const lg = + (await api("/api/mtproto/tail").catch(() => api("/api/mtproto/logs"))) || + ({ logsTail: "" }); + const tail = typeof lg?.logsTail === "string" ? lg.logsTail : ""; + if (mtprotoLogsTailEl) mtprotoLogsTailEl.textContent = tail; + } catch { + if (mtprotoLogsTailEl) { + mtprotoLogsTailEl.textContent = "(лог не загрузился — обновите раздел позже)"; + } + } + })(); + } else { + const tail = typeof snap.logsTail === "string" ? snap.logsTail : ""; + if (mtprotoLogsTailEl) mtprotoLogsTailEl.textContent = tail; + } + + if (mtprotoCalloutEl) { + if (snap.exists) { + const hp = + snap.hostPort != null && snap.hostPort !== "" ? String(snap.hostPort) : null; + mtprotoCalloutEl.classList.remove("hidden"); + mtprotoCalloutEl.textContent = hp + ? `Официальный образ слушает 443/tcp внутри контейнера — это норма. На хост проброшен порт ${hp}: в Telegram указывайте публичный IP/DNS этого VPS и порт ${hp}. В логах ниже официальный прокси сам пишет tg:// и port=443 (внутренний процесс); при таком пробросе в клиенте используйте порт ${hp}, а не 443. Ссылку из синего блока панели (если показана) можно открыть как есть.` + : `Официальный образ слушает 443/tcp внутри контейнера; снаружи — порт хоста из строки статуса («порт хоста»). Если в логах видно tg:// с port=443, для подключения с интернета подставляйте порт хоста из статуса, не 443.`; + } else { + mtprotoCalloutEl.textContent = ""; + mtprotoCalloutEl.classList.add("hidden"); + } + } + + const parts = []; + if (snap.exists) parts.push("контейнер есть"); + if (snap.running) parts.push("запущен"); + mtprotoStatusLine.textContent = + parts.length > 0 + ? `${parts.join(" · ")}${ + snap.hostPort ? ` · порт хоста :${String(snap.hostPort)}` : "" + }${snap.secretMasked ? ` · секрет ${snap.secretMasked}` : ""}` + : "не установлен"; + + if (typeof snap.hint === "string" && snap.hint.trim()) { + const hintEl = document.createElement("p"); + hintEl.className = "muted warp-muted"; + hintEl.textContent = snap.hint.trim(); + mtprotoActionsEl.appendChild(hintEl); + } else if (snap.exists && !snap.running && typeof snap.image === "string" && snap.image.trim()) { + const imgHint = document.createElement("p"); + imgHint.className = "muted warp-muted"; + imgHint.textContent = `После установки образ будет доступен здесь (${snap.image.trim()} при актуальной конфигурации).`; + mtprotoActionsEl.appendChild(imgHint); + } + + setMtprotoLinkCard(snap.tgLink); + + const row = document.createElement("div"); + row.className = "warp-actions"; + + row.appendChild( + btn("Установить / пересоздать", "btn small primary", async () => { + try { + setStatus("MTProto: docker pull/run…", false); + const hp = mtprotoHostPortInput?.value?.trim(); + const sec = mtprotoSecretInput?.value?.trim()?.toLowerCase(); + const body = {}; + if (hp !== "" && hp !== undefined) { + const n = Number.parseInt(String(hp), 10); + if (Number.isFinite(n)) body.hostPort = n; + } + if (/^[a-f0-9]{32}$/.test(sec)) body.secret = sec; + const j = await api("/api/mtproto/install", { method: "POST", body: JSON.stringify(body) }); + if (typeof j.tgLink === "string" && j.tgLink.trim()) setMtprotoLinkCard(j.tgLink.trim()); + const lines = []; + if (typeof j.secretHex === "string") lines.push(`Секрет (сохраните): ${j.secretHex}`); + if (typeof j.tgLink === "string" && j.tgLink) lines.push(`Ссылка: ${j.tgLink}`); + if (mtprotoBanner) { + mtprotoBanner.textContent = lines.join("\n"); + mtprotoBanner.classList.remove("hidden"); + } + if (typeof j.secretHex === "string" && mtprotoSecretInput) mtprotoSecretInput.value = ""; + await refreshMtprotoPanel(); + setStatus("MTProto: готово", false); + } catch (e) { + setStatus(String(e?.message || e), true); + } + }), + ); + + row.appendChild( + btn("Перезапустить", "btn small ghost", async () => { + try { + setStatus("MTProto: перезапуск…", false); + await api("/api/mtproto/restart", { method: "POST", body: JSON.stringify({}) }); + await refreshMtprotoPanel(); + setStatus("", false); + } catch (e) { + setStatus(String(e?.message || e), true); + } + }), + ); + + row.appendChild( + btn("Удалить прокси", "btn small warn", async () => { + try { + if ( + !confirm( + "Удалить контейнер MTProto? Секрет в Telegram сохранять не нужно — он пересоздастся заново при установке.", + ) + ) + return; + setStatus("MTProto: удаление…", false); + await api("/api/mtproto/remove", { method: "POST", body: JSON.stringify({}) }); + if (mtprotoBanner) { + mtprotoBanner.classList.add("hidden"); + mtprotoBanner.textContent = ""; + } + await refreshMtprotoPanel(); + setStatus("", false); + } catch (e) { + setStatus(String(e?.message || e), true); + } + }), + ); + + row.appendChild( + btn("Обновить статус", "btn small ghost", async () => { + try { + setStatus("", false); + await refreshMtprotoPanel(); + } catch (e) { + setStatus(String(e?.message || e), true); + } + }), + ); + + mtprotoActionsEl.appendChild(row); + } catch (e) { + if (mtprotoCalloutEl) { + mtprotoCalloutEl.textContent = ""; + mtprotoCalloutEl.classList.add("hidden"); + } + setMtprotoLinkCard(""); + if (mtprotoLogsTailEl) mtprotoLogsTailEl.textContent = ""; + const err = document.createElement("p"); + err.className = "muted warp-muted err"; + err.textContent = String(e.message || e); + mtprotoActionsEl.appendChild(err); + } +} + function applyUiHiddenFromPayload(data) { const u = data?.uiHidden; if (u && typeof u === "object") { @@ -138,11 +345,15 @@ function applyUiHiddenFromPayload(data) { users: Boolean(u.users), warp: Boolean(u.warp), cascade: Boolean(u.cascade), + mtproto: Boolean(u.mtproto), }; } if (usersPanel) usersPanel.hidden = uiHidden.users; if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg; if (cascadePanel) cascadePanel.hidden = uiHidden.cascade; + if (warpPanel) warpPanel.hidden = uiHidden.warp; + if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto; + void refreshMtprotoPanel(); } let dtMode = "disable"; @@ -326,6 +537,8 @@ function setPwMsg(text, isErr) { } async function api(path, opts = {}) { + const pathOnly = + typeof path === "string" ? path.trim().replace(/\?.*$/, "") || String(path).trim() : ""; const res = await fetch(path, { ...opts, credentials: "same-origin", @@ -342,7 +555,19 @@ async function api(path, opts = {}) { data = { raw: text }; } if (!res.ok) { - const msg = data.error || data.raw || res.statusText; + let msg = typeof data.error === "string" ? data.error : ""; + if (!msg) msg = typeof data.raw === "string" ? data.raw : ""; + if (!msg) msg = res.statusText; + const hint = typeof data.hint === "string" && data.hint.trim() ? data.hint.trim() : ""; + if (hint) { + msg = msg ? `${msg} — ${hint}` : hint; + } else if ( + res.status === 404 && + /^\/api\/mtproto\b/.test(pathOnly) && + /^not\s*found\s*$/i.test(String(msg).trim()) + ) { + msg = `${msg.trim()} — проверьте GET /health (version): образ панели должен включать GET /api/mtproto/status, а прокси — весь /api/.`; + } throw new Error(msg); } return data; @@ -871,6 +1096,14 @@ function renderWarpPanel(data) { warpStatusLine.textContent = parts.join(" · "); const selection = new Set((w.selectedAllowedIps || []).map(String)); + const validWarpPeerIps = new Set(); + for (const c of data.clients) { + if (!c.activeInConf) continue; + parseIpv4Cidrs(c.allowedIps).forEach((ip) => validWarpPeerIps.add(ip)); + } + for (const ip of [...selection]) { + if (!validWarpPeerIps.has(ip)) selection.delete(ip); + } function redrawChecks() { warpClientListEl.innerHTML = ""; @@ -1187,9 +1420,44 @@ async function loadClients() { wgShowEl.textContent = ""; peerCountEl.textContent = ""; if (warpPanel) warpPanel.hidden = true; + if (mtprotoPanel) mtprotoPanel.hidden = true; } } +function initMtprotoLinkCopy() { + if (!mtprotoLinkCopyBtn) return; + mtprotoLinkCopyBtn.addEventListener("click", async () => { + const link = + mtprotoLinkCardEl?.dataset?.tgLink || + mtprotoLinkAnchorEl?.href || + ""; + if (!link) return; + try { + if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(link); + else { + const ta = document.createElement("textarea"); + ta.value = link; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + ta.remove(); + } + if (mtprotoLinkCopyState) { + mtprotoLinkCopyState.textContent = "Скопировано"; + setTimeout(() => { + if (mtprotoLinkCopyState) mtprotoLinkCopyState.textContent = ""; + }, 2400); + } + } catch { + if (mtprotoLinkCopyState) { + mtprotoLinkCopyState.textContent = "Не удалось скопировать — выделите вручную."; + } + } + }); +} + async function boot() { const ok = await checkSession(); if (ok) { @@ -1203,4 +1471,5 @@ async function boot() { } } +initMtprotoLinkCopy(); boot(); diff --git a/public/index.html b/public/index.html index 1c621b4..64ece00 100644 --- a/public/index.html +++ b/public/index.html @@ -29,7 +29,7 @@

Панель сервера

Пользователи AmneziaWG PRO

-

Дату отключения задаёте сами (локальное время браузера). Кнопка «Выключить» убирает клиента из туннеля сразу. «Задать дату» без галочки — только запись в таблице; с галочкой — клиент остаётся в туннеле до наступления времени. Несколько инстансов задаются переменной AWG_PROFILES; установщик при апдейте подставляет её из прежнего контейнера или из /root/amnezia-admin.awg-profiles.json. Конфиг .conf — только если на сервере есть last_config; иначе используйте приложение Amnezia.

+

Дату отключения задаёте сами (локальное время браузера). Кнопка «Выключить» убирает клиента из туннеля сразу. «Задать дату» без галочки — только запись в таблице; с галочкой — клиент остаётся в туннеле до наступления времени. Несколько инстансов задаются переменной AWG_PROFILES; установщик при апдейте подставляет её из прежнего контейнера или из /root/amnezia-admin.awg-profiles.json. Конфиг .conf — только если на сервере есть last_config; иначе используйте приложение Amnezia. Отдельно: раздел Telegram MTProto‑прокси ставит официальный Docker‑образ telegrammessenger/proxy через панель.

@@ -116,6 +116,48 @@
+ +
diff --git a/public/styles.css b/public/styles.css index 85c91f9..241f4a1 100644 --- a/public/styles.css +++ b/public/styles.css @@ -947,6 +947,89 @@ tr:last-child td { line-height: 1.45; } -.edition-banner-cta { - flex-shrink: 0; +/* --- Telegram MTProto --- */ +.mtproto-link-card { + margin: 0.65rem 0 0.85rem; + padding: 0.75rem 0.9rem; + border-radius: 12px; + border: 1px solid rgba(125, 211, 252, 0.45); + background: rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.mtproto-link-card.hidden { + display: none; +} + +.mtproto-link-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.75rem; +} + +.mtproto-link-label { + color: var(--text); + font-weight: 700; +} + +.mtproto-link-anchor { + flex: 1 1 auto; + word-break: break-all; + color: var(--accent); + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.84rem; +} + +.mtproto-panel .warp-intro.mtproto-intro { + margin-top: 0; +} + +.mtproto-fields { + display: grid; + gap: 0.35rem 0.85rem; + max-width: 28rem; + margin: 0.5rem 0 1rem; +} + +.mtproto-fields label { + font-size: 0.82rem; + color: var(--muted); +} + +.mtproto-fields input { + padding: 0.45rem 0.65rem; + border-radius: 8px; + border: 1px solid var(--line); + background: rgba(10, 15, 22, 0.55); + color: var(--text); + font: inherit; +} + +.mtproto-deep-link strong { + display: inline-block; + margin-right: 0.35rem; +} + +.mtproto-deep-link a { + word-break: break-all; +} + +.mtproto-callout { + margin: 0.35rem 0 0; + padding: 0.65rem 0.85rem; + border-radius: 12px; + border: 1px solid rgba(125, 211, 252, 0.4); + background: rgba(125, 211, 252, 0.1); + color: var(--text); + font-size: 0.86rem; + line-height: 1.52; +} + +#mtproto-banner { + margin: 0.75rem 0 0; + white-space: pre-wrap; + font-size: 0.82rem; } diff --git a/scripts/install.sh b/scripts/install.sh index 601ec40..db2b923 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -166,7 +166,7 @@ PREV_CONTAINER_ENV="" if docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then PREV_CONTAINER_ENV="$(docker inspect "${CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null || true)" fi -for __ui_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE WARP_SSH_INSTALL_DIR; do +for __ui_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE UI_HIDE_MTPROTO WARP_SSH_INSTALL_DIR; do if [[ -z "${!__ui_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then PREV_VAL="" while IFS= read -r __line; do @@ -225,12 +225,19 @@ for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2 fi done -for __ui_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE WARP_SSH_INSTALL_DIR; do +for __ui_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE UI_HIDE_MTPROTO WARP_SSH_INSTALL_DIR; do if [[ -n "${!__ui_var:-}" ]]; then RUN_ENV+=( -e "${__ui_var}=${!__ui_var}" ) fi done +for __mt_vars in MTPRO_PROXY_CONTAINER MTPRO_PROXY_IMAGE MTPRO_INTERNAL_PORT MTPRO_PUBLISH_PORT \ + MTPRO_PUBLISH_BIND MTPRO_PUBLIC_HOST; do + if [[ -n "${!__mt_vars:-}" ]]; then + RUN_ENV+=( -e "${__mt_vars}=${!__mt_vars}" ) + fi +done + if [[ -n "${BOOT_PW}" ]]; then RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" ) elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then diff --git a/server.js b/server.js index d18777e..3dfa29c 100644 --- a/server.js +++ b/server.js @@ -1,5 +1,5 @@ import express from "express"; -import { spawn } from "child_process"; +import { spawn, spawnSync } from "child_process"; import crypto from "crypto"; import path from "path"; import fs from "fs"; @@ -7,6 +7,16 @@ import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +/** Из package.json — для /health и сверки образа после деплоя. */ +let PANEL_VERSION = "0.0.0"; +try { + const pkgRaw = fs.readFileSync(path.join(__dirname, "package.json"), "utf8"); + const pv = JSON.parse(pkgRaw)?.version; + if (typeof pv === "string" && pv.trim()) PANEL_VERSION = pv.trim(); +} catch { + /* noop */ +} + const PORT = Number(process.env.PORT || 3980); const PROFILE_COOKIE = "amnezia_prof"; const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000); @@ -19,7 +29,7 @@ function envTruthy(v) { return s === "1" || s === "true" || s === "yes"; } -/** Какие блоки веб-интерфейса скрыты: `UI_HIDE_SECTIONS=users,warp,cascade` или `UI_HIDE_USERS` и т.д. */ +/** Какие блоки веб-интерфейса скрыты: `UI_HIDE_SECTIONS=users,warp,cascade,mtproto` или `UI_HIDE_*` по отдельности. */ function resolveUiHidden() { const raw = process.env.UI_HIDE_SECTIONS?.trim(); const set = new Set(); @@ -33,6 +43,7 @@ function resolveUiHidden() { users: set.has("users") || envTruthy(process.env.UI_HIDE_USERS), warp: set.has("warp") || envTruthy(process.env.UI_HIDE_WARP), cascade: set.has("cascade") || envTruthy(process.env.UI_HIDE_CASCADE), + mtproto: set.has("mtproto") || envTruthy(process.env.UI_HIDE_MTPROTO), }; } @@ -63,6 +74,7 @@ function effectiveUiHidden() { users: UI_HIDDEN.users, warp: true, cascade: true, + mtproto: UI_HIDDEN.mtproto, }; } @@ -1355,11 +1367,152 @@ bootstrapPassword(); const MSG_UI_WARP_OFF = "Раздел Cloudflare WARP отключён на этом сервере (UI_HIDE_SECTIONS / UI_HIDE_WARP)."; const MSG_UI_CASCADE_OFF = "Каскад отключён на этом сервере (UI_HIDE_SECTIONS / UI_HIDE_CASCADE)."; +const MSG_UI_MTProto_OFF = + "Раздел MTProto отключён (UI_HIDE_SECTIONS включает mtproto или задан UI_HIDE_MTPROTO=1)."; + +const MTPRO_CONTAINER = (process.env.MTPRO_PROXY_CONTAINER || "mtproto-proxy").trim(); +const MTPRO_IMAGE = (process.env.MTPRO_PROXY_IMAGE || "telegrammessenger/proxy:latest").trim(); +const MTPRO_INTERNAL_PORT = Number(process.env.MTPRO_INTERNAL_PORT || 443) || 443; +const MTPRO_PUBLISH_PORT_DEFAULT = (() => { + const n = Number.parseInt(process.env.MTPRO_PUBLISH_PORT || "8443", 10); + return Number.isFinite(n) && n > 0 ? n : 8443; +})(); +const MTPRO_PUBLISH_BIND = (process.env.MTPRO_PUBLISH_BIND || "0.0.0.0").trim() || "0.0.0.0"; + +let mtprotoInstallBusy = false; + +function dockerSpawnSync(args, timeoutMs = 180_000) { + const r = spawnSync("docker", args, { + encoding: "utf8", + maxBuffer: 5 * 1024 * 1024, + timeout: timeoutMs, + stdio: ["ignore", "pipe", "pipe"], + }); + return { + code: typeof r.status === "number" ? r.status : 1, + stdout: String(r.stdout || ""), + stderr: String(r.stderr || ""), + }; +} + +function envArrayToMap(envArr) { + const out = {}; + if (!Array.isArray(envArr)) return out; + for (const line of envArr) { + const i = line.indexOf("="); + if (i <= 0) continue; + out[line.slice(0, i)] = line.slice(i + 1); + } + return out; +} + +function mtprotoParsedInspect() { + const r = dockerSpawnSync(["inspect", MTPRO_CONTAINER], 20_000); + if (r.code !== 0) return null; + try { + const arr = JSON.parse(r.stdout); + return arr?.[0] ?? null; + } catch { + return null; + } +} + +function mtprotoHostPort(ins) { + const key = `${MTPRO_INTERNAL_PORT}/tcp`; + const bind = ins?.HostConfig?.PortBindings?.[key]; + if (Array.isArray(bind) && bind[0]?.HostPort) return String(bind[0].HostPort); + const ex = ins?.NetworkSettings?.Ports?.[key]; + if (Array.isArray(ex) && ex[0]?.HostPort) return String(ex[0].HostPort); + return null; +} + +function mtprotoAdvertisedHost() { + return process.env.MTPRO_PUBLIC_HOST?.trim() || process.env.CLIENT_CONFIG_ENDPOINT?.trim() || ""; +} + +/** Хост из заголовка Host (без :порт) — фоллбек для tg://, если env не заданы. */ +function hostFromRequest(req) { + const raw = String(req?.headers?.host || "").trim(); + if (!raw) return ""; + const bare = raw.replace(/^\[/, "").replace(/\]:\d+$/, "]").replace(/:\d+$/, ""); + if (!bare || /^(localhost|127\.|0\.0\.0\.0|::1?$)/i.test(bare)) return ""; + return bare; +} + +function mtprotoMaskedSecret(secret) { + const s = String(secret || "").trim(); + if (s.length < 10) return "········"; + return `········${s.slice(-6)}`; +} + +function mtprotoTelegramDeepLink(host, port, secretHex) { + const h = String(host || "").trim(); + const p = Number(port); + const sec = String(secretHex || "").trim(); + if (!h || !Number.isFinite(p) || !sec) return ""; + try { + return `tg://proxy?server=${encodeURIComponent(h)}&port=${encodeURIComponent(String(p))}&secret=${encodeURIComponent(sec)}`; + } catch { + return ""; + } +} + +function mtprotoNormalizeSecret(hex) { + const s = String(hex || "").trim().toLowerCase(); + if (/^[0-9a-f]{32}$/.test(s)) return s; + return ""; +} + +function mtprotoSnapshot(fallbackHost = "") { + const ins = mtprotoParsedInspect(); + const advEnv = mtprotoAdvertisedHost(); + const advFallback = String(fallbackHost || "").trim(); + const advEffective = advEnv || advFallback; + const advSource = advEnv ? "env" : advFallback ? "request" : ""; + + if (!ins) { + return { + exists: false, + running: false, + container: MTPRO_CONTAINER, + image: MTPRO_IMAGE, + hostPort: null, + advertisedHost: advEffective, + advertisedHostSource: advSource, + secretMasked: "", + tgLink: "", + hint: "Контейнер не найден — нажмите «Установить».", + }; + } + const state = String(ins?.State?.Status || "").toLowerCase(); + const running = state === "running"; + const cfg = ins?.Config || {}; + const envMap = envArrayToMap(cfg?.Env); + const secret = envMap.SECRET || ""; + const hostPort = mtprotoHostPort(ins); + return { + exists: true, + running, + container: MTPRO_CONTAINER, + image: String(cfg?.Image || MTPRO_IMAGE), + hostPort, + advertisedHost: advEffective, + advertisedHostSource: advSource, + secretMasked: secret ? mtprotoMaskedSecret(secret) : "", + tgLink: mtprotoTelegramDeepLink(advEffective, Number(hostPort || 0), secret), + restartCount: Number(ins?.RestartCount || 0) || 0, + hint: advEnv + ? "" + : advFallback + ? `Хост ${advFallback} взят из адреса, по которому открыта эта панель. Чтобы зафиксировать публичный IP/DNS, задайте MTPRO_PUBLIC_HOST или CLIENT_CONFIG_ENDPOINT в контейнере панели.` + : "Задайте MTPRO_PUBLIC_HOST или CLIENT_CONFIG_ENDPOINT на IP/DNS VPS — тогда появится прямая ссылка tg:// для клиентов.", + }; +} const app = express(); -if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade) { +if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade || UI_HIDDEN.mtproto) { console.warn( - `UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade}`, + `UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade} mtproto=${UI_HIDDEN.mtproto}`, ); } if (IS_COMMUNITY) { @@ -1368,7 +1521,7 @@ if (IS_COMMUNITY) { app.use(express.json({ limit: "512kb" })); app.get("/health", (_req, res) => { - res.json({ ok: true }); + res.json({ ok: true, version: PANEL_VERSION }); }); app.get("/api/session", (req, res) => { @@ -1426,6 +1579,171 @@ app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => { }); }); +function mtprotoLogsTailPayload(req) { + const snap = mtprotoSnapshot(hostFromRequest(req)); + if (!snap.exists || !snap.running) return { logsTail: "" }; + const l = dockerSpawnSync(["logs", "--tail", "100", MTPRO_CONTAINER], 12_000); + let logsTail = ""; + if (l.code === 0) logsTail = l.stdout.trim().slice(-4500); + return { logsTail }; +} + +app.get("/api/mtproto/status", requireAuth, (req, res) => { + if (effectiveUiHidden().mtproto) { + return res.status(403).json({ error: MSG_UI_MTProto_OFF }); + } + const snap = mtprotoSnapshot(hostFromRequest(req)); + const withLogs = + typeof req.query.withLogs === "string" && + (req.query.withLogs === "1" || req.query.withLogs === ""); + if (!withLogs) { + res.json({ ...snap }); + return; + } + const { logsTail } = mtprotoLogsTailPayload(req); + res.json({ ...snap, logsTail, logsFetched: true }); +}); + +app.get("/api/mtproto/logs", requireAuth, (req, res) => { + if (effectiveUiHidden().mtproto) { + return res.status(403).json({ error: MSG_UI_MTProto_OFF }); + } + res.json(mtprotoLogsTailPayload(req)); +}); + +app.get("/api/mtproto/tail", requireAuth, (req, res) => { + if (effectiveUiHidden().mtproto) { + return res.status(403).json({ error: MSG_UI_MTProto_OFF }); + } + res.json(mtprotoLogsTailPayload(req)); +}); + +app.post("/api/mtproto/install", requireAuth, (req, res) => { + if (effectiveUiHidden().mtproto) { + return res.status(403).json({ error: MSG_UI_MTProto_OFF }); + } + if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(MTPRO_CONTAINER)) { + return res.status(500).json({ + error: + "Некорректное имя Docker-контейнера MTProto (переменная окружения MTPRO_PROXY_CONTAINER).", + }); + } + if (!/^[a-zA-Z0-9_.\-/:@]+$/.test(MTPRO_IMAGE)) { + return res.status(500).json({ + error: "Некорректное имя образа MTProto (переменная MTPRO_PROXY_IMAGE).", + }); + } + if (!/^[0-9.:a-fA-F]+$/.test(MTPRO_PUBLISH_BIND)) { + return res.status(500).json({ + error: "Некорректный MTPRO_PUBLISH_BIND.", + }); + } + + let hostPort = MTPRO_PUBLISH_PORT_DEFAULT; + const hpReq = req.body?.hostPort; + if (hpReq !== undefined && hpReq !== null && hpReq !== "") { + const parsed = typeof hpReq === "number" ? hpReq : Number.parseInt(String(hpReq).trim(), 10); + if (!Number.isFinite(parsed) || parsed < 512 || parsed > 65535) { + return res.status(400).json({ error: "Порт (hostPort): целое 512 … 65535." }); + } + hostPort = parsed; + } + + const userSec = mtprotoNormalizeSecret(typeof req.body?.secret === "string" ? req.body.secret : ""); + const secretFinal = userSec || crypto.randomBytes(16).toString("hex"); + + if (mtprotoInstallBusy) return res.status(429).json({ error: "Установка MTProto уже выполняется." }); + + mtprotoInstallBusy = true; + try { + const rm = dockerSpawnSync(["rm", "-f", MTPRO_CONTAINER], 120_000); + if (rm.code !== 0 && !/No such container/i.test(`${rm.stderr} ${rm.stdout}`)) { + return res.status(400).json({ + error: `docker rm завершился с кодом ${rm.code}`, + stderr: rm.stderr.trim().slice(0, 2000), + }); + } + + const pull = dockerSpawnSync(["pull", MTPRO_IMAGE], 600_000); + if (pull.code !== 0) { + return res.status(400).json({ + error: "Не удалось docker pull образа.", + stderr: `${pull.stderr || ""}${pull.stdout || ""}`.trim().slice(0, 2000), + }); + } + + const pub = `${MTPRO_PUBLISH_BIND}:${hostPort}:${MTPRO_INTERNAL_PORT}/tcp`; + const args = [ + "run", + "-d", + "--name", + MTPRO_CONTAINER, + "--restart", + "unless-stopped", + "-p", + pub, + "-e", + `SECRET=${secretFinal}`, + MTPRO_IMAGE, + ]; + const run = dockerSpawnSync(args, 120_000); + if (run.code !== 0) { + return res.status(400).json({ + error: `docker run завершился с кодом ${run.code}`, + stderr: `${run.stderr}${run.stdout}`.trim().slice(0, 4000), + }); + } + + const fallback = hostFromRequest(req); + const advEnv = mtprotoAdvertisedHost(); + const advEffective = advEnv || fallback; + const snap = mtprotoSnapshot(fallback); + const tgLink = mtprotoTelegramDeepLink(advEffective, hostPort, secretFinal); + + res.json({ + ok: true, + secretHex: secretFinal, + hostPort, + tgLink, + advertisedHost: advEffective, + advertisedHostSource: advEnv ? "env" : fallback ? "request" : "", + snapshot: snap, + }); + } finally { + mtprotoInstallBusy = false; + } +}); + +app.post("/api/mtproto/remove", requireAuth, (_req, res) => { + if (effectiveUiHidden().mtproto) { + return res.status(403).json({ error: MSG_UI_MTProto_OFF }); + } + const r = dockerSpawnSync(["rm", "-f", MTPRO_CONTAINER], 120_000); + if (r.code !== 0 && !/No such container/i.test(`${r.stderr} ${r.stdout}`)) { + return res.status(400).json({ + error: "Не удалось удалить контейнер MTProto.", + stderr: `${r.stderr}${r.stdout}`.trim().slice(0, 2000), + }); + } + res.json({ ok: true }); +}); + +app.post("/api/mtproto/restart", requireAuth, (_req, res) => { + if (effectiveUiHidden().mtproto) { + return res.status(403).json({ error: MSG_UI_MTProto_OFF }); + } + const snap = mtprotoSnapshot(); + if (!snap.exists) return res.status(400).json({ error: "Контейнер MTProto ещё не установлен." }); + const r = dockerSpawnSync(["restart", MTPRO_CONTAINER], 180_000); + if (r.code !== 0) { + return res.status(400).json({ + error: `docker restart завершился с кодом ${r.code}`, + stderr: `${r.stderr}${r.stdout}`.trim().slice(0, 2000), + }); + } + res.json({ ok: true }); +}); + app.post("/api/sync-host-time", requireAuth, requireProTier, async (req, res) => { if (!hostTimeSyncConfigured()) { return res.status(503).json({ @@ -2106,13 +2424,22 @@ if (fs.existsSync(pub)) { ); } -app.use((_req, res) => { +app.use((req, res) => { + if (typeof req.path === "string" && req.path.startsWith("/api/")) { + res.status(404).json({ + error: "Not found", + path: `${req.originalUrl || req.path}`, + hint: + "Эндпоинта нет. Проверьте GET /health (version), что запрос идёт в контейнер панели и прокси пробрасывает весь префикс /api/.", + }); + return; + } res.status(404).send("Not found"); }); app.listen(PORT, "0.0.0.0", () => { const summary = PROFILES.map((p) => `${p.label}→${p.container}`).join("; "); - console.log(`amnezia-admin on :${PORT} · ${summary} · data:${DATA_DIR}`); + console.log(`amnezia-admin v${PANEL_VERSION} on :${PORT} · ${summary} · data:${DATA_DIR}`); }); setInterval(() => {