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`**, часы сервера и браузера, смена пароля панели.
|
Веб‑панель **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
|
### Что умеет Amnezia Web
|
||||||
|
|
||||||
* **Управление через браузер** — таблица клиентов AmneziaWG, время сервера и браузера, быстрые действия там, где редакция это позволяет (FREE vs PRO).
|
* **Управление через браузер** — таблица клиентов AmneziaWG, время сервера и браузера, удаление peer, установка MTProto‑прокси.
|
||||||
* **Быстрое развёртывание панели** — образ админки и опционально страницы для пользователей через **`scripts/install.sh`**.
|
* **Быстрое развёртывание панели** — образ админки и опционально страницы для пользователей через **`scripts/install.sh`**.
|
||||||
* **Приватность** — всё крутится **на вашем VPS**; доступ к панели ограничиваете паролем (и при желании сетью/TLS).
|
* **Приватность** — всё крутится **на вашем VPS**; доступ к панели ограничиваете паролем (и при желании сетью/TLS).
|
||||||
* **Удобство** — одно место для обзора клиентов и (в FREE) установки **MTProto‑прокси** из интерфейса.
|
* **Удобство** — одно место для обзора клиентов и (в FREE) установки **MTProto‑прокси** из интерфейса.
|
||||||
* **Несколько инстансов AWG на одном хосте** — переключатель профилей через **`AWG_PROFILES`**.
|
* **Несколько инстансов AWG на одном хосте** — переключатель профилей через **`AWG_PROFILES`**.
|
||||||
* **Современный стек** — Docker, Node.js, веб‑интерфейс.
|
* **Современный стек** — 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">
|
<p align="center">
|
||||||
<img src="docs/screenshots/panel-users-table.png" alt="Панель: пользователи AmneziaWG, выбор инстанса, время, статусы в туннеле" width="780"/>
|
<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
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Форк или ветка (**с тем же режимом маркера по желанию**):
|
Смена порта (пример):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export GITHUB_REPO="вы/репо"
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh \
|
||||||
export BRANCH="main"
|
| sudo -E env HOST_PORT=8884 LANDING_PORT=8081 bash
|
||||||
export INSTALL_FREE_COMMUNITY_ACTIVATION=1
|
|
||||||
curl -fsSL "https://raw.githubusercontent.com/${GITHUB_REPO}/${BRANCH}/scripts/install.sh" | sudo -E 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
|
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}`**).
|
После сообщения **`→ Клонирование релиза …`** несколько минут **может ничего не печататься**: идёт `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`**.
|
Принудительная пересборка образа: **`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 (если доступно). |
|
| **Время** | Отображение часов контейнера и браузера; синхронизация времени хоста VPS по SSH (если доступно). |
|
||||||
| **Cloudflare WARP** | *Необязательно.* Вывод части клиентов в интернет через интерфейс `warp` в контейнере AWG. Если WARP не ставили — статус **«Не установлен»** нормален; панель и VPN без этого работают. Установка и удаление — скрипт `scripts/warp-amnezia.sh` на хосте (`install` / **`uninstall`**), подробности в основном [README](../README.md). |
|
| **Cloudflare WARP** | *Необязательно.* Вывод части клиентов в интернет через интерфейс `warp` в контейнере AWG. Если WARP не ставили — статус **«Не установлен»** нормален; панель и VPN без этого работают. Установка и удаление — скрипт `scripts/warp-amnezia.sh` на хосте (`install` / **`uninstall`**), подробности в основном [README](../README.md). |
|
||||||
| **Новый клиент под каскад** | Создание нового peer на сервере с **вашим Endpoint** (промежуточный узел); выдача готового `.conf`. |
|
| **Новый клиент под каскад** | Создание нового peer на сервере с **вашим Endpoint** (промежуточный узел); выдача готового `.conf`. |
|
||||||
| **Пользователи** | В FREE: просмотр и **удаление** peer с сервера. В PRO дополнительно: вкл/выкл, переименование, даты отключения; экспорт `.conf` если в записи есть `userData.last_config`. |
|
| **Пользователи** | Просмотр клиентов и **удаление** peer с сервера. Вкл/выкл, переименование, даты отключения и экспорт `.conf` в базовой версии недоступны. |
|
||||||
|
|
||||||
## Переключатель «Инстанс» не отображается
|
## Переключатель «Инстанс» не отображается
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "amnezia-admin",
|
"name": "amnezia-admin",
|
||||||
"version": "1.2.29",
|
"version": "1.2.29",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "amnezia_web — открытая веб-панель AmneziaWG на VPS (Docker): FREE — просмотр/удаление клиентов, MTProto-прокси; PRO — полное управление и обновления.",
|
"description": "amnezia_web — открытая базовая веб-панель AmneziaWG на VPS (Docker): просмотр и удаление клиентов, MTProto-прокси.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
583
public/app.js
583
public/app.js
@@ -21,6 +21,18 @@ const warpActionsEl = document.querySelector("#warp-actions");
|
|||||||
const warpClientListEl = document.querySelector("#warp-client-list");
|
const warpClientListEl = document.querySelector("#warp-client-list");
|
||||||
const warpWgShowEl = document.querySelector("#warp-wg-show");
|
const warpWgShowEl = document.querySelector("#warp-wg-show");
|
||||||
|
|
||||||
|
const 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 mtprotoPanel = document.querySelector("#mtproto-panel");
|
||||||
const mtprotoStatusLine = document.querySelector("#mtproto-status-line");
|
const mtprotoStatusLine = document.querySelector("#mtproto-status-line");
|
||||||
const mtprotoActionsEl = document.querySelector("#mtproto-actions");
|
const mtprotoActionsEl = document.querySelector("#mtproto-actions");
|
||||||
@@ -74,353 +86,27 @@ let warpSshPendingCmd = null;
|
|||||||
/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
|
/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
|
||||||
let uiHidden = { users: false, warp: false, cascade: false, mtproto: false };
|
let uiHidden = { users: false, warp: false, cascade: false, mtproto: false };
|
||||||
|
|
||||||
const editionBanner = document.querySelector("#edition-banner");
|
/** Базовые возможности панели (просмотр клиентов + удаление). */
|
||||||
const DEFAULT_HEADER_SUB = document.querySelector(".top .sub")?.textContent?.trim() || "";
|
|
||||||
|
|
||||||
/** 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 = {
|
let editionState = {
|
||||||
tier: "pro",
|
readOnlyClients: true,
|
||||||
readOnlyClients: false,
|
allowDeleteClients: true,
|
||||||
allowDeleteClients: false,
|
showDebugWg: false,
|
||||||
upgradeUrl: null,
|
|
||||||
upgradePitch: null,
|
|
||||||
showDebugWg: true,
|
|
||||||
githubActivationAllowed: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyEditionPayload(data) {
|
function applyEditionPayload(data) {
|
||||||
const ed = data?.edition;
|
const ed = data?.edition;
|
||||||
if (!ed || typeof ed !== "object") return;
|
if (!ed || typeof ed !== "object") return;
|
||||||
editionState = {
|
editionState = {
|
||||||
tier: ed.tier === "community" ? "community" : "pro",
|
readOnlyClients: ed.readOnlyClients !== false,
|
||||||
readOnlyClients: Boolean(ed.readOnlyClients),
|
allowDeleteClients: ed.allowDeleteClients !== false,
|
||||||
allowDeleteClients: Boolean(ed.allowDeleteClients),
|
showDebugWg: Boolean(ed.showDebugWg),
|
||||||
upgradeUrl: typeof ed.upgradeUrl === "string" ? ed.upgradeUrl : null,
|
|
||||||
upgradePitch: typeof ed.upgradePitch === "string" ? ed.upgradePitch : null,
|
|
||||||
showDebugWg: ed.showDebugWg !== false,
|
|
||||||
githubActivationAllowed: Boolean(ed.githubActivationAllowed),
|
|
||||||
};
|
};
|
||||||
const titleEl = document.querySelector(".top h1");
|
const titleEl = document.querySelector(".top h1");
|
||||||
if (titleEl) {
|
if (titleEl) titleEl.textContent = "Пользователи AmneziaWG";
|
||||||
titleEl.textContent =
|
|
||||||
editionState.tier === "community"
|
|
||||||
? "Пользователи AmneziaWG FREE"
|
|
||||||
: "Пользователи AmneziaWG PRO";
|
|
||||||
}
|
|
||||||
const subEl = document.querySelector(".top .sub");
|
const subEl = document.querySelector(".top .sub");
|
||||||
if (subEl) {
|
if (subEl) {
|
||||||
if (editionState.tier === "community") {
|
subEl.textContent =
|
||||||
subEl.textContent =
|
"Базовая панель: просмотр клиентов и статусов, удаление клиента с сервера, установка Telegram MTProto‑прокси (Docker).";
|
||||||
"Базовая панель 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 = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
document.querySelector(".clock-host-sync")?.classList.toggle("hidden", editionState.readOnlyClients);
|
document.querySelector(".clock-host-sync")?.classList.toggle("hidden", editionState.readOnlyClients);
|
||||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
||||||
@@ -627,6 +313,7 @@ function applyUiHiddenFromPayload(data) {
|
|||||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
||||||
if (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
|
if (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
|
||||||
if (warpPanel) warpPanel.hidden = uiHidden.warp;
|
if (warpPanel) warpPanel.hidden = uiHidden.warp;
|
||||||
|
if (warpCfPanel) warpCfPanel.hidden = editionState.readOnlyClients;
|
||||||
if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto;
|
if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto;
|
||||||
void refreshMtprotoPanel();
|
void refreshMtprotoPanel();
|
||||||
}
|
}
|
||||||
@@ -1090,10 +777,10 @@ async function loadTimeSyncCaps() {
|
|||||||
const btn = document.querySelector("#sync-host-time");
|
const btn = document.querySelector("#sync-host-time");
|
||||||
try {
|
try {
|
||||||
const c = await api("/api/time-sync-capabilities");
|
const c = await api("/api/time-sync-capabilities");
|
||||||
if (editionState.readOnlyClients || c.communityBlocked) {
|
if (editionState.readOnlyClients) {
|
||||||
if (hint) {
|
if (hint) {
|
||||||
hint.textContent =
|
hint.textContent =
|
||||||
"В базовой версии синхронизация времени хоста по SSH недоступна — это функция PRO.";
|
"Синхронизация времени хоста по SSH недоступна в базовой версии панели.";
|
||||||
}
|
}
|
||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
return;
|
return;
|
||||||
@@ -1216,7 +903,7 @@ function renderRows(clients) {
|
|||||||
roHint.className = "muted hint-mini";
|
roHint.className = "muted hint-mini";
|
||||||
roHint.style.margin = "0.35rem 0 0";
|
roHint.style.margin = "0.35rem 0 0";
|
||||||
roHint.textContent = c.exportAvailable
|
roHint.textContent = c.exportAvailable
|
||||||
? "На сервере есть данные для .conf — скачивание доступно в PRO."
|
? "На сервере есть данные для .conf — экспорт недоступен в базовой версии."
|
||||||
: "Нет last_config на сервере — полный конфиг в приложении Amnezia.";
|
: "Нет last_config на сервере — полный конфиг в приложении Amnezia.";
|
||||||
nameWrap.appendChild(roHint);
|
nameWrap.appendChild(roHint);
|
||||||
}
|
}
|
||||||
@@ -1258,13 +945,13 @@ function renderRows(clients) {
|
|||||||
note.className = "muted";
|
note.className = "muted";
|
||||||
note.style.fontSize = "0.85rem";
|
note.style.fontSize = "0.85rem";
|
||||||
note.style.marginBottom = "0.35rem";
|
note.style.marginBottom = "0.35rem";
|
||||||
note.textContent = "Вкл/выкл, даты, переименование, экспорт — PRO";
|
note.textContent = "Вкл/выкл, даты, переименование, экспорт недоступны";
|
||||||
actTd.appendChild(note);
|
actTd.appendChild(note);
|
||||||
actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId)));
|
actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId)));
|
||||||
} else {
|
} else {
|
||||||
const lock = document.createElement("span");
|
const lock = document.createElement("span");
|
||||||
lock.className = "muted";
|
lock.className = "muted";
|
||||||
lock.textContent = "Только PRO";
|
lock.textContent = "Недоступно";
|
||||||
actTd.appendChild(lock);
|
actTd.appendChild(lock);
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
function escapeHtml(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -1691,6 +1589,7 @@ async function loadClients() {
|
|||||||
wgShowEl.textContent = data.wgShow || "";
|
wgShowEl.textContent = data.wgShow || "";
|
||||||
}
|
}
|
||||||
renderWarpPanel(data);
|
renderWarpPanel(data);
|
||||||
|
void refreshWarpCfPanel();
|
||||||
renderRows(data.clients);
|
renderRows(data.clients);
|
||||||
setStatus("", false);
|
setStatus("", false);
|
||||||
void refreshServerClock();
|
void refreshServerClock();
|
||||||
@@ -1707,6 +1606,7 @@ async function loadClients() {
|
|||||||
wgShowEl.textContent = "";
|
wgShowEl.textContent = "";
|
||||||
peerCountEl.textContent = "";
|
peerCountEl.textContent = "";
|
||||||
if (warpPanel) warpPanel.hidden = true;
|
if (warpPanel) warpPanel.hidden = true;
|
||||||
|
if (warpCfPanel) warpCfPanel.hidden = true;
|
||||||
if (mtprotoPanel) mtprotoPanel.hidden = true;
|
if (mtprotoPanel) mtprotoPanel.hidden = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1803,4 +1703,5 @@ function initMtprotoLinkCopy() {
|
|||||||
initThemeSwitch();
|
initThemeSwitch();
|
||||||
initMtprotoLinkCopy();
|
initMtprotoLinkCopy();
|
||||||
void hydratePanelPromoFooter();
|
void hydratePanelPromoFooter();
|
||||||
|
if (warpCfPort) warpCfPort.addEventListener("input", () => redrawWarpCfPresets());
|
||||||
boot();
|
boot();
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<div class="panel-promo-links">
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<span id="panel-strip-sep-ozon-tg" class="panel-promo-sep" aria-hidden="true">·</span>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
Поддержка веб‑панелей (вне этого сервера):<br/>
|
Поддержка веб‑панелей (вне этого сервера):<br/>
|
||||||
<a href="https://github.com/andrey271192/amnezia_web" target="_blank" rel="noopener noreferrer">GitHub</a>
|
<a href="https://github.com/andrey271192/amnezia_web" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||||
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
<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>
|
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
||||||
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Донат</a>
|
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Донат</a>
|
||||||
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
<header class="top">
|
<header class="top">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Панель сервера</p>
|
<p class="eyebrow">Панель сервера</p>
|
||||||
<h1>Пользователи AmneziaWG FREE</h1>
|
<h1>Пользователи AmneziaWG</h1>
|
||||||
<p class="sub">Базовая открытая панель: просмотр клиентов AmneziaWG и (после входа) установка Telegram MTProto‑прокси в Docker без PRO. Полное управление peer, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO.</p>
|
<p class="sub">Базовая панель: просмотр клиентов AmneziaWG, удаление peer с сервера и (после входа) установка Telegram MTProto‑прокси в Docker.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="token-box">
|
<div class="token-box">
|
||||||
<div class="theme-switch" role="group" aria-label="Тема оформления">
|
<div class="theme-switch" role="group" aria-label="Тема оформления">
|
||||||
@@ -99,8 +99,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="edition-banner" class="edition-banner hidden" role="region" aria-label="Редакция панели"></div>
|
|
||||||
|
|
||||||
<section class="toolbar">
|
<section class="toolbar">
|
||||||
<div class="pill proto-pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
|
<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">
|
<div id="proto-switch" class="proto-switch hidden">
|
||||||
@@ -165,6 +163,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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>
|
<details class="panel-fold mtproto-panel" id="mtproto-panel" hidden>
|
||||||
<summary class="fold-summary">
|
<summary class="fold-summary">
|
||||||
<span class="fold-arrow" aria-hidden="true"></span>
|
<span class="fold-arrow" aria-hidden="true"></span>
|
||||||
@@ -175,7 +220,7 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<div class="panel-fold-body">
|
<div class="panel-fold-body">
|
||||||
<p class="muted warp-intro mtproto-intro">
|
<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>
|
Ссылка вида <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).
|
(переменные контейнера панели, см. install.sh README).
|
||||||
Сохраните <strong>секрет</strong>, который показывается один раз после установки — в интерфейсе он маскируется.
|
Сохраните <strong>секрет</strong>, который показывается один раз после установки — в интерфейсе он маскируется.
|
||||||
@@ -282,14 +327,14 @@
|
|||||||
<span class="support-sep" aria-hidden="true">·</span>
|
<span class="support-sep" aria-hidden="true">·</span>
|
||||||
<a href="https://github.com/andrey271192/amnezia_web" target="_blank" rel="noopener noreferrer">GitHub</a>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<a id="footer-promo-telegram" class="support-promo-link" href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">Telegram‑канал спонсора</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="support-blurb">
|
<p class="support-blurb">
|
||||||
AWG‑клиенты, статусы и MTProto‑прокси в FREE. Жёлтая шапка поддержки совпадает с подвалом; текст ссылок может подтягиваться с сервера (Boosty подписка / донат). На пользовательских лендингах блоков поддержки нет.
|
AWG‑клиенты, статусы и MTProto‑прокси. Жёлтая шапка поддержки совпадает с подвалом; текст ссылок может подтягиваться с сервера. На пользовательских лендингах блоков поддержки нет.
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -1193,133 +1193,3 @@ tr:last-child td {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-size: 0.82rem;
|
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
|
#!/usr/bin/env bash
|
||||||
# Установка Amnezia Admin WebUI одной командой (см. README).
|
# Установка Amnezia Admin WebUI одной командой (см. README).
|
||||||
# Клиентский режим: INSTALL_FREE_COMMUNITY_ACTIVATION=1 — маркер в томе данных + явно включает ALLOW в контейнере (поле PAT в FREE и так включено по умолчанию в server.js).
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
GITHUB_REPO="${GITHUB_REPO:-andrey271192/amnezia_web}"
|
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)"
|
PREV_CONTAINER_ENV="$(docker inspect "${CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null || true)"
|
||||||
fi
|
fi
|
||||||
for __reuse_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE WARP_SSH_INSTALL_DIR \
|
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
|
EXPORT_CONFIG_SECRET CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2; do
|
||||||
if [[ -z "${!__reuse_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then
|
if [[ -z "${!__reuse_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then
|
||||||
PREV_VAL=""
|
PREV_VAL=""
|
||||||
@@ -184,20 +178,6 @@ for __reuse_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE W
|
|||||||
fi
|
fi
|
||||||
done
|
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=()
|
DOCKER_BUILD_EXTRA=()
|
||||||
if [[ "${NO_CACHE:-}" == "1" ]]; then
|
if [[ "${NO_CACHE:-}" == "1" ]]; then
|
||||||
@@ -256,18 +236,8 @@ elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:
|
|||||||
RUN_ENV+=( -e "ALLOW_DEFAULT_PASSWORD=1" )
|
RUN_ENV+=( -e "ALLOW_DEFAULT_PASSWORD=1" )
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${AMNEZIA_EDITION:-}" ]]; then
|
for __ce_var in PANEL_FOOTER_DONATE_URL PANEL_FOOTER_OZON_URL PANEL_FOOTER_TELEGRAM_URL \
|
||||||
RUN_ENV+=( -e "AMNEZIA_EDITION=${AMNEZIA_EDITION}" )
|
PANEL_FOOTER_DONATE_LABEL PANEL_FOOTER_OZON_LABEL PANEL_FOOTER_TELEGRAM_LABEL PANEL_FOOTER_PROMO_SUBTITLE; do
|
||||||
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
|
|
||||||
if [[ -n "${!__ce_var:-}" ]]; then
|
if [[ -n "${!__ce_var:-}" ]]; then
|
||||||
RUN_ENV+=( -e "${__ce_var}=${!__ce_var}" )
|
RUN_ENV+=( -e "${__ce_var}=${!__ce_var}" )
|
||||||
fi
|
fi
|
||||||
|
|||||||
807
server.js
807
server.js
@@ -29,14 +29,6 @@ function envTruthy(v) {
|
|||||||
return s === "1" || s === "true" || s === "yes";
|
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` и т.д. */
|
/** Какие блоки веб-интерфейса скрыты: `UI_HIDE_SECTIONS=users,warp,cascade` или `UI_HIDE_USERS` и т.д. */
|
||||||
function resolveUiHidden() {
|
function resolveUiHidden() {
|
||||||
const raw = process.env.UI_HIDE_SECTIONS?.trim();
|
const raw = process.env.UI_HIDE_SECTIONS?.trim();
|
||||||
@@ -57,27 +49,7 @@ function resolveUiHidden() {
|
|||||||
|
|
||||||
const UI_HIDDEN = resolveUiHidden();
|
const UI_HIDDEN = resolveUiHidden();
|
||||||
|
|
||||||
const AMNEZIA_EDITION = (process.env.AMNEZIA_EDITION || "pro").trim().toLowerCase();
|
const PANEL_FOOTER_DONATE_DEFAULT = "https://boosty.to/andrey27/donate";
|
||||||
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_TELEGRAM_DEFAULT = "https://t.me/lot_andrey";
|
const PANEL_FOOTER_TELEGRAM_DEFAULT = "https://t.me/lot_andrey";
|
||||||
const PANEL_FOOTER_OZON_DEFAULT =
|
const PANEL_FOOTER_OZON_DEFAULT =
|
||||||
@@ -99,7 +71,7 @@ function sanitizeHttpsHttpUrl(raw) {
|
|||||||
function panelPromoFooterPayload() {
|
function panelPromoFooterPayload() {
|
||||||
const donateResolved =
|
const donateResolved =
|
||||||
sanitizeHttpsHttpUrl(
|
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 =
|
const telegramResolved =
|
||||||
sanitizeHttpsHttpUrl(
|
sanitizeHttpsHttpUrl(
|
||||||
@@ -130,19 +102,13 @@ function panelPromoFooterPayload() {
|
|||||||
|
|
||||||
function editionPayload() {
|
function editionPayload() {
|
||||||
return {
|
return {
|
||||||
tier: IS_COMMUNITY ? "community" : "pro",
|
readOnlyClients: true,
|
||||||
readOnlyClients: IS_COMMUNITY,
|
allowDeleteClients: true,
|
||||||
/** В FREE разрешено удаление записи клиента (остальные мутации — PRO). */
|
showDebugWg: false,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectiveUiHidden() {
|
function effectiveUiHidden() {
|
||||||
if (!IS_COMMUNITY) return { ...UI_HIDDEN };
|
|
||||||
return {
|
return {
|
||||||
users: UI_HIDDEN.users,
|
users: UI_HIDDEN.users,
|
||||||
warp: true,
|
warp: true,
|
||||||
@@ -461,199 +427,15 @@ function requireAuthOrExportToken(req, res, next) {
|
|||||||
requireAuth(req, res, next);
|
requireAuth(req, res, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejectCommunityProOnly(res) {
|
function rejectUnavailableFeature(res) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
error:
|
error:
|
||||||
"Доступно в версии PRO: полное управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста. Раздел MTProto‑прокси Telegram (Docker) в базовой панели уже без PRO.",
|
"Эта функция недоступна в базовой версии панели: полное управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста.",
|
||||||
upgradeRequired: true,
|
|
||||||
upgradeUrl: COMMUNITY_UPGRADE_URL,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Токен 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) {
|
function requireProTier(_req, res, next) {
|
||||||
if (!IS_COMMUNITY) {
|
rejectUnavailableFeature(res);
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rejectCommunityProOnly(res);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeFromExportRequest(req) {
|
function runtimeFromExportRequest(req) {
|
||||||
@@ -1693,6 +1475,8 @@ const MSG_UI_WARP_OFF = "Раздел Cloudflare WARP отключён на эт
|
|||||||
const MSG_UI_CASCADE_OFF =
|
const MSG_UI_CASCADE_OFF =
|
||||||
"Каскад отключён на этом сервере (UI_HIDE_SECTIONS / UI_HIDE_CASCADE).";
|
"Каскад отключён на этом сервере (UI_HIDE_SECTIONS / UI_HIDE_CASCADE).";
|
||||||
const MSG_UI_MTProto_OFF = "Раздел MTProto отключён (UI_HIDE_SECTIONS включает mtproto или задан UI_HIDE_MTPROTO=1).";
|
const 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_CONTAINER = (process.env.MTPRO_PROXY_CONTAINER || "mtproto-proxy").trim();
|
||||||
const MTPRO_IMAGE = (process.env.MTPRO_PROXY_IMAGE || "telegrammessenger/proxy:latest").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}`,
|
`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) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ ok: true, version: PANEL_VERSION });
|
res.json({ ok: true, version: PANEL_VERSION });
|
||||||
});
|
});
|
||||||
@@ -2262,17 +1674,8 @@ app.get("/api/server-time", requireAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/time-sync-capabilities", 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({
|
res.json({
|
||||||
hostTimeSync: hostTimeSyncConfigured(),
|
hostTimeSync: false,
|
||||||
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
|
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
|
||||||
serverClockTimeZone: resolveServerClockTimeZone(),
|
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) => {
|
app.post("/api/login", (req, res) => {
|
||||||
const pw = req.body?.password;
|
const pw = req.body?.password;
|
||||||
if (typeof pw !== "string" || !pw) {
|
if (typeof pw !== "string" || !pw) {
|
||||||
@@ -2566,9 +2129,7 @@ app.get("/api/protocols", requireAuth, (req, res) => {
|
|||||||
const rt = runtimeForRequest(req);
|
const rt = runtimeForRequest(req);
|
||||||
const hintSingle =
|
const hintSingle =
|
||||||
PROFILES.length < 2
|
PROFILES.length < 2
|
||||||
? IS_COMMUNITY
|
? "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json."
|
||||||
? "Один инстанс в интерфейсе. Несколько контейнеров и профиль AWG_PROFILES — в полной панели PRO."
|
|
||||||
: "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json."
|
|
||||||
: "";
|
: "";
|
||||||
res.json({
|
res.json({
|
||||||
currentId: rt.profile.id,
|
currentId: rt.profile.id,
|
||||||
@@ -2668,14 +2229,8 @@ app.get("/api/clients", requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function serveClientConfigExport(req, res) {
|
async function serveClientConfigExport(req, res) {
|
||||||
if (IS_COMMUNITY) {
|
rejectUnavailableFeature(res);
|
||||||
res.status(403).json({
|
return;
|
||||||
error: "Экспорт .conf доступен в версии PRO.",
|
|
||||||
upgradeRequired: true,
|
|
||||||
upgradeUrl: COMMUNITY_UPGRADE_URL,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tokenOk =
|
const tokenOk =
|
||||||
req.method === "GET" &&
|
req.method === "GET" &&
|
||||||
verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "");
|
verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "");
|
||||||
@@ -3131,7 +2686,7 @@ app.use((req, res) => {
|
|||||||
error: "Not found",
|
error: "Not found",
|
||||||
path: `${req.originalUrl || req.path}`,
|
path: `${req.originalUrl || req.path}`,
|
||||||
hint:
|
hint:
|
||||||
"Эндпоинта нет на этом процессе. Проверьте версию приложения через GET /health на том же хосту:порту, что панель, и что HTTP-прокси (если есть) проксирует префикс /api/*. После перехода на PRO пересоберите образ amnezia-admin из актуального релиза репозитория.",
|
"Эндпоинта нет на этом процессе. Проверьте версию приложения через GET /health на том же хосту:порту, что панель, и что HTTP-прокси (если есть) проксирует префикс /api/*. Пересоберите образ amnezia-admin из актуального релиза репозитория.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3144,13 +2699,7 @@ app.listen(PORT, "0.0.0.0", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!IS_COMMUNITY) {
|
/* scheduled disconnects not available in basic panel */
|
||||||
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
|
||||||
}
|
|
||||||
}, SCHEDULER_MS);
|
}, SCHEDULER_MS);
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!IS_COMMUNITY) {
|
|
||||||
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
|
||||||
}
|
|
||||||
}, 4000);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user