mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
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:
26
README.md
26
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 для клиента)
|
||||
|
||||
@@ -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 или блок **«Новый клиент под каскад»** (новый ключ на сервере).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>; clients: Record<string, unknown>[] }} 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);
|
||||
|
||||
@@ -135,9 +135,7 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Пользователи</h2>
|
||||
<section class="panel" id="users-panel">
|
||||
<span class="muted" id="peer-count"></span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@@ -156,8 +154,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="raw">
|
||||
<summary>Вывод awg show (отладка)</summary>
|
||||
<details class="raw" id="wg-raw-details">
|
||||
<pre id="wg-show"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
48
server.js
48
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({
|
||||
|
||||
Reference in New Issue
Block a user