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:
Андрей Бобырев
2026-05-15 04:39:19 +03:00
parent 845683b986
commit 0e7a7df636
8 changed files with 779 additions and 23 deletions

View File

@@ -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`**, чтобы кнопка на лендинге вела на нужную админку.

View File

@@ -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 |

View File

@@ -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",

View File

@@ -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();

View File

@@ -29,7 +29,7 @@
<div>
<p class="eyebrow">Панель сервера</p>
<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 class="token-box">
<div class="session-actions">
@@ -116,6 +116,48 @@
</div>
</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>Хвост логов контейнера mtprotoproxy</summary>
<pre id="mtproto-logs-tail"></pre>
</details>
</div>
</details>
<details class="panel-fold cascade-panel" id="cascade-panel" open>
<summary class="fold-summary">
<span class="fold-arrow" aria-hidden="true"></span>

View File

@@ -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;
}

View File

@@ -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

341
server.js
View File

@@ -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(() => {