mirror of
https://github.com/andrey271192/amnezia_web.git
synced 2026-09-20 14:41:59 +00:00
refactor: separate basic web panel from pro product
Remove upgrade banners, GitHub PAT install flow, pro edition tier, and all references to amnezia_web-PRO. Panel is now a standalone basic product: view/delete clients and MTProto proxy only. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
community
|
||||
115
README.md
115
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
Веб‑панель **AmneziaWG** на своём VPS (**Docker**): таблица клиентов, статусы «в туннеле», AllowedIPs, несколько инстансов через **`AWG_PROFILES`**, часы сервера и браузера, смена пароля панели.
|
||||
|
||||
**Редакция по умолчанию — FREE (`community`):** просмотр клиентов, **удаление** peer с сервера, раздел установки **Telegram MTProto‑прокси** (Docker). Полное управление (вкл/выкл, даты, переименование, экспорт `.conf`, каскад, Cloudflare WARP, синхронизация времени и т.д.) — в **[amnezia_web-PRO](https://github.com/andrey271192/amnezia_web-PRO)**; из FREE можно запустить переход на PRO по GitHub‑токену (см. жёлтый баннер после входа).
|
||||
**Базовая открытая панель:** просмотр клиентов, **удаление** peer с сервера, раздел установки **Telegram MTProto‑прокси** (Docker), несколько инстансов через **`AWG_PROFILES`**, смена пароля. Вкл/выкл клиентов, даты, переименование, экспорт `.conf`, каскад, Cloudflare WARP и синхронизация времени хоста в этой версии **не поддерживаются**.
|
||||
|
||||
---
|
||||
|
||||
@@ -24,15 +24,13 @@
|
||||
|
||||
### Что умеет Amnezia Web
|
||||
|
||||
* **Управление через браузер** — таблица клиентов AmneziaWG, время сервера и браузера, быстрые действия там, где редакция это позволяет (FREE vs PRO).
|
||||
* **Управление через браузер** — таблица клиентов AmneziaWG, время сервера и браузера, удаление peer, установка MTProto‑прокси.
|
||||
* **Быстрое развёртывание панели** — образ админки и опционально страницы для пользователей через **`scripts/install.sh`**.
|
||||
* **Приватность** — всё крутится **на вашем VPS**; доступ к панели ограничиваете паролем (и при желании сетью/TLS).
|
||||
* **Удобство** — одно место для обзора клиентов и (в FREE) установки **MTProto‑прокси** из интерфейса.
|
||||
* **Несколько инстансов AWG на одном хосте** — переключатель профилей через **`AWG_PROFILES`**.
|
||||
* **Современный стек** — Docker, Node.js, веб‑интерфейс.
|
||||
|
||||
Полный список возможностей FREE и PRO см. в тексте выше и в репозитории **[amnezia_web-PRO](https://github.com/andrey271192/amnezia_web-PRO)**.
|
||||
|
||||
---
|
||||
|
||||
### Для кого этот проект
|
||||
@@ -59,7 +57,7 @@
|
||||
|
||||
## Скриншоты
|
||||
|
||||
В редакции FREE сверху панели отображается жёлтый блок «базовая версия» и меньше кнопок, чем в PRO (см. список ограничений выше); общая разметка и таблица клиентов сохраняются.
|
||||
Интерфейс ориентирован на просмотр клиентов, удаление peer и установку MTProto‑прокси; расширенные действия с клиентами в UI недоступны.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/panel-users-table.png" alt="Панель: пользователи AmneziaWG, выбор инстанса, время, статусы в туннеле" width="780"/>
|
||||
@@ -72,100 +70,20 @@
|
||||
|
||||
---
|
||||
|
||||
## Установка для клиента (маркер в томе + явный ALLOW в контейнере)
|
||||
|
||||
Раньше многие полагались на этот режим ради поля PAT — сейчас оно включено по умолчанию. **`INSTALL_FREE_COMMUNITY_ACTIVATION=1`** по-прежнему полезен: создаёт **`/opt/amnezia-admin-data/.install-free-github-pro-opt-in`** и при следующих **`curl … | sudo bash`** снова прокидывает **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=1`** в контейнер (если когда-то задали отключение вручную).
|
||||
|
||||
```bash
|
||||
export INSTALL_FREE_COMMUNITY_ACTIVATION=1
|
||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh | sudo -E bash
|
||||
```
|
||||
если нужно сменить порт например на 8884
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh \
|
||||
| sudo -E env HOST_PORT=8884 LANDING_PORT=8081 bash
|
||||
```
|
||||
|
||||
Переменная **`INSTALL_FREE_COMMUNITY_ACTIVATION`** сохранится в томе как файл **`.install-free-github-pro-opt-in`**: следующие апдейты через **`sudo bash`** подхватят маркер **без** повторного `export` (или с **`sudo -E`**, если передаёте другие переменные в той же сессии).
|
||||
|
||||
---
|
||||
|
||||
## Отключить поле GitHub и установку из панели (жёсткий FREE)
|
||||
|
||||
Чтобы под баннером **не было** ввода токена и **запретить** **`POST …/run-private-install`**, задайте в контейнере **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=0`** (или **`false`** / **`no`** / **`off`**) и перезапустите панель. Обычная установка **`curl … | sudo bash`** без этой переменной **оставляет поле включённым** в FREE.
|
||||
|
||||
Стандартная установка того же образа без отключения:
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Форк или ветка (**с тем же режимом маркера по желанию**):
|
||||
Смена порта (пример):
|
||||
|
||||
```bash
|
||||
export GITHUB_REPO="вы/репо"
|
||||
export BRANCH="main"
|
||||
export INSTALL_FREE_COMMUNITY_ACTIVATION=1
|
||||
curl -fsSL "https://raw.githubusercontent.com/${GITHUB_REPO}/${BRANCH}/scripts/install.sh" | sudo -E bash
|
||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh \
|
||||
| sudo -E env HOST_PORT=8884 LANDING_PORT=8081 bash
|
||||
```
|
||||
|
||||
Уже скачали проект:
|
||||
|
||||
```bash
|
||||
cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bash scripts/install.sh
|
||||
```
|
||||
|
||||
### Переменные окружения и `sudo`
|
||||
|
||||
Если передаёте **`AWG_PROFILES`** или **`ADMIN_PASSWORD`** в одной строке с `curl`, используйте **`sudo -E bash`**, иначе `sudo` не увидит переменные. Альтернатива — записать JSON профилей в **`/root/amnezia-admin.awg-profiles.json`** и запустить обычный `curl … | sudo bash`. Подробнее см. историю коммитов и зеркальный README в репозитории PRO.
|
||||
|
||||
### Важные переменные
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|------------|--------------|------------|
|
||||
| `INSTALL_FREE_COMMUNITY_ACTIVATION` | _(не задано)_ | `1`: маркер **`.install-free-github-pro-opt-in`** в томе + явно **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=1`** при сборке **`RUN_ENV`** (удобно после ручного `ALLOW=0` или если нужно навсегда фиксировать в томе).
|
||||
| `GITHUB_REPO` | `andrey271192/amnezia_web` | Архив для установки |
|
||||
| `HOST_PORT` | `8080` | Порт панели |
|
||||
| `AWG_CONTAINER` | `amnezia-awg2` | Контейнер WG по умолчанию |
|
||||
| `AWG_PROFILES` | _(нет)_ | Несколько инстансов; см. примеры в репозитории PRO |
|
||||
| `COMMUNITY_UPGRADE_URL` | Страница подписки PRO на Boosty (зашита в код по умолчанию) | Переопределите, если смените уровень подписки |
|
||||
| `COMMUNITY_UPGRADE_PITCH` | _(текст по умолчанию в коде)_ | Текст под заголовком базовой версии |
|
||||
| `PANEL_FOOTER_DONATE_URL` | Как **`COMMUNITY_UPGRADE_URL`** | Ссылка «донат» в **шапке и подвале** веб‑панели (**не** добавляется на nginx‑лендинг пользователей). |
|
||||
| `PANEL_FOOTER_TELEGRAM_URL` | `https://t.me/lot_andrey` | Ссылка Telegram‑спонсора / канала (там же: только панель). |
|
||||
| `PANEL_FOOTER_OZON_URL` | Ссылка на **Ozon СБП** автора (дефолт в `server.js`) | Донат через Ozon — **шапка и подвал** панели с остальными ссылками. |
|
||||
| `PANEL_FOOTER_DONATE_LABEL` | `Поддержать проект` | Текст якоря доната |
|
||||
| `PANEL_FOOTER_OZON_LABEL` | `Донат · Ozon СБП` | Текст якоря Ozon |
|
||||
| `PANEL_FOOTER_TELEGRAM_LABEL` | `Telegram‑канал спонсора` | Текст якоря Telegram |
|
||||
| `PANEL_FOOTER_PROMO_SUBTITLE` | _(строка по умолчанию в коде)_ | Поясняющий текст в верхней полосе панели |
|
||||
| `SKIP_LANDING` | `0` | `1` — без лендинга на порту 80 |
|
||||
| `ALLOW_COMMUNITY_GITHUB_ACTIVATION` | _в редакции **`community` поле вкл по умолчанию_ | Явное **`0`** / **`false`** / **`no`** / **`off`** — **скрыть** ввод PAT и заблокировать **`POST /api/community/run-private-install`** (после перезапуска контейнера). **`1`** / **`true`** не обязательны. |
|
||||
| `COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL` | `https://raw.githubusercontent.com/andrey271192/amnezia_web-pro/main/scripts/install.sh` | Сырой URL `scripts/install.sh` в **вашем** приватном репозитории PRO |
|
||||
| `COMMUNITY_INSTALL_FETCH_MS` | `60000` | Таймаут HTTP при скачивании установщика из GitHub |
|
||||
| `PRIVATE_INSTALL_SCRIPT_MAX_BYTES` | `2097152` | Максимальный размер скачанного скрипта (байты) |
|
||||
| `COMMUNITY_INSTALL_LOG_TAIL_BYTES` | `98304` | Первый ответ **`GET /api/community/install-log`** без `since` отдаёт **хвост** последних N байт (для живого блока в UI при установке PRO). Диапазон 1 KiB … 512 KiB через env. |
|
||||
| `COMMUNITY_INSTALL_LOG_API_CHUNK_MAX` | `524288` | Максимум байт **за один** запрос «догонять» журнал (~512 KiB; верхний предел через env ограничен). |
|
||||
| `COMMUNITY_DISABLE_DOCKER_CLI_HELPER` | _(не задано)_ | Если `1`, установка из UI снова только «bash внутри панели» (часто конфликт **:8080** с PRO). По умолчанию используется одноразовый контейнер **docker:cli**, который **снимает FREE** и зависший PRO перед `install.sh`. |
|
||||
| `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`). |
|
||||
|
||||
**MTProto‑прокси и API панели:** **`GET /api/mtproto/status`** отдаёт состояние контейнера и ссылку **`tg://`** без тяжёлых операций при открытии блока. Опционально **`GET /api/mtproto/status?withLogs=1`** добавляет в ответ хвост **`docker logs`** (поле **`logsFetched`**). Отдельные эндпоинты: **`GET /api/mtproto/logs`** и **`GET /api/mtproto/tail`** (тот же смысл, если обратный прокси режет URI с **`logs`**). За nginx/Caddy перед панелью нужно пробрасывать **весь** префикс **`/api/`** одним правилом — см. блок **«Not found»** в **«Частые проблемы»** ниже.
|
||||
|
||||
Поле токена **по умолчанию уже есть**. Чтобы **убрать** его — см. раздел **«Отключить поле GitHub…»** выше. URL приватного `install.sh` задаёт **`COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL`**.
|
||||
|
||||
**Важно:** при одноразовом запуске с переменными в одной строке с `curl … | sudo bash` они **пропадают** — нужен **`export`** перед `curl` и **`sudo -E bash`**, см. блок **«Установка для клиента»** выше.
|
||||
|
||||
Остальные переменные совместимы с образом панели (см. Dockerfile / `server.js` в этом репозитории).
|
||||
|
||||
---
|
||||
|
||||
## Обновление
|
||||
@@ -176,8 +94,6 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
|
||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Если нужен маркер **`.install-free-github-pro-opt-in`** в томе (см. раздел выше) или переменные хоста при **`sudo`**, используйте **`export INSTALL_FREE_COMMUNITY_ACTIVATION=1`** и **`sudo -E bash`**.
|
||||
|
||||
После сообщения **`→ Клонирование релиза …`** несколько минут **может ничего не печататься**: идёт `curl … | tar` с GitHub. Для **полосы прогресса**: `sudo INSTALL_SCRIPT_VERBOSE=1 bash` (или передайте переменную в окружение до `|`). Если обрывает по времени или «тишина» часами задайте таймауты: **`CURL_CONNECT_TIMEOUT`** (по умолчанию 30 с к подключению), **`CURL_MAX_TIME`** (по умолчанию до 900 с на загрузку), **`CURL_RETRY`**. Полный URL tar.gz можно подменить: **`GITHUB_REPO_URL_OVERRIDE`** (должен указывать на архив того же вида **`${REPO}-${BRANCH}.tar.gz`**, после распаковки каталог будет **`ИмяРепозитория-${BRANCH}`**).
|
||||
|
||||
Принудительная пересборка образа: **`NO_CACHE=1`**.
|
||||
@@ -194,23 +110,6 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scrip
|
||||
|
||||
## Частые проблемы
|
||||
|
||||
### Пропало поле «токен GitHub» под плашкой FREE
|
||||
|
||||
Редакция должна быть **`community`**. Чаще всего в **`docker inspect amnezia-admin` → Env** осталось **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=0`** (или **`false`** / **`no`** / **`off`**) — удалите переменную или поставьте **`1`** и снова выполните **`install.sh`**. Установщик при апдейте переносит **`ALLOW_*`** из **предыдущего** контейнера. Маркер **`${DATA_DIR}/.install-free-github-pro-opt-in`** при **`INSTALL_FREE_COMMUNITY_ACTIVATION=1`** снова прокинет **`ALLOW=1`**, если установщику на хосте не задали **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=0`**.
|
||||
|
||||
### Журнал установки и docker-helper
|
||||
|
||||
После успешной кнопки **«Установить PRO»** в той же панели открывается **живой блок «Журнал установки»** (поллинг каждые 2 с к **`GET /api/community/install-log`**; для запросов нужна сессия в браузере). Пока старый контейнер FREE снят и новый образ ещё не поднял ту же вкладку, поток журнала в браузере **прерывается** — откройте адрес панели снова после старта PRO; файл **`community-install-last.log`** на томе данных тот же. Кнопка **«Пауза»** останавливает опрос журнала.
|
||||
|
||||
В режиме **docker-helper** панель пишет в **`community-install-last.log`** финальную строку **`--- helper завершён code=…`** **внутри** одноразового контейнера: её видно даже если процесс FREE-панели убрал **`docker rm`** и Node не успевает добавить строку **`--- завершено …`**. Если в логе после преамбулы одноразового установщика **нет** ни одной финальной строки — обновите образ панели с актуального **`amnezia_web`** и повторите установку.
|
||||
|
||||
### `Bind for 0.0.0.0:8080 failed: port is already allocated` у `amnezia-admin-pro`
|
||||
|
||||
На **8080** уже слушает **FREE**‑панель (**`amnezia-admin`**). Пока она работает, второй контейнер панели (PRO) на тот же порт не поднимется. **Кнопка «Установить PRO» из UI по умолчанию** использует одноразовый контейнер **`docker:cli`** (пауза → **`docker rm`** FREE / лендинг / **`amnezia-admin-pro` и любые имена вида `_…_…-amnezia-admin-pro`** из docker compose → затем ваш **`install.sh`**). Иначе вручную: **`docker rm -f amnezia-admin`**, затем установщик PRO. Отключить авто-снос: **`COMMUNITY_DISABLE_DOCKER_CLI_HELPER=1`**.
|
||||
|
||||
### В `community-install-last.log`: **`curl: command not found`**, **`tar: invalid magic`**, **`code=2` сразу после helper
|
||||
|
||||
Образ **`docker:26-cli`** на Alpine обычно **без `bash` и без `curl`**. PRO `install.sh` с **`#!/usr/bin/env bash`** и загрузкой архива через **`curl | tar`** без них не стартуют (**`curl: command not found`**, **`tar: invalid magic`**). Если после строки «одноразовый установщик» почти сразу **`--- завершено … code=2`** — возможны ошибка сборки prelude в **`sh`**, ошибка **`docker run … -e TOKEN=…`** из‑за спецсимволов в PAT, или **apk** не смог добавить **`bash`** (сеть из контейнера). Обновите панель с **`main`**: helper целиком шлёт **stdout/stderr в лог**, токены передаются средой процесса (без интерполяции в аргументе `-e`), prelude разделён **`;`**; перед **`install.sh`** скрипт сообщает если **нет bash/curl** (**выход по коду ~126**). Если **apk недоступен** — свой **`COMMUNITY_PRO_INSTALL_HELPER_IMAGE`** с **`bash` и `curl`**, либо в приватном `install.sh` перейти на **`#!/bin/sh`** и **`wget`**. Отключить подготовку: **`COMMUNITY_HELPER_SKIP_PREPARE_TOOLS=1`**.
|
||||
|
||||
### Установка «зависла» на сообщении **`→ Клонирование релиза …`** и долго без вывода
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| **Время** | Отображение часов контейнера и браузера; синхронизация времени хоста VPS по SSH (если доступно). |
|
||||
| **Cloudflare WARP** | *Необязательно.* Вывод части клиентов в интернет через интерфейс `warp` в контейнере AWG. Если WARP не ставили — статус **«Не установлен»** нормален; панель и VPN без этого работают. Установка и удаление — скрипт `scripts/warp-amnezia.sh` на хосте (`install` / **`uninstall`**), подробности в основном [README](../README.md). |
|
||||
| **Новый клиент под каскад** | Создание нового peer на сервере с **вашим Endpoint** (промежуточный узел); выдача готового `.conf`. |
|
||||
| **Пользователи** | В FREE: просмотр и **удаление** peer с сервера. В PRO дополнительно: вкл/выкл, переименование, даты отключения; экспорт `.conf` если в записи есть `userData.last_config`. |
|
||||
| **Пользователи** | Просмотр клиентов и **удаление** peer с сервера. Вкл/выкл, переименование, даты отключения и экспорт `.conf` в базовой версии недоступны. |
|
||||
|
||||
## Переключатель «Инстанс» не отображается
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "amnezia-admin",
|
||||
"version": "1.2.29",
|
||||
"private": true,
|
||||
"description": "amnezia_web — открытая веб-панель AmneziaWG на VPS (Docker): FREE — просмотр/удаление клиентов, MTProto-прокси; PRO — полное управление и обновления.",
|
||||
"description": "amnezia_web — открытая базовая веб-панель AmneziaWG на VPS (Docker): просмотр и удаление клиентов, MTProto-прокси.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
581
public/app.js
581
public/app.js
@@ -21,6 +21,18 @@ const warpActionsEl = document.querySelector("#warp-actions");
|
||||
const warpClientListEl = document.querySelector("#warp-client-list");
|
||||
const warpWgShowEl = document.querySelector("#warp-wg-show");
|
||||
|
||||
const warpCfPanel = document.querySelector("#warp-cf-panel");
|
||||
const warpCfStatusLine = document.querySelector("#warp-cf-status-line");
|
||||
const warpCfActionsEl = document.querySelector("#warp-cf-actions");
|
||||
const warpCfLogEl = document.querySelector("#warp-cf-log");
|
||||
const warpCfRootPw = document.querySelector("#warp-cf-root-pw");
|
||||
const warpCfPort = document.querySelector("#warp-cf-port");
|
||||
const warpCfLicense = document.querySelector("#warp-cf-license");
|
||||
const warpCfXrayPath = document.querySelector("#warp-cf-xray-path");
|
||||
const warpCfDomains = document.querySelector("#warp-cf-domains");
|
||||
const warpCfBanner = document.querySelector("#warp-cf-banner");
|
||||
const warpCfPresetsEl = document.querySelector("#warp-cf-presets");
|
||||
|
||||
const mtprotoPanel = document.querySelector("#mtproto-panel");
|
||||
const mtprotoStatusLine = document.querySelector("#mtproto-status-line");
|
||||
const mtprotoActionsEl = document.querySelector("#mtproto-actions");
|
||||
@@ -74,353 +86,27 @@ let warpSshPendingCmd = null;
|
||||
/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
|
||||
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() || "";
|
||||
|
||||
/** POLL для живого журнала установки PRO (@type {number | null}) */
|
||||
let communityInstallLogInterval = null;
|
||||
/** Следующий offset для GET /api/community/install-log (@type {number | null}) */
|
||||
let communityInstallLogSince = null;
|
||||
/** Аккумулированный текст (дополняется кусками) */
|
||||
let communityInstallLogShown = "";
|
||||
|
||||
function stopCommunityInstallLogPolling() {
|
||||
if (communityInstallLogInterval !== null) {
|
||||
clearInterval(communityInstallLogInterval);
|
||||
communityInstallLogInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollCommunityInstallLogOnce() {
|
||||
const pre = editionBanner?.querySelector(".edition-banner-install-pre");
|
||||
const statusLine = editionBanner?.querySelector(".edition-banner-install-status");
|
||||
if (!pre || !editionBanner?.querySelector(".edition-banner-install-log")) {
|
||||
stopCommunityInstallLogPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url =
|
||||
communityInstallLogSince === null
|
||||
? "/api/community/install-log"
|
||||
: `/api/community/install-log?since=${encodeURIComponent(String(communityInstallLogSince))}`;
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
const j = await r.json().catch(() => null);
|
||||
if (!r.ok) {
|
||||
if (statusLine) {
|
||||
statusLine.textContent =
|
||||
typeof j?.error === "string" ? j.error : `Ошибка ${r.status}`;
|
||||
statusLine.classList.add("err");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (statusLine) {
|
||||
statusLine.classList.remove("err");
|
||||
if (!j.exists) {
|
||||
statusLine.textContent =
|
||||
communityInstallLogSince === null ? "Журнал пока пустой — секунду…" : "Обновление…";
|
||||
} else if (communityInstallLogSince === null && j.totalBytes > j.since1) {
|
||||
statusLine.textContent = `Хвост журнала (${fmtBytesRu(Math.max(j.since1 - j.since0, 0))} из ~${fmtBytesRu(j.totalBytes)}) · автообновление…`;
|
||||
} else {
|
||||
statusLine.textContent = `Размер журнала ≈ ${fmtBytesRu(j.totalBytes)} · автообновление каждые 2 с…`;
|
||||
}
|
||||
}
|
||||
|
||||
const chunk = typeof j.chunk === "string" ? j.chunk : "";
|
||||
if (!j.exists) return;
|
||||
|
||||
const replace = Boolean(j.resetSuggested || communityInstallLogSince === null);
|
||||
if (replace) communityInstallLogShown = chunk;
|
||||
else communityInstallLogShown += chunk;
|
||||
|
||||
pre.textContent = communityInstallLogShown;
|
||||
communityInstallLogSince = typeof j.since1 === "number" ? j.since1 : communityInstallLogSince;
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
} catch (_e) {
|
||||
if (statusLine) {
|
||||
statusLine.textContent = "Не удалось запросить журнал (сеть).";
|
||||
statusLine.classList.add("err");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBytesRu(n) {
|
||||
const x = Number(n);
|
||||
if (!Number.isFinite(x)) return "";
|
||||
if (x < 1024) return `${Math.round(x)} B`;
|
||||
if (x < 1024 * 1024) return `${Math.round(x / 102.4) / 10} KiB`;
|
||||
return `${Math.round(x / (1024 * 102.4)) / 10} MiB`;
|
||||
}
|
||||
|
||||
/** Ссылки доната и Telegram‑спонсора: шапка + подвал панели (`GET /api/panel/footer`). */
|
||||
function wirePanelPromoAnchor(el, url, label) {
|
||||
const u = typeof url === "string" ? url.trim() : "";
|
||||
const lb = typeof label === "string" ? label.trim() : "";
|
||||
if (!el || !u || !lb) {
|
||||
if (el) {
|
||||
el.classList.add("hidden");
|
||||
el.removeAttribute("href");
|
||||
el.textContent = "";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
el.href = u;
|
||||
el.textContent = lb;
|
||||
el.classList.remove("hidden");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function hydratePanelPromoFooter() {
|
||||
const strip = document.getElementById("panel-promo-strip");
|
||||
const sub = document.getElementById("panel-promo-subtitle");
|
||||
const aDonTop = document.getElementById("panel-strip-donate");
|
||||
const sepDO = document.getElementById("panel-strip-sep-donate-ozon");
|
||||
const aOzonTop = document.getElementById("panel-strip-ozon");
|
||||
const sepOT = document.getElementById("panel-strip-sep-ozon-tg");
|
||||
const aTgTop = document.getElementById("panel-strip-telegram");
|
||||
const gap1 = document.getElementById("footer-promo-gap1");
|
||||
const aDonF = document.getElementById("footer-promo-donate");
|
||||
const fSepDO = document.getElementById("footer-promo-sep-donate-ozon");
|
||||
const aOzonF = document.getElementById("footer-promo-ozon");
|
||||
const fSepOT = document.getElementById("footer-promo-sep-ozon-tg");
|
||||
const aTgF = document.getElementById("footer-promo-telegram");
|
||||
|
||||
try {
|
||||
const r = await fetch("/api/panel/footer", { credentials: "same-origin" });
|
||||
if (!r.ok) return;
|
||||
const j = await r.json().catch(() => null);
|
||||
if (!j || typeof j !== "object") return;
|
||||
|
||||
const subtitle = typeof j.promoSubtitle === "string" ? j.promoSubtitle.trim() : "";
|
||||
if (sub) sub.textContent = subtitle;
|
||||
|
||||
const okD = wirePanelPromoAnchor(aDonTop, j.donateUrl, j.donateLabel);
|
||||
const okO = wirePanelPromoAnchor(aOzonTop, j.ozonUrl, j.ozonLabel);
|
||||
const okT = wirePanelPromoAnchor(aTgTop, j.telegramUrl, j.telegramLabel);
|
||||
|
||||
if (sepDO) sepDO.classList.toggle("hidden", !(okD && (okO || okT)));
|
||||
if (sepOT) sepOT.classList.toggle("hidden", !(okO && okT));
|
||||
|
||||
wirePanelPromoAnchor(aDonF, j.donateUrl, j.donateLabel);
|
||||
wirePanelPromoAnchor(aOzonF, j.ozonUrl, j.ozonLabel);
|
||||
wirePanelPromoAnchor(aTgF, j.telegramUrl, j.telegramLabel);
|
||||
|
||||
const footerD = aDonF && !aDonF.classList.contains("hidden");
|
||||
const footerO = aOzonF && !aOzonF.classList.contains("hidden");
|
||||
const footerT = aTgF && !aTgF.classList.contains("hidden");
|
||||
|
||||
if (gap1) gap1.classList.toggle("hidden", !(footerD || footerO || footerT));
|
||||
if (fSepDO) fSepDO.classList.toggle("hidden", !(footerD && (footerO || footerT)));
|
||||
if (fSepOT) fSepOT.classList.toggle("hidden", !(footerO && footerT));
|
||||
|
||||
if (strip) strip.classList.remove("hidden");
|
||||
} catch {
|
||||
/* офлайн или сбой — в HTML уже дефолтные ссылки шапки/подвала */
|
||||
}
|
||||
}
|
||||
|
||||
function ensureEditionBannerInstallLogPanel() {
|
||||
if (!editionBanner) return null;
|
||||
let wrap = editionBanner.querySelector(".edition-banner-install-log");
|
||||
if (!wrap) {
|
||||
wrap = document.createElement("div");
|
||||
wrap.className = "edition-banner-install-log";
|
||||
const hdr = document.createElement("div");
|
||||
hdr.className = "edition-banner-install-log-head";
|
||||
hdr.innerHTML =
|
||||
'<span class="muted">Журнал установки</span> <button type="button" class="btn small ghost edition-install-log-pause" title="Пауза обновления">Пауза</button>';
|
||||
|
||||
const statusLine = document.createElement("div");
|
||||
statusLine.className = "edition-banner-install-status muted small";
|
||||
statusLine.textContent = "Запрос журнала…";
|
||||
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "edition-banner-install-pre monospace";
|
||||
|
||||
hdr.querySelector(".edition-install-log-pause")?.addEventListener("click", () => {
|
||||
const btn = hdr.querySelector(".edition-install-log-pause");
|
||||
if (!btn || !editionBanner.contains(wrap)) return;
|
||||
const paused = btn.dataset.paused === "1";
|
||||
if (paused) {
|
||||
btn.dataset.paused = "";
|
||||
btn.textContent = "Пауза";
|
||||
startCommunityInstallLogPollingResume();
|
||||
} else {
|
||||
btn.dataset.paused = "1";
|
||||
btn.textContent = "Продолжить";
|
||||
stopCommunityInstallLogPolling();
|
||||
}
|
||||
});
|
||||
|
||||
wrap.append(hdr, statusLine, pre);
|
||||
editionBanner.querySelector(".edition-banner-activation")?.appendChild(wrap);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function startCommunityInstallLogPollingResume() {
|
||||
stopCommunityInstallLogPolling();
|
||||
void pollCommunityInstallLogOnce();
|
||||
communityInstallLogInterval = window.setInterval(() => {
|
||||
void pollCommunityInstallLogOnce();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/** Сброс и показ живого журнала (после «Установить PRO»). */
|
||||
function startCommunityInstallLogPollingFresh() {
|
||||
communityInstallLogSince = null;
|
||||
communityInstallLogShown = "";
|
||||
const wrap = ensureEditionBannerInstallLogPanel();
|
||||
const pre = wrap?.querySelector(".edition-banner-install-pre");
|
||||
const st = wrap?.querySelector(".edition-banner-install-status");
|
||||
if (pre) pre.textContent = "";
|
||||
if (st) {
|
||||
st.classList.remove("err");
|
||||
st.textContent = "Запрос журнала…";
|
||||
}
|
||||
startCommunityInstallLogPollingResume();
|
||||
}
|
||||
|
||||
/** Состояние редакции панели (community = просмотр + удаление; остальные мутации — PRO). */
|
||||
/** Базовые возможности панели (просмотр клиентов + удаление). */
|
||||
let editionState = {
|
||||
tier: "pro",
|
||||
readOnlyClients: false,
|
||||
allowDeleteClients: false,
|
||||
upgradeUrl: null,
|
||||
upgradePitch: null,
|
||||
showDebugWg: true,
|
||||
githubActivationAllowed: false,
|
||||
readOnlyClients: true,
|
||||
allowDeleteClients: true,
|
||||
showDebugWg: false,
|
||||
};
|
||||
|
||||
function applyEditionPayload(data) {
|
||||
const ed = data?.edition;
|
||||
if (!ed || typeof ed !== "object") return;
|
||||
editionState = {
|
||||
tier: ed.tier === "community" ? "community" : "pro",
|
||||
readOnlyClients: Boolean(ed.readOnlyClients),
|
||||
allowDeleteClients: Boolean(ed.allowDeleteClients),
|
||||
upgradeUrl: typeof ed.upgradeUrl === "string" ? ed.upgradeUrl : null,
|
||||
upgradePitch: typeof ed.upgradePitch === "string" ? ed.upgradePitch : null,
|
||||
showDebugWg: ed.showDebugWg !== false,
|
||||
githubActivationAllowed: Boolean(ed.githubActivationAllowed),
|
||||
readOnlyClients: ed.readOnlyClients !== false,
|
||||
allowDeleteClients: ed.allowDeleteClients !== false,
|
||||
showDebugWg: Boolean(ed.showDebugWg),
|
||||
};
|
||||
const titleEl = document.querySelector(".top h1");
|
||||
if (titleEl) {
|
||||
titleEl.textContent =
|
||||
editionState.tier === "community"
|
||||
? "Пользователи AmneziaWG FREE"
|
||||
: "Пользователи AmneziaWG PRO";
|
||||
}
|
||||
if (titleEl) titleEl.textContent = "Пользователи AmneziaWG";
|
||||
const subEl = document.querySelector(".top .sub");
|
||||
if (subEl) {
|
||||
if (editionState.tier === "community") {
|
||||
subEl.textContent =
|
||||
"Базовая панель amnezia_web: просмотр клиентов и статусов, удаление клиента с сервера, установка Telegram MTProto‑прокси (Docker) в этом интерфейсе. Включение/выключение туннеля, даты, переименование, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO.";
|
||||
} else {
|
||||
subEl.textContent = DEFAULT_HEADER_SUB;
|
||||
}
|
||||
}
|
||||
if (editionBanner) {
|
||||
if (editionState.tier === "community") {
|
||||
const preservedInstallLog = editionBanner.querySelector(".edition-banner-install-log");
|
||||
editionBanner.classList.remove("hidden");
|
||||
editionBanner.innerHTML = "";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "edition-banner-inner";
|
||||
const textCol = document.createElement("div");
|
||||
textCol.className = "edition-banner-text";
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = "Базовая версия · просмотр и удаление клиентов";
|
||||
const pitch = document.createElement("p");
|
||||
pitch.className = "edition-banner-pitch muted";
|
||||
pitch.textContent = editionState.upgradePitch || "";
|
||||
textCol.append(strong, pitch);
|
||||
const cta = document.createElement("a");
|
||||
cta.className = "btn small primary edition-banner-cta";
|
||||
cta.rel = "noopener noreferrer";
|
||||
cta.target = "_blank";
|
||||
cta.href =
|
||||
editionState.upgradeUrl ||
|
||||
"https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link";
|
||||
cta.textContent = "Разблокировать PRO (Boosty)";
|
||||
wrap.append(textCol, cta);
|
||||
editionBanner.appendChild(wrap);
|
||||
|
||||
{
|
||||
const act = document.createElement("div");
|
||||
act.className = "edition-banner-activation muted";
|
||||
const cap = document.createElement("div");
|
||||
cap.className = "edition-banner-act-title";
|
||||
cap.textContent =
|
||||
"Есть GitHub‑токен к приватному репозиторию PRO? Вставьте ниже — установка заменит FREE на PRO (порт сохранится):";
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "edition-banner-act-row";
|
||||
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "password";
|
||||
inp.autocomplete = "new-password";
|
||||
inp.spellcheck = false;
|
||||
inp.placeholder = "GitHub PAT (classic: repo или fine‑grained: Contents Read)";
|
||||
inp.className = "edition-banner-act-input monospace";
|
||||
|
||||
const go = document.createElement("button");
|
||||
go.type = "button";
|
||||
go.className = "btn small primary";
|
||||
go.textContent = "Установить PRO";
|
||||
|
||||
const msg = document.createElement("p");
|
||||
msg.className = "edition-banner-act-msg muted";
|
||||
msg.setAttribute("role", "status");
|
||||
msg.textContent = "";
|
||||
|
||||
go.addEventListener("click", async () => {
|
||||
msg.textContent = "";
|
||||
msg.className = "edition-banner-act-msg muted";
|
||||
const tok = inp.value.trim();
|
||||
if (!tok) {
|
||||
msg.className = "edition-banner-act-msg status err";
|
||||
msg.textContent = "Вставьте токен.";
|
||||
return;
|
||||
}
|
||||
go.disabled = true;
|
||||
try {
|
||||
const r = await fetch("/api/community/run-private-install", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ githubToken: tok }),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
msg.className = "edition-banner-act-msg status err";
|
||||
msg.textContent = j.error || `Ошибка ${r.status}`;
|
||||
go.disabled = false;
|
||||
return;
|
||||
}
|
||||
msg.className = "edition-banner-act-msg edition-act-ok muted";
|
||||
msg.textContent =
|
||||
j.message ||
|
||||
"Установка запущена. Через 2–5 минут откройте панель снова по тому же адресу (или несколько раз обновите страницу).";
|
||||
inp.value = "";
|
||||
startCommunityInstallLogPollingFresh();
|
||||
} catch (e) {
|
||||
msg.className = "edition-banner-act-msg status err";
|
||||
msg.textContent = String(e?.message || e);
|
||||
go.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
row.append(inp, go);
|
||||
act.append(cap, row, msg);
|
||||
if (preservedInstallLog) act.appendChild(preservedInstallLog);
|
||||
editionBanner.appendChild(act);
|
||||
}
|
||||
} else {
|
||||
stopCommunityInstallLogPolling();
|
||||
editionBanner.classList.add("hidden");
|
||||
editionBanner.innerHTML = "";
|
||||
}
|
||||
"Базовая панель: просмотр клиентов и статусов, удаление клиента с сервера, установка Telegram MTProto‑прокси (Docker).";
|
||||
}
|
||||
document.querySelector(".clock-host-sync")?.classList.toggle("hidden", editionState.readOnlyClients);
|
||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
||||
@@ -627,6 +313,7 @@ function applyUiHiddenFromPayload(data) {
|
||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
||||
if (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
|
||||
if (warpPanel) warpPanel.hidden = uiHidden.warp;
|
||||
if (warpCfPanel) warpCfPanel.hidden = editionState.readOnlyClients;
|
||||
if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto;
|
||||
void refreshMtprotoPanel();
|
||||
}
|
||||
@@ -1090,10 +777,10 @@ async function loadTimeSyncCaps() {
|
||||
const btn = document.querySelector("#sync-host-time");
|
||||
try {
|
||||
const c = await api("/api/time-sync-capabilities");
|
||||
if (editionState.readOnlyClients || c.communityBlocked) {
|
||||
if (editionState.readOnlyClients) {
|
||||
if (hint) {
|
||||
hint.textContent =
|
||||
"В базовой версии синхронизация времени хоста по SSH недоступна — это функция PRO.";
|
||||
"Синхронизация времени хоста по SSH недоступна в базовой версии панели.";
|
||||
}
|
||||
if (btn) btn.disabled = true;
|
||||
return;
|
||||
@@ -1216,7 +903,7 @@ function renderRows(clients) {
|
||||
roHint.className = "muted hint-mini";
|
||||
roHint.style.margin = "0.35rem 0 0";
|
||||
roHint.textContent = c.exportAvailable
|
||||
? "На сервере есть данные для .conf — скачивание доступно в PRO."
|
||||
? "На сервере есть данные для .conf — экспорт недоступен в базовой версии."
|
||||
: "Нет last_config на сервере — полный конфиг в приложении Amnezia.";
|
||||
nameWrap.appendChild(roHint);
|
||||
}
|
||||
@@ -1258,13 +945,13 @@ function renderRows(clients) {
|
||||
note.className = "muted";
|
||||
note.style.fontSize = "0.85rem";
|
||||
note.style.marginBottom = "0.35rem";
|
||||
note.textContent = "Вкл/выкл, даты, переименование, экспорт — PRO";
|
||||
note.textContent = "Вкл/выкл, даты, переименование, экспорт недоступны";
|
||||
actTd.appendChild(note);
|
||||
actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId)));
|
||||
} else {
|
||||
const lock = document.createElement("span");
|
||||
lock.className = "muted";
|
||||
lock.textContent = "Только PRO";
|
||||
lock.textContent = "Недоступно";
|
||||
actTd.appendChild(lock);
|
||||
}
|
||||
} else {
|
||||
@@ -1487,6 +1174,217 @@ function renderWarpPanel(data) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeWarpCfPort() {
|
||||
const raw = warpCfPort?.value?.trim();
|
||||
const n = raw ? Number.parseInt(raw, 10) : 40000;
|
||||
if (!Number.isFinite(n) || n < 512 || n > 65535) return 40000;
|
||||
return n;
|
||||
}
|
||||
|
||||
function warpCfPresetText(port) {
|
||||
const p = Number(port) || 40000;
|
||||
return (
|
||||
"### 3x-ui / Xray (на хосте)\n" +
|
||||
"SOCKS5 адрес: 127.0.0.1:" + p + "\n\n" +
|
||||
"Outbound (пример):\n" +
|
||||
"{\n" +
|
||||
' \"tag\": \"warp-socks\",\n' +
|
||||
' \"protocol\": \"socks\",\n' +
|
||||
' \"settings\": {\"servers\": [{\"address\": \"127.0.0.1\", \"port\": ' + p + '}]}\n' +
|
||||
"}\n\n" +
|
||||
"Routing rule (пример):\n" +
|
||||
"{\n" +
|
||||
' \"type\": \"field\",\n' +
|
||||
' \"outboundTag\": \"warp-socks\",\n' +
|
||||
' \"domain\": [\"geosite:google\", \"geosite:openai\"]\n' +
|
||||
"}\n\n" +
|
||||
"### Amnezia (xray в Docker)\n" +
|
||||
"SOCKS5 адрес: 172.17.0.1:" + p + " (из контейнера до хоста)\n\n" +
|
||||
"Outbound (пример):\n" +
|
||||
"{\n" +
|
||||
' \"tag\": \"warp-socks\",\n' +
|
||||
' \"protocol\": \"socks\",\n' +
|
||||
' \"settings\": {\"servers\": [{\"address\": \"172.17.0.1\", \"port\": ' + p + '}]}\n' +
|
||||
"}\n"
|
||||
);
|
||||
}
|
||||
|
||||
function redrawWarpCfPresets() {
|
||||
if (!warpCfPresetsEl) return;
|
||||
warpCfPresetsEl.textContent = warpCfPresetText(normalizeWarpCfPort());
|
||||
}
|
||||
|
||||
async function refreshWarpCfPanel() {
|
||||
if (!warpCfPanel || !warpCfStatusLine || !warpCfActionsEl || !warpCfLogEl) return;
|
||||
if (editionState.readOnlyClients) {
|
||||
warpCfPanel.hidden = true;
|
||||
return;
|
||||
}
|
||||
warpCfPanel.hidden = false;
|
||||
redrawWarpCfPresets();
|
||||
|
||||
warpCfActionsEl.innerHTML = "";
|
||||
if (warpCfBanner) {
|
||||
warpCfBanner.classList.add("hidden");
|
||||
warpCfBanner.textContent = "";
|
||||
}
|
||||
|
||||
const pw = String(warpCfRootPw?.value || "").trim();
|
||||
const port = normalizeWarpCfPort();
|
||||
|
||||
const explain = document.createElement("p");
|
||||
explain.className = "muted warp-muted";
|
||||
explain.textContent =
|
||||
"Для статуса/установки/удаления нужен пароль root по SSH на хост VPS. Пароль не сохраняется — вводите при каждом действии.";
|
||||
warpCfActionsEl.appendChild(explain);
|
||||
|
||||
warpCfActionsEl.appendChild(
|
||||
btn("Статус", "btn small ghost", async () => {
|
||||
try {
|
||||
if (!pw) throw new Error("Введите пароль root.");
|
||||
setStatus("WARP (официальный): статус…", false);
|
||||
warpCfLogEl.textContent = "Запрос статуса…";
|
||||
const j = await api("/api/warp-cf/status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rootPassword: pw }),
|
||||
});
|
||||
warpCfStatusLine.textContent =
|
||||
j.installed
|
||||
? `${j.running ? "подключён" : "не подключён"} · mode ${j.mode || "?"}` +
|
||||
(j.proxyPort ? ` · SOCKS :${j.proxyPort}` : "") +
|
||||
(j.account ? ` · ${j.account}` : "")
|
||||
: "не установлен";
|
||||
warpCfLogEl.textContent = (j.rawStatus || "") + (j.rawAccount ? "\n\n--- account ---\n" + j.rawAccount : "");
|
||||
setStatus("", false);
|
||||
} catch (e) {
|
||||
setStatus(String(e?.message || e), true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
warpCfActionsEl.appendChild(
|
||||
btn("Установить (proxy mode)", "btn small primary", async () => {
|
||||
try {
|
||||
if (!pw) throw new Error("Введите пароль root.");
|
||||
if (!confirm(`Установить официальный Cloudflare WARP на хост VPS и поднять SOCKS5 на 127.0.0.1:${port}?`)) {
|
||||
return;
|
||||
}
|
||||
setStatus("WARP (официальный): установка…", false);
|
||||
warpCfLogEl.textContent = "Установка…";
|
||||
const j = await api("/api/warp-cf/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rootPassword: pw, socksPort: port }),
|
||||
});
|
||||
warpCfLogEl.textContent = j.output || "Готово.";
|
||||
if (warpCfBanner) {
|
||||
warpCfBanner.textContent = `Готово: SOCKS5 на 127.0.0.1:${port}`;
|
||||
warpCfBanner.classList.remove("hidden");
|
||||
}
|
||||
setStatus("", false);
|
||||
} catch (e) {
|
||||
setStatus(String(e?.message || e), true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
warpCfActionsEl.appendChild(
|
||||
btn("Применить WARP+ ключ", "btn small ghost", async () => {
|
||||
try {
|
||||
if (!pw) throw new Error("Введите пароль root.");
|
||||
const key = String(warpCfLicense?.value || "").trim();
|
||||
if (!key) throw new Error("Вставьте WARP+ ключ.");
|
||||
setStatus("WARP (официальный): применяю ключ…", false);
|
||||
warpCfLogEl.textContent = "Применение ключа…";
|
||||
const j = await api("/api/warp-cf/license", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rootPassword: pw, licenseKey: key }),
|
||||
});
|
||||
warpCfLogEl.textContent = j.output || "Ключ применён.";
|
||||
if (warpCfLicense) warpCfLicense.value = "";
|
||||
setStatus("", false);
|
||||
} catch (e) {
|
||||
setStatus(String(e?.message || e), true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
warpCfActionsEl.appendChild(
|
||||
btn("Удалить", "btn small warn", async () => {
|
||||
try {
|
||||
if (!pw) throw new Error("Введите пароль root.");
|
||||
if (!confirm("Удалить официальный Cloudflare WARP с хоста VPS (apt remove cloudflare-warp)?")) return;
|
||||
setStatus("WARP (официальный): удаление…", false);
|
||||
warpCfLogEl.textContent = "Удаление…";
|
||||
const j = await api("/api/warp-cf/uninstall", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rootPassword: pw }),
|
||||
});
|
||||
warpCfLogEl.textContent = j.output || "Удалено.";
|
||||
warpCfStatusLine.textContent = "не установлен";
|
||||
setStatus("", false);
|
||||
} catch (e) {
|
||||
setStatus(String(e?.message || e), true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
warpCfActionsEl.appendChild(
|
||||
btn("Применить пресет в Xray (3x‑ui: 127.0.0.1)", "btn small ghost", async () => {
|
||||
try {
|
||||
if (!pw) throw new Error("Введите пароль root.");
|
||||
const filePath = String(warpCfXrayPath?.value || "").trim();
|
||||
if (!filePath) throw new Error("Укажите путь к JSON конфигу Xray.");
|
||||
const domains = String(warpCfDomains?.value || "").trim();
|
||||
if (!confirm(`Внести изменения в ${filePath} (создадим .bak* рядом)?`)) return;
|
||||
setStatus("Xray preset: применяю…", false);
|
||||
warpCfLogEl.textContent = "Применение…";
|
||||
const j = await api("/api/warp-cf/xray-apply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
rootPassword: pw,
|
||||
filePath,
|
||||
socksAddress: "127.0.0.1",
|
||||
socksPort: port,
|
||||
domains,
|
||||
}),
|
||||
});
|
||||
warpCfLogEl.textContent = JSON.stringify(j, null, 2);
|
||||
setStatus("Готово (проверьте и перезапустите Xray/x-ui).", false);
|
||||
} catch (e) {
|
||||
setStatus(String(e?.message || e), true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
warpCfActionsEl.appendChild(
|
||||
btn("Применить пресет в Xray (Docker: 172.17.0.1)", "btn small ghost", async () => {
|
||||
try {
|
||||
if (!pw) throw new Error("Введите пароль root.");
|
||||
const filePath = String(warpCfXrayPath?.value || "").trim();
|
||||
if (!filePath) throw new Error("Укажите путь к JSON конфигу Xray.");
|
||||
const domains = String(warpCfDomains?.value || "").trim();
|
||||
if (!confirm(`Внести изменения в ${filePath} (создадим .bak* рядом)?`)) return;
|
||||
setStatus("Xray preset: применяю…", false);
|
||||
warpCfLogEl.textContent = "Применение…";
|
||||
const j = await api("/api/warp-cf/xray-apply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
rootPassword: pw,
|
||||
filePath,
|
||||
socksAddress: "172.17.0.1",
|
||||
socksPort: port,
|
||||
domains,
|
||||
}),
|
||||
});
|
||||
warpCfLogEl.textContent = JSON.stringify(j, null, 2);
|
||||
setStatus("Готово (проверьте и перезапустите Xray/контейнер).", false);
|
||||
} catch (e) {
|
||||
setStatus(String(e?.message || e), true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
@@ -1691,6 +1589,7 @@ async function loadClients() {
|
||||
wgShowEl.textContent = data.wgShow || "";
|
||||
}
|
||||
renderWarpPanel(data);
|
||||
void refreshWarpCfPanel();
|
||||
renderRows(data.clients);
|
||||
setStatus("", false);
|
||||
void refreshServerClock();
|
||||
@@ -1707,6 +1606,7 @@ async function loadClients() {
|
||||
wgShowEl.textContent = "";
|
||||
peerCountEl.textContent = "";
|
||||
if (warpPanel) warpPanel.hidden = true;
|
||||
if (warpCfPanel) warpCfPanel.hidden = true;
|
||||
if (mtprotoPanel) mtprotoPanel.hidden = true;
|
||||
}
|
||||
}
|
||||
@@ -1803,4 +1703,5 @@ function initMtprotoLinkCopy() {
|
||||
initThemeSwitch();
|
||||
initMtprotoLinkCopy();
|
||||
void hydratePanelPromoFooter();
|
||||
if (warpCfPort) warpCfPort.addEventListener("input", () => redrawWarpCfPresets());
|
||||
boot();
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<div class="panel-promo-links">
|
||||
<a class="panel-promo-a panel-promo-github-fixed" href="https://github.com/andrey271192/amnezia_web" target="_blank" rel="noopener noreferrer">⭐ GitHub</a>
|
||||
<span class="panel-promo-sep" aria-hidden="true">·</span>
|
||||
<a id="panel-strip-donate" class="panel-promo-a" href="https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link" target="_blank" rel="noopener noreferrer">Поддержать проект</a>
|
||||
<a id="panel-strip-donate" class="panel-promo-a" href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Поддержать проект</a>
|
||||
<span id="panel-strip-sep-donate-ozon" class="panel-promo-sep" aria-hidden="true">·</span>
|
||||
<a id="panel-strip-ozon" class="panel-promo-a" href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener noreferrer">Донат · Ozon СБП</a>
|
||||
<span id="panel-strip-sep-ozon-tg" class="panel-promo-sep" aria-hidden="true">·</span>
|
||||
@@ -53,7 +53,7 @@
|
||||
Поддержка веб‑панелей (вне этого сервера):<br/>
|
||||
<a href="https://github.com/andrey271192/amnezia_web" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
||||
<a href="https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link" target="_blank" rel="noopener noreferrer">Boosty · PRO панели</a>
|
||||
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty · донат</a>
|
||||
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
||||
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Донат</a>
|
||||
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
||||
@@ -68,8 +68,8 @@
|
||||
<header class="top">
|
||||
<div>
|
||||
<p class="eyebrow">Панель сервера</p>
|
||||
<h1>Пользователи AmneziaWG FREE</h1>
|
||||
<p class="sub">Базовая открытая панель: просмотр клиентов AmneziaWG и (после входа) установка Telegram MTProto‑прокси в Docker без PRO. Полное управление peer, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO.</p>
|
||||
<h1>Пользователи AmneziaWG</h1>
|
||||
<p class="sub">Базовая панель: просмотр клиентов AmneziaWG, удаление peer с сервера и (после входа) установка Telegram MTProto‑прокси в Docker.</p>
|
||||
</div>
|
||||
<div class="token-box">
|
||||
<div class="theme-switch" role="group" aria-label="Тема оформления">
|
||||
@@ -99,8 +99,6 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="edition-banner" class="edition-banner hidden" role="region" aria-label="Редакция панели"></div>
|
||||
|
||||
<section class="toolbar">
|
||||
<div class="pill proto-pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
|
||||
<div id="proto-switch" class="proto-switch hidden">
|
||||
@@ -165,6 +163,53 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel-fold warp-panel" id="warp-cf-panel" hidden>
|
||||
<summary class="fold-summary">
|
||||
<span class="fold-arrow" aria-hidden="true"></span>
|
||||
<span class="fold-titles">
|
||||
<span class="fold-h">Cloudflare WARP (официальный клиент · SOCKS5)</span>
|
||||
<span class="muted fold-meta" id="warp-cf-status-line"></span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="panel-fold-body">
|
||||
<p class="muted warp-intro">
|
||||
Это <strong>другой</strong> сценарий, чем блок выше: здесь ставится официальный пакет <code class="inline">cloudflare-warp</code> на <strong>хост VPS</strong>,
|
||||
а <code class="inline">warp-cli</code> включается в режим <strong>Local Proxy</strong> (SOCKS5/HTTP) на <code class="inline">127.0.0.1:PORT</code> (по умолчанию 40000).
|
||||
Для платного <strong>WARP+</strong> вставьте ключ лицензии и нажмите «Применить».
|
||||
</p>
|
||||
|
||||
<div class="mtproto-fields warp-cf-fields">
|
||||
<label for="warp-cf-root-pw">Пароль root на хосте VPS (не сохраняется)</label>
|
||||
<input id="warp-cf-root-pw" type="password" autocomplete="off" placeholder="root пароль (для install/uninstall/status/license)">
|
||||
|
||||
<label for="warp-cf-port">SOCKS5 порт (127.0.0.1:PORT)</label>
|
||||
<input id="warp-cf-port" type="number" min="512" max="65535" autocomplete="off" placeholder="40000">
|
||||
|
||||
<label for="warp-cf-license">WARP+ ключ (необязательно)</label>
|
||||
<input id="warp-cf-license" type="text" autocomplete="off" placeholder="Вставьте ваш реальный WARP+ ключ">
|
||||
|
||||
<label for="warp-cf-xray-path">Путь к Xray JSON (3x‑ui / Amnezia)</label>
|
||||
<input id="warp-cf-xray-path" type="text" autocomplete="off" placeholder="/etc/x-ui/xray/config.json или /opt/amnezia/.../server.json">
|
||||
|
||||
<label for="warp-cf-domains">Домены/geosite для routing (необязательно, через запятую)</label>
|
||||
<input id="warp-cf-domains" type="text" autocomplete="off" placeholder="geosite:google,geosite:openai">
|
||||
</div>
|
||||
|
||||
<div id="warp-cf-actions" class="warp-actions"></div>
|
||||
<p id="warp-cf-banner" class="status ok hidden" role="status"></p>
|
||||
|
||||
<details class="raw warp-raw" open>
|
||||
<summary>Лог / статус</summary>
|
||||
<pre id="warp-cf-log"></pre>
|
||||
</details>
|
||||
|
||||
<details class="raw warp-raw">
|
||||
<summary>Пресеты для клиентов (3x‑ui / Amnezia)</summary>
|
||||
<pre id="warp-cf-presets"></pre>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel-fold mtproto-panel" id="mtproto-panel" hidden>
|
||||
<summary class="fold-summary">
|
||||
<span class="fold-arrow" aria-hidden="true"></span>
|
||||
@@ -175,7 +220,7 @@
|
||||
</summary>
|
||||
<div class="panel-fold-body">
|
||||
<p class="muted warp-intro mtproto-intro">
|
||||
<strong>Во всех редакциях панели (FREE и PRO).</strong> Отдельный Docker‑контейнер (образ Telegram), публикация порта на хосте VPS.
|
||||
<strong>Раздел доступен в базовой панели.</strong> Отдельный Docker‑контейнер (образ Telegram), публикация порта на хосте VPS.
|
||||
Ссылка вида <code class="inline">tg://proxy?…</code> может появляться без env, если панель открыта по публичному IP/DNS; для фиксированного хоста задайте <code class="inline">MTPRO_PUBLIC_HOST</code> или <code class="inline">CLIENT_CONFIG_ENDPOINT</code>
|
||||
(переменные контейнера панели, см. install.sh README).
|
||||
Сохраните <strong>секрет</strong>, который показывается один раз после установки — в интерфейсе он маскируется.
|
||||
@@ -282,14 +327,14 @@
|
||||
<span class="support-sep" aria-hidden="true">·</span>
|
||||
<a href="https://github.com/andrey271192/amnezia_web" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
<span id="footer-promo-gap1" class="support-sep" aria-hidden="true">·</span>
|
||||
<a id="footer-promo-donate" class="support-promo-link" href="https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link" target="_blank" rel="noopener noreferrer">Поддержать проект</a>
|
||||
<a id="footer-promo-donate" class="support-promo-link" href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Поддержать проект</a>
|
||||
<span id="footer-promo-sep-donate-ozon" class="support-sep" aria-hidden="true">·</span>
|
||||
<a id="footer-promo-ozon" class="support-promo-link" href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener noreferrer">Донат · Ozon СБП</a>
|
||||
<span id="footer-promo-sep-ozon-tg" class="support-sep" aria-hidden="true">·</span>
|
||||
<a id="footer-promo-telegram" class="support-promo-link" href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">Telegram‑канал спонсора</a>
|
||||
</div>
|
||||
<p class="support-blurb">
|
||||
AWG‑клиенты, статусы и MTProto‑прокси в FREE. Жёлтая шапка поддержки совпадает с подвалом; текст ссылок может подтягиваться с сервера (Boosty подписка / донат). На пользовательских лендингах блоков поддержки нет.
|
||||
AWG‑клиенты, статусы и MTProto‑прокси. Жёлтая шапка поддержки совпадает с подвалом; текст ссылок может подтягиваться с сервера. На пользовательских лендингах блоков поддержки нет.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -1193,133 +1193,3 @@ tr:last-child td {
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.edition-banner {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(125, 211, 252, 0.35);
|
||||
background: linear-gradient(135deg, rgba(125, 211, 252, 0.12), rgba(94, 234, 212, 0.06));
|
||||
}
|
||||
|
||||
.edition-banner.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edition-banner-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.edition-banner-text {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.edition-banner-pitch {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.edition-banner-cta {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edition-banner-activation {
|
||||
margin-top: 0.85rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px dashed rgba(125, 211, 252, 0.35);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edition-banner-act-title {
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.edition-banner-act-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.edition-banner-act-input {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.edition-banner-act-input.monospace {
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.edition-banner-act-msg {
|
||||
margin: 0.55rem 0 0 !important;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.edition-act-ok {
|
||||
color: var(--ok) !important;
|
||||
}
|
||||
|
||||
.edition-banner-no-activation {
|
||||
margin: 0.85rem 0 0;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px dashed rgba(125, 211, 252, 0.28);
|
||||
width: 100%;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
.edition-banner-install-log {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.edition-banner-install-log-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.edition-banner-install-status.small {
|
||||
font-size: 0.72rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.edition-banner-install-status.err {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.edition-banner-install-pre {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--pre-bg);
|
||||
color: var(--pre-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.edition-banner-install-pre.monospace {
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Установка Amnezia Admin WebUI одной командой (см. README).
|
||||
# Клиентский режим: INSTALL_FREE_COMMUNITY_ACTIVATION=1 — маркер в томе данных + явно включает ALLOW в контейнере (поле PAT в FREE и так включено по умолчанию в server.js).
|
||||
set -euo pipefail
|
||||
|
||||
GITHUB_REPO="${GITHUB_REPO:-andrey271192/amnezia_web}"
|
||||
@@ -163,11 +162,6 @@ 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 __reuse_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE WARP_SSH_INSTALL_DIR \
|
||||
ALLOW_COMMUNITY_GITHUB_ACTIVATION COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL COMMUNITY_INSTALL_FETCH_MS PRIVATE_INSTALL_SCRIPT_MAX_BYTES \
|
||||
COMMUNITY_UPGRADE_URL COMMUNITY_UPGRADE_PITCH AMNEZIA_EDITION \
|
||||
COMMUNITY_DISABLE_DOCKER_CLI_HELPER COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO COMMUNITY_PRO_INSTALL_HELPER_IMAGE \
|
||||
FREE_PANEL_CONTAINER_FOR_PRO_INSTALL FREE_LANDING_CONTAINER_FOR_PRO_INSTALL STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL \
|
||||
COMMUNITY_INSTALL_LOG_TAIL_BYTES COMMUNITY_INSTALL_LOG_API_CHUNK_MAX COMMUNITY_HELPER_SKIP_PREPARE_TOOLS \
|
||||
EXPORT_CONFIG_SECRET CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2; do
|
||||
if [[ -z "${!__reuse_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then
|
||||
PREV_VAL=""
|
||||
@@ -184,20 +178,6 @@ for __reuse_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE W
|
||||
fi
|
||||
done
|
||||
|
||||
INSTALL_FREE_MARKER="${DATA_DIR}/.install-free-github-pro-opt-in"
|
||||
if [[ "${INSTALL_FREE_COMMUNITY_ACTIVATION:-}" == "1" ]] || [[ "${INSTALL_FREE_COMMUNITY_ACTIVATION:-}" == "true" ]]; then
|
||||
umask 022
|
||||
printf '1\n' >"${INSTALL_FREE_MARKER}"
|
||||
AMNEZIA_EDITION=community
|
||||
ALLOW_COMMUNITY_GITHUB_ACTIVATION=1
|
||||
export AMNEZIA_EDITION ALLOW_COMMUNITY_GITHUB_ACTIVATION
|
||||
echo "→ INSTALL_FREE_COMMUNITY_ACTIVATION=1 — FREE-панель, поле GitHub-токена и «Установить PRO» (${INSTALL_FREE_MARKER})."
|
||||
elif [[ -z "${ALLOW_COMMUNITY_GITHUB_ACTIVATION:-}" ]] && [[ -f "${INSTALL_FREE_MARKER}" ]]; then
|
||||
AMNEZIA_EDITION=community
|
||||
ALLOW_COMMUNITY_GITHUB_ACTIVATION=1
|
||||
export AMNEZIA_EDITION ALLOW_COMMUNITY_GITHUB_ACTIVATION
|
||||
echo "→ Обнаружен признак установки FREE+GitHub (${INSTALL_FREE_MARKER##*/}); повторяю эту конфигурацию."
|
||||
fi
|
||||
|
||||
DOCKER_BUILD_EXTRA=()
|
||||
if [[ "${NO_CACHE:-}" == "1" ]]; then
|
||||
@@ -256,18 +236,8 @@ elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:
|
||||
RUN_ENV+=( -e "ALLOW_DEFAULT_PASSWORD=1" )
|
||||
fi
|
||||
|
||||
if [[ -n "${AMNEZIA_EDITION:-}" ]]; then
|
||||
RUN_ENV+=( -e "AMNEZIA_EDITION=${AMNEZIA_EDITION}" )
|
||||
elif [[ -f "${INSTALL_DIR}/.amnezia-panel-edition" ]]; then
|
||||
__PE="$(tr -d '\r\n' <"${INSTALL_DIR}/.amnezia-panel-edition" | head -c 48)"
|
||||
if [[ -n "${__PE}" ]]; then
|
||||
RUN_ENV+=( -e "AMNEZIA_EDITION=${__PE}" )
|
||||
echo "→ AMNEZIA_EDITION из ${INSTALL_DIR}/.amnezia-panel-edition: ${__PE}"
|
||||
fi
|
||||
fi
|
||||
for __ce_var in COMMUNITY_UPGRADE_URL COMMUNITY_UPGRADE_PITCH PANEL_FOOTER_DONATE_URL PANEL_FOOTER_OZON_URL PANEL_FOOTER_TELEGRAM_URL \
|
||||
PANEL_FOOTER_DONATE_LABEL PANEL_FOOTER_OZON_LABEL PANEL_FOOTER_TELEGRAM_LABEL PANEL_FOOTER_PROMO_SUBTITLE \
|
||||
ALLOW_COMMUNITY_GITHUB_ACTIVATION COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL COMMUNITY_INSTALL_FETCH_MS PRIVATE_INSTALL_SCRIPT_MAX_BYTES COMMUNITY_DISABLE_DOCKER_CLI_HELPER COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO COMMUNITY_PRO_INSTALL_HELPER_IMAGE FREE_PANEL_CONTAINER_FOR_PRO_INSTALL FREE_LANDING_CONTAINER_FOR_PRO_INSTALL STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL COMMUNITY_INSTALL_LOG_TAIL_BYTES COMMUNITY_INSTALL_LOG_API_CHUNK_MAX COMMUNITY_HELPER_SKIP_PREPARE_TOOLS; do
|
||||
for __ce_var in PANEL_FOOTER_DONATE_URL PANEL_FOOTER_OZON_URL PANEL_FOOTER_TELEGRAM_URL \
|
||||
PANEL_FOOTER_DONATE_LABEL PANEL_FOOTER_OZON_LABEL PANEL_FOOTER_TELEGRAM_LABEL PANEL_FOOTER_PROMO_SUBTITLE; do
|
||||
if [[ -n "${!__ce_var:-}" ]]; then
|
||||
RUN_ENV+=( -e "${__ce_var}=${!__ce_var}" )
|
||||
fi
|
||||
|
||||
801
server.js
801
server.js
@@ -29,14 +29,6 @@ function envTruthy(v) {
|
||||
return s === "1" || s === "true" || s === "yes";
|
||||
}
|
||||
|
||||
/** Явное отключение поля GitHub‑токена / установки PRO из UI (`0`, `false`, `no`, `off`). Пустое или неизвестное — не отключает. */
|
||||
function communityGithubActivationDisabledByEnv() {
|
||||
const v = process.env.ALLOW_COMMUNITY_GITHUB_ACTIVATION;
|
||||
if (typeof v !== "string") return false;
|
||||
const s = v.trim().toLowerCase();
|
||||
return s === "0" || s === "false" || s === "no" || s === "off";
|
||||
}
|
||||
|
||||
/** Какие блоки веб-интерфейса скрыты: `UI_HIDE_SECTIONS=users,warp,cascade` или `UI_HIDE_USERS` и т.д. */
|
||||
function resolveUiHidden() {
|
||||
const raw = process.env.UI_HIDE_SECTIONS?.trim();
|
||||
@@ -57,27 +49,7 @@ function resolveUiHidden() {
|
||||
|
||||
const UI_HIDDEN = resolveUiHidden();
|
||||
|
||||
const AMNEZIA_EDITION = (process.env.AMNEZIA_EDITION || "pro").trim().toLowerCase();
|
||||
const IS_COMMUNITY = AMNEZIA_EDITION === "community";
|
||||
/** В редакции FREE поле для PAT и установка из панели включены по умолчанию; выключить: `ALLOW_COMMUNITY_GITHUB_ACTIVATION=0` (или `false`/`no`/`off`). */
|
||||
const ALLOW_COMMUNITY_GITHUB_ACTIVATION =
|
||||
IS_COMMUNITY && !communityGithubActivationDisabledByEnv();
|
||||
const COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL =
|
||||
process.env.COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL?.trim() ||
|
||||
"https://raw.githubusercontent.com/andrey271192/amnezia_web-pro/main/scripts/install.sh";
|
||||
const PRIVATE_INSTALL_SCRIPT_MAX_BYTES = Number(process.env.PRIVATE_INSTALL_SCRIPT_MAX_BYTES || 2_097_152) || 2_097_152;
|
||||
/** Флаг выполнения одноразового bash install из UI (держим второй параллельный запрос). */
|
||||
let communityPrivateInstallBusy = false;
|
||||
const COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO = envTruthy(
|
||||
process.env.COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO,
|
||||
);
|
||||
const COMMUNITY_DISABLE_DOCKER_CLI_HELPER = envTruthy(process.env.COMMUNITY_DISABLE_DOCKER_CLI_HELPER);
|
||||
const COMMUNITY_UPGRADE_URL =
|
||||
process.env.COMMUNITY_UPGRADE_URL?.trim() ||
|
||||
"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: удаление клиента с сервера + отдельный раздел установки Telegram MTProto-прокси (Docker-контейнер). Полная сборка AWG-панели — приватный репозиторий amnezia_web-PRO; доступ по подписке Boosty.";
|
||||
const PANEL_FOOTER_DONATE_DEFAULT = "https://boosty.to/andrey27/donate";
|
||||
|
||||
const PANEL_FOOTER_TELEGRAM_DEFAULT = "https://t.me/lot_andrey";
|
||||
const PANEL_FOOTER_OZON_DEFAULT =
|
||||
@@ -99,7 +71,7 @@ function sanitizeHttpsHttpUrl(raw) {
|
||||
function panelPromoFooterPayload() {
|
||||
const donateResolved =
|
||||
sanitizeHttpsHttpUrl(
|
||||
process.env.PANEL_FOOTER_DONATE_URL?.trim() || COMMUNITY_UPGRADE_URL.trim(),
|
||||
process.env.PANEL_FOOTER_DONATE_URL?.trim() || PANEL_FOOTER_DONATE_DEFAULT,
|
||||
) || "";
|
||||
const telegramResolved =
|
||||
sanitizeHttpsHttpUrl(
|
||||
@@ -130,19 +102,13 @@ function panelPromoFooterPayload() {
|
||||
|
||||
function editionPayload() {
|
||||
return {
|
||||
tier: IS_COMMUNITY ? "community" : "pro",
|
||||
readOnlyClients: IS_COMMUNITY,
|
||||
/** В FREE разрешено удаление записи клиента (остальные мутации — PRO). */
|
||||
allowDeleteClients: IS_COMMUNITY,
|
||||
upgradeUrl: IS_COMMUNITY ? COMMUNITY_UPGRADE_URL : null,
|
||||
upgradePitch: IS_COMMUNITY ? COMMUNITY_UPGRADE_PITCH : null,
|
||||
showDebugWg: !IS_COMMUNITY,
|
||||
githubActivationAllowed: ALLOW_COMMUNITY_GITHUB_ACTIVATION && IS_COMMUNITY,
|
||||
readOnlyClients: true,
|
||||
allowDeleteClients: true,
|
||||
showDebugWg: false,
|
||||
};
|
||||
}
|
||||
|
||||
function effectiveUiHidden() {
|
||||
if (!IS_COMMUNITY) return { ...UI_HIDDEN };
|
||||
return {
|
||||
users: UI_HIDDEN.users,
|
||||
warp: true,
|
||||
@@ -461,199 +427,15 @@ function requireAuthOrExportToken(req, res, next) {
|
||||
requireAuth(req, res, next);
|
||||
}
|
||||
|
||||
function rejectCommunityProOnly(res) {
|
||||
function rejectUnavailableFeature(res) {
|
||||
res.status(403).json({
|
||||
error:
|
||||
"Доступно в версии PRO: полное управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста. Раздел MTProto‑прокси Telegram (Docker) в базовой панели уже без PRO.",
|
||||
upgradeRequired: true,
|
||||
upgradeUrl: COMMUNITY_UPGRADE_URL,
|
||||
"Эта функция недоступна в базовой версии панели: полное управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста.",
|
||||
});
|
||||
}
|
||||
|
||||
/** Токен GitHub только из печатаемых ASCII без пробелов/newline (classic ghp_* / github_pat_*). */
|
||||
function validateGithubBearerToken(tok) {
|
||||
if (typeof tok !== "string") return false;
|
||||
const s = tok.trim();
|
||||
if (s.length < 20 || s.length > 4096) return false;
|
||||
return /^[!-~]+$/.test(s);
|
||||
}
|
||||
|
||||
/** Имя контейнера для встраивания в `sh -lc` во внешнем helper (docker rm -f …). */
|
||||
function shellSafeDockerContainerName(raw, fallback) {
|
||||
const s = typeof raw === "string" ? raw.trim() : "";
|
||||
if (/^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/.test(s)) return s;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Одноразовый helper (`docker:*-cli`) почти всегда Alpine: нет **`bash`** (#!/usr/bin/env bash у install.sh), **`curl`**, **`openssl`** и **`tar`**.
|
||||
* Без них `exec bash ./install.sh` падает сразу (~код 2/127 и пустое продолжение лога после строки про helper).
|
||||
* Отключить: COMMUNITY_HELPER_SKIP_PREPARE_TOOLS=1 (если свой образ уже со всем необходимым).
|
||||
*/
|
||||
function dockerCliHelperEnsureFetchToolsScript() {
|
||||
if (envTruthy(process.env.COMMUNITY_HELPER_SKIP_PREPARE_TOOLS)) return "";
|
||||
return `
|
||||
# bash + curl + openssl + tar для приватного install.sh:
|
||||
# - bash: shebang #!/usr/bin/env bash
|
||||
# - curl: скачивание tar.gz релиза с GitHub
|
||||
# - openssl: генерация первичного пароля/секретов
|
||||
# - tar: распаковка tar.gz
|
||||
if ! command -v bash >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 \\
|
||||
|| ! command -v openssl >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1; then
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache bash curl ca-certificates openssl tar \\
|
||||
|| echo "⚠ helper: apk add bash/curl/openssl/tar failed — install.sh может не запуститься"
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq >/dev/null \\
|
||||
&& apt-get install -y -qq bash curl ca-certificates openssl tar \\
|
||||
|| echo "⚠ helper: apt bash/curl/openssl/tar failed — install.sh может не запуститься"
|
||||
fi
|
||||
fi
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Пути `conf` / `clientsTable` внутри контейнера Amnezia: не всегда `awg0.conf`
|
||||
* в `/opt/amnezia/awg/` (legacy — `wg0.conf`, второй сервис — подкаталог `awg2`).
|
||||
*/
|
||||
function probeAmneziaTunnelPaths(containerName) {
|
||||
const safe = /^[a-zA-Z0-9_.-]+$/.test(containerName) ? containerName : "";
|
||||
if (!safe) return null;
|
||||
const script = `
|
||||
set -eu
|
||||
CONF=""
|
||||
for f in "/opt/amnezia/awg/awg0.conf" "/opt/amnezia/awg/wg0.conf" "/opt/amnezia/awg2/awg0.conf" "/opt/amnezia/awg2/wg0.conf" "/etc/wireguard/wg0.conf" "/etc/wireguard/awg0.conf"; do
|
||||
if [ -f "$f" ]; then CONF="$f"; break; fi
|
||||
done
|
||||
if [ -z "$CONF" ]; then
|
||||
CONF="$(find /opt/amnezia /etc/wireguard -maxdepth 10 \\( -name 'wg0.conf' -o -name 'awg0.conf' \\) 2>/dev/null | head -n 1)"
|
||||
fi
|
||||
[ -n "$CONF" ] && [ -f "$CONF" ] || exit 10
|
||||
DIR="$(dirname "$CONF")"
|
||||
CLIENTS=""
|
||||
for c in "$DIR/clientsTable" "/opt/amnezia/awg/clientsTable" "/opt/amnezia/awg2/clientsTable"; do
|
||||
if [ -f "$c" ]; then CLIENTS="$c"; break; fi
|
||||
done
|
||||
[ -n "$CLIENTS" ] || exit 11
|
||||
PSK=""
|
||||
for p in "$DIR/wireguard_psk.key" "/opt/amnezia/awg/wireguard_psk.key"; do
|
||||
if [ -f "$p" ]; then PSK="$p"; break; fi
|
||||
done
|
||||
printf '%s|%s|%s\\n' "$CONF" "$CLIENTS" "$PSK"
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
const out = execFileSync("docker", ["exec", containerName, "sh", "-c", script], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf8",
|
||||
timeout: 12000,
|
||||
}).trimEnd();
|
||||
const line = (out.split(/\r?\n/).find((l) => l.includes("|")) ?? "").trim();
|
||||
const [confPath = "", clientsPath = "", pskPath = ""] = line.split("|").map((s) => s.trim());
|
||||
if (!confPath || !clientsPath) return null;
|
||||
return {
|
||||
confPath,
|
||||
clientsPath,
|
||||
...(pskPath ? { pskPath } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Авто-обнаружение профилей `amnezia-awg*` до старта PRO `install.sh`.
|
||||
* Возвращает JSON для `-e AWG_PROFILES=...` или пустую строку при сбое.
|
||||
*
|
||||
* Для каждого контейнера ищутся реальные `confPath`/`clientsTable` (не только дефолт `awg0.conf`).
|
||||
*
|
||||
* Если запущен ровно один `amnezia-awg*`, профиль один — переключатель в UI может не понадаться,
|
||||
* но верные пути всё равно попадают в панель.
|
||||
*/
|
||||
function autoDiscoverAwgProfilesJson() {
|
||||
if (envTruthy(process.env.COMMUNITY_DISABLE_AWG_PROFILES_AUTODETECT)) return "";
|
||||
if (process.env.AWG_PROFILES?.trim()) return process.env.AWG_PROFILES.trim();
|
||||
try {
|
||||
const namesOut = execFileSync(
|
||||
"docker",
|
||||
["ps", "--format", "{{.Names}}", "--filter", "name=^amnezia-awg"],
|
||||
{ stdio: ["ignore", "pipe", "ignore"], timeout: 5000 },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const names = namesOut.split("\n").map((x) => x.trim()).filter(Boolean);
|
||||
if (names.length === 0) return "";
|
||||
const entries = names
|
||||
.map((name) => {
|
||||
let firstUdp = null;
|
||||
let portLabel = "";
|
||||
try {
|
||||
const ports = execFileSync("docker", ["port", name], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 5000,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
const udpLine = ports.split("\n").find((l) => /\/udp\b/.test(l));
|
||||
if (udpLine) {
|
||||
const m = udpLine.match(/^(\d+)\/udp/);
|
||||
if (m) {
|
||||
firstUdp = Number(m[1]);
|
||||
portLabel = ` (${m[1]})`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
const idSuffix = name.replace(/^amnezia-/, "") || name;
|
||||
return {
|
||||
id: idSuffix,
|
||||
label: `AmneziaWG${portLabel}`,
|
||||
container: name,
|
||||
firstUdp: firstUdp ?? 1 << 30,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.firstUdp - b.firstUdp || a.container.localeCompare(b.container));
|
||||
const resolved = [];
|
||||
for (const row of entries) {
|
||||
const probed = probeAmneziaTunnelPaths(row.container);
|
||||
if (!probed) {
|
||||
console.warn(
|
||||
`autoDiscover AWG: в контейнере ${row.container} не найдены conf/clientsTable — пропуск авто-AWG_PROFILES`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
resolved.push({
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
container: row.container,
|
||||
confPath: probed.confPath,
|
||||
clientsPath: probed.clientsPath,
|
||||
...(probed.pskPath ? { pskPath: probed.pskPath } : {}),
|
||||
});
|
||||
}
|
||||
return JSON.stringify(resolved);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function privateInstallScriptUrlLogged() {
|
||||
try {
|
||||
const u = new URL(COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL);
|
||||
return `${u.hostname}${u.pathname}`;
|
||||
} catch {
|
||||
return "(invalid PRIVATE_INSTALL_SCRIPT_URL)";
|
||||
}
|
||||
}
|
||||
|
||||
function requireProTier(_req, res, next) {
|
||||
if (!IS_COMMUNITY) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
rejectCommunityProOnly(res);
|
||||
rejectUnavailableFeature(res);
|
||||
}
|
||||
|
||||
function runtimeFromExportRequest(req) {
|
||||
@@ -1693,6 +1475,8 @@ const MSG_UI_WARP_OFF = "Раздел Cloudflare 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 MSG_WARP_CF_HELP =
|
||||
"Официальный cloudflare-warp (warp-cli SOCKS5): нужен sshpass в образе панели и вход root по паролю на хосте (TIME_SYNC_SSH_HOST, часто 172.17.0.1). Скрипт: scripts/warp-install-cf.sh";
|
||||
|
||||
const MTPRO_CONTAINER = (process.env.MTPRO_PROXY_CONTAINER || "mtproto-proxy").trim();
|
||||
const MTPRO_IMAGE = (process.env.MTPRO_PROXY_IMAGE || "telegrammessenger/proxy:latest").trim();
|
||||
@@ -1842,378 +1626,6 @@ if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade || UI_HIDDEN.mtproto)
|
||||
`UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade} mtproto=${UI_HIDDEN.mtproto}`,
|
||||
);
|
||||
}
|
||||
if (IS_COMMUNITY) {
|
||||
console.warn(`Редакция community (просмотр клиентов и удаление). Остальное — PRO: ${COMMUNITY_UPGRADE_URL}`);
|
||||
}
|
||||
if (ALLOW_COMMUNITY_GITHUB_ACTIVATION && IS_COMMUNITY) {
|
||||
console.warn(
|
||||
"Установка PRO из панели включена (по умолчанию в FREE). Отключить: ALLOW_COMMUNITY_GITHUB_ACTIVATION=0. GitHub-токен не сохранять в журналах; ограничьте доступ к паролю панели и Docker-сокету.",
|
||||
);
|
||||
} else if (IS_COMMUNITY) {
|
||||
console.warn(
|
||||
"Установка PRO из панели отключена: ALLOW_COMMUNITY_GITHUB_ACTIVATION=0|false|no|off.",
|
||||
);
|
||||
}
|
||||
app.use(express.json({ limit: "512kb" }));
|
||||
|
||||
app.post("/api/community/run-private-install", requireAuth, async (req, res) => {
|
||||
if (!IS_COMMUNITY || !ALLOW_COMMUNITY_GITHUB_ACTIVATION) {
|
||||
return res.status(403).json({
|
||||
error: "Запуск установки PRO из панели отключён.",
|
||||
});
|
||||
}
|
||||
if (communityPrivateInstallBusy) {
|
||||
return res.status(429).json({
|
||||
error: "Установка PRO уже выполняется. Через 1–2 минуты обновите страницу или см. журнал ниже.",
|
||||
});
|
||||
}
|
||||
|
||||
const token = typeof req.body?.githubToken === "string" ? req.body.githubToken.trim() : "";
|
||||
if (!validateGithubBearerToken(token)) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Нужен GitHub-токен с доступом к приватному репозиторию (classic: repo; fine-grained: Contents Read для репозитория со scripts/install.sh).",
|
||||
});
|
||||
}
|
||||
|
||||
let tmpDir = "";
|
||||
communityPrivateInstallBusy = true;
|
||||
|
||||
try {
|
||||
const ac = new AbortController();
|
||||
const timeoutMs = Number(process.env.COMMUNITY_INSTALL_FETCH_MS || 60_000) || 60_000;
|
||||
const tmo = setTimeout(() => ac.abort(), timeoutMs);
|
||||
const ghRes = await fetch(COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL, {
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "*/*",
|
||||
"User-Agent": "amnezia-web-community-private-install",
|
||||
},
|
||||
signal: ac.signal,
|
||||
}).finally(() => clearTimeout(tmo));
|
||||
|
||||
const buf = Buffer.from(await ghRes.arrayBuffer());
|
||||
if (!ghRes.ok || buf.byteLength === 0) {
|
||||
communityPrivateInstallBusy = false;
|
||||
return res.status(400).json({
|
||||
error: `Не удалось скачать install (${ghRes.status}). Проверьте токен и URL.`,
|
||||
urlForDebug: privateInstallScriptUrlLogged(),
|
||||
});
|
||||
}
|
||||
if (buf.byteLength > PRIVATE_INSTALL_SCRIPT_MAX_BYTES) {
|
||||
communityPrivateInstallBusy = false;
|
||||
return res.status(400).json({ error: "Скачанный скрипт слишком большой — отказ." });
|
||||
}
|
||||
|
||||
const stageRoot = path.join(DATA_DIR, ".priv-install-tmp");
|
||||
try {
|
||||
fs.mkdirSync(stageRoot, { recursive: true });
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
tmpDir = fs.mkdtempSync(path.join(stageRoot, "amnezia-priv-inst-"));
|
||||
const scriptPath = path.join(tmpDir, "install.sh");
|
||||
fs.writeFileSync(scriptPath, buf);
|
||||
fs.chmodSync(scriptPath, 0o700);
|
||||
|
||||
ensureDataDir();
|
||||
const logAbs = path.join(DATA_DIR, "community-install-last.log");
|
||||
const header = `\n${"=".repeat(60)}\n${new Date().toISOString()} — старт install из UI (COMMUNITY)\nURL: ${COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL}\n${"=".repeat(60)}\n`;
|
||||
fs.appendFileSync(logAbs, header);
|
||||
|
||||
const useDockerCliHelper = !COMMUNITY_DISABLE_DOCKER_CLI_HELPER;
|
||||
const helperImage =
|
||||
process.env.COMMUNITY_PRO_INSTALL_HELPER_IMAGE?.trim() || "docker:26-cli";
|
||||
const freePanelName = shellSafeDockerContainerName(
|
||||
process.env.FREE_PANEL_CONTAINER_FOR_PRO_INSTALL,
|
||||
"amnezia-admin",
|
||||
);
|
||||
const freeLandingName = shellSafeDockerContainerName(
|
||||
process.env.FREE_LANDING_CONTAINER_FOR_PRO_INSTALL,
|
||||
"amnezia-web-landing",
|
||||
);
|
||||
const staleProName = shellSafeDockerContainerName(
|
||||
process.env.STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL,
|
||||
"amnezia-admin-pro",
|
||||
);
|
||||
|
||||
if (useDockerCliHelper) {
|
||||
fs.appendFileSync(
|
||||
logAbs,
|
||||
`\n→ Одноразовый установщик: ${helperImage}; FREE=${freePanelName}, лендинг=${freeLandingName}, удаление зависшего PRO=${staleProName}. Отключить слой: COMMUNITY_DISABLE_DOCKER_CLI_HELPER=1\n`,
|
||||
);
|
||||
} else {
|
||||
fs.appendFileSync(
|
||||
logAbs,
|
||||
"\n→ COMMUNITY_DISABLE_DOCKER_CLI_HELPER=1 — install идёт в том же контейнере; FREE не удаляется до скрипта (частый конфликт :8080 с PRO).\n",
|
||||
);
|
||||
}
|
||||
|
||||
const logStream = useDockerCliHelper ? null : fs.createWriteStream(logAbs, { flags: "a" });
|
||||
|
||||
let finalized = false;
|
||||
const finalize = (code = null, signal = null, errText = "") => {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
communityPrivateInstallBusy = false;
|
||||
const foot = `\n--- завершено ${new Date().toISOString()}, code=${code ?? "?"}${
|
||||
signal ? ` signal=${signal}` : ""
|
||||
}${errText ? ` err=${errText}` : ""} ---\n`;
|
||||
try {
|
||||
fs.appendFileSync(logAbs, foot);
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
const rmTmp = () => {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
};
|
||||
if (logStream) {
|
||||
logStream.end(() => rmTmp());
|
||||
} else {
|
||||
rmTmp();
|
||||
}
|
||||
};
|
||||
|
||||
if (useDockerCliHelper) {
|
||||
const dataHost = HOST_DATA_DIR;
|
||||
const relStage = path.relative(DATA_DIR, tmpDir);
|
||||
const stageInHelper = `/mnt/data/${relStage}`;
|
||||
const autoAwgProfilesJson = autoDiscoverAwgProfilesJson();
|
||||
if (autoAwgProfilesJson) {
|
||||
fs.appendFileSync(
|
||||
logAbs,
|
||||
`→ AWG_PROFILES для PRO собран автоматически из живых amnezia-awg* контейнеров: ${autoAwgProfilesJson}\n`,
|
||||
);
|
||||
}
|
||||
const prelude = COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO
|
||||
? "sleep 2"
|
||||
: [
|
||||
"sleep 3",
|
||||
`docker rm -f ${freePanelName} ${freeLandingName} 2>/dev/null || true`,
|
||||
`docker rm -f ${staleProName} 2>/dev/null || true`,
|
||||
'for __amz_hp in $(docker ps -aq --filter name=amnezia-admin-pro 2>/dev/null); do docker rm -f "$__amz_hp" 2>/dev/null || true; done',
|
||||
].join("; ");
|
||||
const fetchTools = dockerCliHelperEnsureFetchToolsScript();
|
||||
const sh = `
|
||||
exec >>"/mnt/data/community-install-last.log" 2>&1
|
||||
set -eu
|
||||
echo "→ helper (docker cli): prelude + bash/curl..."
|
||||
echo " staging: ${stageInHelper} (host: ${tmpDir.replace(DATA_DIR, dataHost)})"
|
||||
[ -f "${stageInHelper}/install.sh" ] || { echo "⚠ helper: нет install.sh в ${stageInHelper} (проверьте монтирование ${dataHost}:/mnt/data)"; ls -la "${stageInHelper}" 2>&1 || true; exit 125; }
|
||||
${prelude}
|
||||
${fetchTools ? `${fetchTools}\n` : ""}command -v bash >/dev/null 2>&1 || { echo "⚠ helper: нет bash после apk/apt — см. ошибки apk выше"; exit 126; }
|
||||
command -v curl >/dev/null 2>&1 || { echo "⚠ helper: нет curl после apk/apt"; exit 126; }
|
||||
command -v openssl >/dev/null 2>&1 || { echo "⚠ helper: нет openssl после apk/apt — install.sh падает на генерации пароля"; exit 126; }
|
||||
command -v tar >/dev/null 2>&1 || { echo "⚠ helper: нет tar после apk/apt — install.sh падает на распаковке релиза"; exit 126; }
|
||||
set +e
|
||||
( cd ${stageInHelper} && bash ./install.sh )
|
||||
rv=$?
|
||||
set -e
|
||||
echo ""
|
||||
echo "--- helper завершён code=\${rv} (\$(date -u +%Y-%m-%dT%H:%M:%SZ) UTC) ---"
|
||||
exit "\${rv}"
|
||||
`.trim();
|
||||
|
||||
const dr = spawn(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"--pull=missing",
|
||||
"-v",
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
`-v`,
|
||||
`${dataHost}:/mnt/data`,
|
||||
"-e",
|
||||
"GITHUB_TOKEN",
|
||||
"-e",
|
||||
"GH_TOKEN",
|
||||
"-e",
|
||||
"GIT_TERMINAL_PROMPT",
|
||||
...(autoAwgProfilesJson ? ["-e", "AWG_PROFILES"] : []),
|
||||
helperImage,
|
||||
"sh",
|
||||
"-lc",
|
||||
sh,
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_TOKEN: token,
|
||||
GH_TOKEN: token,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
...(autoAwgProfilesJson ? { AWG_PROFILES: autoAwgProfilesJson } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
dr.unref();
|
||||
dr.once("exit", (code, signal) => finalize(code, signal));
|
||||
dr.once("error", (err) => {
|
||||
console.warn("community-private-install docker-cli helper:", err?.message || err);
|
||||
finalize(null, null, String(err.message || err));
|
||||
});
|
||||
} else {
|
||||
const child = spawn("bash", [scriptPath], {
|
||||
cwd: tmpDir,
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_TOKEN: token,
|
||||
GH_TOKEN: token,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.pipe(logStream, { end: false });
|
||||
child.stderr.pipe(logStream, { end: false });
|
||||
child.once("exit", (code, signal) => finalize(code, signal));
|
||||
child.once("error", (err) => {
|
||||
console.warn("community-private-install bash:", err?.message || err);
|
||||
finalize(null, null, String(err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
res.status(202).json({
|
||||
ok: true,
|
||||
queued: true,
|
||||
message: useDockerCliHelper
|
||||
? "Установка PRO: отдельный контейнер сначала снимает FREE (освобождается порт панели) и зависший PRO при наличии, затем выполняется install.sh. Подождите 3–10 мин и откройте адрес снова. Журнал: на VPS каталог данных панели (часто /opt/amnezia-admin-data/community-install-last.log)."
|
||||
: "Режим без docker-helper: при «port already allocated» снимите FREE вручную. Журнал: /data/community-install-last.log.",
|
||||
logInsideContainer: "community-install-last.log",
|
||||
});
|
||||
} catch (e) {
|
||||
communityPrivateInstallBusy = false;
|
||||
if (tmpDir) {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
const msg =
|
||||
e?.name === "AbortError"
|
||||
? "Таймаут загрузки install.sh с GitHub."
|
||||
: e?.message || String(e);
|
||||
return res.status(400).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
const COMMUNITY_INSTALL_LOG_BASENAME = "community-install-last.log";
|
||||
|
||||
function clampCommunityInstallLogRange(name, fallback, minV, maxV) {
|
||||
const x = Number(process.env[name]);
|
||||
if (!Number.isFinite(x)) return fallback;
|
||||
return Math.min(Math.max(Math.trunc(x), minV), maxV);
|
||||
}
|
||||
|
||||
/**
|
||||
* Прочитать кусок `community-install-last.log`: без `since` — хвост (для первого экрана);
|
||||
* с `since` — блок байт начиная с offset (до CHUNK байт за запрос).
|
||||
*/
|
||||
app.get("/api/community/install-log", requireAuth, (req, res) => {
|
||||
if (!IS_COMMUNITY || !ALLOW_COMMUNITY_GITHUB_ACTIVATION) {
|
||||
return res.status(403).json({ error: "Недоступно." });
|
||||
}
|
||||
const TAIL_BYTES = clampCommunityInstallLogRange(
|
||||
"COMMUNITY_INSTALL_LOG_TAIL_BYTES",
|
||||
96 * 1024,
|
||||
1024,
|
||||
512 * 1024,
|
||||
);
|
||||
const MAX_CHUNK = clampCommunityInstallLogRange(
|
||||
"COMMUNITY_INSTALL_LOG_API_CHUNK_MAX",
|
||||
512 * 1024,
|
||||
4096,
|
||||
2 * 1024 * 1024,
|
||||
);
|
||||
|
||||
const logPath = path.join(DATA_DIR, COMMUNITY_INSTALL_LOG_BASENAME);
|
||||
|
||||
const sendAbsent = () =>
|
||||
res.json({
|
||||
exists: false,
|
||||
totalBytes: 0,
|
||||
chunk: "",
|
||||
since0: 0,
|
||||
since1: 0,
|
||||
resetSuggested: false,
|
||||
});
|
||||
|
||||
if (!fs.existsSync(logPath)) {
|
||||
sendAbsent();
|
||||
return;
|
||||
}
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(logPath);
|
||||
if (!stat.isFile()) {
|
||||
sendAbsent();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
return res.status(500).json({ error: "Не удалось прочитать журнал установки." });
|
||||
}
|
||||
|
||||
const totalBytes = Number(stat.size) || 0;
|
||||
let sinceParsed = NaN;
|
||||
const q = req.query.since;
|
||||
if (typeof q === "string" && q.trim() !== "") {
|
||||
sinceParsed = Number(q.trim());
|
||||
}
|
||||
let resetSuggested = false;
|
||||
let sinceByte;
|
||||
if (!Number.isFinite(sinceParsed) || sinceParsed < 0) {
|
||||
sinceByte = Math.max(0, totalBytes - TAIL_BYTES);
|
||||
} else if (sinceParsed > totalBytes) {
|
||||
resetSuggested = true;
|
||||
sinceByte = Math.max(0, totalBytes - TAIL_BYTES);
|
||||
} else {
|
||||
sinceByte = sinceParsed;
|
||||
}
|
||||
|
||||
const readable = Math.max(0, totalBytes - sinceByte);
|
||||
const readLen = Math.min(readable, MAX_CHUNK);
|
||||
if (readLen === 0 || totalBytes <= 0) {
|
||||
res.json({
|
||||
exists: true,
|
||||
totalBytes,
|
||||
chunk: "",
|
||||
since0: sinceByte,
|
||||
since1: sinceByte,
|
||||
resetSuggested,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let chunkBuf = Buffer.alloc(readLen);
|
||||
try {
|
||||
const fd = fs.openSync(logPath, "r");
|
||||
try {
|
||||
fs.readSync(fd, chunkBuf, 0, readLen, sinceByte);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return res.status(500).json({ error: "Ошибка чтения журнала установки." });
|
||||
}
|
||||
|
||||
res.json({
|
||||
exists: true,
|
||||
totalBytes,
|
||||
chunk: chunkBuf.toString("utf8"),
|
||||
since0: sinceByte,
|
||||
since1: sinceByte + readLen,
|
||||
resetSuggested,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ ok: true, version: PANEL_VERSION });
|
||||
});
|
||||
@@ -2262,19 +1674,10 @@ app.get("/api/server-time", requireAuth, (req, res) => {
|
||||
});
|
||||
|
||||
app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => {
|
||||
if (IS_COMMUNITY) {
|
||||
res.json({
|
||||
hostTimeSync: false,
|
||||
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
|
||||
serverClockTimeZone: resolveServerClockTimeZone(),
|
||||
communityBlocked: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
hostTimeSync: hostTimeSyncConfigured(),
|
||||
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
|
||||
serverClockTimeZone: resolveServerClockTimeZone(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2520,6 +1923,166 @@ app.post("/api/warp/host-setup", requireAuth, requireProTier, async (req, res) =
|
||||
}
|
||||
});
|
||||
|
||||
function safeSshInstallDirOrDefault() {
|
||||
try {
|
||||
return assertSafeUnixPath(process.env.WARP_SSH_INSTALL_DIR?.trim() || "/opt/amnezia-admin");
|
||||
} catch {
|
||||
return "/opt/amnezia-admin";
|
||||
}
|
||||
}
|
||||
|
||||
function assertPort512_65535(raw, fallback = 40000) {
|
||||
if (raw == null || raw === "") return fallback;
|
||||
const n = typeof raw === "number" ? raw : Number.parseInt(String(raw).trim(), 10);
|
||||
if (!Number.isFinite(n) || n < 512 || n > 65535) {
|
||||
throw new Error("Порт: целое 512 … 65535.");
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
async function sshRunWarpCf(pw, action, { socksPort, licenseKey } = {}) {
|
||||
const host = process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1";
|
||||
const installDir = safeSshInstallDirOrDefault();
|
||||
const port = socksPort != null ? assertPort512_65535(socksPort, 40000) : 40000;
|
||||
const key = typeof licenseKey === "string" ? licenseKey.trim() : "";
|
||||
|
||||
let scriptCall = "";
|
||||
if (action === "install") scriptCall = `./scripts/warp-install-cf.sh install ${port}`;
|
||||
if (action === "uninstall") scriptCall = `./scripts/warp-install-cf.sh uninstall`;
|
||||
if (action === "status") scriptCall = `./scripts/warp-install-cf.sh status`;
|
||||
if (action === "license") {
|
||||
if (!key) throw new Error("WARP+ ключ обязателен.");
|
||||
const q = key.replace(/'/g, "'\\''");
|
||||
scriptCall = `./scripts/warp-install-cf.sh license '${q}'`;
|
||||
}
|
||||
if (!scriptCall) throw new Error("bad action");
|
||||
|
||||
const remoteCmd = `bash -lc 'cd ${installDir} && chmod +x scripts/warp-install-cf.sh 2>/dev/null || true && ${scriptCall}'`;
|
||||
return await sshRootRun(pw.trim(), host, remoteCmd);
|
||||
}
|
||||
|
||||
app.post("/api/warp-cf/install", requireAuth, requireProTier, async (req, res) => {
|
||||
const pw = req.body?.rootPassword;
|
||||
if (typeof pw !== "string" || !pw.trim()) return res.status(400).json({ error: "Укажите пароль root VPS" });
|
||||
if (!hostTimeSyncConfigured()) {
|
||||
return res.status(503).json({ error: `Недоступно: нет sshpass или TIME_SYNC_DISABLED=1. ${MSG_WARP_CF_HELP}` });
|
||||
}
|
||||
try {
|
||||
const socksPort = assertPort512_65535(req.body?.socksPort, 40000);
|
||||
const out = await sshRunWarpCf(pw, "install", { socksPort });
|
||||
res.json({ ok: true, output: out.slice(0, 12_000), socksPort });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/warp-cf/uninstall", requireAuth, requireProTier, async (req, res) => {
|
||||
const pw = req.body?.rootPassword;
|
||||
if (typeof pw !== "string" || !pw.trim()) return res.status(400).json({ error: "Укажите пароль root VPS" });
|
||||
if (!hostTimeSyncConfigured()) {
|
||||
return res.status(503).json({ error: `Недоступно: нет sshpass или TIME_SYNC_DISABLED=1. ${MSG_WARP_CF_HELP}` });
|
||||
}
|
||||
try {
|
||||
const out = await sshRunWarpCf(pw, "uninstall");
|
||||
res.json({ ok: true, output: out.slice(0, 12_000) });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/warp-cf/license", requireAuth, requireProTier, async (req, res) => {
|
||||
const pw = req.body?.rootPassword;
|
||||
const key = req.body?.licenseKey;
|
||||
if (typeof pw !== "string" || !pw.trim()) return res.status(400).json({ error: "Укажите пароль root VPS" });
|
||||
if (typeof key !== "string" || !key.trim()) return res.status(400).json({ error: "Укажите WARP+ ключ" });
|
||||
if (!hostTimeSyncConfigured()) {
|
||||
return res.status(503).json({ error: `Недоступно: нет sshpass или TIME_SYNC_DISABLED=1. ${MSG_WARP_CF_HELP}` });
|
||||
}
|
||||
try {
|
||||
const out = await sshRunWarpCf(pw, "license", { licenseKey: key });
|
||||
res.json({ ok: true, output: out.slice(0, 12_000) });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/warp-cf/status", requireAuth, requireProTier, async (req, res) => {
|
||||
const pw = req.body?.rootPassword;
|
||||
if (typeof pw !== "string" || !pw.trim()) return res.status(400).json({ error: "Укажите пароль root VPS" });
|
||||
if (!hostTimeSyncConfigured()) {
|
||||
return res.status(503).json({ error: `Недоступно: нет sshpass или TIME_SYNC_DISABLED=1. ${MSG_WARP_CF_HELP}` });
|
||||
}
|
||||
try {
|
||||
const raw = await sshRunWarpCf(pw, "status");
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(String(raw || "").trim());
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return res.json({
|
||||
installed: false,
|
||||
running: false,
|
||||
mode: "",
|
||||
proxyPort: null,
|
||||
account: "",
|
||||
rawStatus: String(raw || "").slice(0, 12_000),
|
||||
rawAccount: "",
|
||||
});
|
||||
}
|
||||
res.json(parsed);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/warp-cf/xray-apply", requireAuth, requireProTier, async (req, res) => {
|
||||
const pw = req.body?.rootPassword;
|
||||
const filePath = req.body?.filePath;
|
||||
const socksAddress = req.body?.socksAddress;
|
||||
if (typeof pw !== "string" || !pw.trim()) return res.status(400).json({ error: "Укажите пароль root VPS" });
|
||||
if (typeof filePath !== "string" || !filePath.trim()) return res.status(400).json({ error: "Укажите filePath" });
|
||||
if (typeof socksAddress !== "string" || !socksAddress.trim()) {
|
||||
return res.status(400).json({ error: "Укажите socksAddress (127.0.0.1 или 172.17.0.1)" });
|
||||
}
|
||||
if (!hostTimeSyncConfigured()) {
|
||||
return res.status(503).json({ error: `Недоступно: нет sshpass или TIME_SYNC_DISABLED=1. ${MSG_WARP_CF_HELP}` });
|
||||
}
|
||||
let port;
|
||||
try {
|
||||
port = assertPort512_65535(req.body?.socksPort, 40000);
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: String(e.message || e) });
|
||||
}
|
||||
const domains = typeof req.body?.domains === "string" ? req.body.domains.trim() : "";
|
||||
try {
|
||||
const host = process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1";
|
||||
const installDir = safeSshInstallDirOrDefault();
|
||||
const f = assertSafeUnixPath(filePath.trim());
|
||||
const addr = socksAddress.trim();
|
||||
if (!/^(127\.0\.0\.1|172\.17\.0\.1)$/.test(addr)) {
|
||||
return res.status(400).json({ error: "socksAddress: разрешено только 127.0.0.1 или 172.17.0.1" });
|
||||
}
|
||||
const dom = domains.replace(/'/g, "'\\''");
|
||||
const remoteCmd =
|
||||
`bash -lc 'cd ${installDir} && chmod +x scripts/xray-warp-apply.py 2>/dev/null || true && ` +
|
||||
`python3 ./scripts/xray-warp-apply.py --file '${f.replace(/'/g, "'\\''")}' --address '${addr}' --port ${port}` +
|
||||
(dom ? ` --domains '${dom}'` : "") +
|
||||
`'`;
|
||||
const out = await sshRootRun(pw.trim(), host, remoteCmd);
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(String(out || "").trim());
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
res.json(parsed || { ok: true, raw: String(out || "").slice(0, 12_000) });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: String(e.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/login", (req, res) => {
|
||||
const pw = req.body?.password;
|
||||
if (typeof pw !== "string" || !pw) {
|
||||
@@ -2566,9 +2129,7 @@ app.get("/api/protocols", requireAuth, (req, res) => {
|
||||
const rt = runtimeForRequest(req);
|
||||
const hintSingle =
|
||||
PROFILES.length < 2
|
||||
? IS_COMMUNITY
|
||||
? "Один инстанс в интерфейсе. Несколько контейнеров и профиль AWG_PROFILES — в полной панели PRO."
|
||||
: "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json."
|
||||
? "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json."
|
||||
: "";
|
||||
res.json({
|
||||
currentId: rt.profile.id,
|
||||
@@ -2668,14 +2229,8 @@ app.get("/api/clients", requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
async function serveClientConfigExport(req, res) {
|
||||
if (IS_COMMUNITY) {
|
||||
res.status(403).json({
|
||||
error: "Экспорт .conf доступен в версии PRO.",
|
||||
upgradeRequired: true,
|
||||
upgradeUrl: COMMUNITY_UPGRADE_URL,
|
||||
});
|
||||
rejectUnavailableFeature(res);
|
||||
return;
|
||||
}
|
||||
const tokenOk =
|
||||
req.method === "GET" &&
|
||||
verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "");
|
||||
@@ -3131,7 +2686,7 @@ app.use((req, res) => {
|
||||
error: "Not found",
|
||||
path: `${req.originalUrl || req.path}`,
|
||||
hint:
|
||||
"Эндпоинта нет на этом процессе. Проверьте версию приложения через GET /health на том же хосту:порту, что панель, и что HTTP-прокси (если есть) проксирует префикс /api/*. После перехода на PRO пересоберите образ amnezia-admin из актуального релиза репозитория.",
|
||||
"Эндпоинта нет на этом процессе. Проверьте версию приложения через GET /health на том же хосту:порту, что панель, и что HTTP-прокси (если есть) проксирует префикс /api/*. Пересоберите образ amnezia-admin из актуального релиза репозитория.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -3144,13 +2699,7 @@ app.listen(PORT, "0.0.0.0", () => {
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (!IS_COMMUNITY) {
|
||||
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
||||
}
|
||||
/* scheduled disconnects not available in basic panel */
|
||||
}, SCHEDULER_MS);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!IS_COMMUNITY) {
|
||||
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user