mirror of
https://github.com/andrey271192/amnezia_web.git
synced 2026-09-20 14:41:59 +00:00
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:
@@ -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_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` падают). |
|
||||
| `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`). |
|
||||
|
||||
Чтобы включить форму установки из панели после «обычной» установки без **`INSTALL_FREE…`**, задайте в контейнере **`AMNEZIA_EDITION=community`** и **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=1`** (или проще переустановите с **`INSTALL_FREE_COMMUNITY_ACTIVATION=1`**). URL приватного `install.sh` при необходимости переопределите через **`COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL`**.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "amnezia-admin",
|
||||
"version": "1.2.15",
|
||||
"version": "1.2.16",
|
||||
"private": true,
|
||||
"description": "amnezia_web — базовая панель AmneziaWG (FREE: просмотр и удаление клиентов; полная версия — PRO).",
|
||||
"license": "MIT",
|
||||
|
||||
152
public/app.js
152
public/app.js
@@ -21,6 +21,14 @@ 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 cascadePanel = document.querySelector("#cascade-panel");
|
||||
const usersPanel = document.querySelector("#users-panel");
|
||||
const wgRawDetails = document.querySelector("#wg-raw-details");
|
||||
@@ -58,7 +66,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() || "";
|
||||
@@ -234,7 +242,7 @@ function applyEditionPayload(data) {
|
||||
if (subEl) {
|
||||
if (editionState.tier === "community") {
|
||||
subEl.textContent =
|
||||
"Базовая панель amnezia_web: просмотр клиентов и статусов, а также удаление клиента с сервера. Включение/выключение туннеля, даты, переименование, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO.";
|
||||
"Базовая панель amnezia_web: просмотр клиентов и статусов, удаление клиента с сервера, установка Telegram MTProto‑прокси (Docker) в этом интерфейсе. Включение/выключение туннеля, даты, переименование, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO.";
|
||||
} else {
|
||||
subEl.textContent = DEFAULT_HEADER_SUB;
|
||||
}
|
||||
@@ -345,6 +353,141 @@ function applyEditionPayload(data) {
|
||||
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) {
|
||||
const u = data?.uiHidden;
|
||||
if (u && typeof u === "object") {
|
||||
@@ -352,11 +495,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";
|
||||
@@ -1412,6 +1559,7 @@ async function loadClients() {
|
||||
wgShowEl.textContent = "";
|
||||
peerCountEl.textContent = "";
|
||||
if (warpPanel) warpPanel.hidden = true;
|
||||
if (mtprotoPanel) mtprotoPanel.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Панель сервера</p>
|
||||
<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 class="token-box">
|
||||
<div class="session-actions">
|
||||
@@ -116,6 +116,37 @@
|
||||
</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">
|
||||
<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>Хвост логов контейнера mtproto‑proxy</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>
|
||||
@@ -198,7 +229,7 @@
|
||||
<a href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">✉️ Telegram @lot_andrey</a>
|
||||
</div>
|
||||
<p class="support-blurb">
|
||||
Открытая базовая сборка — просмотр клиентов и удаление записи с сервера. Полная панель PRO (приватный репозиторий) и доступ по подписке — см. Boosty и плашку в интерфейсе после входа.
|
||||
Открытая базовая сборка — AWG клиентов, удаление с сервера, MTProto‑прокси Telegram (Docker). Остальной PRO доступ — см. Boosty и плашку в интерфейсе.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -917,7 +917,44 @@ tr:last-child td {
|
||||
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;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 14px;
|
||||
|
||||
@@ -236,13 +236,20 @@ for __warp_var in WARP_DIR WARP_CONF_PATH WARP_CLIENTS_LIST AMNEZIA_START_SCRIPT
|
||||
fi
|
||||
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
|
||||
if [[ -n "${!__panel_env_var:-}" ]]; then
|
||||
RUN_ENV+=( -e "${__panel_env_var}=${!__panel_env_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
|
||||
|
||||
277
server.js
277
server.js
@@ -1,5 +1,5 @@
|
||||
import express from "express";
|
||||
import { spawn, execFileSync } from "child_process";
|
||||
import { spawn, spawnSync, execFileSync } from "child_process";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
@@ -33,6 +33,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ const COMMUNITY_UPGRADE_URL =
|
||||
"https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link";
|
||||
const COMMUNITY_UPGRADE_PITCH =
|
||||
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() {
|
||||
return {
|
||||
@@ -77,6 +78,7 @@ function effectiveUiHidden() {
|
||||
users: UI_HIDDEN.users,
|
||||
warp: true,
|
||||
cascade: true,
|
||||
mtproto: UI_HIDDEN.mtproto,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -392,8 +394,8 @@ function requireAuthOrExportToken(req, res, next) {
|
||||
|
||||
function rejectCommunityProOnly(res) {
|
||||
res.status(403).json({
|
||||
error:
|
||||
"Доступно в версии PRO: управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста.",
|
||||
error:
|
||||
"Доступно в версии PRO: полное управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста. Раздел MTProto‑прокси Telegram (Docker) в базовой панели уже без PRO.",
|
||||
upgradeRequired: true,
|
||||
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_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() {
|
||||
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();
|
||||
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) {
|
||||
@@ -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) => {
|
||||
if (!hostTimeSyncConfigured()) {
|
||||
return res.status(503).json({
|
||||
|
||||
Reference in New Issue
Block a user