diff --git a/README.md b/README.md index 8480ff7..0b34d45 100644 --- a/README.md +++ b/README.md @@ -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`**. diff --git a/package.json b/package.json index c0d4728..3de70ea 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/app.js b/public/app.js index 9a92701..c3fa592 100644 --- a/public/app.js +++ b/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; } } diff --git a/public/index.html b/public/index.html index 4d5fe3f..17618e5 100644 --- a/public/index.html +++ b/public/index.html @@ -29,7 +29,7 @@
Панель сервера
Базовая открытая панель: просмотр клиентов и статусов туннеля. Управление peer, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста доступны в версии PRO (ссылка в жёлтой плашке после входа).
+Базовая открытая панель: просмотр клиентов AmneziaWG и (после входа) установка Telegram MTProto‑прокси в Docker без PRO. Полное управление peer, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO.
+ В базовой панели (FREE). Отдельный Docker‑контейнер (образ Telegram), публикация порта на хосте VPS.
+ Ссылка вида tg://proxy?… появляется, если задан MTPRO_PUBLIC_HOST или CLIENT_CONFIG_ENDPOINT
+ (переменные контейнера панели, см. install.sh README).
+ Сохраните секрет, который показывается один раз после установки — в интерфейсе он маскируется.
+
- Открытая базовая сборка — просмотр клиентов и удаление записи с сервера. Полная панель PRO (приватный репозиторий) и доступ по подписке — см. Boosty и плашку в интерфейсе после входа. + Открытая базовая сборка — AWG клиентов, удаление с сервера, MTProto‑прокси Telegram (Docker). Остальной PRO доступ — см. Boosty и плашку в интерфейсе.
diff --git a/public/styles.css b/public/styles.css index 1c6a3ac..5de250f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; diff --git a/scripts/install.sh b/scripts/install.sh index 3646c29..4e5edc7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 diff --git a/server.js b/server.js index 6754961..f04065d 100644 --- a/server.js +++ b/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({