diff --git a/.amnezia-panel-edition b/.amnezia-panel-edition deleted file mode 100644 index 1cfafac..0000000 --- a/.amnezia-panel-edition +++ /dev/null @@ -1 +0,0 @@ -community diff --git a/README.md b/README.md index 7b77566..fb363d6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Веб‑панель **AmneziaWG** на своём VPS (**Docker**): таблица клиентов, статусы «в туннеле», AllowedIPs, несколько инстансов через **`AWG_PROFILES`**, часы сервера и браузера, смена пароля панели. -**Редакция по умолчанию — FREE (`community`):** просмотр клиентов, **удаление** peer с сервера, раздел установки **Telegram MTProto‑прокси** (Docker). Полное управление (вкл/выкл, даты, переименование, экспорт `.conf`, каскад, Cloudflare WARP, синхронизация времени и т.д.) — в **[amnezia_web-PRO](https://github.com/andrey271192/amnezia_web-PRO)**; из FREE можно запустить переход на PRO по GitHub‑токену (см. жёлтый баннер после входа). +**Базовая открытая панель:** просмотр клиентов, **удаление** peer с сервера, раздел установки **Telegram MTProto‑прокси** (Docker), несколько инстансов через **`AWG_PROFILES`**, смена пароля. Вкл/выкл клиентов, даты, переименование, экспорт `.conf`, каскад, Cloudflare WARP и синхронизация времени хоста в этой версии **не поддерживаются**. --- @@ -24,15 +24,13 @@ ### Что умеет Amnezia Web -* **Управление через браузер** — таблица клиентов AmneziaWG, время сервера и браузера, быстрые действия там, где редакция это позволяет (FREE vs PRO). +* **Управление через браузер** — таблица клиентов AmneziaWG, время сервера и браузера, удаление peer, установка MTProto‑прокси. * **Быстрое развёртывание панели** — образ админки и опционально страницы для пользователей через **`scripts/install.sh`**. * **Приватность** — всё крутится **на вашем VPS**; доступ к панели ограничиваете паролем (и при желании сетью/TLS). * **Удобство** — одно место для обзора клиентов и (в FREE) установки **MTProto‑прокси** из интерфейса. * **Несколько инстансов AWG на одном хосте** — переключатель профилей через **`AWG_PROFILES`**. * **Современный стек** — Docker, Node.js, веб‑интерфейс. -Полный список возможностей FREE и PRO см. в тексте выше и в репозитории **[amnezia_web-PRO](https://github.com/andrey271192/amnezia_web-PRO)**. - --- ### Для кого этот проект @@ -59,7 +57,7 @@ ## Скриншоты -В редакции FREE сверху панели отображается жёлтый блок «базовая версия» и меньше кнопок, чем в PRO (см. список ограничений выше); общая разметка и таблица клиентов сохраняются. +Интерфейс ориентирован на просмотр клиентов, удаление peer и установку MTProto‑прокси; расширенные действия с клиентами в UI недоступны.

Панель: пользователи AmneziaWG, выбор инстанса, время, статусы в туннеле @@ -72,100 +70,20 @@ --- -## Установка для клиента (маркер в томе + явный ALLOW в контейнере) -Раньше многие полагались на этот режим ради поля PAT — сейчас оно включено по умолчанию. **`INSTALL_FREE_COMMUNITY_ACTIVATION=1`** по-прежнему полезен: создаёт **`/opt/amnezia-admin-data/.install-free-github-pro-opt-in`** и при следующих **`curl … | sudo bash`** снова прокидывает **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=1`** в контейнер (если когда-то задали отключение вручную). - -```bash -export INSTALL_FREE_COMMUNITY_ACTIVATION=1 -curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh | sudo -E bash -``` -если нужно сменить порт например на 8884 - -```bash -curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh \ - | sudo -E env HOST_PORT=8884 LANDING_PORT=8081 bash -``` - -Переменная **`INSTALL_FREE_COMMUNITY_ACTIVATION`** сохранится в томе как файл **`.install-free-github-pro-opt-in`**: следующие апдейты через **`sudo bash`** подхватят маркер **без** повторного `export` (или с **`sudo -E`**, если передаёте другие переменные в той же сессии). - ---- - -## Отключить поле GitHub и установку из панели (жёсткий FREE) - -Чтобы под баннером **не было** ввода токена и **запретить** **`POST …/run-private-install`**, задайте в контейнере **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=0`** (или **`false`** / **`no`** / **`off`**) и перезапустите панель. Обычная установка **`curl … | sudo bash`** без этой переменной **оставляет поле включённым** в FREE. - -Стандартная установка того же образа без отключения: +## Установка ```bash curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh | sudo bash ``` -Форк или ветка (**с тем же режимом маркера по желанию**): +Смена порта (пример): ```bash -export GITHUB_REPO="вы/репо" -export BRANCH="main" -export INSTALL_FREE_COMMUNITY_ACTIVATION=1 -curl -fsSL "https://raw.githubusercontent.com/${GITHUB_REPO}/${BRANCH}/scripts/install.sh" | sudo -E bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh \ + | sudo -E env HOST_PORT=8884 LANDING_PORT=8081 bash ``` -Уже скачали проект: - -```bash -cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bash scripts/install.sh -``` - -### Переменные окружения и `sudo` - -Если передаёте **`AWG_PROFILES`** или **`ADMIN_PASSWORD`** в одной строке с `curl`, используйте **`sudo -E bash`**, иначе `sudo` не увидит переменные. Альтернатива — записать JSON профилей в **`/root/amnezia-admin.awg-profiles.json`** и запустить обычный `curl … | sudo bash`. Подробнее см. историю коммитов и зеркальный README в репозитории PRO. - -### Важные переменные - -| Переменная | По умолчанию | Назначение | -|------------|--------------|------------| -| `INSTALL_FREE_COMMUNITY_ACTIVATION` | _(не задано)_ | `1`: маркер **`.install-free-github-pro-opt-in`** в томе + явно **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=1`** при сборке **`RUN_ENV`** (удобно после ручного `ALLOW=0` или если нужно навсегда фиксировать в томе). -| `GITHUB_REPO` | `andrey271192/amnezia_web` | Архив для установки | -| `HOST_PORT` | `8080` | Порт панели | -| `AWG_CONTAINER` | `amnezia-awg2` | Контейнер WG по умолчанию | -| `AWG_PROFILES` | _(нет)_ | Несколько инстансов; см. примеры в репозитории PRO | -| `COMMUNITY_UPGRADE_URL` | Страница подписки PRO на Boosty (зашита в код по умолчанию) | Переопределите, если смените уровень подписки | -| `COMMUNITY_UPGRADE_PITCH` | _(текст по умолчанию в коде)_ | Текст под заголовком базовой версии | -| `PANEL_FOOTER_DONATE_URL` | Как **`COMMUNITY_UPGRADE_URL`** | Ссылка «донат» в **шапке и подвале** веб‑панели (**не** добавляется на nginx‑лендинг пользователей). | -| `PANEL_FOOTER_TELEGRAM_URL` | `https://t.me/lot_andrey` | Ссылка Telegram‑спонсора / канала (там же: только панель). | -| `PANEL_FOOTER_OZON_URL` | Ссылка на **Ozon СБП** автора (дефолт в `server.js`) | Донат через Ozon — **шапка и подвал** панели с остальными ссылками. | -| `PANEL_FOOTER_DONATE_LABEL` | `Поддержать проект` | Текст якоря доната | -| `PANEL_FOOTER_OZON_LABEL` | `Донат · Ozon СБП` | Текст якоря Ozon | -| `PANEL_FOOTER_TELEGRAM_LABEL` | `Telegram‑канал спонсора` | Текст якоря Telegram | -| `PANEL_FOOTER_PROMO_SUBTITLE` | _(строка по умолчанию в коде)_ | Поясняющий текст в верхней полосе панели | -| `SKIP_LANDING` | `0` | `1` — без лендинга на порту 80 | -| `ALLOW_COMMUNITY_GITHUB_ACTIVATION` | _в редакции **`community` поле вкл по умолчанию_ | Явное **`0`** / **`false`** / **`no`** / **`off`** — **скрыть** ввод PAT и заблокировать **`POST /api/community/run-private-install`** (после перезапуска контейнера). **`1`** / **`true`** не обязательны. | -| `COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL` | `https://raw.githubusercontent.com/andrey271192/amnezia_web-pro/main/scripts/install.sh` | Сырой URL `scripts/install.sh` в **вашем** приватном репозитории PRO | -| `COMMUNITY_INSTALL_FETCH_MS` | `60000` | Таймаут HTTP при скачивании установщика из GitHub | -| `PRIVATE_INSTALL_SCRIPT_MAX_BYTES` | `2097152` | Максимальный размер скачанного скрипта (байты) | -| `COMMUNITY_INSTALL_LOG_TAIL_BYTES` | `98304` | Первый ответ **`GET /api/community/install-log`** без `since` отдаёт **хвост** последних N байт (для живого блока в UI при установке PRO). Диапазон 1 KiB … 512 KiB через env. | -| `COMMUNITY_INSTALL_LOG_API_CHUNK_MAX` | `524288` | Максимум байт **за один** запрос «догонять» журнал (~512 KiB; верхний предел через env ограничен). | -| `COMMUNITY_DISABLE_DOCKER_CLI_HELPER` | _(не задано)_ | Если `1`, установка из UI снова только «bash внутри панели» (часто конфликт **:8080** с PRO). По умолчанию используется одноразовый контейнер **docker:cli**, который **снимает FREE** и зависший PRO перед `install.sh`. | -| `COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO` | `0` | `1` — не выполнять `docker rm` FREE/лендинга перед install (если знаете, что делаете). | -| `COMMUNITY_PRO_INSTALL_HELPER_IMAGE` | `docker:26-cli` | Образ с бинарём `docker` для фонового контейнера (нужен доступ к демону по сокету). | -| `COMMUNITY_HELPER_SKIP_PREPARE_TOOLS` | _(не задано)_ | `1` — helper не ставит **`bash`** и **`curl`** в образе Alpine (обычно **не нужно**; без них `#!/usr/bin/env bash` или `curl` в PRO `install.sh` падают). | -| `MTPRO_PROXY_CONTAINER` | `mtproto-proxy` | Имя контейнера MTProto‑прокси Telegram (официальный образ **telegrammessenger/proxy**, см. Dockerfile на Docker Hub). | -| `MTPRO_PROXY_IMAGE` | `telegrammessenger/proxy:latest` | Образ **`docker pull` + `docker run`** при установке из панели (FREE/community). | -| `MTPRO_INTERNAL_PORT` | `443` | Порт процесса **внутри** контейнера (официальный прокси слушает 443/tcp). Мапится на **`MTPRO_PUBLISH_*`**. | -| `MTPRO_PUBLISH_PORT` | `8443` | Порт хоста VPS (**внешний**), проброшенный в контейнер. | -| `MTPRO_PUBLISH_BIND` | `0.0.0.0` | Адрес биндинга на хосте (`-p` в Docker); при нескольких сетевых интерфейсах при необходимости уточняют. | -| `MTPRO_PUBLIC_HOST` | _(нет)_ | Публичный IP или DNS для ссылки **`tg://proxy`** в UI; можно вместо него задать **`CLIENT_CONFIG_ENDPOINT`**. | -| `UI_HIDE_MTPROTO` | _(не задано)_ | `1` или **`UI_HIDE_SECTIONS=...,mtproto`** — скрыть раздел установки MTProto в веб‑интерфейсе (по умолчанию виден даже FREE). | -| `FREE_PANEL_CONTAINER_FOR_PRO_INSTALL` и др. | см. `server.js` | Имена контейнеров для `docker rm` перед install (по умолчанию `amnezia-admin`, `amnezia-web-landing`, `amnezia-admin-pro`). | - -**MTProto‑прокси и API панели:** **`GET /api/mtproto/status`** отдаёт состояние контейнера и ссылку **`tg://`** без тяжёлых операций при открытии блока. Опционально **`GET /api/mtproto/status?withLogs=1`** добавляет в ответ хвост **`docker logs`** (поле **`logsFetched`**). Отдельные эндпоинты: **`GET /api/mtproto/logs`** и **`GET /api/mtproto/tail`** (тот же смысл, если обратный прокси режет URI с **`logs`**). За nginx/Caddy перед панелью нужно пробрасывать **весь** префикс **`/api/`** одним правилом — см. блок **«Not found»** в **«Частые проблемы»** ниже. - -Поле токена **по умолчанию уже есть**. Чтобы **убрать** его — см. раздел **«Отключить поле GitHub…»** выше. URL приватного `install.sh` задаёт **`COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL`**. - -**Важно:** при одноразовом запуске с переменными в одной строке с `curl … | sudo bash` они **пропадают** — нужен **`export`** перед `curl` и **`sudo -E bash`**, см. блок **«Установка для клиента»** выше. - -Остальные переменные совместимы с образом панели (см. Dockerfile / `server.js` в этом репозитории). - --- ## Обновление @@ -176,8 +94,6 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh | sudo bash ``` -Если нужен маркер **`.install-free-github-pro-opt-in`** в томе (см. раздел выше) или переменные хоста при **`sudo`**, используйте **`export INSTALL_FREE_COMMUNITY_ACTIVATION=1`** и **`sudo -E bash`**. - После сообщения **`→ Клонирование релиза …`** несколько минут **может ничего не печататься**: идёт `curl … | tar` с GitHub. Для **полосы прогресса**: `sudo INSTALL_SCRIPT_VERBOSE=1 bash` (или передайте переменную в окружение до `|`). Если обрывает по времени или «тишина» часами задайте таймауты: **`CURL_CONNECT_TIMEOUT`** (по умолчанию 30 с к подключению), **`CURL_MAX_TIME`** (по умолчанию до 900 с на загрузку), **`CURL_RETRY`**. Полный URL tar.gz можно подменить: **`GITHUB_REPO_URL_OVERRIDE`** (должен указывать на архив того же вида **`${REPO}-${BRANCH}.tar.gz`**, после распаковки каталог будет **`ИмяРепозитория-${BRANCH}`**). Принудительная пересборка образа: **`NO_CACHE=1`**. @@ -194,23 +110,6 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scrip ## Частые проблемы -### Пропало поле «токен GitHub» под плашкой FREE - -Редакция должна быть **`community`**. Чаще всего в **`docker inspect amnezia-admin` → Env** осталось **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=0`** (или **`false`** / **`no`** / **`off`**) — удалите переменную или поставьте **`1`** и снова выполните **`install.sh`**. Установщик при апдейте переносит **`ALLOW_*`** из **предыдущего** контейнера. Маркер **`${DATA_DIR}/.install-free-github-pro-opt-in`** при **`INSTALL_FREE_COMMUNITY_ACTIVATION=1`** снова прокинет **`ALLOW=1`**, если установщику на хосте не задали **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=0`**. - -### Журнал установки и docker-helper - -После успешной кнопки **«Установить PRO»** в той же панели открывается **живой блок «Журнал установки»** (поллинг каждые 2 с к **`GET /api/community/install-log`**; для запросов нужна сессия в браузере). Пока старый контейнер FREE снят и новый образ ещё не поднял ту же вкладку, поток журнала в браузере **прерывается** — откройте адрес панели снова после старта PRO; файл **`community-install-last.log`** на томе данных тот же. Кнопка **«Пауза»** останавливает опрос журнала. - -В режиме **docker-helper** панель пишет в **`community-install-last.log`** финальную строку **`--- helper завершён code=…`** **внутри** одноразового контейнера: её видно даже если процесс FREE-панели убрал **`docker rm`** и Node не успевает добавить строку **`--- завершено …`**. Если в логе после преамбулы одноразового установщика **нет** ни одной финальной строки — обновите образ панели с актуального **`amnezia_web`** и повторите установку. - -### `Bind for 0.0.0.0:8080 failed: port is already allocated` у `amnezia-admin-pro` - -На **8080** уже слушает **FREE**‑панель (**`amnezia-admin`**). Пока она работает, второй контейнер панели (PRO) на тот же порт не поднимется. **Кнопка «Установить PRO» из UI по умолчанию** использует одноразовый контейнер **`docker:cli`** (пауза → **`docker rm`** FREE / лендинг / **`amnezia-admin-pro` и любые имена вида `_…_…-amnezia-admin-pro`** из docker compose → затем ваш **`install.sh`**). Иначе вручную: **`docker rm -f amnezia-admin`**, затем установщик PRO. Отключить авто-снос: **`COMMUNITY_DISABLE_DOCKER_CLI_HELPER=1`**. - -### В `community-install-last.log`: **`curl: command not found`**, **`tar: invalid magic`**, **`code=2` сразу после helper - -Образ **`docker:26-cli`** на Alpine обычно **без `bash` и без `curl`**. PRO `install.sh` с **`#!/usr/bin/env bash`** и загрузкой архива через **`curl | tar`** без них не стартуют (**`curl: command not found`**, **`tar: invalid magic`**). Если после строки «одноразовый установщик» почти сразу **`--- завершено … code=2`** — возможны ошибка сборки prelude в **`sh`**, ошибка **`docker run … -e TOKEN=…`** из‑за спецсимволов в PAT, или **apk** не смог добавить **`bash`** (сеть из контейнера). Обновите панель с **`main`**: helper целиком шлёт **stdout/stderr в лог**, токены передаются средой процесса (без интерполяции в аргументе `-e`), prelude разделён **`;`**; перед **`install.sh`** скрипт сообщает если **нет bash/curl** (**выход по коду ~126**). Если **apk недоступен** — свой **`COMMUNITY_PRO_INSTALL_HELPER_IMAGE`** с **`bash` и `curl`**, либо в приватном `install.sh` перейти на **`#!/bin/sh`** и **`wget`**. Отключить подготовку: **`COMMUNITY_HELPER_SKIP_PREPARE_TOOLS=1`**. ### Установка «зависла» на сообщении **`→ Клонирование релиза …`** и долго без вывода diff --git a/docs/panel-guide.md b/docs/panel-guide.md index 46a0980..cf3c82c 100644 --- a/docs/panel-guide.md +++ b/docs/panel-guide.md @@ -10,7 +10,7 @@ | **Время** | Отображение часов контейнера и браузера; синхронизация времени хоста VPS по SSH (если доступно). | | **Cloudflare WARP** | *Необязательно.* Вывод части клиентов в интернет через интерфейс `warp` в контейнере AWG. Если WARP не ставили — статус **«Не установлен»** нормален; панель и VPN без этого работают. Установка и удаление — скрипт `scripts/warp-amnezia.sh` на хосте (`install` / **`uninstall`**), подробности в основном [README](../README.md). | | **Новый клиент под каскад** | Создание нового peer на сервере с **вашим Endpoint** (промежуточный узел); выдача готового `.conf`. | -| **Пользователи** | В FREE: просмотр и **удаление** peer с сервера. В PRO дополнительно: вкл/выкл, переименование, даты отключения; экспорт `.conf` если в записи есть `userData.last_config`. | +| **Пользователи** | Просмотр клиентов и **удаление** peer с сервера. Вкл/выкл, переименование, даты отключения и экспорт `.conf` в базовой версии недоступны. | ## Переключатель «Инстанс» не отображается diff --git a/package.json b/package.json index 60c24b0..c99b0e4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "amnezia-admin", "version": "1.2.29", "private": true, - "description": "amnezia_web — открытая веб-панель AmneziaWG на VPS (Docker): FREE — просмотр/удаление клиентов, MTProto-прокси; PRO — полное управление и обновления.", + "description": "amnezia_web — открытая базовая веб-панель AmneziaWG на VPS (Docker): просмотр и удаление клиентов, MTProto-прокси.", "license": "MIT", "repository": { "type": "git", diff --git a/public/app.js b/public/app.js index 9bba8fb..a57cb21 100644 --- a/public/app.js +++ b/public/app.js @@ -21,6 +21,18 @@ const warpActionsEl = document.querySelector("#warp-actions"); const warpClientListEl = document.querySelector("#warp-client-list"); const warpWgShowEl = document.querySelector("#warp-wg-show"); +const warpCfPanel = document.querySelector("#warp-cf-panel"); +const warpCfStatusLine = document.querySelector("#warp-cf-status-line"); +const warpCfActionsEl = document.querySelector("#warp-cf-actions"); +const warpCfLogEl = document.querySelector("#warp-cf-log"); +const warpCfRootPw = document.querySelector("#warp-cf-root-pw"); +const warpCfPort = document.querySelector("#warp-cf-port"); +const warpCfLicense = document.querySelector("#warp-cf-license"); +const warpCfXrayPath = document.querySelector("#warp-cf-xray-path"); +const warpCfDomains = document.querySelector("#warp-cf-domains"); +const warpCfBanner = document.querySelector("#warp-cf-banner"); +const warpCfPresetsEl = document.querySelector("#warp-cf-presets"); + const mtprotoPanel = document.querySelector("#mtproto-panel"); const mtprotoStatusLine = document.querySelector("#mtproto-status-line"); const mtprotoActionsEl = document.querySelector("#mtproto-actions"); @@ -74,353 +86,27 @@ let warpSshPendingCmd = null; /** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */ let uiHidden = { users: false, warp: false, cascade: false, mtproto: false }; -const editionBanner = document.querySelector("#edition-banner"); -const DEFAULT_HEADER_SUB = document.querySelector(".top .sub")?.textContent?.trim() || ""; - -/** POLL для живого журнала установки PRO (@type {number | null}) */ -let communityInstallLogInterval = null; -/** Следующий offset для GET /api/community/install-log (@type {number | null}) */ -let communityInstallLogSince = null; -/** Аккумулированный текст (дополняется кусками) */ -let communityInstallLogShown = ""; - -function stopCommunityInstallLogPolling() { - if (communityInstallLogInterval !== null) { - clearInterval(communityInstallLogInterval); - communityInstallLogInterval = null; - } -} - -async function pollCommunityInstallLogOnce() { - const pre = editionBanner?.querySelector(".edition-banner-install-pre"); - const statusLine = editionBanner?.querySelector(".edition-banner-install-status"); - if (!pre || !editionBanner?.querySelector(".edition-banner-install-log")) { - stopCommunityInstallLogPolling(); - return; - } - - try { - const url = - communityInstallLogSince === null - ? "/api/community/install-log" - : `/api/community/install-log?since=${encodeURIComponent(String(communityInstallLogSince))}`; - const r = await fetch(url, { credentials: "same-origin" }); - const j = await r.json().catch(() => null); - if (!r.ok) { - if (statusLine) { - statusLine.textContent = - typeof j?.error === "string" ? j.error : `Ошибка ${r.status}`; - statusLine.classList.add("err"); - } - return; - } - if (statusLine) { - statusLine.classList.remove("err"); - if (!j.exists) { - statusLine.textContent = - communityInstallLogSince === null ? "Журнал пока пустой — секунду…" : "Обновление…"; - } else if (communityInstallLogSince === null && j.totalBytes > j.since1) { - statusLine.textContent = `Хвост журнала (${fmtBytesRu(Math.max(j.since1 - j.since0, 0))} из ~${fmtBytesRu(j.totalBytes)}) · автообновление…`; - } else { - statusLine.textContent = `Размер журнала ≈ ${fmtBytesRu(j.totalBytes)} · автообновление каждые 2 с…`; - } - } - - const chunk = typeof j.chunk === "string" ? j.chunk : ""; - if (!j.exists) return; - - const replace = Boolean(j.resetSuggested || communityInstallLogSince === null); - if (replace) communityInstallLogShown = chunk; - else communityInstallLogShown += chunk; - - pre.textContent = communityInstallLogShown; - communityInstallLogSince = typeof j.since1 === "number" ? j.since1 : communityInstallLogSince; - pre.scrollTop = pre.scrollHeight; - } catch (_e) { - if (statusLine) { - statusLine.textContent = "Не удалось запросить журнал (сеть)."; - statusLine.classList.add("err"); - } - } -} - -function fmtBytesRu(n) { - const x = Number(n); - if (!Number.isFinite(x)) return ""; - if (x < 1024) return `${Math.round(x)} B`; - if (x < 1024 * 1024) return `${Math.round(x / 102.4) / 10} KiB`; - return `${Math.round(x / (1024 * 102.4)) / 10} MiB`; -} - -/** Ссылки доната и Telegram‑спонсора: шапка + подвал панели (`GET /api/panel/footer`). */ -function wirePanelPromoAnchor(el, url, label) { - const u = typeof url === "string" ? url.trim() : ""; - const lb = typeof label === "string" ? label.trim() : ""; - if (!el || !u || !lb) { - if (el) { - el.classList.add("hidden"); - el.removeAttribute("href"); - el.textContent = ""; - } - return false; - } - el.href = u; - el.textContent = lb; - el.classList.remove("hidden"); - return true; -} - -async function hydratePanelPromoFooter() { - const strip = document.getElementById("panel-promo-strip"); - const sub = document.getElementById("panel-promo-subtitle"); - const aDonTop = document.getElementById("panel-strip-donate"); - const sepDO = document.getElementById("panel-strip-sep-donate-ozon"); - const aOzonTop = document.getElementById("panel-strip-ozon"); - const sepOT = document.getElementById("panel-strip-sep-ozon-tg"); - const aTgTop = document.getElementById("panel-strip-telegram"); - const gap1 = document.getElementById("footer-promo-gap1"); - const aDonF = document.getElementById("footer-promo-donate"); - const fSepDO = document.getElementById("footer-promo-sep-donate-ozon"); - const aOzonF = document.getElementById("footer-promo-ozon"); - const fSepOT = document.getElementById("footer-promo-sep-ozon-tg"); - const aTgF = document.getElementById("footer-promo-telegram"); - - try { - const r = await fetch("/api/panel/footer", { credentials: "same-origin" }); - if (!r.ok) return; - const j = await r.json().catch(() => null); - if (!j || typeof j !== "object") return; - - const subtitle = typeof j.promoSubtitle === "string" ? j.promoSubtitle.trim() : ""; - if (sub) sub.textContent = subtitle; - - const okD = wirePanelPromoAnchor(aDonTop, j.donateUrl, j.donateLabel); - const okO = wirePanelPromoAnchor(aOzonTop, j.ozonUrl, j.ozonLabel); - const okT = wirePanelPromoAnchor(aTgTop, j.telegramUrl, j.telegramLabel); - - if (sepDO) sepDO.classList.toggle("hidden", !(okD && (okO || okT))); - if (sepOT) sepOT.classList.toggle("hidden", !(okO && okT)); - - wirePanelPromoAnchor(aDonF, j.donateUrl, j.donateLabel); - wirePanelPromoAnchor(aOzonF, j.ozonUrl, j.ozonLabel); - wirePanelPromoAnchor(aTgF, j.telegramUrl, j.telegramLabel); - - const footerD = aDonF && !aDonF.classList.contains("hidden"); - const footerO = aOzonF && !aOzonF.classList.contains("hidden"); - const footerT = aTgF && !aTgF.classList.contains("hidden"); - - if (gap1) gap1.classList.toggle("hidden", !(footerD || footerO || footerT)); - if (fSepDO) fSepDO.classList.toggle("hidden", !(footerD && (footerO || footerT))); - if (fSepOT) fSepOT.classList.toggle("hidden", !(footerO && footerT)); - - if (strip) strip.classList.remove("hidden"); - } catch { - /* офлайн или сбой — в HTML уже дефолтные ссылки шапки/подвала */ - } -} - -function ensureEditionBannerInstallLogPanel() { - if (!editionBanner) return null; - let wrap = editionBanner.querySelector(".edition-banner-install-log"); - if (!wrap) { - wrap = document.createElement("div"); - wrap.className = "edition-banner-install-log"; - const hdr = document.createElement("div"); - hdr.className = "edition-banner-install-log-head"; - hdr.innerHTML = - 'Журнал установки '; - - const statusLine = document.createElement("div"); - statusLine.className = "edition-banner-install-status muted small"; - statusLine.textContent = "Запрос журнала…"; - - const pre = document.createElement("pre"); - pre.className = "edition-banner-install-pre monospace"; - - hdr.querySelector(".edition-install-log-pause")?.addEventListener("click", () => { - const btn = hdr.querySelector(".edition-install-log-pause"); - if (!btn || !editionBanner.contains(wrap)) return; - const paused = btn.dataset.paused === "1"; - if (paused) { - btn.dataset.paused = ""; - btn.textContent = "Пауза"; - startCommunityInstallLogPollingResume(); - } else { - btn.dataset.paused = "1"; - btn.textContent = "Продолжить"; - stopCommunityInstallLogPolling(); - } - }); - - wrap.append(hdr, statusLine, pre); - editionBanner.querySelector(".edition-banner-activation")?.appendChild(wrap); - } - return wrap; -} - -function startCommunityInstallLogPollingResume() { - stopCommunityInstallLogPolling(); - void pollCommunityInstallLogOnce(); - communityInstallLogInterval = window.setInterval(() => { - void pollCommunityInstallLogOnce(); - }, 2000); -} - -/** Сброс и показ живого журнала (после «Установить PRO»). */ -function startCommunityInstallLogPollingFresh() { - communityInstallLogSince = null; - communityInstallLogShown = ""; - const wrap = ensureEditionBannerInstallLogPanel(); - const pre = wrap?.querySelector(".edition-banner-install-pre"); - const st = wrap?.querySelector(".edition-banner-install-status"); - if (pre) pre.textContent = ""; - if (st) { - st.classList.remove("err"); - st.textContent = "Запрос журнала…"; - } - startCommunityInstallLogPollingResume(); -} - -/** Состояние редакции панели (community = просмотр + удаление; остальные мутации — PRO). */ +/** Базовые возможности панели (просмотр клиентов + удаление). */ let editionState = { - tier: "pro", - readOnlyClients: false, - allowDeleteClients: false, - upgradeUrl: null, - upgradePitch: null, - showDebugWg: true, - githubActivationAllowed: false, + readOnlyClients: true, + allowDeleteClients: true, + showDebugWg: false, }; function applyEditionPayload(data) { const ed = data?.edition; if (!ed || typeof ed !== "object") return; editionState = { - tier: ed.tier === "community" ? "community" : "pro", - readOnlyClients: Boolean(ed.readOnlyClients), - allowDeleteClients: Boolean(ed.allowDeleteClients), - upgradeUrl: typeof ed.upgradeUrl === "string" ? ed.upgradeUrl : null, - upgradePitch: typeof ed.upgradePitch === "string" ? ed.upgradePitch : null, - showDebugWg: ed.showDebugWg !== false, - githubActivationAllowed: Boolean(ed.githubActivationAllowed), + readOnlyClients: ed.readOnlyClients !== false, + allowDeleteClients: ed.allowDeleteClients !== false, + showDebugWg: Boolean(ed.showDebugWg), }; const titleEl = document.querySelector(".top h1"); - if (titleEl) { - titleEl.textContent = - editionState.tier === "community" - ? "Пользователи AmneziaWG FREE" - : "Пользователи AmneziaWG PRO"; - } + if (titleEl) titleEl.textContent = "Пользователи AmneziaWG"; const subEl = document.querySelector(".top .sub"); if (subEl) { - if (editionState.tier === "community") { - subEl.textContent = - "Базовая панель amnezia_web: просмотр клиентов и статусов, удаление клиента с сервера, установка Telegram MTProto‑прокси (Docker) в этом интерфейсе. Включение/выключение туннеля, даты, переименование, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO."; - } else { - subEl.textContent = DEFAULT_HEADER_SUB; - } - } - if (editionBanner) { - if (editionState.tier === "community") { - const preservedInstallLog = editionBanner.querySelector(".edition-banner-install-log"); - editionBanner.classList.remove("hidden"); - editionBanner.innerHTML = ""; - const wrap = document.createElement("div"); - wrap.className = "edition-banner-inner"; - const textCol = document.createElement("div"); - textCol.className = "edition-banner-text"; - const strong = document.createElement("strong"); - strong.textContent = "Базовая версия · просмотр и удаление клиентов"; - const pitch = document.createElement("p"); - pitch.className = "edition-banner-pitch muted"; - pitch.textContent = editionState.upgradePitch || ""; - textCol.append(strong, pitch); - const cta = document.createElement("a"); - cta.className = "btn small primary edition-banner-cta"; - cta.rel = "noopener noreferrer"; - cta.target = "_blank"; - cta.href = - editionState.upgradeUrl || - "https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link"; - cta.textContent = "Разблокировать PRO (Boosty)"; - wrap.append(textCol, cta); - editionBanner.appendChild(wrap); - - { - const act = document.createElement("div"); - act.className = "edition-banner-activation muted"; - const cap = document.createElement("div"); - cap.className = "edition-banner-act-title"; - cap.textContent = - "Есть GitHub‑токен к приватному репозиторию PRO? Вставьте ниже — установка заменит FREE на PRO (порт сохранится):"; - - const row = document.createElement("div"); - row.className = "edition-banner-act-row"; - - const inp = document.createElement("input"); - inp.type = "password"; - inp.autocomplete = "new-password"; - inp.spellcheck = false; - inp.placeholder = "GitHub PAT (classic: repo или fine‑grained: Contents Read)"; - inp.className = "edition-banner-act-input monospace"; - - const go = document.createElement("button"); - go.type = "button"; - go.className = "btn small primary"; - go.textContent = "Установить PRO"; - - const msg = document.createElement("p"); - msg.className = "edition-banner-act-msg muted"; - msg.setAttribute("role", "status"); - msg.textContent = ""; - - go.addEventListener("click", async () => { - msg.textContent = ""; - msg.className = "edition-banner-act-msg muted"; - const tok = inp.value.trim(); - if (!tok) { - msg.className = "edition-banner-act-msg status err"; - msg.textContent = "Вставьте токен."; - return; - } - go.disabled = true; - try { - const r = await fetch("/api/community/run-private-install", { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ githubToken: tok }), - }); - const j = await r.json().catch(() => ({})); - if (!r.ok) { - msg.className = "edition-banner-act-msg status err"; - msg.textContent = j.error || `Ошибка ${r.status}`; - go.disabled = false; - return; - } - msg.className = "edition-banner-act-msg edition-act-ok muted"; - msg.textContent = - j.message || - "Установка запущена. Через 2–5 минут откройте панель снова по тому же адресу (или несколько раз обновите страницу)."; - inp.value = ""; - startCommunityInstallLogPollingFresh(); - } catch (e) { - msg.className = "edition-banner-act-msg status err"; - msg.textContent = String(e?.message || e); - go.disabled = false; - } - }); - - row.append(inp, go); - act.append(cap, row, msg); - if (preservedInstallLog) act.appendChild(preservedInstallLog); - editionBanner.appendChild(act); - } - } else { - stopCommunityInstallLogPolling(); - editionBanner.classList.add("hidden"); - editionBanner.innerHTML = ""; - } + subEl.textContent = + "Базовая панель: просмотр клиентов и статусов, удаление клиента с сервера, установка Telegram MTProto‑прокси (Docker)."; } document.querySelector(".clock-host-sync")?.classList.toggle("hidden", editionState.readOnlyClients); if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg; @@ -627,6 +313,7 @@ function applyUiHiddenFromPayload(data) { if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg; if (cascadePanel) cascadePanel.hidden = uiHidden.cascade; if (warpPanel) warpPanel.hidden = uiHidden.warp; + if (warpCfPanel) warpCfPanel.hidden = editionState.readOnlyClients; if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto; void refreshMtprotoPanel(); } @@ -1090,10 +777,10 @@ async function loadTimeSyncCaps() { const btn = document.querySelector("#sync-host-time"); try { const c = await api("/api/time-sync-capabilities"); - if (editionState.readOnlyClients || c.communityBlocked) { + if (editionState.readOnlyClients) { if (hint) { hint.textContent = - "В базовой версии синхронизация времени хоста по SSH недоступна — это функция PRO."; + "Синхронизация времени хоста по SSH недоступна в базовой версии панели."; } if (btn) btn.disabled = true; return; @@ -1216,7 +903,7 @@ function renderRows(clients) { roHint.className = "muted hint-mini"; roHint.style.margin = "0.35rem 0 0"; roHint.textContent = c.exportAvailable - ? "На сервере есть данные для .conf — скачивание доступно в PRO." + ? "На сервере есть данные для .conf — экспорт недоступен в базовой версии." : "Нет last_config на сервере — полный конфиг в приложении Amnezia."; nameWrap.appendChild(roHint); } @@ -1258,13 +945,13 @@ function renderRows(clients) { note.className = "muted"; note.style.fontSize = "0.85rem"; note.style.marginBottom = "0.35rem"; - note.textContent = "Вкл/выкл, даты, переименование, экспорт — PRO"; + note.textContent = "Вкл/выкл, даты, переименование, экспорт недоступны"; actTd.appendChild(note); actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId))); } else { const lock = document.createElement("span"); lock.className = "muted"; - lock.textContent = "Только PRO"; + lock.textContent = "Недоступно"; actTd.appendChild(lock); } } else { @@ -1487,6 +1174,217 @@ function renderWarpPanel(data) { ); } +function normalizeWarpCfPort() { + const raw = warpCfPort?.value?.trim(); + const n = raw ? Number.parseInt(raw, 10) : 40000; + if (!Number.isFinite(n) || n < 512 || n > 65535) return 40000; + return n; +} + +function warpCfPresetText(port) { + const p = Number(port) || 40000; + return ( + "### 3x-ui / Xray (на хосте)\n" + + "SOCKS5 адрес: 127.0.0.1:" + p + "\n\n" + + "Outbound (пример):\n" + + "{\n" + + ' \"tag\": \"warp-socks\",\n' + + ' \"protocol\": \"socks\",\n' + + ' \"settings\": {\"servers\": [{\"address\": \"127.0.0.1\", \"port\": ' + p + '}]}\n' + + "}\n\n" + + "Routing rule (пример):\n" + + "{\n" + + ' \"type\": \"field\",\n' + + ' \"outboundTag\": \"warp-socks\",\n' + + ' \"domain\": [\"geosite:google\", \"geosite:openai\"]\n' + + "}\n\n" + + "### Amnezia (xray в Docker)\n" + + "SOCKS5 адрес: 172.17.0.1:" + p + " (из контейнера до хоста)\n\n" + + "Outbound (пример):\n" + + "{\n" + + ' \"tag\": \"warp-socks\",\n' + + ' \"protocol\": \"socks\",\n' + + ' \"settings\": {\"servers\": [{\"address\": \"172.17.0.1\", \"port\": ' + p + '}]}\n' + + "}\n" + ); +} + +function redrawWarpCfPresets() { + if (!warpCfPresetsEl) return; + warpCfPresetsEl.textContent = warpCfPresetText(normalizeWarpCfPort()); +} + +async function refreshWarpCfPanel() { + if (!warpCfPanel || !warpCfStatusLine || !warpCfActionsEl || !warpCfLogEl) return; + if (editionState.readOnlyClients) { + warpCfPanel.hidden = true; + return; + } + warpCfPanel.hidden = false; + redrawWarpCfPresets(); + + warpCfActionsEl.innerHTML = ""; + if (warpCfBanner) { + warpCfBanner.classList.add("hidden"); + warpCfBanner.textContent = ""; + } + + const pw = String(warpCfRootPw?.value || "").trim(); + const port = normalizeWarpCfPort(); + + const explain = document.createElement("p"); + explain.className = "muted warp-muted"; + explain.textContent = + "Для статуса/установки/удаления нужен пароль root по SSH на хост VPS. Пароль не сохраняется — вводите при каждом действии."; + warpCfActionsEl.appendChild(explain); + + warpCfActionsEl.appendChild( + btn("Статус", "btn small ghost", async () => { + try { + if (!pw) throw new Error("Введите пароль root."); + setStatus("WARP (официальный): статус…", false); + warpCfLogEl.textContent = "Запрос статуса…"; + const j = await api("/api/warp-cf/status", { + method: "POST", + body: JSON.stringify({ rootPassword: pw }), + }); + warpCfStatusLine.textContent = + j.installed + ? `${j.running ? "подключён" : "не подключён"} · mode ${j.mode || "?"}` + + (j.proxyPort ? ` · SOCKS :${j.proxyPort}` : "") + + (j.account ? ` · ${j.account}` : "") + : "не установлен"; + warpCfLogEl.textContent = (j.rawStatus || "") + (j.rawAccount ? "\n\n--- account ---\n" + j.rawAccount : ""); + setStatus("", false); + } catch (e) { + setStatus(String(e?.message || e), true); + } + }), + ); + + warpCfActionsEl.appendChild( + btn("Установить (proxy mode)", "btn small primary", async () => { + try { + if (!pw) throw new Error("Введите пароль root."); + if (!confirm(`Установить официальный Cloudflare WARP на хост VPS и поднять SOCKS5 на 127.0.0.1:${port}?`)) { + return; + } + setStatus("WARP (официальный): установка…", false); + warpCfLogEl.textContent = "Установка…"; + const j = await api("/api/warp-cf/install", { + method: "POST", + body: JSON.stringify({ rootPassword: pw, socksPort: port }), + }); + warpCfLogEl.textContent = j.output || "Готово."; + if (warpCfBanner) { + warpCfBanner.textContent = `Готово: SOCKS5 на 127.0.0.1:${port}`; + warpCfBanner.classList.remove("hidden"); + } + setStatus("", false); + } catch (e) { + setStatus(String(e?.message || e), true); + } + }), + ); + + warpCfActionsEl.appendChild( + btn("Применить WARP+ ключ", "btn small ghost", async () => { + try { + if (!pw) throw new Error("Введите пароль root."); + const key = String(warpCfLicense?.value || "").trim(); + if (!key) throw new Error("Вставьте WARP+ ключ."); + setStatus("WARP (официальный): применяю ключ…", false); + warpCfLogEl.textContent = "Применение ключа…"; + const j = await api("/api/warp-cf/license", { + method: "POST", + body: JSON.stringify({ rootPassword: pw, licenseKey: key }), + }); + warpCfLogEl.textContent = j.output || "Ключ применён."; + if (warpCfLicense) warpCfLicense.value = ""; + setStatus("", false); + } catch (e) { + setStatus(String(e?.message || e), true); + } + }), + ); + + warpCfActionsEl.appendChild( + btn("Удалить", "btn small warn", async () => { + try { + if (!pw) throw new Error("Введите пароль root."); + if (!confirm("Удалить официальный Cloudflare WARP с хоста VPS (apt remove cloudflare-warp)?")) return; + setStatus("WARP (официальный): удаление…", false); + warpCfLogEl.textContent = "Удаление…"; + const j = await api("/api/warp-cf/uninstall", { + method: "POST", + body: JSON.stringify({ rootPassword: pw }), + }); + warpCfLogEl.textContent = j.output || "Удалено."; + warpCfStatusLine.textContent = "не установлен"; + setStatus("", false); + } catch (e) { + setStatus(String(e?.message || e), true); + } + }), + ); + + warpCfActionsEl.appendChild( + btn("Применить пресет в Xray (3x‑ui: 127.0.0.1)", "btn small ghost", async () => { + try { + if (!pw) throw new Error("Введите пароль root."); + const filePath = String(warpCfXrayPath?.value || "").trim(); + if (!filePath) throw new Error("Укажите путь к JSON конфигу Xray."); + const domains = String(warpCfDomains?.value || "").trim(); + if (!confirm(`Внести изменения в ${filePath} (создадим .bak* рядом)?`)) return; + setStatus("Xray preset: применяю…", false); + warpCfLogEl.textContent = "Применение…"; + const j = await api("/api/warp-cf/xray-apply", { + method: "POST", + body: JSON.stringify({ + rootPassword: pw, + filePath, + socksAddress: "127.0.0.1", + socksPort: port, + domains, + }), + }); + warpCfLogEl.textContent = JSON.stringify(j, null, 2); + setStatus("Готово (проверьте и перезапустите Xray/x-ui).", false); + } catch (e) { + setStatus(String(e?.message || e), true); + } + }), + ); + + warpCfActionsEl.appendChild( + btn("Применить пресет в Xray (Docker: 172.17.0.1)", "btn small ghost", async () => { + try { + if (!pw) throw new Error("Введите пароль root."); + const filePath = String(warpCfXrayPath?.value || "").trim(); + if (!filePath) throw new Error("Укажите путь к JSON конфигу Xray."); + const domains = String(warpCfDomains?.value || "").trim(); + if (!confirm(`Внести изменения в ${filePath} (создадим .bak* рядом)?`)) return; + setStatus("Xray preset: применяю…", false); + warpCfLogEl.textContent = "Применение…"; + const j = await api("/api/warp-cf/xray-apply", { + method: "POST", + body: JSON.stringify({ + rootPassword: pw, + filePath, + socksAddress: "172.17.0.1", + socksPort: port, + domains, + }), + }); + warpCfLogEl.textContent = JSON.stringify(j, null, 2); + setStatus("Готово (проверьте и перезапустите Xray/контейнер).", false); + } catch (e) { + setStatus(String(e?.message || e), true); + } + }), + ); +} + function escapeHtml(s) { return String(s) .replace(/&/g, "&") @@ -1691,6 +1589,7 @@ async function loadClients() { wgShowEl.textContent = data.wgShow || ""; } renderWarpPanel(data); + void refreshWarpCfPanel(); renderRows(data.clients); setStatus("", false); void refreshServerClock(); @@ -1707,6 +1606,7 @@ async function loadClients() { wgShowEl.textContent = ""; peerCountEl.textContent = ""; if (warpPanel) warpPanel.hidden = true; + if (warpCfPanel) warpCfPanel.hidden = true; if (mtprotoPanel) mtprotoPanel.hidden = true; } } @@ -1803,4 +1703,5 @@ function initMtprotoLinkCopy() { initThemeSwitch(); initMtprotoLinkCopy(); void hydratePanelPromoFooter(); +if (warpCfPort) warpCfPort.addEventListener("input", () => redrawWarpCfPresets()); boot(); diff --git a/public/index.html b/public/index.html index 497b10a..b180b7f 100644 --- a/public/index.html +++ b/public/index.html @@ -29,7 +29,7 @@