From 8f1d7a7c4ab7cceb2ff8f16c7d2dcbca516f5194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Thu, 14 May 2026 20:01:43 +0300 Subject: [PATCH] feat(ui): optional UI_HIDE_* panels; doc WARP free vs paid plans Hide users/warp/cascade blocks via env; block warp/cascade APIs when hidden. install.sh passes UI_HIDE_* and restores from previous container on upgrade. Co-authored-by: Cursor --- README.md | 26 ++++++++++++++++++++++++ docs/panel-guide.md | 8 ++++++++ package.json | 2 +- public/app.js | 36 ++++++++++++++++++++++++++++++++-- public/index.html | 7 ++----- scripts/install.sh | 26 ++++++++++++++++++++++++ server.js | 48 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c9ed088..289a75a 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `LANDING_PORT` | `80` | Порт nginx-лендинга (если `80` занят — например `8081`) | | `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга | | `NO_CACHE` | `0` | `1` — `docker build --no-cache` при проблемах с обновлением образа | +| `UI_HIDE_SECTIONS` | _(нет)_ | Список через запятую: **`users`**, **`warp`**, **`cascade`** — скрыть блоки в веб-панели (см. ниже). При обновлении через `install.sh` значение подтягивается из старого контейнера, если не задано заново | +| `UI_HIDE_USERS` | `0` | `1` или `true` — эквивалент `users` в `UI_HIDE_SECTIONS` | +| `UI_HIDE_WARP` | `0` | `1` — эквивалент `warp` (также блокируются `POST /api/warp/*`) | +| `UI_HIDE_CASCADE` | `0` | `1` — эквивалент `cascade` (блокируется `POST /api/clients/create-cascade`) | Переменная **`AWG_PROFILES`** при установке автоматически сохраняется в **`/root/amnezia-admin.awg-profiles.json`** на VPS; при следующем запуске `install.sh` без `AWG_PROFILES` значение подставляется из этого файла или из **старого контейнера** `amnezia-admin` перед его удалением — так переключатель «Инстанс» не пропадает после обновления панели. @@ -118,6 +122,10 @@ chmod +x scripts/warp-amnezia.sh Скрипт регистрирует туннель через [wgcf](https://github.com/ViRb3/wgcf), создаёт `warp.conf` в контейнере (`/opt/warp` по умолчанию). Дальше в панели отмечаете клиентов и жмёте **«Применить маршрутизацию»** (контейнер AWG перезапустится). +#### Бесплатный WARP и платные продукты Cloudflare + +**wgcf** использует тот же по сути **бесплатный** потребительский туннель WARP, что и мобильное приложение Cloudflare WARP. Отдельные **платные** корпоративные сервисы Cloudflare (Zero Trust / Teams и т.п.) — другая модель: политики, доступ и способ выдачи конфигурации. **Подключить «платный тариф» этой панели одной кнопкой нельзя** — нужен WireGuard-совместимый профиль от вашего договора с Cloudflare и ручная подстановка (замена `warp.conf` и маршрутов), это не входит в автоматический установщик. + Подкоманды: `install`, `start`, `stop`, `status`, `rekey`, **`uninstall`**. Учёт wgcf на хосте: `/root/wgcf-account.toml` (и бинарник `/root/wgcf`, если скачан). **Полностью убрать WARP** после установки (интерфейс `warp`, правила маршрутизации/NAT, блок `# --- WARP-MANAGER ---` в `start.sh`, `warp.conf` и список клиентов в каталоге WARP в контейнере; затем перезапуск контейнера AWG): @@ -134,6 +142,24 @@ cd /opt/amnezia-admin Необязательно задавать в контейнере **amnezia-admin** переменные `WARP_DIR`, `WARP_CONF_PATH`, `WARP_CLIENTS_LIST`, `AMNEZIA_START_SCRIPT` через установщик — см. таблицу выше. +### Скрытие разделов в панели (`UI_HIDE_*`) + +Чтобы упростить интерфейс, можно **не показывать** отдельные блоки: + +| Токен в `UI_HIDE_SECTIONS` | Что скрывается | +|---------------------------|----------------| +| `users` | Секция **«Пользователи»** (таблица) и отладочный вывод **awg show**. Запрос **`GET /api/clients`** по-прежнему нужен странице — API для вкл/выкл peer и экспорта **не отключается**, скрыта только таблица. | +| `warp` | Блок **Cloudflare WARP**; запросы **`POST /api/warp/*`** отвечают **403**. | +| `cascade` | Блок **«Новый клиент под каскад»**; **`POST /api/clients/create-cascade`** отвечает **403**. | + +Пример установки / обновления с частично пустой панелью: + +```bash +UI_HIDE_SECTIONS=warp,cascade curl -fsSL https://raw.githubusercontent.com/andrey271192/Amnezia_web/main/scripts/install.sh | sudo -E bash +``` + +Отдельные флаги **`UI_HIDE_USERS`**, **`UI_HIDE_WARP`**, **`UI_HIDE_CASCADE`** (`1` или `true`) дублируют соответствующий токен. + Итоговые URL после установки совпадают с таблицей **«Что открывается по какому порту»** в начале этого файла; установщик записывает выбранный `HOST_PORT` в **`landing/admin-port.js`**, чтобы кнопка на лендинге вела на нужную админку. ### Каскад (свой Endpoint для клиента) diff --git a/docs/panel-guide.md b/docs/panel-guide.md index 9f8dea5..de67c91 100644 --- a/docs/panel-guide.md +++ b/docs/panel-guide.md @@ -42,6 +42,14 @@ | Включить WARP | На VPS под root: `bash scripts/warp-amnezia.sh install` из каталога репозитория, затем настройка галочек в панели и «Применить» — см. [README](../README.md). | | Полностью убрать WARP | `./scripts/warp-amnezia.sh uninstall` на хосте; учёт wgcf в `/root/` при желании удалите вручную. | +## Скрытие разделов в панели (`UI_HIDE_*`) + +Переменные контейнера **`amnezia-admin`**: **`UI_HIDE_SECTIONS`** (список `users`, `warp`, `cascade`) или отдельно **`UI_HIDE_USERS`**, **`UI_HIDE_WARP`**, **`UI_HIDE_CASCADE`** (`1` / `true`). Подробности и пример — в таблице установки и разделе README про **`UI_HIDE_*`**. + +- **`warp`** — скрывает блок WARP; **`POST /api/warp/*`** → 403. +- **`cascade`** — скрывает каскад; **`POST /api/clients/create-cascade`** → 403. +- **`users`** — скрывает таблицу «Пользователи» и отладку **awg show**; **`GET /api/clients`** и остальные операции с клиентами **остаются** (скрыт только UI таблицы). + ## Конфигурации клиентов - **Старые строки без `last_config`** — полный `.conf` с сервера собрать нельзя (нет приватного ключа). Используйте приложение Amnezia или блок **«Новый клиент под каскад»** (новый ключ на сервере). diff --git a/package.json b/package.json index 2efcf9b..6f0dba2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amnezia-admin", - "version": "1.1.4", + "version": "1.1.5", "private": true, "description": "Web-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)", "license": "MIT", diff --git a/public/app.js b/public/app.js index f684eae..e9aba5f 100644 --- a/public/app.js +++ b/public/app.js @@ -21,6 +21,10 @@ const warpActionsEl = document.querySelector("#warp-actions"); const warpClientListEl = document.querySelector("#warp-client-list"); const warpWgShowEl = document.querySelector("#warp-wg-show"); +const cascadePanel = document.querySelector("#cascade-panel"); +const usersPanel = document.querySelector("#users-panel"); +const wgRawDetails = document.querySelector("#wg-raw-details"); + const protoSwitch = document.querySelector("#proto-switch"); const protoSelect = document.querySelector("#proto-select"); const protoLabel = document.querySelector("#proto-label"); @@ -43,6 +47,23 @@ const dtExtra = document.querySelector("#dt-dialog-extra"); const dtHint = document.querySelector("#dt-dialog-hint"); const dtScheduleTunnel = document.querySelector("#dt-dialog-schedule-tunnel"); +/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */ +let uiHidden = { users: false, warp: false, cascade: false }; + +function applyUiHiddenFromPayload(data) { + const u = data?.uiHidden; + if (u && typeof u === "object") { + uiHidden = { + users: Boolean(u.users), + warp: Boolean(u.warp), + cascade: Boolean(u.cascade), + }; + } + if (usersPanel) usersPanel.hidden = uiHidden.users; + if (wgRawDetails) wgRawDetails.hidden = uiHidden.users; + if (cascadePanel) cascadePanel.hidden = uiHidden.cascade; +} + let dtMode = "disable"; /** @type {Record | null} */ let dtClient = null; @@ -514,6 +535,7 @@ pwForm.addEventListener("submit", async (ev) => { function renderRows(clients) { rowsEl.innerHTML = ""; + if (uiHidden.users) return; clients.forEach((c) => { const tr = document.createElement("tr"); @@ -617,6 +639,10 @@ function parseIpv4Cidrs(allowedIps) { /** @param {{ warp?: Record; clients: Record[] }} data */ function renderWarpPanel(data) { if (!warpPanel || !warpStatusLine || !warpActionsEl || !warpClientListEl || !warpWgShowEl) return; + if (uiHidden.warp) { + warpPanel.hidden = true; + return; + } const w = data.warp; if (!w || w.supported === false) { warpPanel.hidden = true; @@ -934,9 +960,15 @@ async function loadClients() { try { setStatus("Загрузка…", false); const data = await api("/api/clients"); + applyUiHiddenFromPayload(data); const pref = data.profileLabel ? `${data.profileLabel} · ` : ""; - peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`; - wgShowEl.textContent = data.wgShow || ""; + if (uiHidden.users) { + peerCountEl.textContent = ""; + wgShowEl.textContent = ""; + } else { + peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`; + wgShowEl.textContent = data.wgShow || ""; + } renderWarpPanel(data); renderRows(data.clients); setStatus("", false); diff --git a/public/index.html b/public/index.html index 59a6a9d..801fb17 100644 --- a/public/index.html +++ b/public/index.html @@ -135,9 +135,7 @@ -
-
-

Пользователи

+
@@ -156,8 +154,7 @@
-
- Вывод awg show (отладка) +

     
diff --git a/scripts/install.sh b/scripts/install.sh index 4d5e981..7ff337d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -109,6 +109,26 @@ if [[ -z "${AWG_PROFILES:-}" ]] && [[ -f "${AWG_PROFILE_SNAPSHOT}" ]]; then fi fi +PREV_CONTAINER_ENV="" +if docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then + PREV_CONTAINER_ENV="$(docker inspect "${CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null || true)" +fi +for __ui_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE; do + if [[ -z "${!__ui_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then + PREV_VAL="" + while IFS= read -r __line; do + if [[ "${__line}" == "${__ui_var}="* ]]; then + PREV_VAL="${__line#*=}" + break + fi + done <<<"${PREV_CONTAINER_ENV}" + if [[ -n "${PREV_VAL}" ]]; then + printf -v "${__ui_var}" '%s' "${PREV_VAL}" + echo "→ ${__ui_var} восстановлен из предыдущего контейнера ${CONTAINER_NAME}." + fi + fi +done + DOCKER_BUILD_EXTRA=() if [[ "${NO_CACHE:-}" == "1" ]]; then DOCKER_BUILD_EXTRA+=(--no-cache) @@ -152,6 +172,12 @@ for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2 fi done +for __ui_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE; do + if [[ -n "${!__ui_var:-}" ]]; then + RUN_ENV+=( -e "${__ui_var}=${!__ui_var}" ) + fi +done + if [[ -n "${BOOT_PW}" ]]; then RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" ) elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then diff --git a/server.js b/server.js index 30d500e..52931bd 100644 --- a/server.js +++ b/server.js @@ -13,6 +13,31 @@ const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000); /** Если задан, разрешает GET /api/clients/export-config?token=…&clientId=… без сессии (храните секрет только для себя). */ const EXPORT_CONFIG_SECRET = process.env.EXPORT_CONFIG_SECRET?.trim(); +function envTruthy(v) { + if (typeof v !== "string") return false; + const s = v.trim().toLowerCase(); + return s === "1" || s === "true" || s === "yes"; +} + +/** Какие блоки веб-интерфейса скрыты: `UI_HIDE_SECTIONS=users,warp,cascade` или `UI_HIDE_USERS` и т.д. */ +function resolveUiHidden() { + const raw = process.env.UI_HIDE_SECTIONS?.trim(); + const set = new Set(); + if (raw) { + for (const part of raw.split(",")) { + const k = part.trim().toLowerCase(); + if (k) set.add(k); + } + } + return { + users: set.has("users") || envTruthy(process.env.UI_HIDE_USERS), + warp: set.has("warp") || envTruthy(process.env.UI_HIDE_WARP), + cascade: set.has("cascade") || envTruthy(process.env.UI_HIDE_CASCADE), + }; +} + +const UI_HIDDEN = resolveUiHidden(); + function parseProfilesFromEnv() { const raw = process.env.AWG_PROFILES?.trim(); const fallback = () => { @@ -1280,7 +1305,17 @@ ensureDataDir(); loadOrCreateSessionSecret(); bootstrapPassword(); +/** Сообщение для отключённых через UI_HIDE разделов WARP / каскада. */ +const MSG_UI_WARP_OFF = "Раздел Cloudflare WARP отключён на этом сервере (UI_HIDE_SECTIONS / UI_HIDE_WARP)."; +const MSG_UI_CASCADE_OFF = + "Каскад отключён на этом сервере (UI_HIDE_SECTIONS / UI_HIDE_CASCADE)."; + const app = express(); +if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade) { + console.warn( + `UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade}`, + ); +} app.use(express.json({ limit: "512kb" })); app.get("/health", (_req, res) => { @@ -1495,6 +1530,7 @@ app.get("/api/clients", requireAuth, async (req, res) => { clients: rows, wgShow, warp: warpOut, + uiHidden: { ...UI_HIDDEN }, }); } catch (e) { console.error(e); @@ -1583,6 +1619,9 @@ app.post("/api/clients/export-config", requireAuth, (req, res) => { * отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел). */ app.post("/api/clients/create-cascade", requireAuth, async (req, res) => { + if (UI_HIDDEN.cascade) { + return res.status(403).json({ error: MSG_UI_CASCADE_OFF }); + } const rt = runtimeFromExportRequest(req); let endpointHost; try { @@ -1696,6 +1735,9 @@ AllowedIPs = ${tunnelIp}/32 }); app.post("/api/warp/start", requireAuth, async (req, res) => { + if (UI_HIDDEN.warp) { + return res.status(403).json({ error: MSG_UI_WARP_OFF }); + } const rt = runtimeForRequest(req); if (!(await warpFileExists(rt, rt.profile.warpConf))) { return res.status(400).json({ @@ -1714,6 +1756,9 @@ app.post("/api/warp/start", requireAuth, async (req, res) => { }); app.post("/api/warp/stop", requireAuth, async (req, res) => { + if (UI_HIDDEN.warp) { + return res.status(403).json({ error: MSG_UI_WARP_OFF }); + } const rt = runtimeForRequest(req); if (!(await warpFileExists(rt, rt.profile.warpConf))) { return res.status(400).json({ error: "WARP не установлен." }); @@ -1728,6 +1773,9 @@ app.post("/api/warp/stop", requireAuth, async (req, res) => { }); app.post("/api/warp/routing", requireAuth, async (req, res) => { + if (UI_HIDDEN.warp) { + return res.status(403).json({ error: MSG_UI_WARP_OFF }); + } const rt = runtimeForRequest(req); if (!(await warpFileExists(rt, rt.profile.warpConf))) { return res.status(400).json({