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 <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-14 20:01:43 +03:00
parent 50558aff63
commit 8f1d7a7c4a
7 changed files with 145 additions and 8 deletions

View File

@@ -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_PORT` | `80` | Порт nginx-лендинга (если `80` занят — например `8081`) |
| `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга | | `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга |
| `NO_CACHE` | `0` | `1``docker build --no-cache` при проблемах с обновлением образа | | `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` перед его удалением — так переключатель «Инстанс» не пропадает после обновления панели. Переменная **`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 перезапустится). Скрипт регистрирует туннель через [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`, если скачан). Подкоманды: `install`, `start`, `stop`, `status`, `rekey`, **`uninstall`**. Учёт wgcf на хосте: `/root/wgcf-account.toml` (и бинарник `/root/wgcf`, если скачан).
**Полностью убрать WARP** после установки (интерфейс `warp`, правила маршрутизации/NAT, блок `# --- WARP-MANAGER ---` в `start.sh`, `warp.conf` и список клиентов в каталоге WARP в контейнере; затем перезапуск контейнера AWG): **Полностью убрать 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` через установщик — см. таблицу выше. Необязательно задавать в контейнере **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`**, чтобы кнопка на лендинге вела на нужную админку. Итоговые URL после установки совпадают с таблицей **«Что открывается по какому порту»** в начале этого файла; установщик записывает выбранный `HOST_PORT` в **`landing/admin-port.js`**, чтобы кнопка на лендинге вела на нужную админку.
### Каскад (свой Endpoint для клиента) ### Каскад (свой Endpoint для клиента)

View File

@@ -42,6 +42,14 @@
| Включить WARP | На VPS под root: `bash scripts/warp-amnezia.sh install` из каталога репозитория, затем настройка галочек в панели и «Применить» — см. [README](../README.md). | | Включить WARP | На VPS под root: `bash scripts/warp-amnezia.sh install` из каталога репозитория, затем настройка галочек в панели и «Применить» — см. [README](../README.md). |
| Полностью убрать WARP | `./scripts/warp-amnezia.sh uninstall` на хосте; учёт wgcf в `/root/` при желании удалите вручную. | | Полностью убрать 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 или блок **«Новый клиент под каскад»** (новый ключ на сервере). - **Старые строки без `last_config`** — полный `.conf` с сервера собрать нельзя (нет приватного ключа). Используйте приложение Amnezia или блок **«Новый клиент под каскад»** (новый ключ на сервере).

View File

@@ -1,6 +1,6 @@
{ {
"name": "amnezia-admin", "name": "amnezia-admin",
"version": "1.1.4", "version": "1.1.5",
"private": true, "private": true,
"description": "Web-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)", "description": "Web-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
"license": "MIT", "license": "MIT",

View File

@@ -21,6 +21,10 @@ 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 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 protoSwitch = document.querySelector("#proto-switch");
const protoSelect = document.querySelector("#proto-select"); const protoSelect = document.querySelector("#proto-select");
const protoLabel = document.querySelector("#proto-label"); 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 dtHint = document.querySelector("#dt-dialog-hint");
const dtScheduleTunnel = document.querySelector("#dt-dialog-schedule-tunnel"); 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"; let dtMode = "disable";
/** @type {Record<string, unknown> | null} */ /** @type {Record<string, unknown> | null} */
let dtClient = null; let dtClient = null;
@@ -514,6 +535,7 @@ pwForm.addEventListener("submit", async (ev) => {
function renderRows(clients) { function renderRows(clients) {
rowsEl.innerHTML = ""; rowsEl.innerHTML = "";
if (uiHidden.users) return;
clients.forEach((c) => { clients.forEach((c) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
@@ -617,6 +639,10 @@ function parseIpv4Cidrs(allowedIps) {
/** @param {{ warp?: Record<string, unknown>; clients: Record<string, unknown>[] }} data */ /** @param {{ warp?: Record<string, unknown>; clients: Record<string, unknown>[] }} data */
function renderWarpPanel(data) { function renderWarpPanel(data) {
if (!warpPanel || !warpStatusLine || !warpActionsEl || !warpClientListEl || !warpWgShowEl) return; if (!warpPanel || !warpStatusLine || !warpActionsEl || !warpClientListEl || !warpWgShowEl) return;
if (uiHidden.warp) {
warpPanel.hidden = true;
return;
}
const w = data.warp; const w = data.warp;
if (!w || w.supported === false) { if (!w || w.supported === false) {
warpPanel.hidden = true; warpPanel.hidden = true;
@@ -934,9 +960,15 @@ async function loadClients() {
try { try {
setStatus("Загрузка…", false); setStatus("Загрузка…", false);
const data = await api("/api/clients"); const data = await api("/api/clients");
applyUiHiddenFromPayload(data);
const pref = data.profileLabel ? `${data.profileLabel} · ` : ""; const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
if (uiHidden.users) {
peerCountEl.textContent = "";
wgShowEl.textContent = "";
} else {
peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`; peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`;
wgShowEl.textContent = data.wgShow || ""; wgShowEl.textContent = data.wgShow || "";
}
renderWarpPanel(data); renderWarpPanel(data);
renderRows(data.clients); renderRows(data.clients);
setStatus("", false); setStatus("", false);

View File

@@ -135,9 +135,7 @@
</form> </form>
</section> </section>
<section class="panel"> <section class="panel" id="users-panel">
<div class="panel-head">
<h2>Пользователи</h2>
<span class="muted" id="peer-count"></span> <span class="muted" id="peer-count"></span>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
@@ -156,8 +154,7 @@
</div> </div>
</section> </section>
<details class="raw"> <details class="raw" id="wg-raw-details">
<summary>Вывод awg show (отладка)</summary>
<pre id="wg-show"></pre> <pre id="wg-show"></pre>
</details> </details>
</div> </div>

View File

@@ -109,6 +109,26 @@ if [[ -z "${AWG_PROFILES:-}" ]] && [[ -f "${AWG_PROFILE_SNAPSHOT}" ]]; then
fi fi
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=() DOCKER_BUILD_EXTRA=()
if [[ "${NO_CACHE:-}" == "1" ]]; then if [[ "${NO_CACHE:-}" == "1" ]]; then
DOCKER_BUILD_EXTRA+=(--no-cache) DOCKER_BUILD_EXTRA+=(--no-cache)
@@ -152,6 +172,12 @@ for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2
fi fi
done 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 if [[ -n "${BOOT_PW}" ]]; then
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" ) RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then

View File

@@ -13,6 +13,31 @@ const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000);
/** Если задан, разрешает GET /api/clients/export-config?token=…&clientId=… без сессии (храните секрет только для себя). */ /** Если задан, разрешает GET /api/clients/export-config?token=…&clientId=… без сессии (храните секрет только для себя). */
const EXPORT_CONFIG_SECRET = process.env.EXPORT_CONFIG_SECRET?.trim(); 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() { function parseProfilesFromEnv() {
const raw = process.env.AWG_PROFILES?.trim(); const raw = process.env.AWG_PROFILES?.trim();
const fallback = () => { const fallback = () => {
@@ -1280,7 +1305,17 @@ ensureDataDir();
loadOrCreateSessionSecret(); loadOrCreateSessionSecret();
bootstrapPassword(); 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(); 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.use(express.json({ limit: "512kb" }));
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
@@ -1495,6 +1530,7 @@ app.get("/api/clients", requireAuth, async (req, res) => {
clients: rows, clients: rows,
wgShow, wgShow,
warp: warpOut, warp: warpOut,
uiHidden: { ...UI_HIDDEN },
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -1583,6 +1619,9 @@ app.post("/api/clients/export-config", requireAuth, (req, res) => {
* отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел). * отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел).
*/ */
app.post("/api/clients/create-cascade", requireAuth, async (req, res) => { 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); const rt = runtimeFromExportRequest(req);
let endpointHost; let endpointHost;
try { try {
@@ -1696,6 +1735,9 @@ AllowedIPs = ${tunnelIp}/32
}); });
app.post("/api/warp/start", requireAuth, async (req, res) => { 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); const rt = runtimeForRequest(req);
if (!(await warpFileExists(rt, rt.profile.warpConf))) { if (!(await warpFileExists(rt, rt.profile.warpConf))) {
return res.status(400).json({ 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) => { 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); const rt = runtimeForRequest(req);
if (!(await warpFileExists(rt, rt.profile.warpConf))) { if (!(await warpFileExists(rt, rt.profile.warpConf))) {
return res.status(400).json({ error: "WARP не установлен." }); 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) => { 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); const rt = runtimeForRequest(req);
if (!(await warpFileExists(rt, rt.profile.warpConf))) { if (!(await warpFileExists(rt, rt.profile.warpConf))) {
return res.status(400).json({ return res.status(400).json({