feat(community): manage Telegram MTProto proxy from FREE UI

Docker pull/run/remove/restart via host socket (no SSH), optional
secrets and tg:// links when MTPRO_PUBLIC_HOST or CLIENT_CONFIG_ENDPOINT set.
Expose MTPRO_* and UI_HIDE_MTPROTO through install.sh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-15 03:27:50 +03:00
parent 485fea7ef7
commit ecf754c7da
7 changed files with 508 additions and 13 deletions

View File

@@ -75,6 +75,13 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
| `COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO` | `0` | `1` — не выполнять `docker rm` FREE/лендинга перед install (если знаете, что делаете). | | `COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO` | `0` | `1` — не выполнять `docker rm` FREE/лендинга перед install (если знаете, что делаете). |
| `COMMUNITY_PRO_INSTALL_HELPER_IMAGE` | `docker:26-cli` | Образ с бинарём `docker` для фонового контейнера (нужен доступ к демону по сокету). | | `COMMUNITY_PRO_INSTALL_HELPER_IMAGE` | `docker:26-cli` | Образ с бинарём `docker` для фонового контейнера (нужен доступ к демону по сокету). |
| `COMMUNITY_HELPER_SKIP_PREPARE_TOOLS` | _(не задано)_ | `1` — helper не ставит **`bash`** и **`curl`** в образе Alpine (обычно **не нужно**; без них `#!/usr/bin/env bash` или `curl` в PRO `install.sh` падают). | | `COMMUNITY_HELPER_SKIP_PREPARE_TOOLS` | _(не задано)_ | `1` — helper не ставит **`bash`** и **`curl`** в образе Alpine (обычно **не нужно**; без них `#!/usr/bin/env bash` или `curl` в PRO `install.sh` падают). |
| `MTPRO_PROXY_CONTAINER` | `mtproto-proxy` | Имя контейнера MTProtoпрокси Telegram (официальный образ **telegrammessenger/proxy**, см. Dockerfile на Docker Hub). |
| `MTPRO_PROXY_IMAGE` | `telegrammessenger/proxy:latest` | Образ **`docker pull` + `docker run`** при установке из панели (FREE/community). |
| `MTPRO_INTERNAL_PORT` | `443` | Порт процесса **внутри** контейнера (официальный прокси слушает 443/tcp). Мапится на **`MTPRO_PUBLISH_*`**. |
| `MTPRO_PUBLISH_PORT` | `8443` | Порт хоста VPS (**внешний**), проброшенный в контейнер. |
| `MTPRO_PUBLISH_BIND` | `0.0.0.0` | Адрес биндинга на хосте (`-p` в Docker); при нескольких сетевых интерфейсах при необходимости уточняют. |
| `MTPRO_PUBLIC_HOST` | _(нет)_ | Публичный IP или DNS для ссылки **`tg://proxy`** в UI; можно вместо него задать **`CLIENT_CONFIG_ENDPOINT`**. |
| `UI_HIDE_MTPROTO` | _(не задано)_ | `1` или **`UI_HIDE_SECTIONS=...,mtproto`** — скрыть раздел установки MTProto в веб‑интерфейсе (по умолчанию виден даже FREE). |
| `FREE_PANEL_CONTAINER_FOR_PRO_INSTALL` и др. | см. `server.js` | Имена контейнеров для `docker rm` перед install (по умолчанию `amnezia-admin`, `amnezia-web-landing`, `amnezia-admin-pro`). | | `FREE_PANEL_CONTAINER_FOR_PRO_INSTALL` и др. | см. `server.js` | Имена контейнеров для `docker rm` перед install (по умолчанию `amnezia-admin`, `amnezia-web-landing`, `amnezia-admin-pro`). |
Чтобы включить форму установки из панели после «обычной» установки без **`INSTALL_FREE…`**, задайте в контейнере **`AMNEZIA_EDITION=community`** и **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=1`** (или проще переустановите с **`INSTALL_FREE_COMMUNITY_ACTIVATION=1`**). URL приватного `install.sh` при необходимости переопределите через **`COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL`**. Чтобы включить форму установки из панели после «обычной» установки без **`INSTALL_FREE…`**, задайте в контейнере **`AMNEZIA_EDITION=community`** и **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=1`** (или проще переустановите с **`INSTALL_FREE_COMMUNITY_ACTIVATION=1`**). URL приватного `install.sh` при необходимости переопределите через **`COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL`**.

View File

@@ -1,6 +1,6 @@
{ {
"name": "amnezia-admin", "name": "amnezia-admin",
"version": "1.2.15", "version": "1.2.16",
"private": true, "private": true,
"description": "amnezia_web — базовая панель AmneziaWG (FREE: просмотр и удаление клиентов; полная версия — PRO).", "description": "amnezia_web — базовая панель AmneziaWG (FREE: просмотр и удаление клиентов; полная версия — PRO).",
"license": "MIT", "license": "MIT",

View File

@@ -21,6 +21,14 @@ 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 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 +66,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() || "";
@@ -234,7 +242,7 @@ function applyEditionPayload(data) {
if (subEl) { if (subEl) {
if (editionState.tier === "community") { if (editionState.tier === "community") {
subEl.textContent = subEl.textContent =
"Базовая панель amnezia_web: просмотр клиентов и статусов, а также удаление клиента с сервера. Включение/выключение туннеля, даты, переименование, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO."; "Базовая панель amnezia_web: просмотр клиентов и статусов, удаление клиента с сервера, установка Telegram MTProtoпрокси (Docker) в этом интерфейсе. Включение/выключение туннеля, даты, переименование, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO.";
} else { } else {
subEl.textContent = DEFAULT_HEADER_SUB; subEl.textContent = DEFAULT_HEADER_SUB;
} }
@@ -345,6 +353,141 @@ function applyEditionPayload(data) {
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg; if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
} }
async function refreshMtprotoPanel() {
if (!mtprotoPanel || !mtprotoStatusLine || !mtprotoActionsEl) return;
if (uiHidden.mtproto) {
mtprotoPanel.hidden = true;
return;
}
mtprotoPanel.hidden = false;
mtprotoActionsEl.innerHTML = "";
if (mtprotoBanner) {
mtprotoBanner.classList.add("hidden");
mtprotoBanner.textContent = "";
}
try {
if (mtprotoLogsTailEl) mtprotoLogsTailEl.textContent = typeof s.logsTail === "string" ? s.logsTail : "";
const parts = [];
if (s.exists) parts.push("контейнер есть");
if (s.running) parts.push("запущен");
mtprotoStatusLine.textContent =
parts.length > 0
? parts.join(" · ") +
(s.hostPort ? ` · порт хоста :${String(s.hostPort)}` : "") +
(s.secretMasked ? ` · секрет ${s.secretMasked}` : "")
: "не установлен";
if (typeof s.hint === "string" && s.hint.trim()) {
const hintEl = document.createElement("p");
hintEl.className = "muted warp-muted";
hintEl.textContent = s.hint.trim();
mtprotoActionsEl.appendChild(hintEl);
} else if (s.exists && !s.running && typeof s.image === "string" && s.image.trim()) {
const imgHint = document.createElement("p");
imgHint.className = "muted warp-muted";
imgHint.textContent = `После установки образ будет доступен здесь (${s.image.trim()} при актуальной конфигурации).`;
mtprotoActionsEl.appendChild(imgHint);
}
if (s.tgLink) {
const link = document.createElement("div");
link.className = "mtproto-deep-link muted warp-muted";
const lab = document.createElement("strong");
lab.textContent = "Ссылка в Telegram: ";
const a = document.createElement("a");
a.href = s.tgLink;
a.textContent = s.tgLink;
a.target = "_blank";
a.rel = "noopener noreferrer";
link.append(lab, a);
mtprotoActionsEl.appendChild(link);
}
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) });
const lines = [];
if (typeof j.secretHex === "string") lines.push(`Секрет (сохраните): ${j.secretHex}`);
if (typeof j.tgLink === "string" && j.tgLink) lines.push(`Ссылка: ${j.tgLink}`);
if (typeof j.advertiseHint === "string" && j.advertiseHint) lines.push(j.advertiseHint);
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) {
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") {
@@ -352,11 +495,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";
@@ -1412,6 +1559,7 @@ 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;
} }
} }

View File

@@ -29,7 +29,7 @@
<div> <div>
<p class="eyebrow">Панель сервера</p> <p class="eyebrow">Панель сервера</p>
<h1>Пользователи AmneziaWG FREE</h1> <h1>Пользователи AmneziaWG FREE</h1>
<p class="sub">Базовая открытая панель: просмотр клиентов и статусов туннеля. Управление peer, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста доступны в версии PRO (ссылка в жёлтой плашке после входа).</p> <p class="sub">Базовая открытая панель: просмотр клиентов AmneziaWG и (после входа) установка Telegram MTProtoпрокси в Docker без PRO. Полное управление peer, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста в версии PRO.</p>
</div> </div>
<div class="token-box"> <div class="token-box">
<div class="session-actions"> <div class="session-actions">
@@ -116,6 +116,37 @@
</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">
<strong>В базовой панели (FREE).</strong> Отдельный Dockerконтейнер (образ Telegram), публикация порта на хосте VPS.
Ссылка вида <code class="inline">tg://proxy?…</code> появляется, если задан <code class="inline">MTPRO_PUBLIC_HOST</code> или <code class="inline">CLIENT_CONFIG_ENDPOINT</code>
(переменные контейнера панели, см. install.sh README).
Сохраните <strong>секрет</strong>, который показывается один раз после установки — в интерфейсе он маскируется.
</p>
<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> <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>
@@ -198,7 +229,7 @@
<a href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">✉️ Telegram&nbsp;@lot_andrey</a> <a href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">✉️ Telegram&nbsp;@lot_andrey</a>
</div> </div>
<p class="support-blurb"> <p class="support-blurb">
Открытая базовая сборка — просмотр клиентов и удаление записи с сервера. Полная панель PRO (приватный репозиторий) и доступ по подписке — см. Boosty и плашку в интерфейсе после входа. Открытая базовая сборка — AWG клиентов, удаление с сервера, MTProtoпрокси Telegram (Docker). Остальной PRO доступ — см. Boosty и плашку в интерфейсе.
</p> </p>
</footer> </footer>

View File

@@ -917,7 +917,44 @@ tr:last-child td {
overflow: auto; overflow: auto;
} }
.edition-banner { /* --- Telegram MTProto (FREE) --- */
.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(0, 0, 0, 0.35);
}
.mtproto-deep-link strong {
display: inline-block;
margin-right: 0.35rem;
}
.mtproto-deep-link a {
word-break: break-all;
}
#mtproto-banner {
margin: 0.75rem 0 0;
white-space: pre-wrap;
font-size: 0.82rem;
}
margin: 0 0 1rem; margin: 0 0 1rem;
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
border-radius: 14px; border-radius: 14px;

View File

@@ -236,13 +236,20 @@ for __warp_var in WARP_DIR WARP_CONF_PATH WARP_CLIENTS_LIST AMNEZIA_START_SCRIPT
fi fi
done done
for __panel_env_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE \ for __panel_env_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE UI_HIDE_MTPROTO \
EXPORT_CONFIG_SECRET CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2; do EXPORT_CONFIG_SECRET CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2; do
if [[ -n "${!__panel_env_var:-}" ]]; then if [[ -n "${!__panel_env_var:-}" ]]; then
RUN_ENV+=( -e "${__panel_env_var}=${!__panel_env_var}" ) RUN_ENV+=( -e "${__panel_env_var}=${!__panel_env_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

275
server.js
View File

@@ -1,5 +1,5 @@
import express from "express"; import express from "express";
import { spawn, execFileSync } from "child_process"; import { spawn, spawnSync, execFileSync } 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";
@@ -33,6 +33,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),
}; };
} }
@@ -56,7 +57,7 @@ const COMMUNITY_UPGRADE_URL =
"https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link"; "https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link";
const COMMUNITY_UPGRADE_PITCH = const COMMUNITY_UPGRADE_PITCH =
process.env.COMMUNITY_UPGRADE_PITCH?.trim() || process.env.COMMUNITY_UPGRADE_PITCH?.trim() ||
"В PRO: вкл/выкл клиентов, даты и расписание отключений, переименование, экспорт .conf, каскад, Cloudflare WARP, синхронизация времени хоста. В FREE можно удалять клиента с сервера (peer и строка в таблице). Полная сборка — приватный репозиторий amnezia_web-PRO; доступ по подписке Boosty."; "В PRO: вкл/выкл клиентов, даты и расписание отключений, переименование, экспорт .conf, каскад, Cloudflare WARP, синхронизация времени хоста. В FREE: удаление клиента с сервера + отдельный раздел установки Telegram MTProto-прокси (Docker-контейнер). Полная сборка AWG-панели — приватный репозиторий amnezia_web-PRO; доступ по подписке Boosty.";
function editionPayload() { function editionPayload() {
return { return {
@@ -77,6 +78,7 @@ function effectiveUiHidden() {
users: UI_HIDDEN.users, users: UI_HIDDEN.users,
warp: true, warp: true,
cascade: true, cascade: true,
mtproto: UI_HIDDEN.mtproto,
}; };
} }
@@ -393,7 +395,7 @@ function requireAuthOrExportToken(req, res, next) {
function rejectCommunityProOnly(res) { function rejectCommunityProOnly(res) {
res.status(403).json({ res.status(403).json({
error: error:
"Доступно в версии PRO: управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста.", "Доступно в версии PRO: полное управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста. Раздел MTProtoпрокси Telegram (Docker) в базовой панели уже без PRO.",
upgradeRequired: true, upgradeRequired: true,
upgradeUrl: COMMUNITY_UPGRADE_URL, upgradeUrl: COMMUNITY_UPGRADE_URL,
}); });
@@ -1598,11 +1600,137 @@ 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() {
const a =
process.env.MTPRO_PUBLIC_HOST?.trim() ||
process.env.CLIENT_CONFIG_ENDPOINT?.trim();
return a || "";
}
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() {
const ins = mtprotoParsedInspect();
if (!ins) {
return {
exists: false,
running: false,
container: MTPRO_CONTAINER,
image: MTPRO_IMAGE,
hostPort: null,
advertisedHost: mtprotoAdvertisedHost(),
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);
const adv = mtprotoAdvertisedHost();
return {
exists: true,
running,
container: MTPRO_CONTAINER,
image: String(cfg?.Image || MTPRO_IMAGE),
hostPort,
advertisedHost: adv,
secretMasked: secret ? mtprotoMaskedSecret(secret) : "",
tgLink: mtprotoTelegramDeepLink(adv, Number(hostPort || 0), secret),
restartCount: Number(ins?.RestartCount || 0) || 0,
hint: adv
? ""
: "Задайте 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) {
@@ -2032,6 +2160,143 @@ app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => {
}); });
}); });
app.get("/api/mtproto/status", requireAuth, (req, res) => {
if (effectiveUiHidden().mtproto) {
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
}
const snap = mtprotoSnapshot();
let logsTail = "";
if (snap.exists && snap.running) {
const l = dockerSpawnSync(["logs", "--tail", "100", MTPRO_CONTAINER], 12_000);
if (l.code === 0) logsTail = l.stdout.trim().slice(-4500);
}
res.json({ ...snap, logsTail });
});
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 adv = mtprotoAdvertisedHost();
const snap = mtprotoSnapshot();
const tgLink = mtprotoTelegramDeepLink(adv, hostPort, secretFinal);
res.json({
ok: true,
secretHex: secretFinal,
hostPort,
tgLink,
advertisedHost: adv,
advertiseHint: adv ? "" : "Задайте MTPRO_PUBLIC_HOST — иначе в Telegram нужно самим ввести IP и порт из таблицы.",
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({