mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
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 <cursoragent@cursor.com>
This commit is contained in:
30
README.md
30
README.md
@@ -1,6 +1,6 @@
|
|||||||
# amnezia_web-PRO
|
# 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)**.
|
Справочник по интерфейсу, типичным сбоям и HTTP API: **[docs/panel-guide.md](docs/panel-guide.md)**.
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
### Редакции: открытая база и PRO
|
### Редакции: открытая база и 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`**.
|
- **Этот репозиторий (`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`). Текст «проблемы — администратору сервера» и дисклеймер. **Без** блока доната и личных ссылок автора — они только в админке. |
|
| **`http://IP:LANDING_PORT`** (по умолчанию **`:80`**) | **Публичная страница** для приглашённых: инструкция, кнопка перехода в админку (`landing/admin-port.js` подставляет тот же `HOST_PORT`). Текст «проблемы — администратору сервера» и дисклеймер. **Без** блока доната и личных ссылок автора — они только в админке. |
|
||||||
|
|
||||||
Если порт **80** занят другим сервисом, задайте **`LANDING_PORT`** (например `8081`) или **`SKIP_LANDING=1`**. На работу панели по **`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_PORT` | `80` | Порт nginx-лендинга (если `80` занят — например `8081`) |
|
||||||
| `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга |
|
| `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга |
|
||||||
| `NO_CACHE` | `0` | `1` — `docker build --no-cache` при проблемах с обновлением образа |
|
| `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_USERS` | `0` | `1` или `true` — эквивалент `users` в `UI_HIDE_SECTIONS` |
|
||||||
| `UI_HIDE_WARP` | `0` | `1` — эквивалент `warp` (также блокируются `POST /api/warp/*`) |
|
| `UI_HIDE_WARP` | `0` | `1` — эквивалент `warp` (также блокируются `POST /api/warp/*`) |
|
||||||
| `UI_HIDE_CASCADE` | `0` | `1` — эквивалент `cascade` (блокируется `POST /api/clients/create-cascade`) |
|
| `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` перед его удалением — так переключатель «Инстанс» не пропадает после обновления панели.
|
Переменная **`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`** без профилей в этом случае выводится предупреждение в консоль.
|
Если на хосте уже запущено **несколько** контейнеров с именами вида **`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 (необязательно)
|
### Cloudflare WARP (необязательно)
|
||||||
|
|
||||||
**Устанавливать не обязательно.** Панель и обычный AmneziaWG работают без WARP. Статус **«Не установлен»** значит: в контейнере AWG ещё **нет** `warp.conf` (скрипт на VPS не запускали) — это не ошибка.
|
**Устанавливать не обязательно.** Панель и обычный AmneziaWG работают без WARP. Статус **«Не установлен»** значит: в контейнере AWG ещё **нет** `warp.conf` (скрипт на VPS не запускали) — это не ошибка.
|
||||||
@@ -183,6 +202,7 @@ cd /opt/amnezia-admin
|
|||||||
| `users` | Секция **«Пользователи»** (таблица) и отладочный вывод **awg show**. Запрос **`GET /api/clients`** по-прежнему нужен странице — API для вкл/выкл peer и экспорта **не отключается**, скрыта только таблица. |
|
| `users` | Секция **«Пользователи»** (таблица) и отладочный вывод **awg show**. Запрос **`GET /api/clients`** по-прежнему нужен странице — API для вкл/выкл peer и экспорта **не отключается**, скрыта только таблица. |
|
||||||
| `warp` | Блок **Cloudflare WARP**; запросы **`POST /api/warp/*`** отвечают **403**. |
|
| `warp` | Блок **Cloudflare WARP**; запросы **`POST /api/warp/*`** отвечают **403**. |
|
||||||
| `cascade` | Блок **«Новый клиент под каскад»**; **`POST /api/clients/create-cascade`** отвечает **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_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`**, чтобы кнопка на лендинге вела на нужную админку.
|
Итоговые URL после установки совпадают с таблицей **«Что открывается по какому порту»** в начале этого файла; установщик записывает выбранный `HOST_PORT` в **`landing/admin-port.js`**, чтобы кнопка на лендинге вела на нужную админку.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
| **Инстанс** | Переключение между профилями из `AWG_PROFILES` (разные контейнеры AmneziaWG / Legacy). Виден только если в контейнере панели задан JSON из **двух и более** профилей. |
|
| **Инстанс** | Переключение между профилями из `AWG_PROFILES` (разные контейнеры AmneziaWG / Legacy). Виден только если в контейнере панели задан JSON из **двух и более** профилей. |
|
||||||
| **Время** | Отображение часов контейнера и браузера; синхронизация времени хоста VPS по SSH (если доступно). |
|
| **Время** | Отображение часов контейнера и браузера; синхронизация времени хоста VPS по SSH (если доступно). |
|
||||||
| **Cloudflare WARP** | *Необязательно.* Вывод части клиентов в интернет через интерфейс `warp` в контейнере AWG. Если WARP не ставили — статус **«Не установлен»** нормален; панель и VPN без этого работают. Установка и удаление — скрипт `scripts/warp-amnezia.sh` на хосте (`install` / **`uninstall`**), подробности в основном [README](../README.md). |
|
| **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 на сервере с **вашим Endpoint** (промежуточный узел); выдача готового `.conf`. |
|
||||||
| **Пользователи** | Вкл/выкл peer, удаление, переименование, даты отключения; экспорт `.conf` если в записи есть `userData.last_config`. |
|
| **Пользователи** | Вкл/выкл peer, удаление, переименование, даты отключения; экспорт `.conf` если в записи есть `userData.last_config`. |
|
||||||
|
|
||||||
@@ -44,11 +45,12 @@
|
|||||||
|
|
||||||
## Скрытие разделов в панели (`UI_HIDE_*`)
|
## Скрытие разделов в панели (`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.
|
- **`warp`** — скрывает блок WARP; **`POST /api/warp/*`** → 403.
|
||||||
- **`cascade`** — скрывает каскад; **`POST /api/clients/create-cascade`** → 403.
|
- **`cascade`** — скрывает каскад; **`POST /api/clients/create-cascade`** → 403.
|
||||||
- **`users`** — скрывает таблицу «Пользователи» и отладку **awg show**; **`GET /api/clients`** и остальные операции с клиентами **остаются** (скрыт только UI таблицы).
|
- **`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` | Есть ли действующая сессия |
|
| GET | `/api/session` | Есть ли действующая сессия |
|
||||||
| POST | `/api/login` | `{ "password": "…" }` |
|
| POST | `/api/login` | `{ "password": "…" }` |
|
||||||
| POST | `/api/logout` | Выход |
|
| POST | `/api/logout` | Выход |
|
||||||
@@ -86,6 +88,12 @@
|
|||||||
| POST | `/api/warp/start` | Поднять WARP |
|
| POST | `/api/warp/start` | Поднять WARP |
|
||||||
| POST | `/api/warp/stop` | Остановить WARP |
|
| POST | `/api/warp/stop` | Остановить WARP |
|
||||||
| POST | `/api/warp/routing` | Политика по клиентам |
|
| 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/server-time` | Время и подсказки по поясам |
|
||||||
| GET | `/api/time-sync-capabilities` | Доступность синхронизации по SSH |
|
| GET | `/api/time-sync-capabilities` | Доступность синхронизации по SSH |
|
||||||
| POST | `/api/sync-host-time` | Запись времени на хост через SSH |
|
| POST | `/api/sync-host-time` | Запись времени на хост через SSH |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "amnezia-admin",
|
"name": "amnezia-admin",
|
||||||
"version": "1.1.7",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
|
"description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
275
public/app.js
275
public/app.js
@@ -21,6 +21,19 @@ const warpActionsEl = document.querySelector("#warp-actions");
|
|||||||
const warpClientListEl = document.querySelector("#warp-client-list");
|
const warpClientListEl = document.querySelector("#warp-client-list");
|
||||||
const warpWgShowEl = document.querySelector("#warp-wg-show");
|
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 cascadePanel = document.querySelector("#cascade-panel");
|
||||||
const usersPanel = document.querySelector("#users-panel");
|
const usersPanel = document.querySelector("#users-panel");
|
||||||
const wgRawDetails = document.querySelector("#wg-raw-details");
|
const wgRawDetails = document.querySelector("#wg-raw-details");
|
||||||
@@ -58,7 +71,7 @@ const warpSshErr = document.querySelector("#warp-ssh-err");
|
|||||||
let warpSshPendingCmd = null;
|
let warpSshPendingCmd = null;
|
||||||
|
|
||||||
/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
|
/** Какие панели скрыты настройкой сервера (`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 editionBanner = document.querySelector("#edition-banner");
|
||||||
const DEFAULT_HEADER_SUB = document.querySelector(".top .sub")?.textContent?.trim() || "";
|
const DEFAULT_HEADER_SUB = document.querySelector(".top .sub")?.textContent?.trim() || "";
|
||||||
@@ -93,7 +106,7 @@ function applyEditionPayload(data) {
|
|||||||
if (subEl) {
|
if (subEl) {
|
||||||
if (editionState.tier === "community") {
|
if (editionState.tier === "community") {
|
||||||
subEl.textContent =
|
subEl.textContent =
|
||||||
"Базовая панель amnezia_web: только просмотр клиентов AmneziaWG и статусов. Управление туннелем, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO.";
|
"Базовая панель amnezia_web: только просмотр клиентов AmneziaWG и статусов. Управление туннелем, экспорт .conf, каскад, Cloudflare WARP, MTProto‑прокси Telegram и синхронизация времени хоста — в версии PRO.";
|
||||||
} else {
|
} else {
|
||||||
subEl.textContent = DEFAULT_HEADER_SUB;
|
subEl.textContent = DEFAULT_HEADER_SUB;
|
||||||
}
|
}
|
||||||
@@ -131,6 +144,200 @@ function applyEditionPayload(data) {
|
|||||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
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) {
|
function applyUiHiddenFromPayload(data) {
|
||||||
const u = data?.uiHidden;
|
const u = data?.uiHidden;
|
||||||
if (u && typeof u === "object") {
|
if (u && typeof u === "object") {
|
||||||
@@ -138,11 +345,15 @@ function applyUiHiddenFromPayload(data) {
|
|||||||
users: Boolean(u.users),
|
users: Boolean(u.users),
|
||||||
warp: Boolean(u.warp),
|
warp: Boolean(u.warp),
|
||||||
cascade: Boolean(u.cascade),
|
cascade: Boolean(u.cascade),
|
||||||
|
mtproto: Boolean(u.mtproto),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (usersPanel) usersPanel.hidden = uiHidden.users;
|
if (usersPanel) usersPanel.hidden = uiHidden.users;
|
||||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
||||||
if (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
|
if (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
|
||||||
|
if (warpPanel) warpPanel.hidden = uiHidden.warp;
|
||||||
|
if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto;
|
||||||
|
void refreshMtprotoPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
let dtMode = "disable";
|
let dtMode = "disable";
|
||||||
@@ -326,6 +537,8 @@ function setPwMsg(text, isErr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
|
const pathOnly =
|
||||||
|
typeof path === "string" ? path.trim().replace(/\?.*$/, "") || String(path).trim() : "";
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
...opts,
|
...opts,
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
@@ -342,7 +555,19 @@ async function api(path, opts = {}) {
|
|||||||
data = { raw: text };
|
data = { raw: text };
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
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);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
@@ -871,6 +1096,14 @@ function renderWarpPanel(data) {
|
|||||||
warpStatusLine.textContent = parts.join(" · ");
|
warpStatusLine.textContent = parts.join(" · ");
|
||||||
|
|
||||||
const selection = new Set((w.selectedAllowedIps || []).map(String));
|
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() {
|
function redrawChecks() {
|
||||||
warpClientListEl.innerHTML = "";
|
warpClientListEl.innerHTML = "";
|
||||||
@@ -1187,9 +1420,44 @@ async function loadClients() {
|
|||||||
wgShowEl.textContent = "";
|
wgShowEl.textContent = "";
|
||||||
peerCountEl.textContent = "";
|
peerCountEl.textContent = "";
|
||||||
if (warpPanel) warpPanel.hidden = true;
|
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() {
|
async function boot() {
|
||||||
const ok = await checkSession();
|
const ok = await checkSession();
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -1203,4 +1471,5 @@ async function boot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initMtprotoLinkCopy();
|
||||||
boot();
|
boot();
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Панель сервера</p>
|
<p class="eyebrow">Панель сервера</p>
|
||||||
<h1>Пользователи AmneziaWG PRO</h1>
|
<h1>Пользователи AmneziaWG PRO</h1>
|
||||||
<p class="sub">Дату отключения задаёте сами (локальное время браузера). Кнопка «Выключить» убирает клиента из туннеля сразу. «Задать дату» без галочки — только запись в таблице; с галочкой — клиент остаётся в туннеле до наступления времени. Несколько инстансов задаются переменной <code class="inline">AWG_PROFILES</code>; установщик при апдейте подставляет её из прежнего контейнера или из <code class="inline">/root/amnezia-admin.awg-profiles.json</code>. Конфиг .conf — только если на сервере есть <code class="inline">last_config</code>; иначе используйте приложение Amnezia.</p>
|
<p class="sub">Дату отключения задаёте сами (локальное время браузера). Кнопка «Выключить» убирает клиента из туннеля сразу. «Задать дату» без галочки — только запись в таблице; с галочкой — клиент остаётся в туннеле до наступления времени. Несколько инстансов задаются переменной <code class="inline">AWG_PROFILES</code>; установщик при апдейте подставляет её из прежнего контейнера или из <code class="inline">/root/amnezia-admin.awg-profiles.json</code>. Конфиг .conf — только если на сервере есть <code class="inline">last_config</code>; иначе используйте приложение Amnezia. Отдельно: раздел <strong>Telegram MTProto‑прокси</strong> ставит официальный Docker‑образ <code class="inline">telegrammessenger/proxy</code> через панель.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="token-box">
|
<div class="token-box">
|
||||||
<div class="session-actions">
|
<div class="session-actions">
|
||||||
@@ -116,6 +116,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details class="panel-fold mtproto-panel" id="mtproto-panel" hidden>
|
||||||
|
<summary class="fold-summary">
|
||||||
|
<span class="fold-arrow" aria-hidden="true"></span>
|
||||||
|
<span class="fold-titles">
|
||||||
|
<span class="fold-h">Telegram MTProto‑прокси</span>
|
||||||
|
<span class="muted fold-meta" id="mtproto-status-line"></span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div class="panel-fold-body">
|
||||||
|
<p class="muted warp-intro mtproto-intro">
|
||||||
|
Отдельный Docker‑контейнер (<strong>telegrammessenger/proxy</strong>), публикация порта на хосте VPS.
|
||||||
|
Ссылка <code class="inline">tg://proxy?…</code> подставляет хост из URL панели, если не заданы переменные; для постоянного IP/DNS укажите <code class="inline">MTPRO_PUBLIC_HOST</code> или <code class="inline">CLIENT_CONFIG_ENDPOINT</code>.
|
||||||
|
Секрет после установки сохраните вне панели — здесь он показывается маскированно.
|
||||||
|
За nginx/Caddy проксируйте префикс <code class="inline">/api/</code> целиком (см. README).
|
||||||
|
</p>
|
||||||
|
<p id="mtproto-callout" class="mtproto-callout hidden" role="note"></p>
|
||||||
|
<div id="mtproto-link-card" class="mtproto-link-card hidden" role="region" aria-label="Ссылка для Telegram">
|
||||||
|
<div class="mtproto-link-row">
|
||||||
|
<strong class="mtproto-link-label">Ссылка в Telegram</strong>
|
||||||
|
<a id="mtproto-link-anchor" class="mtproto-link-anchor" target="_blank" rel="noopener noreferrer"></a>
|
||||||
|
</div>
|
||||||
|
<div class="mtproto-link-row mtproto-link-actions">
|
||||||
|
<button type="button" id="mtproto-link-copy" class="btn small ghost">Скопировать</button>
|
||||||
|
<span id="mtproto-link-copy-state" class="muted warp-muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mtproto-fields">
|
||||||
|
<label for="mtproto-host-port">Порт хоста (необязательно — по умолчанию см. MTPRO_PUBLISH_PORT, обычно 8443)</label>
|
||||||
|
<input id="mtproto-host-port" type="number" min="512" max="65535" autocomplete="off" placeholder="8443 или другой свободный порт">
|
||||||
|
|
||||||
|
<label for="mtproto-secret-opt">Кастомный секрет hex (32 символа, необязательно)</label>
|
||||||
|
<input id="mtproto-secret-opt" type="text" maxlength="32" autocomplete="off" placeholder="Пусто — сгенерируется">
|
||||||
|
</div>
|
||||||
|
<div id="mtproto-actions" class="warp-actions"></div>
|
||||||
|
<p id="mtproto-banner" class="status ok hidden" role="status"></p>
|
||||||
|
<details class="raw warp-raw">
|
||||||
|
<summary>Хвост логов контейнера mtproto‑proxy</summary>
|
||||||
|
<pre id="mtproto-logs-tail"></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details class="panel-fold cascade-panel" id="cascade-panel" open>
|
<details class="panel-fold cascade-panel" id="cascade-panel" open>
|
||||||
<summary class="fold-summary">
|
<summary class="fold-summary">
|
||||||
<span class="fold-arrow" aria-hidden="true"></span>
|
<span class="fold-arrow" aria-hidden="true"></span>
|
||||||
|
|||||||
@@ -947,6 +947,89 @@ tr:last-child td {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edition-banner-cta {
|
/* --- Telegram MTProto --- */
|
||||||
flex-shrink: 0;
|
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ PREV_CONTAINER_ENV=""
|
|||||||
if docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then
|
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)"
|
PREV_CONTAINER_ENV="$(docker inspect "${CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null || true)"
|
||||||
fi
|
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
|
if [[ -z "${!__ui_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then
|
||||||
PREV_VAL=""
|
PREV_VAL=""
|
||||||
while IFS= read -r __line; do
|
while IFS= read -r __line; do
|
||||||
@@ -225,12 +225,19 @@ for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2
|
|||||||
fi
|
fi
|
||||||
done
|
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
|
if [[ -n "${!__ui_var:-}" ]]; then
|
||||||
RUN_ENV+=( -e "${__ui_var}=${!__ui_var}" )
|
RUN_ENV+=( -e "${__ui_var}=${!__ui_var}" )
|
||||||
fi
|
fi
|
||||||
done
|
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
|
if [[ -n "${BOOT_PW}" ]]; then
|
||||||
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
||||||
elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then
|
elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then
|
||||||
|
|||||||
341
server.js
341
server.js
@@ -1,5 +1,5 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { spawn } from "child_process";
|
import { spawn, spawnSync } from "child_process";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -7,6 +7,16 @@ import { fileURLToPath } from "url";
|
|||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.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 PORT = Number(process.env.PORT || 3980);
|
||||||
const PROFILE_COOKIE = "amnezia_prof";
|
const PROFILE_COOKIE = "amnezia_prof";
|
||||||
const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000);
|
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";
|
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() {
|
function resolveUiHidden() {
|
||||||
const raw = process.env.UI_HIDE_SECTIONS?.trim();
|
const raw = process.env.UI_HIDE_SECTIONS?.trim();
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
@@ -33,6 +43,7 @@ function resolveUiHidden() {
|
|||||||
users: set.has("users") || envTruthy(process.env.UI_HIDE_USERS),
|
users: set.has("users") || envTruthy(process.env.UI_HIDE_USERS),
|
||||||
warp: set.has("warp") || envTruthy(process.env.UI_HIDE_WARP),
|
warp: set.has("warp") || envTruthy(process.env.UI_HIDE_WARP),
|
||||||
cascade: set.has("cascade") || envTruthy(process.env.UI_HIDE_CASCADE),
|
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,
|
users: UI_HIDDEN.users,
|
||||||
warp: true,
|
warp: true,
|
||||||
cascade: 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_WARP_OFF = "Раздел Cloudflare WARP отключён на этом сервере (UI_HIDE_SECTIONS / UI_HIDE_WARP).";
|
||||||
const MSG_UI_CASCADE_OFF =
|
const MSG_UI_CASCADE_OFF =
|
||||||
"Каскад отключён на этом сервере (UI_HIDE_SECTIONS / UI_HIDE_CASCADE).";
|
"Каскад отключён на этом сервере (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();
|
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(
|
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) {
|
if (IS_COMMUNITY) {
|
||||||
@@ -1368,7 +1521,7 @@ if (IS_COMMUNITY) {
|
|||||||
app.use(express.json({ limit: "512kb" }));
|
app.use(express.json({ limit: "512kb" }));
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true, version: PANEL_VERSION });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/session", (req, res) => {
|
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) => {
|
app.post("/api/sync-host-time", requireAuth, requireProTier, async (req, res) => {
|
||||||
if (!hostTimeSyncConfigured()) {
|
if (!hostTimeSyncConfigured()) {
|
||||||
return res.status(503).json({
|
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");
|
res.status(404).send("Not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, "0.0.0.0", () => {
|
app.listen(PORT, "0.0.0.0", () => {
|
||||||
const summary = PROFILES.map((p) => `${p.label}→${p.container}`).join("; ");
|
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(() => {
|
setInterval(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user