diff --git a/.amnezia-panel-edition b/.amnezia-panel-edition
new file mode 100644
index 0000000..1cfafac
--- /dev/null
+++ b/.amnezia-panel-edition
@@ -0,0 +1 @@
+community
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..93f1361
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+node_modules
+npm-debug.log
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..d9a624b
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,5 @@
+# Как в https://github.com/andrey271192/domen_hydra/blob/main/.github/FUNDING.yml
+github: andrey271192
+
+custom:
+ - https://boosty.to/andrey27/donate
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b1d9ed3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+npm-debug.log*
+.DS_Store
+*.swp
+.env
+.env.*
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..bc3e484
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,20 @@
+FROM node:22-alpine
+
+RUN apk add --no-cache docker-cli openssh-client sshpass
+
+RUN mkdir -p /data && chmod 700 /data
+
+WORKDIR /app
+
+COPY package.json ./
+RUN npm install --omit=dev
+
+COPY server.js ./server.js
+COPY public ./public
+
+ENV NODE_ENV=production
+ENV PORT=3980
+
+EXPOSE 3980
+
+CMD ["node", "server.js"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..14fac91
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 7ca0743..e35de93 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,72 @@
-# amnezia_web
\ No newline at end of file
+# amnezia_web
+
+Открытая **базовая** веб-панель для **просмотра** клиентов **AmneziaWG** на своём VPS: таблица клиентов, статус «в туннеле», AllowedIPs, несколько инстансов через **`AWG_PROFILES`**, часы сервера и браузера.
+**Нет** в интерфейсе и по API: включение/выключение peer, правка дат отключения, переименование, удаление, экспорт `.conf`, «Новый клиент под каскад», Cloudflare WARP, синхронизация времени хоста по SSH — это **[версия PRO](https://boosty.to/andrey27/donate)** (приватный репозиторий **amnezia_web-PRO**, доступ подписчикам).
+
+Редакция **`community`** задаётся автоматически файлом **`.amnezia-panel-edition`** в корне репозитория (`community`) или переменной окружения **`AMNEZIA_EDITION=community`** в контейнере. Кнопка и текст про подписку настраиваются **`COMMUNITY_UPGRADE_URL`** и **`COMMUNITY_UPGRADE_PITCH`**.
+
+**Безопасность:** доступ к Docker-сокету в контейнере панели эквивалентен root на хосте — используйте сложный пароль и ограничьте доступ по IP / TLS.
+
+Справочник по типичным сбоям и API (в т.ч. для PRO): в полной документации репозитория PRO.
+
+---
+
+## Установка одной командой
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh | sudo bash
+```
+
+Форк или ветка:
+
+```bash
+GITHUB_REPO=вы/репо BRANCH=main curl -fsSL https://raw.githubusercontent.com/вы/репо/main/scripts/install.sh | sudo 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.
+
+### Важные переменные
+
+| Переменная | По умолчанию | Назначение |
+|------------|--------------|------------|
+| `GITHUB_REPO` | `andrey271192/amnezia_web` | Архив для установки |
+| `HOST_PORT` | `8080` | Порт панели |
+| `AWG_CONTAINER` | `amnezia-awg2` | Контейнер WG по умолчанию |
+| `AWG_PROFILES` | _(нет)_ | Несколько инстансов; см. примеры в репозитории PRO |
+| `COMMUNITY_UPGRADE_URL` | Boosty автора | Куда ведёт кнопка «Разблокировать PRO» |
+| `COMMUNITY_UPGRADE_PITCH` | _(текст по умолчанию в коде)_ | Текст под заголовком базовой версии |
+| `SKIP_LANDING` | `0` | `1` — без лендинга на порту 80 |
+
+Остальные переменные совместимы с образом панели (см. Dockerfile / `server.js` в этом репозитории).
+
+---
+
+## Обновление
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/install.sh | sudo bash
+```
+
+Принудительная пересборка образа: **`NO_CACHE=1`**.
+
+---
+
+## Удаление
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scripts/uninstall.sh | sudo bash
+```
+
+---
+
+## Лицензия
+
+MIT — см. [LICENSE](LICENSE).
diff --git a/docs/panel-guide.md b/docs/panel-guide.md
new file mode 100644
index 0000000..aa898ef
--- /dev/null
+++ b/docs/panel-guide.md
@@ -0,0 +1,93 @@
+# Руководство по Amnezia Admin WebUI
+
+Краткий справочник по функциям, типичным проблемам и HTTP API.
+
+## Возможности
+
+| Блок в интерфейсе | Назначение |
+|-------------------|------------|
+| **Инстанс** | Переключение между профилями из `AWG_PROFILES` (разные контейнеры AmneziaWG / Legacy). Виден только если в контейнере панели задан JSON из **двух и более** профилей. |
+| **Время** | Отображение часов контейнера и браузера; синхронизация времени хоста VPS по SSH (если доступно). |
+| **Cloudflare WARP** | *Необязательно.* Вывод части клиентов в интернет через интерфейс `warp` в контейнере AWG. Если WARP не ставили — статус **«Не установлен»** нормален; панель и VPN без этого работают. Установка и удаление — скрипт `scripts/warp-amnezia.sh` на хосте (`install` / **`uninstall`**), подробности в основном [README](../README.md). |
+| **Новый клиент под каскад** | Создание нового peer на сервере с **вашим Endpoint** (промежуточный узел); выдача готового `.conf`. |
+| **Пользователи** | Вкл/выкл peer, удаление, переименование, даты отключения; экспорт `.conf` если в записи есть `userData.last_config`. |
+
+## Переключатель «Инстанс» не отображается
+
+1. Проверьте переменную контейнера панели:
+ ```bash
+ docker inspect amnezia-admin --format '{{range .Config.Env}}{{println .}}{{end}}' | grep '^AWG_PROFILES='
+ ```
+2. Если строки нет — один раз задайте JSON при установке (пример см. в основном [README](../README.md)) или восстановите файл **`/root/amnezia-admin.awg-profiles.json`** на VPS и снова запустите `install.sh` **без** своего `AWG_PROFILES` — установщик подставит значение из файла или из старого контейнера перед удалением.
+3. После правок обновите страницу с **жёстким сбросом кэша** (Ctrl+Shift+R).
+
+## Лендинг не поднимается (порт 80 занят)
+
+При ошибке bind `:80` используйте при установке **`SKIP_LANDING=1`** или **`LANDING_PORT=8081`** — админка на `HOST_PORT` (например 8080) от этого не зависит.
+
+## Публичная страница и админка
+
+- **`http://IP:LANDING_PORT`** (часто **80**) — статический nginx из каталога **`landing/`**: инструкция, переход в админку, напоминание написать **администратору вашего сервера**, дисклеймер. Ссылок на донат и автора репозитория здесь нет.
+- **`http://IP:HOST_PORT`** (часто **8080**) — сама панель (`public/`). Футер со ссылками автора (GitHub, Boosty и т.д.) только здесь, внизу после таблицы клиентов.
+
+Файл **`landing/admin-port.js`** пересобирается установщиком и задаёт порт админки для кнопки на лендинге.
+
+## Cloudflare WARP: нужно ли ставить
+
+**Нет, если обычного VPN достаточно.** WARP — дополнительная опция «выход в интернет через Cloudflare» для отмеченных в панели клиентов (IPv4 вида `10.8.x.x/32`).
+
+| Задача | Действие |
+|--------|----------|
+| WARP не нужен | Ничего не устанавливайте; раздел в вебе с текстом «Не установлен» можно игнорировать. |
+| Включить 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 или блок **«Новый клиент под каскад»** (новый ключ на сервере).
+- **Экспорт по кнопкам** — только если в `clientsTable` есть **`userData.last_config`** с полем `config` или `client_priv_key`.
+
+### Экспорт: имя файла и прямая ссылка
+
+Ответ **`GET /api/clients/export-config`** отдаёт заголовок **`Content-Disposition: attachment; filename="…"`**. В Node.js значение заголовка должно быть в **ASCII**: кириллическое имя клиента в приложении не попадает в имя файла как есть — используется безопасная подстановка (латиница из имени или короткий префикс от `clientId`), чтобы не было ошибки вида `Invalid character in header content`. Содержимое `.conf` при этом остаётся полным UTF-8 текстом.
+
+Прямая ссылка в браузере работает при активной **cookie-сессии** после входа или с **`?token=…`**, если задан **`EXPORT_CONFIG_SECRET`**.
+
+## HTTP API (все маршруты под `/`, кроме статики)
+
+Требуют cookie-сессии после **`POST /api/login`**, если не указано иное.
+
+| Метод | Путь | Назначение |
+|-------|------|------------|
+| GET | `/health` | Проверка живости |
+| GET | `/api/session` | Есть ли действующая сессия |
+| POST | `/api/login` | `{ "password": "…" }` |
+| POST | `/api/logout` | Выход |
+| POST | `/api/change-password` | Смена пароля |
+| GET | `/api/protocols` | Текущий профиль, список инстансов, подсказка если профиль один |
+| POST | `/api/protocol` | `{ "profileId": "…" }` — смена инстанса |
+| GET | `/api/clients` | Таблица клиентов и метаданные WARP |
+| POST | `/api/clients/disable` | Выключить peer |
+| POST | `/api/clients/enable` | Включить peer |
+| POST | `/api/clients/delete` | Удалить |
+| POST | `/api/clients/rename` | Переименовать |
+| POST | `/api/clients/disconnect-date` | Даты отключения / расписание |
+| GET/POST | `/api/clients/export-config` | Скачать `.conf`; GET — прямая ссылка (сессия); опционально `?token=…` если задан `EXPORT_CONFIG_SECRET` |
+| POST | `/api/clients/create-cascade` | `{ "endpointHost", "endpointPort?", "tunnelIp?", "clientName?", "profileId?" }` — новый peer и файл `.conf` |
+| POST | `/api/warp/host-setup` | Установка/удаление WARP на хосте по SSH: `{ "rootPassword", "cmd": "install" \| "uninstall" }` (как синхронизация времени; каталог скрипта — `WARP_SSH_INSTALL_DIR`) |
+| POST | `/api/warp/start` | Поднять WARP |
+| POST | `/api/warp/stop` | Остановить WARP |
+| POST | `/api/warp/routing` | Политика по клиентам |
+| GET | `/api/server-time` | Время и подсказки по поясам |
+| GET | `/api/time-sync-capabilities` | Доступность синхронизации по SSH |
+| POST | `/api/sync-host-time` | Запись времени на хост через SSH |
+
+Подробности переменных окружения — в таблице установки в [README](../README.md).
diff --git a/docs/screenshots/README.txt b/docs/screenshots/README.txt
new file mode 100644
index 0000000..8798aa1
--- /dev/null
+++ b/docs/screenshots/README.txt
@@ -0,0 +1 @@
+Screenshots live in the PRO repo to keep this clone small.
diff --git a/landing/Dockerfile b/landing/Dockerfile
new file mode 100644
index 0000000..313f0cc
--- /dev/null
+++ b/landing/Dockerfile
@@ -0,0 +1,3 @@
+FROM nginx:1.27-alpine
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY index.html styles.css admin-port.js /usr/share/nginx/html/
diff --git a/landing/admin-port.js b/landing/admin-port.js
new file mode 100644
index 0000000..52d4a1b
--- /dev/null
+++ b/landing/admin-port.js
@@ -0,0 +1 @@
+window.__AMNEZIA_ADMIN_PORT__ = "8080";
diff --git a/landing/index.html b/landing/index.html
new file mode 100644
index 0000000..01c7805
--- /dev/null
+++ b/landing/index.html
@@ -0,0 +1,355 @@
+
+
+
+
+
+ Подключение к VPN · частный сервер
+
+
+
+
+
+
+
+
+
+
+ Частный сервер
+ Подключение к VPN
+ Используйте приложение AmneziaVPN . Конфигурацию выдаёт администратор — импортируйте файл или ключ, который вам передали, и включите туннель. Ниже — пошаговые инструкции для WireGuard и Amnezia VPN.
+
+
+
+
+
Сервер
+
Адрес
+
+
Сервисы на машине: XRay (TCP) · AmneziaWG (UDP)
+
Номера портов и тип протокола уже зашиты в конфиг Amnezia — вручную их обычно не вводят.
+
+
+
+
+ Подключение VPN
+ Смена региона App Store
+
+
+
+
+
+
+ WireGuard Стандарт
+ Amnezia VPN Обход DPI
+
+
+
+
+
+
+
+ iOS
+ Android
+ Windows
+ macOS
+
+
+
+
+
+ 1 Откройте App Store , найдите WireGuard и нажмите «Установить»
+ 2 Откройте приложение, нажмите + в правом верхнем углу
+ 3 Выберите «Сканировать QR-код» и наведите камеру на QR — конфиг добавится автоматически
Или «Создать из файла» → выберите файл .conf
+ 4 Придумайте имя туннелю и нажмите «Сохранить»
+ 5 Нажмите тумблер → появится запрос, нажмите «Разрешить»
+
+
✅ Тумблер зелёный — вы подключены. Значок VPN в строке статуса.
+
+
+
+
+
+
+ 1 Откройте Google Play , найдите WireGuard и установите
+ 2 Запустите приложение, нажмите синюю кнопку + внизу
+ 3 Выберите «Сканировать QR-код» и поднесите телефон к коду
Или «Создать из файла» → файл .conf из загрузок
+ 4 Дайте имя туннелю и нажмите «Создать туннель»
+ 5 Переключите тумблер → нажмите «ОК» в системном запросе
+
+
✅ Значок замка в строке состояния — VPN активен.
+
+
+
+
+
+
+ 1 Скачайте установщик с wireguard.com/install и запустите
+ 2 Откройте WireGuard → «Добавить туннель» → «Импорт из файла»
+ 3
+ 4 Нажмите «Подключиться» — статус сменится на «Активен»
+
+
Если появится запрос UAC — нажмите «Да». Нужно для создания VPN-адаптера.
+
✅ Статус «Активен» — VPN работает.
+
+
+
+
+
+
+ 1 Установите WireGuard из Mac App Store
+ 2 Откройте приложение → в меню File → Import Tunnel(s)
+ 3 Выберите файл .conf и нажмите «Открыть»
+ 4 Нажмите «Подключить» и разрешите VPN в системных настройках
+
+
✅ Иконка в меню-баре активна — вы подключены.
+
+
+
+
+
+
+
+ iOS
+ Android
+ Windows
+ macOS
+
+
+
+
+
+ 1 Откройте App Store , найдите Amnezia VPN и установите
+ 2 Запустите приложение и нажмите «Добавить сервер»
+ 3 Нажмите «У меня есть файл настроек» и выберите файл .vpn
Или «Сканировать QR» → поднесите камеру к коду
+ 4 Нажмите «Подключиться» и разрешите добавить VPN-профиль
+
+
️ Amnezia маскирует трафик под обычный HTTPS — работает там, где WireGuard блокируется.
+
✅ Кнопка стала оранжевой — вы защищены.
+
+
+
+
+
+
+ 1 Установите Amnezia VPN из Google Play или APK с amnezia.org
+ 2 Откройте → «Добавить сервер»
+ 3 Нажмите «Загрузить файл» и выберите файл .vpn
+ 4 Нажмите «Подключиться» → разрешите системный запрос VPN
+
+
✅ Замок в строке состояния — VPN работает.
+
+
+
+
+
+
+ 1 Скачайте с amnezia.org для Windows и запустите установщик
+ 2 Откройте → «Добавить сервер» → «Открыть файл конфигурации»
+ 3 Выберите файл .vpn и нажмите «Подключиться»
+
+
AmneziaWG — улучшенный WireGuard с обходом DPI.
+
✅ Индикатор зелёный — VPN активен.
+
+
+
+
+
+
+ 1 Скачайте с amnezia.org для macOS
+ 2 Перетащите в папку «Программы» и запустите
+ 3 Нажмите «Добавить сервер» → загрузите файл .vpn
+ 4 Нажмите «Подключиться» и разрешите сетевые расширения в системных настройках
+
+
✅ Иконка Amnezia в меню-баре активна — вы защищены.
+
+
+
+
+
+
Как проверить, что VPN работает?
+
+
1. Откройте 2ip.ru или whatismyip.com
+
2. Страна должна измениться на страну вашего сервера
+
3. Попробуйте открыть нужный сайт — загрузится
+
+
+
+
+
Что делать, если что-то пошло не так
+
+
VPN не подключается
Перезапустите приложение Выключите/включите Wi-Fi или мобильный интернет Удалите туннель и добавьте конфиг заново Попробуйте другую сеть
+
VPN включён, нет интернета
Отключитесь и подключитесь снова Убедитесь, что конфиг актуальный Перезагрузите устройство Напишите в поддержку
+
Сайты не открываются
Очистите кеш браузера Смените DNS на 1.1.1.1 Откройте в режиме инкогнито Попробуйте другой браузер
+
Медленная скорость
Проверьте скорость без VPN Переподключитесь Закройте лишние приложения Переключитесь Wi-Fi ↔ 4G
+
+
+
+
+
+
+
+
+
+
+
Смена региона App Store
+
Пошаговые инструкции для iPhone и iPad
+
+
+ Казахстан
+ США
+
+
+
+
Смена региона позволяет скачивать приложения, недоступные в вашем регионе. Покупки сохранятся, но активные подписки могут потребовать подтверждения.
+
+
+
+ 1 Откройте Настройки → нажмите на своё имя вверху
+ 2 Нажмите «Медиаматериалы и покупки» → «Просмотр учётной записи»
+ 3 Нажмите «Страна/Регион» → «Сменить страну или регион»
+ 4 Найдите и выберите Казахстан
+ 5 Нажмите «Принять» условия
+ 6 Способ оплаты — выберите «Нет»
+ 7 Заполните адрес:
Пример адреса · Алматы
Город Алматы
Адрес ул. Абая 1
Индекс 050000
Телефон +7 727 000 0000
+ 8 Нажмите «Далее» — регион изменён
+
+
✅ Готово! Откройте App Store — доступны приложения казахстанского региона.
+
+
+
+
+
Американский App Store даёт максимальный доступ к приложениям. Рекомендуем штат Орегон — там нет налога с продаж.
+
+
+
+ 1 Откройте Настройки → нажмите на своё имя вверху
+ 2 Нажмите «Медиаматериалы и покупки» → «Просмотр учётной записи»
+ 3 Нажмите «Страна/Регион» → «Сменить страну или регион»
+ 4 Найдите и выберите Соединённые Штаты
+ 5 Нажмите «Принять» условия
+ 6 Способ оплаты — выберите «None / Нет»
+ 7 Заполните адрес (штат Орегон — без налогов):
Пример адреса · Oregon, No Tax
Street 1234 SW Main St
City Portland
State Oregon (OR)
ZIP 97201
Phone 503-555-0100
+ 8 Нажмите «Далее» — регион изменён
+
+
✅ Готово! Бесплатные приложения доступны сразу — без карты.
+
Для платных нужна американская карта или подарочная карта iTunes (Gift Card).
+
+
+
Как вернуть свой регион обратно?
+
+
1. Повторите шаги 1–3 из инструкции выше
+
2. Выберите свою страну из списка
+
3. Скачанные приложения останутся на телефоне
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/landing/nginx.conf b/landing/nginx.conf
new file mode 100644
index 0000000..7fdc4bd
--- /dev/null
+++ b/landing/nginx.conf
@@ -0,0 +1,20 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ gzip on;
+ gzip_types text/css text/plain application/json application/javascript;
+
+ add_header X-Content-Type-Options nosniff always;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location ~* \.css$ {
+ expires 1h;
+ add_header Cache-Control "public, max-age=3600";
+ }
+}
diff --git a/landing/styles.css b/landing/styles.css
new file mode 100644
index 0000000..231610b
--- /dev/null
+++ b/landing/styles.css
@@ -0,0 +1,949 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+:root {
+ --teal: #1d9e75;
+ --teal-light: #e1f5ee;
+ --teal-dark: #085041;
+ --blue: #378add;
+ --blue-dark: #185fa5;
+ --amber: #ba7517;
+ --bg: #0d0f12;
+ --bg2: #141720;
+ --bg3: #1c202b;
+ --bg4: #232837;
+ --border: rgba(255, 255, 255, 0.08);
+ --border2: rgba(255, 255, 255, 0.14);
+ --text: #f0f2f5;
+ --muted: #8a93a8;
+ --muted2: #5e6880;
+ --font: "Outfit", system-ui, sans-serif;
+ --mono: "JetBrains Mono", ui-monospace, monospace;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ font-family: var(--font);
+ background: var(--bg);
+ color: var(--text);
+ min-height: 100vh;
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+}
+
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: rgba(13, 15, 18, 0.92);
+ backdrop-filter: blur(20px);
+ border-bottom: 1px solid var(--border);
+ padding: 0 1.5rem;
+}
+
+.header-inner {
+ max-width: 900px;
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-height: 56px;
+ flex-wrap: wrap;
+ padding: 8px 0;
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-weight: 700;
+ font-size: 17px;
+ letter-spacing: -0.02em;
+ text-decoration: none;
+ color: var(--text);
+}
+
+.logo-icon {
+ width: 32px;
+ height: 32px;
+ border-radius: 9px;
+ background: linear-gradient(135deg, var(--teal), #0a5c45);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 15px;
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.header-admin {
+ padding: 8px 14px;
+ border-radius: 10px;
+ border: 1px solid var(--border2);
+ background: var(--bg3);
+ font-family: var(--font);
+ font-size: 13px;
+ font-weight: 600;
+ color: #5fd8b0;
+ text-decoration: none;
+ transition:
+ border-color 0.18s,
+ background 0.18s;
+}
+
+.header-admin:hover {
+ border-color: rgba(29, 158, 117, 0.45);
+ background: rgba(29, 158, 117, 0.1);
+}
+
+.status-pill {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 12px;
+ border-radius: 20px;
+ border: 1px solid rgba(29, 158, 117, 0.3);
+ background: rgba(29, 158, 117, 0.08);
+ font-size: 12px;
+ color: var(--teal);
+ font-weight: 500;
+}
+
+.status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--teal);
+ box-shadow: 0 0 6px var(--teal);
+ animation: pulse 2s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+.hero {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 2.5rem 1.5rem 1.25rem;
+ text-align: center;
+}
+
+.hero-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 14px;
+ border-radius: 20px;
+ border: 1px solid var(--border2);
+ background: var(--bg3);
+ font-size: 12px;
+ color: #5fd8b0;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ margin-bottom: 1rem;
+}
+
+.hero h1 {
+ font-size: clamp(26px, 6vw, 44px);
+ font-weight: 700;
+ letter-spacing: -0.03em;
+ line-height: 1.15;
+ margin-bottom: 0.5rem;
+ background: linear-gradient(135deg, #f0f2f5 30%, #8a93a8);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.hero p {
+ font-size: clamp(14px, 2.5vw, 16px);
+ color: var(--muted);
+ max-width: 520px;
+ margin: 0 auto 1.25rem;
+}
+
+.hero p strong {
+ color: var(--text);
+ font-weight: 600;
+}
+
+.server-summary {
+ max-width: 900px;
+ margin: 0 auto 1.5rem;
+ padding: 0 1.5rem;
+}
+
+.server-summary-card {
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ padding: 1.25rem 1.5rem;
+}
+
+.server-summary-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: #5fd8b0;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ margin-bottom: 12px;
+}
+
+.server-addr-label {
+ display: block;
+ font-size: 13px;
+ color: var(--muted);
+ margin-bottom: 8px;
+}
+
+.server-addr-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.addr-box {
+ flex: 1;
+ min-width: 180px;
+ font-family: var(--mono);
+ font-size: 14px;
+ padding: 10px 14px;
+ background: var(--bg4);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ color: var(--text);
+ word-break: break-all;
+}
+
+.copy-btn {
+ padding: 10px 18px;
+ border-radius: 10px;
+ border: none;
+ background: linear-gradient(135deg, var(--teal), #0a6b4f);
+ color: #fff;
+ font-family: var(--font);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: filter 0.15s;
+}
+
+.copy-btn:hover {
+ filter: brightness(1.06);
+}
+
+.copy-btn.copied {
+ background: var(--bg4);
+ color: #5fd8b0;
+ border: 1px solid rgba(29, 158, 117, 0.4);
+}
+
+.services-line {
+ font-size: 13.5px;
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.services-line strong {
+ color: var(--text);
+ font-weight: 600;
+}
+
+.server-note {
+ margin-top: 10px;
+ font-size: 12.5px;
+ color: var(--muted2);
+ line-height: 1.5;
+}
+
+.page-nav {
+ max-width: 900px;
+ margin: 0 auto 1.5rem;
+ padding: 0 1.5rem;
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.page-nav-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 11px 20px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: var(--bg3);
+ font-family: var(--font);
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--muted);
+ cursor: pointer;
+ transition: all 0.18s;
+ flex: 1;
+ justify-content: center;
+ min-width: 160px;
+}
+
+.page-nav-btn:hover {
+ border-color: var(--border2);
+ color: var(--text);
+}
+
+.page-nav-btn.active {
+ background: var(--bg4);
+ color: var(--text);
+ border-color: var(--border2);
+}
+
+.page-section {
+ display: none;
+}
+
+.page-section.visible {
+ display: block;
+}
+
+.vpn-switcher {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 0 1.5rem;
+}
+
+.vpn-tabs {
+ display: flex;
+ background: var(--bg3);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 4px;
+ gap: 4px;
+ margin-bottom: 1.25rem;
+}
+
+.vpn-tab {
+ flex: 1;
+ padding: 10px 16px;
+ border-radius: 10px;
+ border: none;
+ background: transparent;
+ font-family: var(--font);
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--muted);
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.vpn-tab.active {
+ background: var(--bg4);
+ color: var(--text);
+ border: 1px solid var(--border2);
+}
+
+.vpn-tab-badge {
+ font-size: 10px;
+ padding: 2px 7px;
+ border-radius: 8px;
+ font-weight: 600;
+}
+
+.wg-badge {
+ background: rgba(55, 138, 221, 0.15);
+ color: #7ab8f5;
+}
+
+.amn-badge {
+ background: rgba(29, 158, 117, 0.15);
+ color: #5fd8b0;
+}
+
+.platform-selector {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 1.25rem;
+ flex-wrap: wrap;
+}
+
+.plat-btn {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 8px 14px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--bg3);
+ font-family: var(--font);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--muted);
+ cursor: pointer;
+ transition: all 0.18s;
+ flex: 1;
+ justify-content: center;
+ min-width: 80px;
+}
+
+.plat-btn:hover {
+ border-color: var(--border2);
+ color: var(--text);
+}
+
+.plat-btn.wg-active {
+ background: rgba(55, 138, 221, 0.1);
+ border-color: rgba(55, 138, 221, 0.4);
+ color: #7ab8f5;
+}
+
+.plat-btn.amn-active {
+ background: rgba(29, 158, 117, 0.1);
+ border-color: rgba(29, 158, 117, 0.4);
+ color: #5fd8b0;
+}
+
+.content {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 0 1.5rem 3rem;
+}
+
+.vpn-section {
+ display: none;
+}
+
+.vpn-section.visible {
+ display: block;
+}
+
+.steps-panel {
+ display: none;
+}
+
+.steps-panel.visible {
+ display: block;
+ animation: fadeUp 0.2s ease;
+}
+
+.region-panel {
+ display: none;
+}
+
+.region-panel.visible {
+ display: block;
+ animation: fadeUp 0.2s ease;
+}
+
+@keyframes fadeUp {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.guide-card {
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ overflow: hidden;
+}
+
+.guide-card-header {
+ padding: 1.25rem 1.5rem;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: var(--bg3);
+}
+
+.plat-icon-lg {
+ width: 44px;
+ height: 44px;
+ border-radius: 13px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 22px;
+ flex-shrink: 0;
+}
+
+.ios-icon {
+ background: linear-gradient(135deg, #1c4f8a, #2d7be5);
+}
+
+.android-icon {
+ background: linear-gradient(135deg, #1a5c38, #2ea86a);
+}
+
+.windows-icon {
+ background: linear-gradient(135deg, #173a6e, #0077d4);
+}
+
+.macos-icon {
+ background: linear-gradient(135deg, #3a3a3a, #666);
+}
+
+.guide-card-header-text h2 {
+ font-size: 17px;
+ font-weight: 600;
+ margin-bottom: 2px;
+}
+
+.guide-card-header-text p {
+ font-size: 12.5px;
+ color: var(--muted);
+}
+
+.step-list {
+ list-style: none;
+}
+
+.step-item {
+ display: flex;
+ gap: 14px;
+ padding: 1rem 1.5rem;
+ border-bottom: 1px solid var(--border);
+ align-items: flex-start;
+ transition: background 0.15s;
+}
+
+.step-item:last-child {
+ border-bottom: none;
+}
+
+.step-item:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.step-circle {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ font-weight: 700;
+ margin-top: 1px;
+}
+
+.wg-circle {
+ background: rgba(55, 138, 221, 0.15);
+ color: #7ab8f5;
+ border: 1px solid rgba(55, 138, 221, 0.25);
+}
+
+.amn-circle {
+ background: rgba(29, 158, 117, 0.15);
+ color: #5fd8b0;
+ border: 1px solid rgba(29, 158, 117, 0.25);
+}
+
+.step-body {
+ flex: 1;
+}
+
+.step-body p {
+ font-size: 14.5px;
+ line-height: 1.6;
+}
+
+.step-body p b {
+ color: #fff;
+ font-weight: 600;
+}
+
+.code-tag {
+ font-family: var(--mono);
+ font-size: 12px;
+ background: var(--bg4);
+ color: #7ab8f5;
+ padding: 1px 7px;
+ border-radius: 5px;
+ border: 1px solid var(--border2);
+}
+
+.alt-step {
+ font-size: 12.5px;
+ color: var(--muted);
+ margin-top: 5px;
+ padding: 6px 10px;
+ background: var(--bg4);
+ border-radius: 8px;
+ border-left: 2px solid var(--border2);
+}
+
+.notice {
+ padding: 1rem 1.5rem;
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ border-top: 1px solid var(--border);
+}
+
+.notice-tip {
+ background: rgba(186, 117, 23, 0.08);
+}
+
+.notice-success {
+ background: rgba(29, 158, 117, 0.08);
+}
+
+.notice-icon {
+ font-size: 16px;
+ flex-shrink: 0;
+ margin-top: 1px;
+}
+
+.notice p {
+ font-size: 13.5px;
+ line-height: 1.6;
+}
+
+.notice-tip p {
+ color: #e8b76a;
+}
+
+.notice-success p {
+ color: #5fd8b0;
+}
+
+.ip-check-section {
+ margin-top: 1.5rem;
+}
+
+.ip-check-section h3 {
+ font-size: 15px;
+ font-weight: 600;
+ margin-bottom: 0.875rem;
+}
+
+.check-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 10px;
+}
+
+.check-card {
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 1rem 1.125rem;
+ font-size: 13.5px;
+ line-height: 1.6;
+}
+
+.check-card .n {
+ font-weight: 700;
+ color: var(--teal);
+ margin-right: 4px;
+}
+
+.trouble-section {
+ margin-top: 2rem;
+}
+
+.trouble-section h3 {
+ font-size: 15px;
+ font-weight: 600;
+ margin-bottom: 0.875rem;
+}
+
+.trouble-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 10px;
+}
+
+.trouble-card {
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 1rem 1.125rem;
+}
+
+.trouble-icon {
+ font-size: 18px;
+ margin-bottom: 7px;
+}
+
+.trouble-title {
+ font-size: 13.5px;
+ font-weight: 600;
+ margin-bottom: 6px;
+}
+
+.trouble-list {
+ font-size: 12.5px;
+ color: var(--muted);
+ line-height: 1.8;
+ list-style: none;
+}
+
+.trouble-list li {
+ padding-left: 10px;
+ position: relative;
+}
+
+.trouble-list li::before {
+ content: "·";
+ position: absolute;
+ left: 0;
+ color: var(--teal);
+ font-weight: 700;
+}
+
+.region-tabs {
+ display: flex;
+ background: var(--bg3);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 4px;
+ gap: 4px;
+ margin-bottom: 1.25rem;
+}
+
+.region-tab {
+ flex: 1;
+ padding: 10px 16px;
+ border-radius: 10px;
+ border: none;
+ background: transparent;
+ font-family: var(--font);
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--muted);
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.region-tab.active {
+ background: var(--bg4);
+ color: var(--text);
+ border: 1px solid var(--border2);
+}
+
+.region-hero {
+ text-align: center;
+ padding: 0.5rem 1rem 1.5rem;
+}
+
+.region-hero h2 {
+ font-size: clamp(20px, 4vw, 32px);
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ margin-bottom: 0.4rem;
+ background: linear-gradient(135deg, #f0f2f5 30%, #8a93a8);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.region-hero p {
+ font-size: 14px;
+ color: var(--muted);
+}
+
+.warn-box {
+ background: rgba(186, 117, 23, 0.08);
+ border-radius: 14px;
+ padding: 1rem 1.25rem;
+ margin-bottom: 1.25rem;
+ border: 1px solid rgba(186, 117, 23, 0.2);
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+}
+
+.warn-box p {
+ font-size: 13.5px;
+ color: #e8b76a;
+ line-height: 1.6;
+}
+
+.addr-card {
+ background: var(--bg3);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 1rem 1.25rem;
+ margin-top: 10px;
+}
+
+.addr-card-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 10px;
+}
+
+.addr-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 6px 0;
+ border-bottom: 1px solid var(--border);
+ font-size: 13.5px;
+ gap: 12px;
+}
+
+.addr-row:last-child {
+ border-bottom: none;
+}
+
+.addr-label {
+ color: var(--muted);
+ flex-shrink: 0;
+}
+
+.addr-val {
+ font-family: var(--mono);
+ font-size: 12.5px;
+ color: var(--text);
+ text-align: right;
+}
+
+.site-footer {
+ border-top: 1px solid var(--border);
+ padding: 1.75rem 1.5rem 2rem;
+ text-align: center;
+ font-size: 12px;
+ color: var(--muted2);
+}
+
+.site-footer-lead {
+ color: var(--muted);
+ margin-bottom: 6px;
+}
+
+.support-line {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ gap: 0.35rem 0.5rem;
+ margin-top: 12px;
+ font-size: 12px;
+}
+
+.support-title {
+ color: var(--text);
+ font-weight: 700;
+}
+
+.support-sep {
+ opacity: 0.45;
+ user-select: none;
+}
+
+.support-line a {
+ color: #5fd8b0;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.support-line a:hover {
+ text-decoration: underline;
+}
+
+.support-blurb {
+ margin: 10px auto 0;
+ max-width: 36rem;
+ font-size: 11px;
+ line-height: 1.45;
+ color: var(--muted2);
+}
+
+.disclaimer-footer {
+ margin-top: 16px;
+ font-size: 11px;
+ color: var(--muted2);
+ line-height: 1.45;
+ max-width: 28rem;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+::-webkit-scrollbar {
+ width: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--bg4);
+ border-radius: 3px;
+}
+
+@media (max-width: 600px) {
+ .hero {
+ padding: 1.75rem 1.25rem 1rem;
+ }
+
+ .page-nav,
+ .vpn-switcher,
+ .content,
+ .server-summary {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+ }
+
+ .step-item {
+ padding: 0.875rem 1rem;
+ }
+
+ .guide-card-header {
+ padding: 1rem;
+ }
+
+ .notice {
+ padding: 0.875rem 1rem;
+ }
+
+ .vpn-tab-badge {
+ display: none;
+ }
+
+ .page-nav-btn {
+ font-size: 13px;
+ padding: 9px 12px;
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d7065c4
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "amnezia-admin",
+ "version": "1.1.7",
+ "private": true,
+ "description": "amnezia_web — базовая панель AmneziaWG (только просмотр клиентов; полная версия — PRO).",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/andrey271192/amnezia_web.git"
+ },
+ "type": "module",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "express": "^4.21.2"
+ }
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..d71476f
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,1197 @@
+const loginGate = document.querySelector("#login-gate");
+const appRoot = document.querySelector("#app-root");
+const loginForm = document.querySelector("#login-form");
+const loginPassword = document.querySelector("#login-password");
+const loginError = document.querySelector("#login-error");
+
+const logoutBtn = document.querySelector("#logout");
+const refreshBtn = document.querySelector("#refresh");
+const clockServerEl = document.querySelector("#clock-server");
+const clockLocalEl = document.querySelector("#clock-local");
+const clockZoneDiffEl = document.querySelector("#clock-zone-diff");
+const clockSyncBtn = document.querySelector("#clock-sync");
+const rowsEl = document.querySelector("#rows");
+const statusEl = document.querySelector("#status");
+const peerCountEl = document.querySelector("#peer-count");
+const wgShowEl = document.querySelector("#wg-show");
+
+const warpPanel = document.querySelector("#warp-panel");
+const warpStatusLine = document.querySelector("#warp-status-line");
+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");
+
+const pwForm = document.querySelector("#pw-form");
+const pwCurrent = document.querySelector("#pw-current");
+const pwNew = document.querySelector("#pw-new");
+const pwNew2 = document.querySelector("#pw-new2");
+const pwMsg = document.querySelector("#pw-msg");
+
+const profileHintEl = document.querySelector("#profile-hint");
+
+const dtDialog = document.querySelector("#disconnect-dt-dialog");
+const dtTitle = document.querySelector("#dt-dialog-title");
+const dtClientEl = document.querySelector("#dt-dialog-client");
+const dtInput = document.querySelector("#dt-dialog-input");
+const dtCancel = document.querySelector("#dt-dialog-cancel");
+const dtOk = document.querySelector("#dt-dialog-ok");
+const dtExtra = document.querySelector("#dt-dialog-extra");
+const dtHint = document.querySelector("#dt-dialog-hint");
+const dtScheduleTunnel = document.querySelector("#dt-dialog-schedule-tunnel");
+
+const warpSshDialog = document.querySelector("#warp-ssh-dialog");
+const warpSshTitle = document.querySelector("#warp-ssh-title");
+const warpSshLead = document.querySelector("#warp-ssh-lead");
+const warpSshPw = document.querySelector("#warp-ssh-pw");
+const warpSshCancel = document.querySelector("#warp-ssh-cancel");
+const warpSshOk = document.querySelector("#warp-ssh-ok");
+const warpSshErr = document.querySelector("#warp-ssh-err");
+/** @type {"install" | "uninstall" | null} */
+let warpSshPendingCmd = null;
+
+/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
+let uiHidden = { users: false, warp: false, cascade: false };
+
+const editionBanner = document.querySelector("#edition-banner");
+const DEFAULT_HEADER_SUB = document.querySelector(".top .sub")?.textContent?.trim() || "";
+
+/** Состояние редакции панели (community = только просмотр клиентов). */
+let editionState = {
+ tier: "pro",
+ readOnlyClients: false,
+ upgradeUrl: null,
+ upgradePitch: null,
+ showDebugWg: true,
+};
+
+function applyEditionPayload(data) {
+ const ed = data?.edition;
+ if (!ed || typeof ed !== "object") return;
+ editionState = {
+ tier: ed.tier === "community" ? "community" : "pro",
+ readOnlyClients: Boolean(ed.readOnlyClients),
+ upgradeUrl: typeof ed.upgradeUrl === "string" ? ed.upgradeUrl : null,
+ upgradePitch: typeof ed.upgradePitch === "string" ? ed.upgradePitch : null,
+ showDebugWg: ed.showDebugWg !== false,
+ };
+ const subEl = document.querySelector(".top .sub");
+ if (subEl) {
+ if (editionState.tier === "community") {
+ subEl.textContent =
+ "Базовая панель amnezia_web: только просмотр клиентов AmneziaWG и статусов. Управление туннелем, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO.";
+ } else {
+ subEl.textContent = DEFAULT_HEADER_SUB;
+ }
+ }
+ if (editionBanner) {
+ if (editionState.tier === "community") {
+ 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/donate";
+ cta.textContent = "Разблокировать PRO (Boosty)";
+ wrap.append(textCol, cta);
+ editionBanner.appendChild(wrap);
+ } else {
+ editionBanner.classList.add("hidden");
+ editionBanner.innerHTML = "";
+ }
+ }
+ document.querySelector(".clock-host-sync")?.classList.toggle("hidden", editionState.readOnlyClients);
+ if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
+}
+
+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 || !editionState.showDebugWg;
+ if (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
+}
+
+let dtMode = "disable";
+/** @type {Record | null} */
+let dtClient = null;
+
+function isoToDatetimeLocal(iso) {
+ if (!iso) return "";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "";
+ const pad = (n) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
+function datetimeLocalToIso(localVal) {
+ if (!localVal || !String(localVal).trim()) {
+ throw new Error("Укажите дату и время");
+ }
+ const d = new Date(localVal);
+ if (Number.isNaN(d.getTime())) {
+ throw new Error("Некорректная дата");
+ }
+ return d.toISOString();
+}
+
+function openDisableDialog(c) {
+ dtMode = "disable";
+ dtClient = c;
+ dtTitle.textContent = "Выключить клиента";
+ dtClientEl.textContent = c.name;
+ dtOk.textContent = "Выключить";
+ dtInput.value = isoToDatetimeLocal(new Date().toISOString());
+ dtExtra.classList.add("hidden");
+ dtScheduleTunnel.checked = false;
+ dtDialog.showModal();
+}
+
+function openEditDisconnectDialog(c) {
+ dtMode = "edit";
+ dtClient = c;
+ dtTitle.textContent = "Дата последнего отключения";
+ dtClientEl.textContent = c.name;
+ dtOk.textContent = "Сохранить";
+ const iso =
+ (c.activeInConf && c.scheduledTunnelDisconnectAt) ||
+ c.lastDisconnectedAt ||
+ (!c.activeInConf && c.disabledAt) ||
+ new Date().toISOString();
+ dtInput.value = isoToDatetimeLocal(iso);
+ if (c.activeInConf) {
+ dtExtra.classList.remove("hidden");
+ dtHint.textContent =
+ "Без галочки — только запись даты в таблице, клиент остаётся в туннеле. С галочкой ключ будет убран из туннеля автоматически в выбранный момент (проверка на сервере каждые ~60 с).";
+ dtScheduleTunnel.checked = Boolean(c.scheduledTunnelDisconnectAt);
+ } else {
+ dtExtra.classList.add("hidden");
+ dtScheduleTunnel.checked = false;
+ }
+ dtDialog.showModal();
+}
+
+dtCancel.addEventListener("click", () => {
+ dtDialog.close();
+ dtClient = null;
+});
+
+dtOk.addEventListener("click", async () => {
+ if (!dtClient) return;
+ let iso;
+ try {
+ iso = datetimeLocalToIso(dtInput.value);
+ } catch (e) {
+ setStatus(String(e.message || e), true);
+ return;
+ }
+ try {
+ if (dtMode === "disable") {
+ setStatus("Выполняю…", false);
+ await api("/api/clients/disable", {
+ method: "POST",
+ body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }),
+ });
+ } else {
+ const scheduleTunnel = Boolean(dtScheduleTunnel.checked && dtClient.activeInConf);
+ setStatus(scheduleTunnel ? "Сохраняю расписание отключения…" : "Сохраняю дату…", false);
+ await api("/api/clients/disconnect-date", {
+ method: "POST",
+ body: JSON.stringify({
+ clientId: dtClient.clientId,
+ disconnectedAt: iso,
+ scheduleTunnelDisconnect: scheduleTunnel,
+ }),
+ });
+ }
+ dtDialog.close();
+ dtClient = null;
+ setStatus("Готово.", false);
+ await loadClients();
+ } catch (e) {
+ setStatus(String(e.message || e), true);
+ }
+});
+
+function openWarpHostSetup(cmd) {
+ if (!warpSshDialog || !warpSshTitle || !warpSshLead || !warpSshPw || !warpSshOk || !warpSshErr) return;
+ warpSshPendingCmd = cmd;
+ warpSshErr.textContent = "";
+ warpSshPw.value = "";
+ if (cmd === "install") {
+ warpSshTitle.textContent = "Установить Cloudflare WARP";
+ warpSshLead.textContent =
+ "На хосте VPS выполнится scripts/warp-amnezia.sh install для контейнера текущего инстанса. SSH так же, как у блока синхронизации времени (TIME_SYNC_SSH_HOST, часто 172.17.0.1). Пароль root не сохраняется.";
+ warpSshOk.textContent = "Установить";
+ } else {
+ warpSshTitle.textContent = "Удалить Cloudflare WARP";
+ warpSshLead.textContent =
+ "На хосте выполнится scripts/warp-amnezia.sh uninstall (интерфейс warp, правила, автозапуск в start.sh; контейнер AWG перезапустится).";
+ warpSshOk.textContent = "Удалить";
+ }
+ warpSshDialog.showModal();
+}
+
+if (warpSshCancel && warpSshDialog) {
+ warpSshCancel.addEventListener("click", () => {
+ warpSshDialog.close();
+ warpSshPendingCmd = null;
+ });
+}
+
+if (warpSshOk && warpSshDialog && warpSshPw) {
+ warpSshOk.addEventListener("click", async () => {
+ const cmd = warpSshPendingCmd;
+ if (!cmd) return;
+ const pw = warpSshPw.value;
+ if (!String(pw).trim()) {
+ warpSshErr.textContent = "Введите пароль root.";
+ return;
+ }
+ warpSshErr.textContent = "";
+ try {
+ setStatus(cmd === "install" ? "Устанавливаю WARP на хосте VPS…" : "Удаляю WARP на хосте VPS…", false);
+ await api("/api/warp/host-setup", {
+ method: "POST",
+ body: JSON.stringify({ rootPassword: pw, cmd }),
+ });
+ warpSshDialog.close();
+ warpSshPendingCmd = null;
+ warpSshPw.value = "";
+ setStatus("Готово.", false);
+ await loadClients();
+ } catch (e) {
+ const msg = String(e.message || e);
+ warpSshErr.textContent = msg;
+ setStatus(msg, true);
+ }
+ });
+}
+
+function showLogin() {
+ stopClocks();
+ loginGate.classList.remove("hidden");
+ loginGate.setAttribute("aria-hidden", "false");
+ appRoot.classList.add("hidden");
+}
+
+function showApp() {
+ loginGate.classList.add("hidden");
+ loginGate.setAttribute("aria-hidden", "true");
+ appRoot.classList.remove("hidden");
+ startClocks();
+}
+
+function setStatus(text, isErr) {
+ statusEl.textContent = text || "";
+ statusEl.classList.toggle("err", Boolean(isErr));
+}
+
+function setPwMsg(text, isErr) {
+ pwMsg.textContent = text || "";
+ pwMsg.classList.toggle("err", Boolean(isErr));
+}
+
+async function api(path, opts = {}) {
+ const res = await fetch(path, {
+ ...opts,
+ credentials: "same-origin",
+ headers: {
+ "Content-Type": "application/json",
+ ...opts.headers,
+ },
+ });
+ const text = await res.text();
+ let data;
+ try {
+ data = text ? JSON.parse(text) : {};
+ } catch {
+ data = { raw: text };
+ }
+ if (!res.ok) {
+ const msg = data.error || data.raw || res.statusText;
+ throw new Error(msg);
+ }
+ return data;
+}
+
+async function checkSession() {
+ try {
+ await api("/api/session");
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function loadProtocols() {
+ try {
+ const data = await api("/api/protocols");
+ applyEditionPayload(data);
+ protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`;
+ if (profileHintEl) {
+ if (data.singleProfile && typeof data.profilesPersistHint === "string" && data.profilesPersistHint) {
+ profileHintEl.textContent = data.profilesPersistHint;
+ profileHintEl.classList.remove("hidden");
+ } else {
+ profileHintEl.textContent = "";
+ profileHintEl.classList.add("hidden");
+ }
+ }
+ if (!data.profiles || data.profiles.length < 2) {
+ protoSwitch.classList.add("hidden");
+ return;
+ }
+ protoSwitch.classList.remove("hidden");
+ protoSelect.innerHTML = "";
+ for (const p of data.profiles) {
+ const opt = document.createElement("option");
+ opt.value = p.id;
+ opt.textContent = `${p.label} (${p.container})`;
+ if (p.id === data.currentId) opt.selected = true;
+ protoSelect.appendChild(opt);
+ }
+ } catch {
+ protoSwitch.classList.add("hidden");
+ }
+}
+
+loginForm.addEventListener("submit", async (ev) => {
+ ev.preventDefault();
+ loginError.textContent = "";
+ try {
+ await api("/api/login", {
+ method: "POST",
+ body: JSON.stringify({ password: loginPassword.value }),
+ });
+ loginPassword.value = "";
+ showApp();
+ await loadProtocols();
+ await loadTimeSyncCaps();
+ await loadClients();
+ } catch (e) {
+ loginError.textContent = String(e.message || e);
+ }
+});
+
+logoutBtn.addEventListener("click", async () => {
+ try {
+ await api("/api/logout", { method: "POST", body: JSON.stringify({}) });
+ } catch {
+ /* ignore */
+ }
+ showLogin();
+ loginPassword.focus();
+});
+
+refreshBtn.addEventListener("click", () => {
+ loadClients();
+});
+
+const cascadeForm = document.querySelector("#cascade-form");
+if (cascadeForm) {
+ cascadeForm.addEventListener("submit", (ev) => void downloadCascadeConf(ev));
+}
+
+protoSelect.addEventListener("change", async () => {
+ try {
+ setStatus("Смена инстанса…", false);
+ await api("/api/protocol", {
+ method: "POST",
+ body: JSON.stringify({ profileId: protoSelect.value }),
+ });
+ await loadProtocols();
+ await loadClients();
+ setStatus("", false);
+ } catch (e) {
+ setStatus(String(e.message || e), true);
+ await loadProtocols();
+ }
+});
+
+clockSyncBtn.addEventListener("click", () => {
+ void refreshServerClock();
+});
+
+const clockFmt = new Intl.DateTimeFormat("ru-RU", {
+ dateStyle: "medium",
+ timeStyle: "medium",
+});
+
+/** Часовой пояс строки «Сервер» (IANA), как в /api/server-time */
+let serverDisplayTz = "UTC";
+/** @type {Intl.DateTimeFormat | null} */
+let serverTzFmtCached = null;
+
+function buildServerTzFmt(tz) {
+ try {
+ return new Intl.DateTimeFormat("ru-RU", {
+ dateStyle: "medium",
+ timeStyle: "medium",
+ timeZone: tz,
+ });
+ } catch {
+ return null;
+ }
+}
+
+/** @type {ReturnType | null} */
+let clockTickId = null;
+/** @type {ReturnType | null} */
+let clockServerPollId = null;
+
+/** Метка UTC сервера (мс) по последнему ответу API */
+let serverAnchorUtcMs = /** @type {number | null} */ (null);
+/** Date.now() в момент установки якоря */
+let serverAnchorWallMs = 0;
+
+function browserTimeZoneLabel() {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
+ } catch {
+ return "";
+ }
+}
+
+function tickServerClockDisplay() {
+ if (serverAnchorUtcMs === null) {
+ clockServerEl.dateTime = "";
+ clockServerEl.textContent = "—";
+ return;
+ }
+ const estimatedUtcMs = serverAnchorUtcMs + (Date.now() - serverAnchorWallMs);
+ const d = new Date(estimatedUtcMs);
+ clockServerEl.dateTime = d.toISOString();
+ const fmt = serverTzFmtCached || clockFmt;
+ clockServerEl.textContent = `${fmt.format(d)} · ${serverDisplayTz}`;
+}
+
+async function refreshServerClock() {
+ try {
+ const tz = browserTimeZoneLabel();
+ const q = tz ? `?browserTz=${encodeURIComponent(tz)}` : "";
+ const t = await api(`/api/server-time${q}`);
+ const iso = typeof t.iso === "string" ? t.iso : "";
+ const parsed = new Date(iso).getTime();
+ if (!iso || Number.isNaN(parsed)) {
+ throw new Error("нет времени");
+ }
+ serverAnchorUtcMs = parsed;
+ serverAnchorWallMs = Date.now();
+ serverDisplayTz =
+ typeof t.timeZone === "string" && t.timeZone.trim() ? t.timeZone.trim() : "UTC";
+ serverTzFmtCached = buildServerTzFmt(serverDisplayTz);
+ tickServerClockDisplay();
+ if (clockZoneDiffEl) {
+ const hint = typeof t.zoneCompareHint === "string" ? t.zoneCompareHint.trim() : "";
+ if (hint) {
+ clockZoneDiffEl.textContent = hint;
+ clockZoneDiffEl.classList.remove("hidden");
+ clockZoneDiffEl.classList.toggle("clock-zone-diff--accent", t.zoneSame === false);
+ } else {
+ clockZoneDiffEl.textContent = "";
+ clockZoneDiffEl.classList.add("hidden");
+ clockZoneDiffEl.classList.remove("clock-zone-diff--accent");
+ }
+ }
+ } catch {
+ serverAnchorUtcMs = null;
+ serverTzFmtCached = null;
+ clockServerEl.dateTime = "";
+ clockServerEl.textContent = "—";
+ if (clockZoneDiffEl) {
+ clockZoneDiffEl.textContent = "";
+ clockZoneDiffEl.classList.add("hidden");
+ clockZoneDiffEl.classList.remove("clock-zone-diff--accent");
+ }
+ }
+}
+
+function tickLocalClock() {
+ const n = new Date();
+ clockLocalEl.dateTime = n.toISOString();
+ const tz = browserTimeZoneLabel();
+ clockLocalEl.textContent = tz ? `${clockFmt.format(n)} · ${tz}` : clockFmt.format(n);
+}
+
+function tickClocks() {
+ tickLocalClock();
+ tickServerClockDisplay();
+}
+
+function stopClocks() {
+ if (clockTickId !== null) {
+ clearInterval(clockTickId);
+ clockTickId = null;
+ }
+ if (clockServerPollId !== null) {
+ clearInterval(clockServerPollId);
+ clockServerPollId = null;
+ }
+ serverAnchorUtcMs = null;
+ serverAnchorWallMs = 0;
+ serverTzFmtCached = null;
+ serverDisplayTz = "UTC";
+ clockServerEl.dateTime = "";
+ clockLocalEl.dateTime = "";
+ clockServerEl.textContent = "—";
+ clockLocalEl.textContent = "—";
+ if (clockZoneDiffEl) {
+ clockZoneDiffEl.textContent = "";
+ clockZoneDiffEl.classList.add("hidden");
+ clockZoneDiffEl.classList.remove("clock-zone-diff--accent");
+ }
+}
+
+function startClocks() {
+ stopClocks();
+ tickClocks();
+ void refreshServerClock();
+ clockTickId = setInterval(tickClocks, 1000);
+ clockServerPollId = setInterval(() => void refreshServerClock(), 30_000);
+}
+
+async function loadTimeSyncCaps() {
+ const hint = document.querySelector("#sync-host-hint");
+ const btn = document.querySelector("#sync-host-time");
+ try {
+ const c = await api("/api/time-sync-capabilities");
+ if (editionState.readOnlyClients || c.communityBlocked) {
+ if (hint) {
+ hint.textContent =
+ "В базовой версии синхронизация времени хоста по SSH недоступна — это функция PRO.";
+ }
+ if (btn) btn.disabled = true;
+ return;
+ }
+ if (hint) {
+ hint.textContent = c.hostTimeSync
+ ? `Записывается UTC-момент с этого устройства на хост по SSH (root@${c.sshHost}). Пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.`
+ : `Авто-синхронизация по SSH недоступна (или TIME_SYNC_DISABLED). Пояс «Сервер»: ${c.serverClockTimeZone}. Задайте TZ контейнера панели при необходимости — см. README.`;
+ }
+ if (btn) btn.disabled = !c.hostTimeSync;
+ } catch {
+ if (hint) hint.textContent = "";
+ if (btn) btn.disabled = true;
+ }
+}
+
+document.querySelector("#sync-host-time")?.addEventListener("click", async () => {
+ const inp = document.querySelector("#sync-root-pw");
+ const pw = inp && typeof inp.value === "string" ? inp.value : "";
+ if (!pw.trim()) {
+ setStatus("Введите пароль root на хосте.", true);
+ return;
+ }
+ try {
+ setStatus("Беру время с этого устройства и отправляю на хост…", false);
+ await api("/api/sync-host-time", {
+ method: "POST",
+ body: JSON.stringify({ rootPassword: pw, unixMs: Date.now() }),
+ });
+ inp.value = "";
+ setStatus("Готово: часы хоста выставлены по вашему устройству (UTC). Проверьте строки времени.", false);
+ void refreshServerClock();
+ } catch (e) {
+ setStatus(String(e.message || e), true);
+ }
+});
+
+const dtRu = new Intl.DateTimeFormat("ru-RU", {
+ dateStyle: "short",
+ timeStyle: "short",
+});
+
+function formatLastDisconnect(c) {
+ if (c.scheduledTunnelDisconnectAt && c.activeInConf) {
+ const d = new Date(String(c.scheduledTunnelDisconnectAt));
+ if (!Number.isNaN(d.getTime())) {
+ return `${dtRu.format(d)} · авто`;
+ }
+ }
+ const iso =
+ c.lastDisconnectedAt ||
+ (!c.activeInConf && c.disabledAt) ||
+ null;
+ if (!iso) return "—";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "—";
+ return dtRu.format(d);
+}
+
+pwForm.addEventListener("submit", async (ev) => {
+ ev.preventDefault();
+ setPwMsg("", false);
+ if (pwNew.value !== pwNew2.value) {
+ setPwMsg("Новый пароль и повтор не совпадают.", true);
+ return;
+ }
+ try {
+ const data = await api("/api/change-password", {
+ method: "POST",
+ body: JSON.stringify({
+ currentPassword: pwCurrent.value,
+ newPassword: pwNew.value,
+ }),
+ });
+ pwCurrent.value = "";
+ pwNew.value = "";
+ pwNew2.value = "";
+ setPwMsg(data.message || "Готово.", false);
+ showLogin();
+ loginPassword.focus();
+ } catch (e) {
+ setPwMsg(String(e.message || e), true);
+ }
+});
+
+function renderRows(clients) {
+ rowsEl.innerHTML = "";
+ if (uiHidden.users) return;
+ clients.forEach((c) => {
+ const tr = document.createElement("tr");
+
+ const nameTd = document.createElement("td");
+ const nameWrap = document.createElement("div");
+ nameWrap.className = "name-cell";
+ const strong = document.createElement("strong");
+ strong.textContent = c.name;
+ if (!editionState.readOnlyClients) {
+ const renameWrap = document.createElement("div");
+ renameWrap.className = "rename-inline";
+ renameWrap.appendChild(
+ btn("Переименовать", "btn small ghost", () => void renameClient(c))
+ );
+ nameWrap.append(strong, renameWrap);
+ if (!c.exportAvailable) {
+ const hintFold = document.createElement("details");
+ hintFold.className = "hint-mini";
+ const sum = document.createElement("summary");
+ sum.textContent = "Нет готового .conf на сервере (last_config)";
+ hintFold.appendChild(sum);
+ const exHint = document.createElement("p");
+ exHint.className = "muted export-missing-hint";
+ exHint.textContent =
+ "Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia.";
+ hintFold.appendChild(exHint);
+ nameWrap.appendChild(hintFold);
+ }
+ } else {
+ nameWrap.appendChild(strong);
+ const roHint = document.createElement("p");
+ roHint.className = "muted hint-mini";
+ roHint.style.margin = "0.35rem 0 0";
+ roHint.textContent = c.exportAvailable
+ ? "На сервере есть данные для .conf — скачивание доступно в PRO."
+ : "Нет last_config на сервере — полный конфиг в приложении Amnezia.";
+ nameWrap.appendChild(roHint);
+ }
+ nameTd.appendChild(nameWrap);
+
+ const ipTd = document.createElement("td");
+ ipTd.innerHTML = `${escapeHtml(c.allowedIps || "—")} `;
+
+ const stTd = document.createElement("td");
+ const badge = document.createElement("span");
+ badge.className = `badge ${c.activeInConf ? "on" : "off"}`;
+ if (c.activeInConf && c.warpEnabled) {
+ badge.textContent = "В туннеле · WARP";
+ } else {
+ badge.textContent = c.activeInConf ? "В туннеле" : "Выключен";
+ }
+ stTd.appendChild(badge);
+
+ const offTd = document.createElement("td");
+ offTd.className = "date-cell";
+ const dateLine = document.createElement("div");
+ dateLine.textContent = formatLastDisconnect(c);
+ offTd.appendChild(dateLine);
+ if (!editionState.readOnlyClients) {
+ const dtWrap = document.createElement("div");
+ dtWrap.className = "rename-inline";
+ dtWrap.appendChild(
+ btn("Задать дату", "btn small ghost", () => openEditDisconnectDialog(c))
+ );
+ offTd.appendChild(dtWrap);
+ }
+
+ const actTd = document.createElement("td");
+ actTd.className = "actions";
+
+ if (editionState.readOnlyClients) {
+ const lock = document.createElement("span");
+ lock.className = "muted";
+ lock.textContent = "Только PRO";
+ actTd.appendChild(lock);
+ } else {
+ if (c.activeInConf) {
+ actTd.appendChild(btn("Выключить", "btn small ghost", () => openDisableDialog(c)));
+ } else {
+ actTd.appendChild(
+ btn("Включить", "btn small primary", () => mutate("/api/clients/enable", c.clientId))
+ );
+ }
+ if (c.exportAvailable) {
+ const direct = document.createElement("a");
+ direct.className = "btn small ghost";
+ direct.href = clientExportGetUrl(c.clientId);
+ direct.textContent = "Прямая ссылка";
+ direct.rel = "noopener";
+ direct.title =
+ "Открыть в новой вкладке — скачается .conf, если вы авторизованы в этой панели (cookies).";
+
+ actTd.appendChild(btn("Скачать .conf", "btn small ghost", () => void downloadClientConfig(c)));
+ actTd.appendChild(direct);
+ actTd.appendChild(
+ btn("Копировать URL", "btn small ghost", async () => {
+ const ok = await copyTextToClipboard(clientExportGetUrl(c.clientId));
+ setStatus(ok ? "Ссылка скопирована (вставьте в браузер, будучи залогиненным)." : "Не удалось скопировать.", !ok);
+ }),
+ );
+ }
+ actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId)));
+ }
+
+ tr.append(nameTd, ipTd, stTd, offTd, actTd);
+ rowsEl.appendChild(tr);
+ });
+}
+
+function btn(label, cls, onClick) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = cls;
+ b.textContent = label;
+ b.addEventListener("click", onClick);
+ return b;
+}
+
+/** Для политики WARP на сервере нужны IPv4 вида 10.8.x.x/32 */
+function parseIpv4Cidrs(allowedIps) {
+ if (!allowedIps) return [];
+ return String(allowedIps)
+ .split(",")
+ .map((x) => x.trim())
+ .filter((x) => /^(\d{1,3}\.){3}\d{1,3}\/\d{1,3}$/.test(x));
+}
+
+/** @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;
+ return;
+ }
+ warpPanel.hidden = false;
+ warpActionsEl.innerHTML = "";
+ warpClientListEl.innerHTML = "";
+ warpWgShowEl.textContent = typeof w.wgShowWarp === "string" ? w.wgShowWarp : "";
+
+ if (!w.installed) {
+ warpStatusLine.textContent = "Не установлен";
+ const explain = document.createElement("p");
+ explain.className = "muted warp-muted";
+ explain.textContent =
+ "Так и должно быть, пока на VPS не создан файл warp.conf внутри контейнера AWG. После установки статус сменится; без WARP обычный AmneziaWG уже работает.";
+ warpActionsEl.appendChild(explain);
+
+ if (w.hostSshInstall) {
+ warpActionsEl.appendChild(
+ btn("Установить WARP на VPS", "btn small primary", () => openWarpHostSetup("install")),
+ );
+ const sshHint = document.createElement("p");
+ sshHint.className = "muted warp-muted";
+ sshHint.textContent =
+ "Кнопка запускает на хосте тот же скрипт, что в README; понадобится пароль root по SSH (не сохраняется). Альтернатива — команда вручную по SSH.";
+ warpActionsEl.appendChild(sshHint);
+ }
+
+ const hint = document.createElement("p");
+ hint.className = "muted warp-muted";
+ hint.innerHTML =
+ "Вручную на хосте (root), из каталога репозитория: bash scripts/warp-amnezia.sh install или с именем контейнера: bash scripts/warp-amnezia.sh install amnezia-awg2.";
+ warpActionsEl.appendChild(hint);
+
+ if (!w.hostSshInstall) {
+ const noBtn = document.createElement("p");
+ noBtn.className = "muted warp-muted";
+ noBtn.textContent =
+ "Кнопка установки с панели недоступна: нет sshpass в образе панели или включено TIME_SYNC_DISABLED=1 — используйте SSH вручную.";
+ warpActionsEl.appendChild(noBtn);
+ }
+
+ const skip = document.createElement("p");
+ skip.className = "muted warp-muted";
+ skip.textContent = "Если выход через Cloudflare не нужен, ничего не нажимайте — VPN уже работает без этого блока.";
+ warpActionsEl.appendChild(skip);
+ return;
+ }
+
+ const parts = [];
+ parts.push(w.running ? "Интерфейс warp поднят" : "Интерфейс warp опущен");
+ if (w.exitIp) parts.push(`выход ${w.exitIp}`);
+ warpStatusLine.textContent = parts.join(" · ");
+
+ const selection = new Set((w.selectedAllowedIps || []).map(String));
+
+ function redrawChecks() {
+ warpClientListEl.innerHTML = "";
+ const frag = document.createDocumentFragment();
+ let any = false;
+ for (const c of data.clients) {
+ if (!c.activeInConf) continue;
+ const ips = parseIpv4Cidrs(c.allowedIps);
+ if (!ips.length) continue;
+ any = true;
+ const ip = ips[0];
+ const label = document.createElement("label");
+ label.className = "warp-check-row";
+ const cb = document.createElement("input");
+ cb.type = "checkbox";
+ cb.checked = selection.has(ip);
+ cb.addEventListener("change", () => {
+ if (cb.checked) selection.add(ip);
+ else selection.delete(ip);
+ });
+ const span = document.createElement("span");
+ span.textContent = `${c.name} · ${ip}`;
+ label.append(cb, span);
+ frag.appendChild(label);
+ }
+ warpClientListEl.appendChild(frag);
+ if (!any) {
+ const p = document.createElement("p");
+ p.className = "muted warp-muted";
+ p.textContent =
+ "Нет активных клиентов с IPv4 AllowedIPs (/32) — WARP-политика в вебе работает только для таких адресов.";
+ warpClientListEl.appendChild(p);
+ }
+ }
+
+ redrawChecks();
+
+ warpActionsEl.appendChild(
+ btn("Поднять WARP", "btn small primary", async () => {
+ try {
+ setStatus("Поднимаю WARP…", false);
+ await api("/api/warp/start", { method: "POST", body: JSON.stringify({}) });
+ setStatus("Готово.", false);
+ await loadClients();
+ } catch (e) {
+ setStatus(String(e.message || e), true);
+ }
+ }),
+ );
+ warpActionsEl.appendChild(
+ btn("Остановить WARP", "btn small ghost", async () => {
+ try {
+ setStatus("Останавливаю WARP…", false);
+ await api("/api/warp/stop", { method: "POST", body: JSON.stringify({}) });
+ setStatus("Готово.", false);
+ await loadClients();
+ } catch (e) {
+ setStatus(String(e.message || e), true);
+ }
+ }),
+ );
+ warpActionsEl.appendChild(
+ btn("Все в WARP", "btn small ghost", () => {
+ for (const c of data.clients) {
+ if (!c.activeInConf) continue;
+ parseIpv4Cidrs(c.allowedIps).forEach((ip) => selection.add(ip));
+ }
+ redrawChecks();
+ }),
+ );
+ warpActionsEl.appendChild(
+ btn("Никого", "btn small ghost", () => {
+ selection.clear();
+ redrawChecks();
+ }),
+ );
+ warpActionsEl.appendChild(
+ btn("Применить маршрутизацию", "btn small primary", async () => {
+ try {
+ setStatus("Сохраняю WARP и перезапускаю контейнер AWG…", false);
+ await api("/api/warp/routing", {
+ method: "POST",
+ body: JSON.stringify({ selectedAllowedIps: [...selection] }),
+ });
+ setStatus("Готово.", false);
+ await loadClients();
+ } catch (e) {
+ setStatus(String(e.message || e), true);
+ }
+ }),
+ );
+ warpActionsEl.appendChild(
+ btn("Удалить WARP с VPS", "btn small warn", () => openWarpHostSetup("uninstall")),
+ );
+}
+
+function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function currentProfileIdValue() {
+ if (!protoSelect || !protoSwitch || protoSwitch.classList.contains("hidden")) return "";
+ return String(protoSelect.value || "").trim();
+}
+
+/** Query для нужного инстанса при нескольких профилях AWG_PROFILES */
+function currentProfileQuerySuffix() {
+ const pid = currentProfileIdValue();
+ return pid ? `&profileId=${encodeURIComponent(pid)}` : "";
+}
+
+/** Прямая GET-ссылка на скачивание (работает в браузере с активной сессией панели). */
+function clientExportGetUrl(clientId) {
+ const q = `clientId=${encodeURIComponent(clientId)}${currentProfileQuerySuffix()}`;
+ return `${window.location.origin}/api/clients/export-config?${q}`;
+}
+
+async function copyTextToClipboard(text) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ try {
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.style.position = "fixed";
+ ta.style.left = "-9999px";
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand("copy");
+ ta.remove();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+}
+
+async function downloadClientConfig(c) {
+ try {
+ setStatus("Готовлю конфиг…", false);
+ const res = await fetch(clientExportGetUrl(c.clientId), {
+ method: "GET",
+ credentials: "same-origin",
+ });
+ const text = await res.text();
+ if (!res.ok) {
+ let msg = text;
+ try {
+ const j = JSON.parse(text);
+ msg = typeof j.error === "string" ? j.error : msg;
+ } catch {
+ /* сырой текст */
+ }
+ throw new Error(msg);
+ }
+ const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ const safe = String(c.name || "client")
+ .replace(/[^\w\u0400-\u04FF\-]+/g, "_")
+ .slice(0, 60);
+ a.href = url;
+ a.download = `amnezia-${safe}.conf`;
+ a.rel = "noopener";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ setStatus("Конфиг скачан.", false);
+ } catch (e) {
+ setStatus(String(e.message || e), true);
+ }
+}
+
+async function downloadCascadeConf(ev) {
+ ev.preventDefault();
+ const endpointEl = document.querySelector("#cascade-endpoint");
+ const portEl = document.querySelector("#cascade-port");
+ const tunnelEl = document.querySelector("#cascade-tunnel-ip");
+ const nameEl = document.querySelector("#cascade-name");
+ const endpointHost = endpointEl?.value.trim() || "";
+ if (!endpointHost) {
+ setStatus("Укажите Endpoint (IP или DNS для клиента в каскаде).", true);
+ return;
+ }
+ const body = { endpointHost };
+ const praw = portEl?.value.trim() ?? "";
+ if (praw) {
+ const n = Number(praw);
+ if (!Number.isFinite(n) || n < 1 || n > 65535) {
+ setStatus("Некорректный порт Endpoint (1–65535).", true);
+ return;
+ }
+ body.endpointPort = n;
+ }
+ const tip = tunnelEl?.value.trim();
+ if (tip) body.tunnelIp = tip;
+ const nm = nameEl?.value.trim();
+ if (nm) body.clientName = nm;
+ const pid = currentProfileIdValue();
+ if (pid) body.profileId = pid;
+ try {
+ setStatus("Создаю клиента на сервере и собираю .conf…", false);
+ const res = await fetch("/api/clients/create-cascade", {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const text = await res.text();
+ if (!res.ok) {
+ let msg = text;
+ try {
+ const j = JSON.parse(text);
+ msg = typeof j.error === "string" ? j.error : msg;
+ } catch {
+ /* raw */
+ }
+ throw new Error(msg);
+ }
+ const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ const safe = (nm || "cascade")
+ .replace(/[^\w\u0400-\u04FF\-]+/g, "_")
+ .slice(0, 60);
+ a.href = url;
+ a.download = `amnezia-cascade-${safe}.conf`;
+ a.rel = "noopener";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ setStatus("Клиент добавлен на сервер, .conf скачан. Обновите таблицу.", false);
+ await loadClients();
+ } catch (e) {
+ setStatus(String(e.message || e), true);
+ }
+}
+
+async function renameClient(c) {
+ const next = prompt(`Новое имя для «${c.name}»:`, c.name);
+ if (next === null) return;
+ const trimmed = next.trim().replace(/\s+/g, " ");
+ if (!trimmed) {
+ setStatus("Имя не может быть пустым", true);
+ return;
+ }
+ try {
+ setStatus("Сохраняю имя…", false);
+ await api("/api/clients/rename", {
+ method: "POST",
+ body: JSON.stringify({ clientId: c.clientId, name: trimmed }),
+ });
+ setStatus("Готово.", false);
+ await loadClients();
+ } catch (e) {
+ setStatus(String(e.message || e), true);
+ }
+}
+
+async function mutate(path, clientId) {
+ try {
+ setStatus("Выполняю…", false);
+ await api(path, { method: "POST", body: JSON.stringify({ clientId }) });
+ setStatus("Готово.", false);
+ await loadClients();
+ } catch (e) {
+ setStatus(String(e.message || e), true);
+ }
+}
+
+async function confirmDelete(name, clientId) {
+ const ok = confirm(
+ `Удалить клиента «${name}»? Конфиг из приложения Amnezia перестанет совпадать с сервером.`
+ );
+ if (!ok) return;
+ await mutate("/api/clients/delete", clientId);
+}
+
+async function loadClients() {
+ try {
+ setStatus("Загрузка…", false);
+ const data = await api("/api/clients");
+ applyEditionPayload(data);
+ applyUiHiddenFromPayload(data);
+ const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
+ 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);
+ void refreshServerClock();
+ } catch (e) {
+ const msg = String(e.message || e);
+ if (msg.includes("Unauthorized")) {
+ showLogin();
+ setStatus("", false);
+ loginError.textContent = "Сессия истекла — войдите снова.";
+ return;
+ }
+ setStatus(msg, true);
+ rowsEl.innerHTML = "";
+ wgShowEl.textContent = "";
+ peerCountEl.textContent = "";
+ if (warpPanel) warpPanel.hidden = true;
+ }
+}
+
+async function boot() {
+ const ok = await checkSession();
+ if (ok) {
+ showApp();
+ await loadProtocols();
+ await loadTimeSyncCaps();
+ await loadClients();
+ } else {
+ showLogin();
+ loginPassword.focus();
+ }
+}
+
+boot();
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..5a439d2
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,241 @@
+
+
+
+
+
+ AmneziaWG — просмотр клиентов
+
+
+
+
+
+
+
+
+
Панель сервера
+
Вход
+
Введите пароль администратора панели.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cloudflare WARP
+
+
+
+
+
+ Необязательно. Если достаточно обычного AmneziaWG без выхода части клиентов через Cloudflare — раздел можно не трогать (статус «Не установлен» — норма).
+ Если WARP нужен: выход в интернет через Cloudflare для выбранных клиентов (правила маршрутизации и NAT в контейнере).
+ Один раз на хосте VPS под root: в каталоге с репозиторием выполните bash scripts/warp-amnezia.sh install.
+ Затем отметьте клиентов ниже и нажмите «Применить» (контейнер AWG перезапустится). Установить или удалить WARP с хоста можно кнопками ниже (пароль root по SSH — как при синхронизации времени). Убрать вручную: bash scripts/warp-amnezia.sh uninstall — см. README.
+
+
+
+
+ Вывод wg show warp
+
+
+
+
+
+
+
+
+
+ Новый клиент под каскад
+ Создать peer и скачать .conf
+
+
+
+
+ Укажите Endpoint — публичный IP или DNS узла, куда клиент будет подключаться первым шагом
+ (промежуточный сервер, домашний роутер с пробросом порта и т.д.). Порт по умолчанию совпадает с ListenPort этого инстанса.
+ Клиент будет создан на текущем сервере AmneziaWG (новые ключи); IP в туннеле можно задать явно или оставить пустым — подберём свободный в той же подсети, что у остальных.
+
+
+
+
+
+
+
+
+
+ Пользователи
+
+
+
+
+
+
+
+
+ Имя
+ IP
+ Статус
+ Последнее отключение
+ Действия
+
+
+
+
+
+
+
+
+
+
+
+
+ Вывод awg show (отладка)
+
+
+
+
+
+
+
+
+
+
+
Дата отключения
+
+
Дата и время
+
+
+
+ Отмена
+ Подтвердить
+
+
+
+
+
+
+
WARP на хосте VPS
+
+
Пароль root на хосте VPS (не сохраняется)
+
+
+ Отмена
+ Выполнить
+
+
+
+
+
+
+
+
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..85c91f9
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,952 @@
+:root {
+ --bg: #070b10;
+ --card: #111722;
+ --line: rgba(255, 255, 255, 0.08);
+ --text: #f4f7ff;
+ --muted: #94a3b8;
+ --accent: #7dd3fc;
+ --danger: #fb7185;
+ --ok: #4ade80;
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
+ font-family: "DM Sans", system-ui, sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body.page {
+ margin: 0;
+ background: radial-gradient(circle at 20% 20%, rgba(125, 211, 252, 0.08), transparent 35%),
+ radial-gradient(circle at 80% 0%, rgba(94, 234, 212, 0.06), transparent 40%),
+ var(--bg);
+ color: var(--text);
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.shell {
+ flex: 1 0 auto;
+ max-width: min(1120px, 100%);
+ margin: 0 auto;
+ padding: clamp(1.5rem, 4vw, 2.5rem) clamp(1rem, 3vw, 1.5rem) 3rem;
+}
+
+.top {
+ display: grid;
+ gap: 1.25rem;
+ grid-template-columns: 1fr;
+}
+
+@media (min-width: 880px) {
+ .top {
+ grid-template-columns: 2fr 1fr;
+ align-items: start;
+ }
+}
+
+.eyebrow {
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ font-size: 0.72rem;
+ color: var(--accent);
+ margin: 0 0 0.35rem;
+}
+
+h1 {
+ margin: 0 0 0.5rem;
+ font-size: clamp(1.6rem, 4vw, 2rem);
+}
+
+.sub {
+ margin: 0;
+ color: var(--muted);
+ line-height: 1.55;
+ max-width: 62ch;
+}
+
+.token-box {
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 1rem;
+ background: #0c121b;
+}
+
+.token-box label {
+ display: block;
+ font-size: 0.85rem;
+ color: var(--muted);
+ margin-bottom: 0.35rem;
+}
+
+.token-hint {
+ margin: 0 0 0.65rem;
+ font-size: 0.82rem;
+ line-height: 1.45;
+ color: var(--muted);
+}
+
+.token-hint code.inline {
+ font-family: "JetBrains Mono", ui-monospace, monospace;
+ font-size: 0.78rem;
+ padding: 0.1rem 0.35rem;
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.35);
+ border: 1px solid var(--line);
+}
+
+.hidden {
+ display: none !important;
+}
+
+.gate {
+ flex: 1;
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 2rem 1rem;
+}
+
+.gate-card {
+ width: min(420px, 100%);
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 1.35rem 1.25rem 1.5rem;
+ background: var(--card);
+ box-shadow: var(--shadow);
+}
+
+.gate-card h1 {
+ margin: 0 0 0.35rem;
+ font-size: 1.35rem;
+}
+
+.gate-card .sub {
+ margin: 0 0 1rem;
+}
+
+.gate-card label {
+ display: block;
+ font-size: 0.85rem;
+ color: var(--muted);
+ margin-bottom: 0.35rem;
+}
+
+.gate-card input {
+ width: 100%;
+ padding: 0.65rem 0.75rem;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+ background: #0a0f16;
+ color: var(--text);
+ font: inherit;
+ margin-bottom: 0.75rem;
+}
+
+.btn.full {
+ width: 100%;
+ margin-top: 0.25rem;
+}
+
+.session-actions {
+ margin-bottom: 0.75rem;
+}
+
+.pw-change {
+ margin-top: 0.25rem;
+ color: var(--muted);
+ font-size: 0.88rem;
+}
+
+.pw-change summary {
+ cursor: pointer;
+ color: var(--accent);
+ font-weight: 600;
+}
+
+.pw-change form {
+ margin-top: 0.75rem;
+ display: grid;
+ gap: 0.35rem;
+}
+
+.pw-change label {
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+
+.pw-change input {
+ width: 100%;
+ padding: 0.55rem 0.65rem;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+ background: #0a0f16;
+ color: var(--text);
+ font: inherit;
+}
+
+.token-box input {
+ width: 100%;
+ padding: 0.65rem 0.75rem;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+ background: #0a0f16;
+ color: var(--text);
+ font-family: "JetBrains Mono", ui-monospace, monospace;
+ font-size: 0.85rem;
+}
+
+.toolbar {
+ margin-top: 1.25rem;
+ display: flex;
+ gap: 0.75rem;
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+
+.proto-switch {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ min-width: 12rem;
+}
+
+.proto-switch-label {
+ font-size: 0.78rem;
+}
+
+.proto-select {
+ padding: 0.45rem 0.55rem;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+ background: #0a0f16;
+ color: var(--text);
+ font: inherit;
+ font-size: 0.85rem;
+ max-width: 22rem;
+}
+
+.clock-row--server .clock-kind-sub,
+.clock-row--your-place .clock-kind-sub {
+ font-weight: 400;
+ opacity: 0.82;
+ font-size: 0.72rem;
+}
+
+.clock-row--your-place {
+ padding: 0.35rem 0.55rem;
+ margin: 0.15rem -0.35rem 0;
+ border-radius: 10px;
+ border-left: 3px solid #22d3ee;
+ background: rgba(56, 189, 248, 0.07);
+}
+
+.clock-zone-diff {
+ margin: 0.5rem 0 0;
+ font-size: 0.78rem;
+ line-height: 1.45;
+ max-width: 26rem;
+}
+
+.clock-zone-diff--accent {
+ color: #a5f3fc;
+ padding: 0.45rem 0.55rem;
+ margin-top: 0.45rem;
+ border-radius: 8px;
+ border: 1px solid rgba(34, 211, 238, 0.35);
+ background: rgba(56, 189, 248, 0.08);
+}
+
+.clock-sync-summary strong {
+ color: var(--text);
+ font-weight: 650;
+}
+
+.clock-strip {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ padding: 0.45rem 0.75rem;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.03);
+ font-size: 0.82rem;
+ line-height: 1.35;
+}
+
+.clock-row {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.clock-kind {
+ flex: 0 0 auto;
+ min-width: 7rem;
+ max-width: 11rem;
+}
+
+.clock-time {
+ font-family: "JetBrains Mono", ui-monospace, monospace;
+ font-size: 0.84rem;
+ color: var(--text);
+}
+
+.clock-hint {
+ margin: 0.35rem 0 0;
+ font-size: 0.74rem;
+ line-height: 1.35;
+ max-width: 22rem;
+}
+
+.clock-sync-btn {
+ margin-top: 0.35rem;
+ align-self: flex-start;
+}
+
+.clock-host-sync {
+ margin-top: 0.65rem;
+ padding-top: 0.55rem;
+ border-top: 1px solid var(--line);
+ max-width: 26rem;
+}
+
+.clock-host-sync summary {
+ cursor: pointer;
+ font-size: 0.84rem;
+ color: var(--accent, #38bdf8);
+}
+
+.clock-sync-hint {
+ margin: 0.45rem 0 0.5rem;
+ font-size: 0.76rem;
+ line-height: 1.4;
+}
+
+.clock-sync-input {
+ display: block;
+ width: 100%;
+ max-width: 22rem;
+ margin-top: 0.35rem;
+ padding: 0.45rem 0.55rem;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+ background: #0a0f16;
+ color: var(--text);
+ font: inherit;
+ font-size: 0.85rem;
+}
+
+.clock-sync-submit {
+ margin-top: 0.45rem;
+}
+
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.45rem 0.75rem;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.03);
+ font-size: 0.9rem;
+}
+
+.dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--muted);
+}
+
+.dot.ok {
+ background: var(--ok);
+}
+
+.btn {
+ border: 1px solid transparent;
+ border-radius: 11px;
+ padding: 0.55rem 0.95rem;
+ font-weight: 600;
+ cursor: pointer;
+ font: inherit;
+}
+
+.btn.primary {
+ background: linear-gradient(135deg, #38bdf8, #22d3ee);
+ color: #041018;
+}
+
+.btn.ghost {
+ border-color: var(--line);
+ background: transparent;
+ color: var(--text);
+}
+
+.token-box .btn.ghost {
+ margin-top: 0.5rem;
+ width: 100%;
+}
+
+.rename-inline .btn.small {
+ width: auto;
+}
+
+.btn.warn {
+ border-color: rgba(251, 113, 133, 0.35);
+ background: rgba(251, 113, 133, 0.08);
+ color: #fecdd3;
+}
+
+.btn.small {
+ padding: 0.35rem 0.65rem;
+ font-size: 0.82rem;
+}
+
+.status {
+ min-height: 1.25rem;
+ color: var(--muted);
+ margin: 0.75rem 0 0;
+}
+
+.status.err {
+ color: #fecaca;
+}
+
+.panel {
+ margin-top: 1rem;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: var(--card);
+ overflow: hidden;
+}
+
+.panel-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 1rem;
+ padding: 1rem 1.1rem;
+ border-bottom: 1px solid var(--line);
+}
+
+.panel-head h2 {
+ margin: 0;
+ font-size: 1rem;
+}
+
+/* Сворачиваемые блоки (стрелка в summary, как disclosure) */
+.panel-fold {
+ margin-top: 1rem;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: var(--card);
+ overflow: hidden;
+}
+
+.panel-fold > summary.fold-summary {
+ list-style: none;
+ display: flex;
+ align-items: flex-start;
+ gap: 0.65rem;
+ padding: 0.95rem 1.1rem;
+ cursor: pointer;
+ user-select: none;
+ border-bottom: 1px solid transparent;
+}
+
+.panel-fold[open] > summary.fold-summary {
+ border-bottom-color: var(--line);
+}
+
+.panel-fold > summary.fold-summary::-webkit-details-marker {
+ display: none;
+}
+
+.fold-arrow {
+ flex-shrink: 0;
+ width: 0;
+ height: 0;
+ margin-top: 0.32rem;
+ border-style: solid;
+ border-width: 5px 0 5px 8px;
+ border-color: transparent transparent transparent var(--accent);
+ transition: transform 0.15s ease;
+}
+
+.panel-fold[open] > summary.fold-summary .fold-arrow {
+ transform: rotate(90deg);
+ margin-top: 0.42rem;
+}
+
+.fold-titles {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.35rem 1rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.fold-h {
+ font-weight: 700;
+ font-size: 1rem;
+ margin: 0;
+}
+
+.fold-meta {
+ font-size: 0.82rem;
+}
+
+.panel-fold-body {
+ padding: 0 1.1rem 1.1rem;
+}
+
+.panel-fold-body > .warp-intro:first-child,
+.panel-fold-body > .cascade-intro:first-child {
+ margin-top: 0.85rem;
+}
+
+.panel-fold-raw {
+ margin-top: 1rem;
+}
+
+.panel-fold-raw > summary.fold-summary--raw {
+ list-style: none;
+ display: flex;
+ align-items: flex-start;
+ gap: 0.65rem;
+ padding: 0.65rem 0;
+ cursor: pointer;
+ user-select: none;
+}
+
+.panel-fold-raw[open] > summary.fold-summary--raw .fold-arrow {
+ transform: rotate(90deg);
+ margin-top: 0.42rem;
+}
+
+.panel-fold-raw > summary.fold-summary--raw::-webkit-details-marker {
+ display: none;
+}
+
+.panel-fold-raw > pre {
+ margin-top: 0.5rem;
+}
+
+.th-actions {
+ width: 1%;
+ white-space: nowrap;
+ text-align: right;
+}
+
+.table-wrap {
+ overflow-x: auto;
+}
+
+.muted {
+ color: var(--muted);
+ font-size: 0.88rem;
+}
+
+table.clients-table {
+ table-layout: fixed;
+}
+
+.clients-table th:nth-child(1),
+.clients-table td:nth-child(1) {
+ width: 26%;
+ vertical-align: top;
+}
+
+.clients-table th:nth-child(2),
+.clients-table td:nth-child(2) {
+ width: 17%;
+}
+
+.clients-table th:nth-child(3),
+.clients-table td:nth-child(3) {
+ width: 14%;
+}
+
+.clients-table th:nth-child(4),
+.clients-table td:nth-child(4) {
+ width: 22%;
+}
+
+.clients-table .name-cell {
+ min-width: 0;
+}
+
+.clients-table td.actions {
+ vertical-align: top;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.95rem;
+}
+
+th,
+td {
+ padding: 0.85rem 1rem;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ vertical-align: middle;
+}
+
+th {
+ color: var(--muted);
+ font-weight: 600;
+ font-size: 0.78rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+tr:last-child td {
+ border-bottom: none;
+}
+
+.name-cell strong {
+ font-weight: 600;
+}
+
+.name-cell .rename-inline {
+ margin-top: 0.35rem;
+}
+
+.date-cell {
+ font-size: 0.88rem;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.ip {
+ font-family: "JetBrains Mono", ui-monospace, monospace;
+ font-size: 0.85rem;
+ color: #dbeafe;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.25rem 0.55rem;
+ border-radius: 999px;
+ font-size: 0.82rem;
+ border: 1px solid var(--line);
+}
+
+.badge.on {
+ border-color: rgba(74, 222, 128, 0.35);
+ color: #bbf7d0;
+ background: rgba(74, 222, 128, 0.08);
+}
+
+.badge.off {
+ border-color: rgba(148, 163, 184, 0.35);
+ color: #e2e8f0;
+ background: rgba(148, 163, 184, 0.06);
+}
+
+.actions {
+ display: flex;
+ gap: 0.35rem;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+}
+
+.export-missing-hint {
+ margin: 0.35rem 0 0;
+ font-size: 0.72rem;
+ line-height: 1.35;
+ max-width: none;
+}
+
+.hint-mini {
+ margin-top: 0.35rem;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 0.35rem 0.5rem;
+ background: rgba(0, 0, 0, 0.18);
+}
+
+.hint-mini summary {
+ cursor: pointer;
+ font-size: 0.72rem;
+ color: var(--muted);
+ list-style: none;
+}
+
+.hint-mini summary::-webkit-details-marker {
+ display: none;
+}
+
+.hint-mini .export-missing-hint {
+ margin: 0.4rem 0 0;
+}
+
+.profile-hint {
+ margin: 0 0 0.75rem;
+ padding: 0.65rem 0.95rem;
+ border-radius: 12px;
+ border: 1px solid rgba(251, 191, 36, 0.35);
+ background: rgba(251, 191, 36, 0.07);
+ max-width: 52rem;
+}
+
+.cascade-intro code.inline {
+ font-size: 0.85em;
+}
+
+.cascade-form {
+ display: grid;
+ gap: 0.55rem;
+ max-width: 28rem;
+ margin-top: 0.85rem;
+}
+
+.cascade-form label {
+ font-size: 0.82rem;
+ color: var(--muted);
+}
+
+.cascade-form input {
+ width: 100%;
+}
+
+.raw {
+ margin-top: 1.25rem;
+ color: var(--muted);
+}
+
+.raw summary {
+ cursor: pointer;
+}
+
+.raw pre {
+ white-space: pre-wrap;
+ word-break: break-word;
+ background: #0a0f16;
+ border: 1px solid var(--line);
+ padding: 0.75rem;
+ border-radius: 12px;
+ color: #cbd5f5;
+ font-size: 0.78rem;
+}
+
+.dt-dialog {
+ border: none;
+ border-radius: 16px;
+ padding: 0;
+ background: var(--card);
+ color: var(--text);
+ max-width: min(420px, 92vw);
+ box-shadow: var(--shadow);
+}
+
+.dt-dialog::backdrop {
+ background: rgba(0, 0, 0, 0.55);
+}
+
+.dt-dialog-inner {
+ padding: 1.25rem 1.35rem 1.35rem;
+}
+
+.dt-dialog-inner h3 {
+ margin: 0 0 0.35rem;
+ font-size: 1.05rem;
+}
+
+.dt-dialog-inner label {
+ display: block;
+ font-size: 0.82rem;
+ color: var(--muted);
+ margin: 0.75rem 0 0.35rem;
+}
+
+.dt-dialog-inner input[type="datetime-local"] {
+ width: 100%;
+ padding: 0.55rem 0.65rem;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+ background: #0a0f16;
+ color: var(--text);
+ font: inherit;
+}
+
+.dt-dialog-extra {
+ margin-top: 0.85rem;
+}
+
+.dt-dialog-hint {
+ margin: 0 0 0.65rem;
+ font-size: 0.82rem;
+ line-height: 1.45;
+ color: var(--muted);
+}
+
+.dt-checkbox-label {
+ display: flex;
+ gap: 0.55rem;
+ align-items: flex-start;
+ font-size: 0.85rem;
+ line-height: 1.45;
+ color: var(--text);
+ cursor: pointer;
+}
+
+.dt-checkbox-label input {
+ margin-top: 0.15rem;
+ flex-shrink: 0;
+}
+
+.dt-dialog-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ margin-top: 1rem;
+ flex-wrap: wrap;
+}
+
+.dt-dialog-actions .btn {
+ width: auto;
+}
+
+.support-footer {
+ flex-shrink: 0;
+ margin-top: auto;
+ padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 1.5rem;
+ border-top: 1px solid var(--line);
+ background: rgba(10, 15, 22, 0.92);
+}
+
+.support-line {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem 0.5rem;
+ font-size: 0.92rem;
+ color: var(--muted);
+}
+
+.support-title {
+ color: var(--text);
+ font-weight: 700;
+}
+
+.support-sep {
+ opacity: 0.55;
+ user-select: none;
+}
+
+.support-line a {
+ color: var(--accent);
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.support-line a:hover {
+ text-decoration: underline;
+}
+
+.support-blurb {
+ margin: 0.65rem 0 0;
+ font-size: 0.82rem;
+ line-height: 1.45;
+ color: var(--muted);
+ max-width: 62rem;
+}
+
+/* --- Cloudflare WARP --- */
+.warp-panel .warp-intro {
+ font-size: 0.88rem;
+ line-height: 1.55;
+ margin: 0 0 1rem;
+}
+
+.warp-panel .warp-intro code.inline {
+ font-family: "JetBrains Mono", ui-monospace, monospace;
+ font-size: 0.78rem;
+ padding: 0.12rem 0.38rem;
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.35);
+ border: 1px solid var(--line);
+}
+
+.warp-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.warp-client-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ margin-bottom: 0.85rem;
+}
+
+.warp-check-row {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ font-size: 0.88rem;
+ padding: 0.35rem 0.5rem;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+ background: rgba(0, 0, 0, 0.2);
+}
+
+.warp-check-row input {
+ flex-shrink: 0;
+}
+
+.warp-muted {
+ font-size: 0.78rem;
+ color: var(--muted);
+}
+
+.warp-raw pre {
+ max-height: 220px;
+ overflow: auto;
+}
+
+.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;
+}
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 0000000..10fa0be
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,249 @@
+#!/usr/bin/env bash
+# Установка Amnezia Admin WebUI одной командой (см. README).
+set -euo pipefail
+
+GITHUB_REPO="${GITHUB_REPO:-andrey271192/amnezia_web}"
+BRANCH="${BRANCH:-main}"
+INSTALL_DIR="${INSTALL_DIR:-/opt/amnezia-admin}"
+DATA_DIR="${DATA_DIR:-/opt/amnezia-admin-data}"
+CONTAINER_NAME="${CONTAINER_NAME:-amnezia-admin}"
+HOST_PORT="${HOST_PORT:-8080}"
+LANDING_CONTAINER="${LANDING_CONTAINER:-amnezia-web-landing}"
+LANDING_IMAGE="${LANDING_IMAGE:-amnezia-web-landing:latest}"
+LANDING_PORT="${LANDING_PORT:-80}"
+
+need_root() {
+ if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
+ echo "Запустите от root: sudo bash или: curl ... | sudo bash"
+ exit 1
+ fi
+}
+
+need_docker() {
+ command -v docker >/dev/null 2>&1 || {
+ echo "Ошибка: нужен Docker."
+ exit 1
+ }
+ docker info >/dev/null 2>&1 || {
+ echo "Ошибка: демон Docker не отвечает."
+ exit 1
+ }
+}
+
+need_root
+need_docker
+
+REPO_SLUG="${GITHUB_REPO##*/}"
+TMP=""
+cleanup() {
+ [[ -n "${TMP}" ]] && rm -rf "${TMP}"
+}
+trap cleanup EXIT
+
+if [[ "${SKIP_DOWNLOAD:-}" != "1" ]]; then
+ echo "→ Клонирование релиза ${GITHUB_REPO} (${BRANCH})..."
+ TMP=$(mktemp -d)
+ curl -fsSL \
+ -H 'Cache-Control: no-cache' \
+ -H 'Pragma: no-cache' \
+ "https://github.com/${GITHUB_REPO}/archive/refs/heads/${BRANCH}.tar.gz" \
+ | tar xz -C "${TMP}"
+ rm -rf "${INSTALL_DIR}"
+ mkdir -p "$(dirname "${INSTALL_DIR}")"
+ mv "${TMP}/${REPO_SLUG}-${BRANCH}" "${INSTALL_DIR}"
+ TMP=""
+fi
+
+mkdir -p "${DATA_DIR}"
+
+# При повторном запуске не менять внешний порт панели, если не указали HOST_PORT явно (по умолчанию 8080).
+PREV_HOST_PORT=""
+if docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then
+ PREV_HOST_PORT="$(docker port "${CONTAINER_NAME}" 3980/tcp 2>/dev/null | head -1 | awk -F: '{print $NF}')"
+ if [[ -n "${PREV_HOST_PORT}" && "${HOST_PORT}" == "8080" ]]; then
+ HOST_PORT="${PREV_HOST_PORT}"
+ echo "→ Уже запущен ${CONTAINER_NAME}: сохраняю внешний порт ${HOST_PORT} (укажите HOST_PORT=… чтобы сменить)."
+ fi
+fi
+
+BOOT_PW=""
+PASS_FILE="/root/amnezia-admin.initial-password"
+if [[ -f "${DATA_DIR}/password.hash" ]]; then
+ echo "→ В ${DATA_DIR} уже есть password.hash — контейнер поднимется с прежним паролем."
+elif [[ -n "${ADMIN_PASSWORD:-}" ]]; then
+ BOOT_PW="${ADMIN_PASSWORD}"
+ echo "→ Использую ADMIN_PASSWORD из окружения."
+elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then
+ echo "→ ALLOW_DEFAULT_PASSWORD=1 — см. README, пароль по умолчанию для входа."
+else
+ BOOT_PW="$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 22 || openssl rand -hex 16)"
+ umask 077
+ printf '%s\n' "${BOOT_PW}" >"${PASS_FILE}"
+ echo "→ Первый пароль записан в ${PASS_FILE}"
+fi
+
+# AWG_PROFILES: не терять при апдейте без переменной (пропадает список «Инстанс»).
+AWG_PROFILE_SNAPSHOT="/root/amnezia-admin.awg-profiles.json"
+if [[ -n "${AWG_PROFILES:-}" ]]; then
+ umask 077
+ printf '%s\n' "${AWG_PROFILES}" >"${AWG_PROFILE_SNAPSHOT}" 2>/dev/null || true
+elif docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then
+ PREV_AWG_PROFILES=""
+ while IFS= read -r __env_line; do
+ if [[ "${__env_line}" == AWG_PROFILES=* ]]; then
+ PREV_AWG_PROFILES="${__env_line#AWG_PROFILES=}"
+ break
+ fi
+ done < <(docker inspect "${CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}')
+ if [[ -n "${PREV_AWG_PROFILES}" ]]; then
+ AWG_PROFILES="${PREV_AWG_PROFILES}"
+ echo "→ AWG_PROFILES восстановлен из предыдущего контейнера ${CONTAINER_NAME}."
+ umask 077
+ printf '%s\n' "${AWG_PROFILES}" >"${AWG_PROFILE_SNAPSHOT}" 2>/dev/null || true
+ fi
+fi
+if [[ -z "${AWG_PROFILES:-}" ]] && [[ -f "${AWG_PROFILE_SNAPSHOT}" ]]; then
+ AWG_PROFILES="$(tr -d '\r\n' <"${AWG_PROFILE_SNAPSHOT}" || true)"
+ if [[ -n "${AWG_PROFILES}" ]]; then
+ echo "→ AWG_PROFILES восстановлен из ${AWG_PROFILE_SNAPSHOT}."
+ fi
+fi
+
+if [[ -z "${AWG_PROFILES:-}" ]]; then
+ __awg_multi_count="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E '^amnezia-awg' | wc -l | tr -d '[:space:]')"
+ if [[ "${__awg_multi_count:-0}" =~ ^[0-9]+$ ]] && [[ "${__awg_multi_count}" -gt 1 ]]; then
+ echo "⚠ Запущено ${__awg_multi_count} контейнеров с именами amnezia-awg*, но AWG_PROFILES не задан."
+ echo " Переключатель «Инстанс» в панели не появится: см. README, раздел «Несколько инстансов» и «Переменные окружения и sudo»."
+ echo " Без sudo -E: запишите JSON одной строкой в ${AWG_PROFILE_SNAPSHOT} и снова запустите этот установщик."
+ 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 WARP_SSH_INSTALL_DIR; 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)
+ echo "→ NO_CACHE=1 — сборка без слоя кэша Docker."
+fi
+
+echo "→ Сборка образа amnezia-admin:latest ..."
+docker build "${DOCKER_BUILD_EXTRA[@]}" -t amnezia-admin:latest "${INSTALL_DIR}"
+
+docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
+
+RUN_ENV=(
+ -e AWG_CONTAINER="${AWG_CONTAINER:-amnezia-awg2}"
+)
+
+if [[ -n "${AWG_PROFILES:-}" ]]; then
+ RUN_ENV+=( -e "AWG_PROFILES=${AWG_PROFILES}" )
+fi
+
+if [[ -n "${TIME_SYNC_SSH_HOST:-}" ]]; then
+ RUN_ENV+=( -e "TIME_SYNC_SSH_HOST=${TIME_SYNC_SSH_HOST}" )
+fi
+
+if [[ -n "${TIME_SYNC_DISABLED:-}" ]]; then
+ RUN_ENV+=( -e "TIME_SYNC_DISABLED=${TIME_SYNC_DISABLED}" )
+fi
+
+if [[ -n "${TZ:-}" ]]; then
+ RUN_ENV+=( -e "TZ=${TZ}" )
+fi
+
+for __warp_var in WARP_DIR WARP_CONF_PATH WARP_CLIENTS_LIST AMNEZIA_START_SCRIPT WARP_SSH_INSTALL_DIR; do
+ if [[ -n "${!__warp_var:-}" ]]; then
+ RUN_ENV+=( -e "${__warp_var}=${!__warp_var}" )
+ fi
+done
+
+for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2 EXPORT_CONFIG_SECRET; do
+ if [[ -n "${!__export_var:-}" ]]; then
+ RUN_ENV+=( -e "${__export_var}=${!__export_var}" )
+ fi
+done
+
+for __ui_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE WARP_SSH_INSTALL_DIR; 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
+ RUN_ENV+=( -e "ALLOW_DEFAULT_PASSWORD=1" )
+fi
+
+if [[ -n "${AMNEZIA_EDITION:-}" ]]; then
+ RUN_ENV+=( -e "AMNEZIA_EDITION=${AMNEZIA_EDITION}" )
+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; do
+ if [[ -n "${!__ce_var:-}" ]]; then
+ RUN_ENV+=( -e "${__ce_var}=${!__ce_var}" )
+ fi
+done
+
+IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
+
+docker run -d --name "${CONTAINER_NAME}" --restart unless-stopped \
+ -p "${HOST_PORT}:3980" \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v "${DATA_DIR}:/data" \
+ "${RUN_ENV[@]}" \
+ amnezia-admin:latest
+
+if [[ "${SKIP_LANDING:-}" != "1" ]] && [[ -d "${INSTALL_DIR}/landing" ]]; then
+ printf "window.__AMNEZIA_ADMIN_PORT__='%s';\n" "${HOST_PORT}" >"${INSTALL_DIR}/landing/admin-port.js"
+ echo "→ Сборка образа ${LANDING_IMAGE} (страница на порту ${LANDING_PORT})..."
+ docker build "${DOCKER_BUILD_EXTRA[@]}" -t "${LANDING_IMAGE}" "${INSTALL_DIR}/landing"
+ docker rm -f "${LANDING_CONTAINER}" 2>/dev/null || true
+ if docker run -d --name "${LANDING_CONTAINER}" --restart unless-stopped \
+ -p "${LANDING_PORT}:80" \
+ "${LANDING_IMAGE}"; then
+ echo "→ Публичная страница (лендинг): http://${IP:-SERVER_IP}:${LANDING_PORT}/"
+ else
+ echo "⚠ Не удалось запустить лендинг (часто порт ${LANDING_PORT} занят). Поставьте LANDING_PORT=8081 или SKIP_LANDING=1."
+ fi
+else
+ echo "→ Лендинг пропущен (SKIP_LANDING=1 или нет каталога landing)."
+fi
+
+echo ""
+echo "=== Готово ==="
+echo "Админ-панель: http://${IP:-SERVER_IP}:${HOST_PORT}"
+if [[ "${SKIP_LANDING:-}" != "1" ]]; then
+ echo "Лендинг для пользователей: http://${IP:-SERVER_IP}:${LANDING_PORT}/ (ссылки доната автора — только в админ-панели)"
+fi
+if [[ -f "${PASS_FILE}" ]]; then
+ echo "Первый пароль: $(cat "${PASS_FILE}")"
+fi
+if [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then
+ echo "Пароль по умолчанию (смените в панели): AmneziaAdmin!ChangeMe"
+fi
+echo ""
+echo "Удаление: curl -fsSL https://raw.githubusercontent.com/${GITHUB_REPO}/${BRANCH}/scripts/uninstall.sh | sudo bash"
diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh
new file mode 100755
index 0000000..876b8b5
--- /dev/null
+++ b/scripts/uninstall.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Удаление контейнера и опционально данных (см. README).
+set -euo pipefail
+
+CONTAINER_NAME="${CONTAINER_NAME:-amnezia-admin}"
+IMAGE_NAME="${IMAGE_NAME:-amnezia-admin:latest}"
+INSTALL_DIR="${INSTALL_DIR:-/opt/amnezia-admin}"
+DATA_DIR="${DATA_DIR:-/opt/amnezia-admin-data}"
+
+need_root() {
+ if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
+ echo "Запустите от root: curl ... | sudo bash"
+ exit 1
+ fi
+}
+
+need_root
+
+echo "→ Останавливаю лендинг ${LANDING_CONTAINER:-amnezia-web-landing}..."
+docker rm -f "${LANDING_CONTAINER:-amnezia-web-landing}" 2>/dev/null || echo "(лендинг уже отсутствует)"
+
+echo "→ Останавливаю контейнер ${CONTAINER_NAME}..."
+docker rm -f "${CONTAINER_NAME}" 2>/dev/null || echo "(контейнер уже отсутствует)"
+
+if [[ "${REMOVE_LANDING_IMAGE:-}" == "1" ]]; then
+ echo "→ Удаляю образ ${LANDING_IMAGE:-amnezia-web-landing:latest}..."
+ docker rmi "${LANDING_IMAGE:-amnezia-web-landing:latest}" 2>/dev/null || true
+fi
+
+if [[ "${REMOVE_IMAGE:-}" == "1" ]]; then
+ echo "→ Удаляю образ ${IMAGE_NAME}..."
+ docker rmi "${IMAGE_NAME}" 2>/dev/null || true
+fi
+
+if [[ "${REMOVE_DATA:-}" == "1" ]]; then
+ echo "→ Удаляю данные панели ${DATA_DIR}..."
+ rm -rf "${DATA_DIR}"
+fi
+
+if [[ "${REMOVE_SRC:-}" == "1" ]]; then
+ echo "→ Удаляю каталог исходников ${INSTALL_DIR}..."
+ rm -rf "${INSTALL_DIR}"
+fi
+
+echo "Готово."
+echo "Подсказка: REMOVE_LANDING_IMAGE=1 — удалить образ лендинга; REMOVE_DATA=1 REMOVE_SRC=1 REMOVE_IMAGE=1 curl ... | sudo bash — полная очистка."
diff --git a/scripts/warp-amnezia.sh b/scripts/warp-amnezia.sh
new file mode 100755
index 0000000..31c9dda
--- /dev/null
+++ b/scripts/warp-amnezia.sh
@@ -0,0 +1,280 @@
+#!/usr/bin/env bash
+# Cloudflare WARP внутри контейнера AmneziaWG (wgcf → warp.conf → wg-quick).
+# Запускать на хосте VPS от root: установка и обслуживание туннеля WARP для панели Amnezia Admin.
+# После install управление «кто выходит через WARP» — в веб-панели (раздел WARP). Полное снятие: подкоманда uninstall.
+set -euo pipefail
+
+WGCF_VERSION="${WGCF_VERSION:-2.2.30}"
+WGCF_BIN="${WGCF_BIN:-/root/wgcf}"
+WGCF_ACCOUNT="${WGCF_ACCOUNT:-/root/wgcf-account.toml}"
+WGCF_PROFILE="${WGCF_PROFILE:-/root/wgcf-profile.conf}"
+
+usage() {
+ echo "Использование: $0 {install|start|stop|status|rekey|uninstall} [имя_контейнера]"
+ echo "Переменные: AWG_CONTAINER, WARP_DIR (по умолчанию /opt/warp), AMNEZIA_START_SCRIPT (для uninstall, по умолчанию /opt/amnezia/start.sh)"
+ exit 1
+}
+
+need_root() {
+ if [[ "${EUID:-0}" -ne 0 ]]; then
+ echo "Запустите от root."
+ exit 1
+ fi
+}
+
+pick_container() {
+ local c="${2:-${AWG_CONTAINER:-}}"
+ if [[ -n "$c" ]] && docker exec "$c" true 2>/dev/null; then
+ CONTAINER="$c"
+ return 0
+ fi
+ local -a found=()
+ while IFS= read -r n; do found+=("$n"); done < <(docker ps --format '{{.Names}}' | grep -E '^amnezia-awg2$|^amnezia-awg$' || true)
+ if [[ ${#found[@]} -eq 1 ]]; then
+ CONTAINER="${found[0]}"
+ return 0
+ fi
+ if [[ ${#found[@]} -gt 1 ]]; then
+ echo "Несколько контейнеров: ${found[*]}. Укажите вторым аргументом или AWG_CONTAINER="
+ exit 1
+ fi
+ echo "Не найден контейнер amnezia-awg / amnezia-awg2."
+ exit 1
+}
+
+load_paths() {
+ AWG_WARP_DIR="${WARP_DIR:-/opt/warp}"
+ AWG_WARP_CONF="${AWG_WARP_DIR}/warp.conf"
+ AWG_VPN_CONF=""
+ if docker exec "$CONTAINER" test -f /opt/amnezia/awg/awg0.conf 2>/dev/null; then
+ AWG_VPN_CONF="/opt/amnezia/awg/awg0.conf"
+ elif docker exec "$CONTAINER" test -f /opt/amnezia/awg/wg0.conf 2>/dev/null; then
+ AWG_VPN_CONF="/opt/amnezia/awg/wg0.conf"
+ else
+ for f in /opt/amnezia/awg/wg0.conf /opt/amnezia/awg/awg0.conf /etc/wireguard/wg0.conf; do
+ if docker exec "$CONTAINER" test -f "$f" 2>/dev/null; then
+ AWG_VPN_CONF="$f"
+ break
+ fi
+ done
+ fi
+ [[ -n "$AWG_VPN_CONF" ]] || {
+ echo "Не найден конфиг WireGuard/AWG в контейнере."
+ exit 1
+ }
+}
+
+install_wgcf() {
+ [[ -x "$WGCF_BIN" ]] && return 0
+ local arch wa
+ arch="$(uname -m)"
+ case "$arch" in
+ x86_64) wa="amd64" ;;
+ aarch64 | arm64) wa="arm64" ;;
+ armv7l) wa="armv7" ;;
+ *) echo "Архитектура не поддерживается: $arch"; exit 1 ;;
+ esac
+ wget -q -O "$WGCF_BIN" "https://github.com/ViRb3/wgcf/releases/download/v${WGCF_VERSION}/wgcf_${WGCF_VERSION}_linux_${wa}"
+ chmod +x "$WGCF_BIN"
+}
+
+ensure_account() {
+ if [[ ! -f "$WGCF_ACCOUNT" ]]; then
+ echo "Регистрация WARP (wgcf register)…"
+ (cd /root && yes | "$WGCF_BIN" register >/dev/null 2>&1 || true)
+ fi
+ [[ -f "$WGCF_ACCOUNT" ]] || {
+ echo "Не создан $WGCF_ACCOUNT"
+ exit 1
+ }
+}
+
+generate_profile() {
+ (cd /root && yes | "$WGCF_BIN" generate >/dev/null 2>&1 || true)
+ [[ -f "$WGCF_PROFILE" ]] || {
+ echo "Не создан $WGCF_PROFILE"
+ exit 1
+ }
+}
+
+resolve_endpoint() {
+ local ep
+ ep="$(getent ahostsv4 engage.cloudflareclient.com 2>/dev/null | awk 'NR==1{print $1}')"
+ [[ -n "$ep" ]] || {
+ echo "Не удалось резолвить engage.cloudflareclient.com"
+ exit 1
+ }
+ echo "$ep"
+}
+
+build_warp_conf() {
+ local endpoint_ip="$1"
+ local pk pub addr
+ pk="$(awk -F' = ' '/^PrivateKey = /{print $2}' "$WGCF_PROFILE")"
+ pub="$(awk -F' = ' '/^PublicKey = /{print $2}' "$WGCF_PROFILE")"
+ addr="$(awk -F' = ' '/^Address = /{print $2}' "$WGCF_PROFILE" | cut -d',' -f1)"
+ docker exec "$CONTAINER" sh -c "mkdir -p '$AWG_WARP_DIR'"
+ docker cp "$WGCF_PROFILE" "${CONTAINER}:${AWG_WARP_DIR}/wgcf-profile.conf" 2>/dev/null || true
+ docker exec "$CONTAINER" sh -c "cat > '$AWG_WARP_CONF' </dev/null 2>&1 || true"
+ docker exec "$CONTAINER" sh -c "wg-quick up '$AWG_WARP_CONF'"
+ docker exec "$CONTAINER" ip addr show warp >/dev/null 2>&1 || {
+ echo "Интерфейс warp не поднялся."
+ exit 1
+ }
+}
+
+warp_down() {
+ docker exec "$CONTAINER" sh -c "wg-quick down '$AWG_WARP_CONF' 2>/dev/null || true"
+}
+
+is_installed() {
+ docker exec "$CONTAINER" test -f "$AWG_WARP_CONF" 2>/dev/null
+}
+
+is_running() {
+ docker exec "$CONTAINER" ip addr show warp >/dev/null 2>&1
+}
+
+cmd_uninstall() {
+ echo "→ Останавливаю WARP и убираю автозапуск в контейнере ${CONTAINER}…"
+ warp_down || true
+ local START_SCRIPT="${AMNEZIA_START_SCRIPT:-/opt/amnezia/start.sh}"
+ docker exec \
+ -e START_SCRIPT="$START_SCRIPT" \
+ -e WARP_CONF="$AWG_WARP_CONF" \
+ -e WARP_DIR="$AWG_WARP_DIR" \
+ "$CONTAINER" sh -c '
+set +e
+ip rule | awk "/lookup 100/ {print \$1}" | sed "s/://g" | sort -rn | while read -r pr; do ip rule del priority "$pr" 2>/dev/null || true; done
+iptables -t nat -S POSTROUTING 2>/dev/null | grep -- "-o warp -j MASQUERADE" | while read -r line; do
+ rule=$(echo "$line" | sed "s/^-A /-D /")
+ iptables -t nat $rule 2>/dev/null || true
+done
+ip route flush table 100 2>/dev/null || true
+if [ -f "$START_SCRIPT" ] && grep -qF "# --- WARP-MANAGER BEGIN ---" "$START_SCRIPT" 2>/dev/null; then
+ sed -i "/# --- WARP-MANAGER BEGIN ---/,/# --- WARP-MANAGER END ---/d" "$START_SCRIPT"
+fi
+rm -f "$WARP_CONF" "${WARP_DIR}/clients.list" "${WARP_DIR}/wgcf-profile.conf" 2>/dev/null || true
+'
+ echo "→ Перезапускаю контейнер ${CONTAINER}…"
+ docker restart "$CONTAINER" >/dev/null
+ echo "Готово: WARP отключён, файлы в контейнере и блок в start.sh убраны."
+ echo "Учёт wgcf на хосте при желании удалите вручную: $WGCF_ACCOUNT $WGCF_PROFILE (и $WGCF_BIN, если не нужен)."
+}
+
+cmd_install() {
+ echo "Бэкап конфигов в контейнере…"
+ docker exec "$CONTAINER" sh -c "
+ ts=\$(date +%Y%m%d-%H%M%S)
+ cp '$AWG_VPN_CONF' '${AWG_VPN_CONF}.bak-warp-'\$ts 2>/dev/null || true
+ cp /opt/amnezia/start.sh /opt/amnezia/start.sh.bak-warp-\$ts 2>/dev/null || true
+ true
+ "
+ install_wgcf
+ ensure_account
+ generate_profile
+ local ep
+ ep="$(resolve_endpoint)"
+ echo "Endpoint: $ep"
+ build_warp_conf "$ep"
+ warp_up
+ echo "Готово: WARP установлен. Управление клиентами — в веб-панели (раздел WARP)."
+}
+
+cmd_status() {
+ if is_installed; then
+ echo "warp.conf: есть ($AWG_WARP_CONF)"
+ else
+ echo "warp.conf: нет — выполните: $0 install"
+ exit 1
+ fi
+ if is_running; then
+ echo "Интерфейс warp: поднят"
+ docker exec "$CONTAINER" wg show warp 2>/dev/null || true
+ echo -n "Внешний IP через WARP: "
+ docker exec "$CONTAINER" sh -c "curl -fsS --interface warp --connect-timeout 4 https://ifconfig.me 2>/dev/null || echo '?'"
+ echo
+ else
+ echo "Интерфейс warp: опущен ($0 start)"
+ fi
+}
+
+cmd_rekey() {
+ is_installed || {
+ echo "Сначала install."
+ exit 1
+ }
+ warp_down || true
+ rm -f "$WGCF_ACCOUNT"
+ ensure_account
+ generate_profile
+ local ep
+ ep="$(resolve_endpoint)"
+ build_warp_conf "$ep"
+ warp_up
+ echo "Ключ WARP перевыпущен. Заново отметьте клиентов в веб-панели и примените маршрутизацию."
+}
+
+[[ "${1:-}" ]] || usage
+need_root
+command -v docker >/dev/null || {
+ echo "Нужен docker в PATH."
+ exit 1
+}
+
+CMD="$1"
+pick_container "$@"
+load_paths
+
+case "$CMD" in
+ install)
+ if is_installed && is_running; then
+ echo "Уже установлен и работает."
+ exit 0
+ fi
+ if is_installed && ! is_running; then
+ echo "Конфиг есть — поднимаю интерфейс…"
+ warp_up
+ exit 0
+ fi
+ cmd_install
+ ;;
+ start)
+ is_installed || {
+ echo "Нет warp.conf — сначала install."
+ exit 1
+ }
+ is_running && {
+ echo "Уже работает."
+ exit 0
+ }
+ warp_up
+ echo "WARP поднят."
+ ;;
+ stop)
+ is_installed || exit 0
+ warp_down
+ echo "WARP остановлен."
+ ;;
+ uninstall) cmd_uninstall ;;
+ status) cmd_status ;;
+ rekey) cmd_rekey ;;
+ *) usage ;;
+esac
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..772ef79
--- /dev/null
+++ b/server.js
@@ -0,0 +1,2126 @@
+import express from "express";
+import { spawn } from "child_process";
+import crypto from "crypto";
+import path from "path";
+import fs from "fs";
+import { fileURLToPath } from "url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const PORT = Number(process.env.PORT || 3980);
+const PROFILE_COOKIE = "amnezia_prof";
+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();
+
+const AMNEZIA_EDITION = (process.env.AMNEZIA_EDITION || "pro").trim().toLowerCase();
+const IS_COMMUNITY = AMNEZIA_EDITION === "community";
+const COMMUNITY_UPGRADE_URL = process.env.COMMUNITY_UPGRADE_URL?.trim() || "https://boosty.to/andrey27/donate";
+const COMMUNITY_UPGRADE_PITCH =
+ process.env.COMMUNITY_UPGRADE_PITCH?.trim() ||
+ "В PRO: вкл/выкл клиентов, даты и расписание отключений, переименование, удаление, экспорт .conf, каскад, Cloudflare WARP, синхронизация времени хоста. Полная сборка — приватный репозиторий amnezia_web-PRO; доступ по подписке Boosty.";
+
+function editionPayload() {
+ return {
+ tier: IS_COMMUNITY ? "community" : "pro",
+ readOnlyClients: IS_COMMUNITY,
+ upgradeUrl: IS_COMMUNITY ? COMMUNITY_UPGRADE_URL : null,
+ upgradePitch: IS_COMMUNITY ? COMMUNITY_UPGRADE_PITCH : null,
+ showDebugWg: !IS_COMMUNITY,
+ };
+}
+
+function effectiveUiHidden() {
+ if (!IS_COMMUNITY) return { ...UI_HIDDEN };
+ return {
+ users: UI_HIDDEN.users,
+ warp: true,
+ cascade: true,
+ };
+}
+
+
+function parseProfilesFromEnv() {
+ const raw = process.env.AWG_PROFILES?.trim();
+ const fallback = () => {
+ const warpDir = (process.env.WARP_DIR || "/opt/warp").replace(/\/+$/, "") || "/opt/warp";
+ return [
+ {
+ id: "awg",
+ label: process.env.AWG_PROFILE_LABEL || "AmneziaWG",
+ container: process.env.AWG_CONTAINER || "amnezia-awg2",
+ confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf",
+ clientsPath: process.env.AWG_CLIENTS_PATH || "/opt/amnezia/awg/clientsTable",
+ iface: process.env.AWG_IFACE || "awg0",
+ wgBinary: process.env.AWG_BINARY || "awg",
+ pskPath: process.env.AWG_PSK_PATH || "/opt/amnezia/awg/wireguard_psk.key",
+ warpDir,
+ warpConf: process.env.WARP_CONF_PATH || `${warpDir}/warp.conf`,
+ warpClientsList: process.env.WARP_CLIENTS_LIST || `${warpDir}/clients.list`,
+ startScript: process.env.AMNEZIA_START_SCRIPT || "/opt/amnezia/start.sh",
+ },
+ ];
+ };
+ if (!raw) return fallback();
+ try {
+ const arr = JSON.parse(raw);
+ if (!Array.isArray(arr) || arr.length === 0) return fallback();
+ return arr
+ .map((row, i) => {
+ const warpDirRaw = row.warpDir ?? "/opt/warp";
+ const warpDir = String(warpDirRaw).replace(/\/+$/, "") || "/opt/warp";
+ const warpConf = row.warpConf ? String(row.warpConf) : `${warpDir}/warp.conf`;
+ const warpClientsList = row.warpClientsList
+ ? String(row.warpClientsList)
+ : `${warpDir}/clients.list`;
+ const startScript = String(row.startScript ?? "/opt/amnezia/start.sh");
+ return {
+ id: String(row.id ?? `p${i}`),
+ label: String(row.label ?? row.id ?? `Профиль ${i + 1}`),
+ container: String(row.container ?? ""),
+ confPath: String(row.confPath ?? row.conf ?? "/opt/amnezia/awg/awg0.conf"),
+ clientsPath: String(row.clientsPath ?? row.clients ?? "/opt/amnezia/awg/clientsTable"),
+ iface: String(row.iface ?? row.IFACE ?? "awg0"),
+ wgBinary: String(row.wgBinary ?? row.binary ?? "awg"),
+ pskPath: String(row.pskPath ?? row.psk ?? "/opt/amnezia/awg/wireguard_psk.key"),
+ warpDir,
+ warpConf,
+ warpClientsList,
+ startScript,
+ };
+ })
+ .filter((p) => p.container);
+ } catch {
+ console.warn("AWG_PROFILES: невалидный JSON, используется профиль по умолчанию.");
+ return fallback();
+ }
+}
+
+const PROFILES = parseProfilesFromEnv();
+if (!PROFILES.length) {
+ console.error("Нет ни одного профиля AWG: укажите container в AWG_PROFILES или переменные по умолчанию.");
+ process.exit(1);
+}
+
+const DATA_DIR = process.env.DATA_DIR || "/data";
+const PW_FILE = path.join(DATA_DIR, "password.hash");
+const SECRET_FILE = path.join(DATA_DIR, "session.secret");
+
+const SESSION_COOKIE = "amnezia_sess";
+const SESSION_MS = 7 * 24 * 60 * 60 * 1000;
+
+let passwordHashStored = "";
+let sessionSecret = "";
+
+function ensureDataDir() {
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+}
+
+function hashPassword(password) {
+ const salt = crypto.randomBytes(16);
+ const hash = crypto.scryptSync(password, salt, 64);
+ return `${salt.toString("hex")}:${hash.toString("hex")}`;
+}
+
+function verifyPassword(password, stored) {
+ const parts = stored.split(":");
+ if (parts.length !== 2) return false;
+ const salt = Buffer.from(parts[0], "hex");
+ const expected = Buffer.from(parts[1], "hex");
+ let hash;
+ try {
+ hash = crypto.scryptSync(password, salt, 64);
+ } catch {
+ return false;
+ }
+ if (hash.length !== expected.length) return false;
+ return crypto.timingSafeEqual(hash, expected);
+}
+
+function loadOrCreateSessionSecret() {
+ ensureDataDir();
+ if (fs.existsSync(SECRET_FILE)) {
+ sessionSecret = fs.readFileSync(SECRET_FILE, "utf8").trim();
+ if (sessionSecret.length < 32) {
+ throw new Error("session.secret слишком короткий — удалите файл для пересоздания");
+ }
+ return;
+ }
+ sessionSecret = crypto.randomBytes(32).toString("hex");
+ fs.writeFileSync(SECRET_FILE, `${sessionSecret}\n`, { mode: 0o600 });
+}
+
+function rotateSessionSecret() {
+ sessionSecret = crypto.randomBytes(32).toString("hex");
+ fs.writeFileSync(SECRET_FILE, `${sessionSecret}\n`, { mode: 0o600 });
+}
+
+function bootstrapPassword() {
+ ensureDataDir();
+ if (fs.existsSync(PW_FILE)) {
+ passwordHashStored = fs.readFileSync(PW_FILE, "utf8").trim();
+ if (!passwordHashStored) throw new Error("password.hash пуст");
+ return;
+ }
+ const bootstrap = process.env.ADMIN_PASSWORD || "";
+ if (bootstrap) {
+ passwordHashStored = hashPassword(bootstrap);
+ fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 });
+ console.warn(
+ "Пароль сохранён в /data/password.hash. Уберите ADMIN_PASSWORD из окружения после первого старта."
+ );
+ return;
+ }
+ const legacyToken = process.env.ADMIN_TOKEN || "";
+ if (legacyToken) {
+ passwordHashStored = hashPassword(legacyToken);
+ fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 });
+ console.warn(
+ "Миграция: пароль взяли из ADMIN_TOKEN и сохранили в /data/password.hash. Удалите ADMIN_TOKEN из окружения."
+ );
+ return;
+ }
+ const allowDefault =
+ process.env.ALLOW_DEFAULT_PASSWORD === "1" ||
+ process.env.ALLOW_DEFAULT_PASSWORD === "true";
+ const docPass = process.env.DEFAULT_ADMIN_PASSWORD || "AmneziaAdmin!ChangeMe";
+ if (allowDefault) {
+ passwordHashStored = hashPassword(docPass);
+ fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 });
+ console.warn(
+ "Включён пароль по умолчанию из документации (README). Смените его в панели и отключите ALLOW_DEFAULT_PASSWORD."
+ );
+ return;
+ }
+ console.error(
+ "Нет пароля: задайте ADMIN_PASSWORD при первом запуске, см. README, или ALLOW_DEFAULT_PASSWORD=1 только для теста."
+ );
+ process.exit(1);
+}
+
+function signSession(payload) {
+ const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
+ const sig = crypto.createHmac("sha256", sessionSecret).update(body).digest("base64url");
+ return `${body}.${sig}`;
+}
+
+function readSession(token) {
+ if (!token || !sessionSecret) return null;
+ const dot = token.indexOf(".");
+ if (dot === -1) return null;
+ const body = token.slice(0, dot);
+ const sig = token.slice(dot + 1);
+ let expected;
+ try {
+ expected = crypto.createHmac("sha256", sessionSecret).update(body).digest("base64url");
+ } catch {
+ return null;
+ }
+ try {
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
+ } catch {
+ return null;
+ }
+ let payload;
+ try {
+ payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
+ } catch {
+ return null;
+ }
+ if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null;
+ return payload;
+}
+
+function getSessionToken(req) {
+ const raw = req.headers.cookie || "";
+ for (const part of raw.split(";")) {
+ const p = part.trim();
+ if (p.startsWith(`${SESSION_COOKIE}=`)) {
+ return decodeURIComponent(p.slice(SESSION_COOKIE.length + 1));
+ }
+ }
+ return null;
+}
+
+function getProfileCookie(req) {
+ const raw = req.headers.cookie || "";
+ if (!raw) return null;
+ for (const part of raw.split(";")) {
+ const s = part.trim();
+ const eq = s.indexOf("=");
+ if (eq === -1) continue;
+ const k = decodeURIComponent(s.slice(0, eq).trim());
+ if (k !== PROFILE_COOKIE) continue;
+ return decodeURIComponent(s.slice(eq + 1).trim());
+ }
+ return null;
+}
+
+function cookieSecureFlag() {
+ return process.env.COOKIE_SECURE === "1" || process.env.COOKIE_SECURE === "true";
+}
+
+function setSessionCookie(res, token, maxAgeSec) {
+ const sec = cookieSecureFlag();
+ res.setHeader(
+ "Set-Cookie",
+ `${SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${maxAgeSec}; Path=/; HttpOnly; SameSite=Lax${sec ? "; Secure" : ""}`
+ );
+}
+
+function clearSessionCookie(res) {
+ const sec = cookieSecureFlag();
+ res.setHeader(
+ "Set-Cookie",
+ `${SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${sec ? "; Secure" : ""}`
+ );
+}
+
+function setProfileCookie(res, profileId) {
+ const sec = cookieSecureFlag();
+ res.setHeader(
+ "Set-Cookie",
+ `${PROFILE_COOKIE}=${encodeURIComponent(profileId)}; Max-Age=${31536000}; Path=/; SameSite=Lax${sec ? "; Secure" : ""}`
+ );
+}
+
+function requireAuth(req, res, next) {
+ const sess = readSession(getSessionToken(req));
+ if (!sess) {
+ res.status(401).json({ error: "Unauthorized" });
+ return;
+ }
+ next();
+}
+
+function verifyExportQueryToken(token) {
+ if (!EXPORT_CONFIG_SECRET || typeof token !== "string" || !token) return false;
+ const a = Buffer.from(token, "utf8");
+ const b = Buffer.from(EXPORT_CONFIG_SECRET, "utf8");
+ if (a.length !== b.length) return false;
+ try {
+ return crypto.timingSafeEqual(a, b);
+ } catch {
+ return false;
+ }
+}
+
+function requireAuthOrExportToken(req, res, next) {
+ if (req.method === "GET" && verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "")) {
+ next();
+ return;
+ }
+ requireAuth(req, res, next);
+}
+
+function rejectCommunityProOnly(res) {
+ res.status(403).json({
+ error:
+ "Доступно в версии PRO: управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста.",
+ upgradeRequired: true,
+ upgradeUrl: COMMUNITY_UPGRADE_URL,
+ });
+}
+
+function requireProTier(_req, res, next) {
+ if (!IS_COMMUNITY) {
+ next();
+ return;
+ }
+ rejectCommunityProOnly(res);
+}
+
+function runtimeFromExportRequest(req) {
+ const qPid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : "";
+ const bodyPid =
+ req.method === "POST" && typeof req.body?.profileId === "string" ? req.body.profileId.trim() : "";
+ const pid = qPid || bodyPid;
+ if (pid) {
+ const p = PROFILES.find((x) => x.id === pid);
+ if (p) return createRuntime(p);
+ }
+ return runtimeForRequest(req);
+}
+
+function execDocker(args, stdin = null) {
+ return new Promise((resolve, reject) => {
+ const child = spawn("docker", args, { stdio: ["pipe", "pipe", "pipe"] });
+ let out = "";
+ let err = "";
+ child.stdout.on("data", (c) => (out += c));
+ child.stderr.on("data", (c) => (err += c));
+ child.on("error", reject);
+ child.on("close", (code) => {
+ if (code === 0) resolve({ stdout: out, stderr: err });
+ else reject(new Error(err.trim() || out.trim() || `exit ${code}`));
+ });
+ if (stdin != null) {
+ child.stdin.write(stdin);
+ child.stdin.end();
+ } else {
+ child.stdin.end();
+ }
+ });
+}
+
+/** Запуск `sh -s` внутри контейнера со скриптом по stdin (многострочный shell без экранирования). */
+function dockerExecStdin(container, script) {
+ return new Promise((resolve, reject) => {
+ const child = spawn("docker", ["exec", "-i", container, "sh", "-s"], {
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ let out = "";
+ let err = "";
+ child.stdout.on("data", (c) => (out += c));
+ child.stderr.on("data", (c) => (err += c));
+ child.on("error", reject);
+ child.on("close", (code) => {
+ if (code === 0) resolve({ stdout: out, stderr: err });
+ else reject(new Error(err.trim() || out.trim() || `exit ${code}`));
+ });
+ child.stdin.write(script);
+ child.stdin.end();
+ });
+}
+
+function assertSafeUnixPath(p) {
+ const s = String(p).trim();
+ if (!/^\/[a-zA-Z0-9_/.-]+$/.test(s)) {
+ throw new Error(`Недопустимый путь: ${p}`);
+ }
+ return s;
+}
+
+/** Разрешённые адреса клиента AmneziaWG для правил WARP (обычно одно значение с /32). */
+function assertAllowedIpCidr(token) {
+ const s = String(token).trim();
+ if (!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,3}$/.test(s)) {
+ throw new Error(`Недопустимый AllowedIPs для WARP: ${token}`);
+ }
+ return s;
+}
+
+function peerAllowedIpTokens(peer) {
+ const raw = peer?.allowedIPs || "";
+ return raw
+ .split(",")
+ .map((x) => x.trim())
+ .filter(Boolean);
+}
+
+async function dockerRestartContainer(container) {
+ await execDocker(["restart", container]);
+ for (let i = 0; i < 24; i++) {
+ try {
+ await execDocker(["exec", container, "sh", "-c", "true"]);
+ return;
+ } catch {
+ await new Promise((r) => setTimeout(r, 500));
+ }
+ }
+ throw new Error("Контейнер не ответил после restart");
+}
+
+async function warpFileExists(rt, remotePath) {
+ try {
+ await execDocker(["exec", rt.profile.container, "test", "-f", remotePath]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function warpInterfaceUp(rt) {
+ try {
+ await execDocker(["exec", rt.profile.container, "ip", "addr", "show", "warp"]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function warpLoadSelectedIps(rt) {
+ try {
+ const raw = await rt.dockerReadFile(rt.profile.warpClientsList);
+ const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
+ return lines.map((l) => assertAllowedIpCidr(l));
+ } catch {
+ return [];
+ }
+}
+
+async function warpSaveSelectedIps(rt, ips) {
+ const uniq = [...new Set(ips.map((x) => assertAllowedIpCidr(x)))];
+ const content = uniq.length ? `${uniq.join("\n")}\n` : "";
+ await rt.dockerExec(`mkdir -p '${rt.profile.warpDir}'`);
+ await rt.dockerWriteFile(rt.profile.warpClientsList, content);
+}
+
+async function warpCleanupRules(rt) {
+ const sh = `#!/bin/sh
+set +e
+ip rule | awk '/lookup 100/ {print \$1}' | sed 's/://g' | sort -rn | while read -r pr; do
+ ip rule del priority "\$pr" 2>/dev/null || true
+done
+iptables -t nat -S POSTROUTING 2>/dev/null | grep -- '-o warp -j MASQUERADE' | while read -r line; do
+ rule=$(echo "\$line" | sed 's/^-A /-D /')
+ iptables -t nat \$rule 2>/dev/null || true
+done
+ip route flush table 100 2>/dev/null || true
+exit 0
+`;
+ try {
+ await dockerExecStdin(rt.profile.container, sh);
+ } catch {
+ /* ignore */
+ }
+}
+
+async function warpApplyRouting(rt, ips) {
+ await warpCleanupRules(rt);
+ const list = ips.map((x) => assertAllowedIpCidr(x));
+ if (!list.length) return;
+ await rt.dockerExec(
+ "ip route add default dev warp table 100 2>/dev/null || ip route replace default dev warp table 100 2>/dev/null || true",
+ );
+ let prio = 100;
+ for (const ip of list) {
+ await rt.dockerExec(
+ `ip rule add from ${ip} table 100 priority ${prio} 2>/dev/null || true && ` +
+ `(iptables -t nat -C POSTROUTING -s ${ip} -o warp -j MASQUERADE 2>/dev/null || ` +
+ `iptables -t nat -I POSTROUTING 1 -s ${ip} -o warp -j MASQUERADE)`,
+ );
+ prio += 1;
+ }
+}
+
+function buildWarpBootBlock(warpConf, ips) {
+ assertSafeUnixPath(warpConf);
+ const list = ips.map((x) => assertAllowedIpCidr(x));
+ let routing = "";
+ if (list.length > 0) {
+ routing +=
+ "ip route add default dev warp table 100 2>/dev/null || ip route replace default dev warp table 100 2>/dev/null || true\n\n";
+ let prio = 100;
+ for (const ip of list) {
+ routing += `ip rule add from ${ip} table 100 priority ${prio} 2>/dev/null || true\n`;
+ routing += `iptables -t nat -C POSTROUTING -s ${ip} -o warp -j MASQUERADE 2>/dev/null || iptables -t nat -I POSTROUTING 1 -s ${ip} -o warp -j MASQUERADE\n`;
+ prio += 1;
+ }
+ routing += "\n";
+ }
+ return (
+ "# --- WARP-MANAGER BEGIN ---\n\n" +
+ `if [ -f '${warpConf}' ]; then\n` +
+ ` wg-quick up '${warpConf}' || true\n` +
+ ` sleep 3\n` +
+ `fi\n\n` +
+ routing +
+ "# --- WARP-MANAGER END ---\n"
+ );
+}
+
+async function warpPatchStartSh(rt, ips) {
+ const startScript = rt.profile.startScript;
+ assertSafeUnixPath(startScript);
+ const block = buildWarpBootBlock(rt.profile.warpConf, ips);
+ const delim = `WARPBLK_${crypto.randomBytes(8).toString("hex")}`;
+ if (block.includes(delim)) {
+ throw new Error("internal delimiter collision");
+ }
+ const sq = startScript.replace(/'/g, "'\\''");
+ const remote = [
+ "#!/bin/sh",
+ "set -e",
+ `START_SH='${sq}'`,
+ `BLOCK=$(cat <<'${delim}'`,
+ block.trimEnd(),
+ delim,
+ ")",
+ 'if grep -qF \'# --- WARP-MANAGER BEGIN ---\' "$START_SH" 2>/dev/null; then',
+ ' sed -i \'/# --- WARP-MANAGER BEGIN ---/,/# --- WARP-MANAGER END ---/d\' "$START_SH"',
+ "fi",
+ 'if grep -qF \'tail -f /dev/null\' "$START_SH"; then',
+ " tmpfile=$(mktemp)",
+ " while IFS= read -r line; do",
+ ' if echo "$line" | grep -qF \'tail -f /dev/null\'; then',
+ ' printf \'%s\\n\' "$BLOCK"',
+ " fi",
+ ' printf \'%s\\n\' "$line"',
+ ' done < "$START_SH" > "$tmpfile"',
+ ' mv "$tmpfile" "$START_SH"',
+ ' chmod +x "$START_SH"',
+ "else",
+ ' printf \'\\n%s\\n\' "$BLOCK" >> "$START_SH"',
+ ' chmod +x "$START_SH"',
+ "fi",
+ "",
+ ].join("\n");
+ await dockerExecStdin(rt.profile.container, remote);
+}
+
+async function warpPersistAndRestart(rt, selectedIps) {
+ await rt.backupRemoteFiles();
+ await warpSaveSelectedIps(rt, selectedIps);
+ await warpApplyRouting(rt, selectedIps);
+ await warpPatchStartSh(rt, selectedIps);
+ await dockerRestartContainer(rt.profile.container);
+}
+
+function activePeerAllowedIpSet(conf) {
+ const set = new Set();
+ for (const p of conf.peers) {
+ for (const t of peerAllowedIpTokens(p)) {
+ try {
+ set.add(assertAllowedIpCidr(t));
+ } catch {
+ /* только ipv4 /cidr */
+ }
+ }
+ }
+ return set;
+}
+
+async function warpSummaryForRt(rt) {
+ try {
+ assertSafeUnixPath(rt.profile.warpConf);
+ assertSafeUnixPath(rt.profile.warpClientsList);
+ assertSafeUnixPath(rt.profile.warpDir);
+ assertSafeUnixPath(rt.profile.startScript);
+ } catch {
+ return { supported: false };
+ }
+ let installed = false;
+ try {
+ installed = await warpFileExists(rt, rt.profile.warpConf);
+ } catch {
+ installed = false;
+ }
+ const running = installed ? await warpInterfaceUp(rt) : false;
+ let exitIp = null;
+ if (running) {
+ try {
+ const out = await rt.dockerExec(
+ "curl -fsS --interface warp --connect-timeout 4 https://ifconfig.me 2>/dev/null || true",
+ );
+ const t = out.trim();
+ exitIp = t || null;
+ } catch {
+ exitIp = null;
+ }
+ }
+ let selectedAllowedIps = [];
+ if (installed) {
+ try {
+ selectedAllowedIps = await warpLoadSelectedIps(rt);
+ } catch {
+ selectedAllowedIps = [];
+ }
+ }
+ let wgShowWarp = "";
+ if (installed && running) {
+ try {
+ wgShowWarp = await rt.dockerExec("wg show warp 2>/dev/null || true");
+ } catch {
+ wgShowWarp = "";
+ }
+ }
+ return {
+ supported: true,
+ installed,
+ running,
+ exitIp,
+ wgShowWarp,
+ selectedAllowedIps,
+ paths: {
+ warpConf: rt.profile.warpConf,
+ clientsList: rt.profile.warpClientsList,
+ warpDir: rt.profile.warpDir,
+ startScript: rt.profile.startScript,
+ },
+ };
+}
+
+function peerUsesWarp(peer, selectedSet) {
+ if (!peer || !selectedSet.size) return false;
+ for (const t of peerAllowedIpTokens(peer)) {
+ try {
+ if (selectedSet.has(assertAllowedIpCidr(t))) return true;
+ } catch {
+ /* ipv6 и др. */
+ }
+ }
+ return false;
+}
+
+function createRuntime(profile) {
+ const container = profile.container;
+ const confPath = profile.confPath;
+ const clientsPath = profile.clientsPath;
+ const iface = profile.iface;
+ const wgBinary = profile.wgBinary;
+ const pskPath = profile.pskPath;
+
+ async function dockerExec(cmd) {
+ const { stdout, stderr } = await execDocker(["exec", container, "sh", "-c", cmd]);
+ return stdout + stderr;
+ }
+
+ async function dockerReadFile(remotePath) {
+ const { stdout } = await execDocker(["exec", container, "cat", remotePath]);
+ return stdout;
+ }
+
+ async function dockerWriteFile(remotePath, content) {
+ await execDocker(
+ [
+ "exec",
+ "-i",
+ container,
+ "sh",
+ "-c",
+ `cat > '${remotePath}.tmp' && mv '${remotePath}.tmp' '${remotePath}'`,
+ ],
+ content
+ );
+ }
+
+ async function backupRemoteFiles() {
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
+ await dockerExec(`cp '${confPath}' '${confPath}.bak-admin-${stamp}' 2>/dev/null || true`);
+ await dockerExec(
+ `cp '${clientsPath}' '${clientsPath}.bak-admin-${stamp}' 2>/dev/null || true`
+ );
+ }
+
+ async function applySyncconf() {
+ await dockerExec(
+ `wg-quick strip '${confPath}' > /tmp/wg-admin-strip.conf && ${wgBinary} syncconf ${iface} /tmp/wg-admin-strip.conf`
+ );
+ }
+
+ async function loadState() {
+ const [confText, tableText] = await Promise.all([
+ dockerReadFile(confPath),
+ dockerReadFile(clientsPath),
+ ]);
+ const conf = splitAwgConf(confText);
+ const clients = parseClientsTable(tableText);
+ const peerByKey = new Map(conf.peers.map((p) => [p.publicKey, p]));
+ return { confText, conf, clients, peerByKey };
+ }
+
+ async function inferPskFromConf(conf) {
+ if (conf.peers.length) return conf.peers[0].presharedKey;
+ try {
+ const text = await dockerReadFile(pskPath);
+ return text.trim();
+ } catch {
+ return null;
+ }
+ }
+
+ return {
+ profile,
+ dockerExec,
+ dockerReadFile,
+ dockerWriteFile,
+ backupRemoteFiles,
+ applySyncconf,
+ loadState,
+ inferPskFromConf,
+ confPath,
+ clientsPath,
+ };
+}
+
+function runtimeForRequest(req) {
+ const wanted = getProfileCookie(req);
+ const profile = PROFILES.find((p) => p.id === wanted) || PROFILES[0];
+ return createRuntime(profile);
+}
+
+function splitAwgConf(text) {
+ const t = text.replace(/\r\n/g, "\n");
+ const parts = t.split(/(?=^\[Peer\])/m);
+ const head = parts[0].trimEnd();
+ const peers = parts.slice(1).map(parsePeerBlock).filter((p) => p.publicKey);
+ return { head, peers };
+}
+
+function parsePeerBlock(block) {
+ const lineMap = (key) => {
+ const m = block.match(new RegExp(`^${key}\\s*=\\s*(.+)$`, "m"));
+ return m ? m[1].trim() : null;
+ };
+ const publicKey = lineMap("PublicKey");
+ const presharedKey = lineMap("PresharedKey");
+ const allowedIPs = lineMap("AllowedIPs");
+ const raw = block.trimEnd();
+ return { raw, publicKey, presharedKey, allowedIPs };
+}
+
+function serializeAwgConf(head, peers) {
+ const body = peers.map((p) => p.raw.trim()).join("\n\n");
+ return (body ? `${head}\n\n${body}\n` : `${head}\n`).replace(/\n+$/, "\n");
+}
+
+function parseClientsTable(raw) {
+ const data = JSON.parse(raw);
+ if (!Array.isArray(data)) throw new Error("clientsTable is not an array");
+ return data;
+}
+
+function stringifyClientsTable(rows) {
+ return `${JSON.stringify(rows, null, 4)}\n`;
+}
+
+/** Совпадает с defaults Amnezia Desktop (protocolConstants awg, desktop MTU). */
+const AWG_EXPORT_DEFAULTS = {
+ Jc: "3",
+ Jmin: "10",
+ Jmax: "30",
+ S1: "15",
+ S2: "18",
+ S3: "20",
+ S4: "23",
+ H1: "1020325451",
+ H2: "3288052141",
+ H3: "1766607858",
+ H4: "2528465083",
+ I1: "",
+ I2: "",
+ I3: "",
+ I4: "",
+ I5: "",
+};
+
+function parseLastConfigFromClientRow(row) {
+ const ud = row?.userData;
+ if (!ud || typeof ud !== "object") return null;
+ let raw = ud.last_config ?? ud.lastConfig;
+ if (typeof raw === "string") {
+ raw = raw.trim();
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ }
+ if (raw && typeof raw === "object") return raw;
+ return null;
+}
+
+function clientHasExportableLastConfig(row) {
+ const lc = parseLastConfigFromClientRow(row);
+ if (!lc) return false;
+ if (String(lc.config ?? lc.nativeConfig ?? "").trim()) return true;
+ const priv = lc.client_priv_key || lc.clientPrivKey;
+ return Boolean(priv && typeof priv === "string");
+}
+
+function pickLc(lc, ...keys) {
+ for (const k of keys) {
+ const v = lc[k];
+ if (v != null && v !== "") return v;
+ }
+ return undefined;
+}
+
+function parseInterfaceKeyValues(head) {
+ const out = {};
+ for (const line of String(head).split("\n")) {
+ const t = line.trim();
+ if (!t || t.startsWith("#")) continue;
+ const eq = t.indexOf("=");
+ if (eq === -1) continue;
+ const k = t.slice(0, eq).trim();
+ const v = t.slice(eq + 1).trim();
+ out[k] = v;
+ }
+ return out;
+}
+
+/** Имя файла только из ASCII — иначе Node отклоняет заголовок Content-Disposition. */
+function safeExportFilenamePart(name, fallback) {
+ const toAsciiToken = (s) =>
+ String(s ?? "")
+ .normalize("NFKD")
+ .replace(/[^\x20-\x7E]/g, "")
+ .replace(/[^a-zA-Z0-9._-]/g, "_")
+ .replace(/_+/g, "_")
+ .replace(/^_|_$/g, "")
+ .slice(0, 80);
+ return toAsciiToken(name) || toAsciiToken(fallback) || "client";
+}
+
+function formatExportAllowedIps(lc, fallback = "0.0.0.0/0, ::/0") {
+ const v = lc.allowed_ips ?? lc.allowedIps;
+ if (Array.isArray(v)) {
+ const joined = v.map(String).join(", ");
+ return joined.trim() || fallback;
+ }
+ if (typeof v === "string" && v.trim()) return v.trim();
+ return fallback;
+}
+
+async function wgPubkeyFromPrivate(rt, privKeyB64) {
+ const key = String(privKeyB64).trim();
+ if (!/^[A-Za-z0-9+/=_-]+$/.test(key)) {
+ throw new Error("Некорректный формат приватного ключа сервера в awg0.conf");
+ }
+ const q = key.replace(/'/g, `'\\''`);
+ const out = await rt.dockerExec(`printf '%s\\n' '${q}' | ${rt.profile.wgBinary} pubkey`);
+ const pub = out.trim().split(/\s+/)[0];
+ if (!pub) throw new Error("Не удалось получить публичный ключ сервера (wg pubkey).");
+ return pub;
+}
+
+function tunnelClientIpv4(peer, lc, row) {
+ const fromLc = pickLc(lc, "client_ip", "clientIp");
+ if (fromLc) return String(fromLc).replace(/\/\d+$/, "").trim();
+ if (peer?.allowedIPs) {
+ const m = String(peer.allowedIPs).match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);
+ if (m) return m[1];
+ }
+ const ud = row?.userData || {};
+ const udIp = ud.allowedIps || ud.preservedAllowedIPs;
+ if (udIp) {
+ const m = String(udIp).match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);
+ if (m) return m[1];
+ }
+ throw new Error(
+ "Нет client_ip в last_config и не удалось взять IPv4 из AllowedIPs peer или из записи клиента (выключен без сохранённого адреса).",
+ );
+}
+
+function resolveExportEndpointHost(lc, req) {
+ const env = process.env.CLIENT_CONFIG_ENDPOINT?.trim();
+ if (env) return env;
+ const hn = pickLc(lc, "hostName", "hostname", "host");
+ if (hn && String(hn).trim()) return String(hn).trim();
+ const h = req.headers.host;
+ if (h && typeof h === "string") {
+ const hostPart = h.split(":")[0].trim();
+ if (hostPart && hostPart !== "localhost") return hostPart;
+ }
+ throw new Error(
+ "Не удалось определить Endpoint. Задайте CLIENT_CONFIG_ENDPOINT для контейнера панели (публичный IP или DNS VPS) или hostName в last_config клиента.",
+ );
+}
+
+async function buildClientConfExport(rt, lc, ifaceMap, req, row, conf) {
+ const native = String(lc.config ?? lc.nativeConfig ?? "").trim();
+ if (native) return native;
+
+ const priv = pickLc(lc, "client_priv_key", "clientPrivKey");
+ if (!priv || typeof priv !== "string") {
+ throw new Error(
+ "В last_config нет готового текста (config) и нет client_priv_key — восстановить .conf с сервера нельзя.",
+ );
+ }
+
+ const peer = conf.peers.find((p) => p.publicKey === row.clientId);
+ const tunnelIp = tunnelClientIpv4(peer || {}, lc, row);
+
+ let serverPub = pickLc(lc, "server_pub_key", "serverPubKey");
+ if (!serverPub && ifaceMap.PrivateKey) {
+ serverPub = await wgPubkeyFromPrivate(rt, ifaceMap.PrivateKey);
+ }
+ if (!serverPub) {
+ throw new Error("Нет server_pub_key в last_config и PrivateKey в секции [Interface] сервера.");
+ }
+
+ const psk = pickLc(lc, "psk_key", "pskKey");
+ if (!psk || typeof psk !== "string") {
+ throw new Error("В last_config нет psk_key (общий ключ с сервером).");
+ }
+
+ const endpointHost = resolveExportEndpointHost(lc, req);
+ const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN;
+ const portNum = Number(pickLc(lc, "port")) || (Number.isFinite(listenPort) ? listenPort : NaN);
+ const defaultPort = rt.profile.wgBinary === "awg" ? 55424 : 51820;
+ const port = Number.isFinite(portNum) && portNum > 0 ? portNum : defaultPort;
+
+ const dns1 = String(pickLc(lc, "dns1") || process.env.CLIENT_EXPORT_DNS1?.trim() || "8.8.8.8");
+ const dns2 = String(pickLc(lc, "dns2") || process.env.CLIENT_EXPORT_DNS2?.trim() || "8.8.4.4");
+ const peerAllowed = formatExportAllowedIps(lc);
+ const keepAlive = String(pickLc(lc, "persistent_keep_alive", "persistentKeepAlive") || "25");
+ const mtuVal = pickLc(lc, "mtu", "MTU");
+ const mtuLine = mtuVal ? `MTU = ${String(mtuVal).trim()}\n` : "";
+
+ if (rt.profile.wgBinary === "awg") {
+ const Jc = String(pickLc(lc, "Jc", "junk_packet_count", "junkPacketCount") ?? AWG_EXPORT_DEFAULTS.Jc);
+ const Jmin = String(pickLc(lc, "Jmin", "junk_packet_min_size", "junkPacketMinSize") ?? AWG_EXPORT_DEFAULTS.Jmin);
+ const Jmax = String(pickLc(lc, "Jmax", "junk_packet_max_size", "junkPacketMaxSize") ?? AWG_EXPORT_DEFAULTS.Jmax);
+ const S1 = String(pickLc(lc, "S1", "init_packet_junk_size", "initPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S1);
+ const S2 = String(pickLc(lc, "S2", "response_packet_junk_size", "responsePacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S2);
+ const S3 = String(
+ pickLc(lc, "S3", "cookie_reply_packet_junk_size", "cookieReplyPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S3,
+ );
+ const S4 = String(
+ pickLc(lc, "S4", "transport_packet_junk_size", "transportPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S4,
+ );
+ const H1 = String(pickLc(lc, "H1", "init_packet_magic_header", "initPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H1);
+ const H2 = String(
+ pickLc(lc, "H2", "response_packet_magic_header", "responsePacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H2,
+ );
+ const H3 = String(
+ pickLc(lc, "H3", "underload_packet_magic_header", "underloadPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H3,
+ );
+ const H4 = String(
+ pickLc(lc, "H4", "transport_packet_magic_header", "transportPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H4,
+ );
+ const I1 = String(pickLc(lc, "I1", "special_junk_1", "specialJunk1") ?? AWG_EXPORT_DEFAULTS.I1);
+ const I2 = String(pickLc(lc, "I2", "special_junk_2", "specialJunk2") ?? AWG_EXPORT_DEFAULTS.I2);
+ const I3 = String(pickLc(lc, "I3", "special_junk_3", "specialJunk3") ?? AWG_EXPORT_DEFAULTS.I3);
+ const I4 = String(pickLc(lc, "I4", "special_junk_4", "specialJunk4") ?? AWG_EXPORT_DEFAULTS.I4);
+ const I5 = String(pickLc(lc, "I5", "special_junk_5", "specialJunk5") ?? AWG_EXPORT_DEFAULTS.I5);
+
+ return `[Interface]
+Address = ${tunnelIp}/32
+DNS = ${dns1}, ${dns2}
+PrivateKey = ${priv.trim()}
+Jc = ${Jc}
+Jmin = ${Jmin}
+Jmax = ${Jmax}
+S1 = ${S1}
+S2 = ${S2}
+S3 = ${S3}
+S4 = ${S4}
+H1 = ${H1}
+H2 = ${H2}
+H3 = ${H3}
+H4 = ${H4}
+I1 = ${I1}
+I2 = ${I2}
+I3 = ${I3}
+I4 = ${I4}
+I5 = ${I5}
+${mtuLine}[Peer]
+PublicKey = ${String(serverPub).trim()}
+PresharedKey = ${String(psk).trim()}
+AllowedIPs = ${peerAllowed}
+Endpoint = ${endpointHost}:${port}
+PersistentKeepalive = ${keepAlive}
+`;
+ }
+
+ return `[Interface]
+Address = ${tunnelIp}/32
+DNS = ${dns1}, ${dns2}
+PrivateKey = ${priv.trim()}
+${mtuLine}[Peer]
+PublicKey = ${String(serverPub).trim()}
+PresharedKey = ${String(psk).trim()}
+AllowedIPs = ${peerAllowed}
+Endpoint = ${endpointHost}:${port}
+PersistentKeepalive = ${keepAlive}
+`;
+}
+
+function assertCascadeEndpointHost(raw) {
+ const s = String(raw ?? "").trim();
+ if (!s || s.length > 253) {
+ throw new Error("Укажите IP или DNS для Endpoint (куда клиент будет стучаться в каскаде).");
+ }
+ if (/[\s<>\"']/.test(s)) {
+ throw new Error("Недопустимые символы в Endpoint.");
+ }
+ return s;
+}
+
+function parseIpv4ToParts(ip) {
+ const m = String(ip).trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
+ if (!m) return null;
+ const o = [1, 2, 3, 4].map((i) => parseInt(m[i], 10));
+ if (o.some((x) => x > 255 || Number.isNaN(x))) return null;
+ return o;
+}
+
+async function awgGenKeypair(rt) {
+ const privOut = await rt.dockerExec(`${rt.profile.wgBinary} genkey`);
+ const priv = privOut.trim().split(/\s+/)[0];
+ if (!priv || !/^[A-Za-z0-9+/=_-]+$/.test(priv)) {
+ throw new Error("Не удалось сгенерировать ключ клиента (genkey).");
+ }
+ const q = priv.replace(/'/g, `'\\''`);
+ const pubOut = await rt.dockerExec(`printf '%s\\n' '${q}' | ${rt.profile.wgBinary} pubkey`);
+ const pub = pubOut.trim().split(/\s+/)[0];
+ if (!pub) throw new Error("Не удалось получить публичный ключ клиента.");
+ return { priv, pub };
+}
+
+function obfuscationFieldsFromServerHead(ifaceMap) {
+ const keys = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"];
+ const out = {};
+ for (const k of keys) {
+ const v = ifaceMap[k];
+ if (v != null && String(v).trim() !== "") {
+ out[k] = String(v).trim();
+ }
+ }
+ return out;
+}
+
+function collectUsedTunnelIps(conf) {
+ const used = new Set();
+ for (const p of conf.peers) {
+ const raw = p.allowedIPs || "";
+ const re = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/\d+)?/g;
+ let m;
+ while ((m = re.exec(raw)) !== null) {
+ used.add(m[1]);
+ }
+ }
+ return used;
+}
+
+function inferSubnetPrefixFromConf(conf, ifaceMap) {
+ const addrRaw = ifaceMap.Address || ifaceMap.address;
+ if (addrRaw) {
+ const chunk = String(addrRaw).split(",")[0].trim();
+ const parts = parseIpv4ToParts(chunk.split("/")[0]);
+ if (parts) {
+ return `${parts[0]}.${parts[1]}.${parts[2]}`;
+ }
+ }
+ for (const p of conf.peers) {
+ const m = String(p.allowedIPs || "").match(/(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}/);
+ if (m) return m[1];
+ }
+ return "10.8.1";
+}
+
+function suggestNextTunnelIp(conf, ifaceMap) {
+ const prefix = inferSubnetPrefixFromConf(conf, ifaceMap);
+ const used = collectUsedTunnelIps(conf);
+ let maxLast = 1;
+ for (const ip of used) {
+ if (!ip.startsWith(`${prefix}.`)) continue;
+ const last = parseInt(ip.slice(prefix.length + 1), 10);
+ if (!Number.isNaN(last)) maxLast = Math.max(maxLast, last);
+ }
+ for (let last = Math.max(2, maxLast + 1); last <= 254; last++) {
+ const candidate = `${prefix}.${last}`;
+ if (!used.has(candidate)) return candidate;
+ }
+ throw new Error("Не нашёл свободный IPv4 в подсети VPN для нового клиента.");
+}
+
+function normalizeCascadeTunnelIp(conf, ifaceMap, requested) {
+ const prefix = inferSubnetPrefixFromConf(conf, ifaceMap);
+ if (!requested || !String(requested).trim()) {
+ return suggestNextTunnelIp(conf, ifaceMap);
+ }
+ const stripped = String(requested).trim().replace(/\/32$/i, "");
+ const parts = parseIpv4ToParts(stripped);
+ if (!parts) {
+ throw new Error("Некорректный IP туннеля (ожидается IPv4, например 10.8.1.10).");
+ }
+ const triple = `${parts[0]}.${parts[1]}.${parts[2]}`;
+ if (triple !== prefix) {
+ throw new Error(`IP клиента должен быть в подсети ${prefix}.x как у остальных клиентов этого инстанса.`);
+ }
+ const full = `${parts[0]}.${parts[1]}.${parts[2]}.${parts[3]}`;
+ const used = collectUsedTunnelIps(conf);
+ if (used.has(full)) {
+ throw new Error(`Адрес ${full} уже занят другим клиентом.`);
+ }
+ return full;
+}
+
+async function disableClient(rt, clientId, ts) {
+ await rt.backupRemoteFiles();
+ const { conf, clients } = await rt.loadState();
+ const peer = conf.peers.find((p) => p.publicKey === clientId);
+ if (!peer) {
+ throw new Error("Peer not in config (already disabled?)");
+ }
+ const nextPeers = conf.peers.filter((p) => p.publicKey !== clientId);
+ const nextConfText = serializeAwgConf(conf.head, nextPeers);
+ const idx = clients.findIndex((c) => c.clientId === clientId);
+ if (idx === -1) throw new Error("Client not in clientsTable");
+ const ud = { ...(clients[idx].userData || {}) };
+ ud.disabled = true;
+ ud.disabledAt = ts;
+ ud.lastDisconnectedAt = ts;
+ delete ud.scheduledTunnelDisconnectAt;
+ ud.preservedPresharedKey = peer.presharedKey || ud.preservedPresharedKey;
+ ud.preservedAllowedIPs = peer.allowedIPs || ud.preservedAllowedIPs;
+ clients[idx] = { ...clients[idx], userData: ud };
+ await rt.dockerWriteFile(rt.confPath, nextConfText);
+ await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients));
+ await rt.applySyncconf();
+}
+
+async function processScheduledDisconnects(rt) {
+ const now = Date.now();
+ const { clients, peerByKey } = await rt.loadState();
+ const due = [];
+ for (const c of clients) {
+ const ud = c.userData || {};
+ const iso = ud.scheduledTunnelDisconnectAt;
+ if (!iso || !peerByKey.get(c.clientId)) continue;
+ const t = new Date(iso).getTime();
+ if (Number.isNaN(t) || t > now) continue;
+ due.push({ clientId: c.clientId, ts: new Date(iso).toISOString() });
+ }
+ if (!due.length) return;
+ await rt.backupRemoteFiles();
+ for (const { clientId, ts } of due) {
+ try {
+ await disableClient(rt, clientId, ts);
+ } catch (e) {
+ console.error(`scheduled off ${clientId} [${rt.profile.id}]:`, e);
+ }
+ }
+}
+
+async function processAllScheduledDisconnects() {
+ for (const profile of PROFILES) {
+ await processScheduledDisconnects(createRuntime(profile));
+ }
+}
+
+/** ISO string; пустое значение → текущий момент */
+function normalizeDisconnectedAtOptional(raw) {
+ if (raw == null || raw === "") return new Date().toISOString();
+ const d = new Date(raw);
+ if (Number.isNaN(d.getTime())) {
+ throw new Error("Некорректная дата disconnectedAt");
+ }
+ return d.toISOString();
+}
+
+function requireDisconnectedAt(raw) {
+ if (raw == null || raw === "") {
+ throw new Error("Укажите дату отключения");
+ }
+ const d = new Date(raw);
+ if (Number.isNaN(d.getTime())) {
+ throw new Error("Некорректная дата");
+ }
+ return d.toISOString();
+}
+
+/** Пояс для строки «Сервер»: переменная TZ контейнера или значение из Intl (часто UTC в Docker). Без подмены под пояс браузера. */
+function resolveServerClockTimeZone() {
+ const tzEnv = process.env.TZ?.trim();
+ if (tzEnv) return tzEnv;
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
+ } catch {
+ return "UTC";
+ }
+}
+
+/** Смещение от UTC в минутах для IANA-пояса в данный момент (через GMT± из Intl). */
+function offsetMinutesFromUtc(timeZone, date) {
+ try {
+ const dtf = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ timeZoneName: "longOffset",
+ });
+ const parts = dtf.formatToParts(date);
+ let raw = parts.find((p) => p.type === "timeZoneName")?.value || "";
+ raw = raw.replace(/\u2212/g, "-").trim();
+ let m = raw.match(/^GMT([+-])(\d{1,2})(?::(\d{2}))?$/i);
+ if (!m) {
+ m = raw.match(/^([+-])(\d{2}):(\d{2})$/);
+ if (m) {
+ const sign = m[1] === "-" ? -1 : 1;
+ const h = parseInt(m[2], 10);
+ const min = parseInt(m[3], 10);
+ return sign * (h * 60 + min);
+ }
+ return 0;
+ }
+ const sign = m[1] === "-" ? -1 : 1;
+ const h = parseInt(m[2], 10);
+ const min = m[3] ? parseInt(m[3], 10) : 0;
+ return sign * (h * 60 + min);
+ } catch {
+ return 0;
+ }
+}
+
+function buildZoneCompare(serverTz, browserTz, now) {
+ if (!browserTz) {
+ return { sameZone: null, hint: "", diffMinutes: null };
+ }
+ if (browserTz === serverTz) {
+ return {
+ sameZone: true,
+ hint: "Пояс браузера совпадает с поясом строки «Сервер» — часы совпадут.",
+ diffMinutes: 0,
+ };
+ }
+ const so = offsetMinutesFromUtc(serverTz, now);
+ const bo = offsetMinutesFromUtc(browserTz, now);
+ const diffMin = bo - so;
+ const abs = Math.abs(diffMin);
+ const h = Math.floor(abs / 60);
+ const m = abs % 60;
+ const ahead = diffMin > 0;
+ const hint = ahead
+ ? `Ваше место (${browserTz}): на ${h} ч ${m} мин «впереди» строки «Сервер» (${serverTz}) при одном UTC.`
+ : `Ваше место (${browserTz}): на ${h} ч ${m} мин «позже» пояса сервера (${serverTz}).`;
+ return { sameZone: false, hint, diffMinutes: diffMin };
+}
+
+function sshpassBinaryPath() {
+ for (const p of ["/usr/bin/sshpass", "/usr/local/bin/sshpass"]) {
+ try {
+ fs.accessSync(p, fs.constants.X_OK);
+ return p;
+ } catch {
+ /* next */
+ }
+ }
+ return null;
+}
+
+function hostTimeSyncConfigured() {
+ if (process.env.TIME_SYNC_DISABLED === "1" || process.env.TIME_SYNC_DISABLED === "true") {
+ return false;
+ }
+ return !!sshpassBinaryPath();
+}
+
+function sshRootRun(password, host, remoteCmd) {
+ const bin = sshpassBinaryPath();
+ if (!bin) {
+ return Promise.reject(new Error("sshpass не установлен"));
+ }
+ return new Promise((resolve, reject) => {
+ const args = [
+ "-p",
+ password,
+ "ssh",
+ "-oStrictHostKeyChecking=no",
+ "-oUserKnownHostsFile=/dev/null",
+ "-oConnectTimeout=15",
+ "-oPreferredAuthentications=password",
+ "-oPubkeyAuthentication=no",
+ `root@${host}`,
+ remoteCmd,
+ ];
+ const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
+ let out = "";
+ let err = "";
+ child.stdout.on("data", (c) => (out += c));
+ child.stderr.on("data", (c) => (err += c));
+ child.on("error", reject);
+ child.on("close", (code) => {
+ if (code === 0) resolve(out.trim());
+ else reject(new Error(err.trim() || out.trim() || `ssh код ${code}`));
+ });
+ });
+}
+
+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}`,
+ );
+}
+if (IS_COMMUNITY) {
+ console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`);
+}
+app.use(express.json({ limit: "512kb" }));
+
+app.get("/health", (_req, res) => {
+ res.json({ ok: true });
+});
+
+app.get("/api/session", (req, res) => {
+ if (!readSession(getSessionToken(req))) {
+ res.status(401).json({ ok: false });
+ return;
+ }
+ res.json({ ok: true });
+});
+
+app.get("/api/server-time", requireAuth, (req, res) => {
+ const now = new Date();
+ const timeZone = resolveServerClockTimeZone();
+ let formatted;
+ try {
+ formatted = now.toLocaleString("ru-RU", {
+ dateStyle: "medium",
+ timeStyle: "medium",
+ timeZone,
+ });
+ } catch {
+ formatted = now.toLocaleString("ru-RU", {
+ dateStyle: "medium",
+ timeStyle: "medium",
+ });
+ }
+ const browserTz =
+ typeof req.query.browserTz === "string" ? req.query.browserTz.trim() : "";
+ const zoneCompare = buildZoneCompare(timeZone, browserTz, now);
+ res.json({
+ iso: now.toISOString(),
+ formatted,
+ timeZone,
+ browserTimeZone: browserTz || null,
+ zoneSame: zoneCompare.sameZone,
+ zoneCompareHint: zoneCompare.hint,
+ zoneDiffMinutes: zoneCompare.diffMinutes ?? null,
+ });
+});
+
+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({
+ hostTimeSync: hostTimeSyncConfigured(),
+ sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
+ serverClockTimeZone: resolveServerClockTimeZone(),
+ });
+});
+
+app.post("/api/sync-host-time", requireAuth, requireProTier, async (req, res) => {
+ if (!hostTimeSyncConfigured()) {
+ return res.status(503).json({
+ error:
+ "Синхронизация времени хоста недоступна (нет sshpass или TIME_SYNC_DISABLED=1).",
+ });
+ }
+ const pw = req.body?.rootPassword;
+ const unixMsRaw = req.body?.unixMs;
+ const unixMs =
+ typeof unixMsRaw === "number" && Number.isFinite(unixMsRaw) ? unixMsRaw : Date.now();
+ if (typeof pw !== "string" || !pw) {
+ return res.status(400).json({ error: "Укажите пароль root VPS" });
+ }
+ const unixSec = Math.floor(unixMs / 1000);
+ if (!Number.isFinite(unixSec)) {
+ return res.status(400).json({ error: "Некорректное время" });
+ }
+ const host = process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1";
+ const remoteCmd = `bash -lc 'date -u --set=@${unixSec} 2>/dev/null || date -s @${unixSec}; (command -v hwclock >/dev/null && hwclock -w --utc) || true; date -u +%Y-%m-%dT%H:%M:%SZ'`;
+ try {
+ const confirmed = await sshRootRun(pw, host, remoteCmd);
+ res.json({ ok: true, utc: confirmed });
+ } catch {
+ console.warn("sync-host-time: ssh не выполнен");
+ res.status(400).json({
+ error:
+ "Не удалось выставить время по SSH. Проверьте пароль root, вход root по паролю на хосте и переменную TIME_SYNC_SSH_HOST (часто 172.17.0.1 с контейнера).",
+ });
+ }
+});
+
+app.post("/api/warp/host-setup", requireAuth, requireProTier, async (req, res) => {
+ if (UI_HIDDEN.warp) {
+ return res.status(403).json({ error: MSG_UI_WARP_OFF });
+ }
+ if (!hostTimeSyncConfigured()) {
+ return res.status(503).json({
+ error:
+ "С панели недоступно: в образе панели нет sshpass или задано TIME_SYNC_DISABLED=1. Запустите на хосте VPS вручную: bash /opt/amnezia-admin/scripts/warp-amnezia.sh install",
+ });
+ }
+ const pw = req.body?.rootPassword;
+ const cmd = req.body?.cmd;
+ if (typeof pw !== "string" || !pw.trim()) {
+ return res.status(400).json({ error: "Укажите пароль root VPS" });
+ }
+ if (cmd !== "install" && cmd !== "uninstall") {
+ return res.status(400).json({ error: "Ожидается cmd: install или uninstall" });
+ }
+ const rt = runtimeForRequest(req);
+ const container = String(rt.profile.container || "").trim();
+ if (!/^[a-zA-Z0-9_.-]+$/.test(container)) {
+ return res.status(400).json({ error: "Некорректное имя контейнера в профиле AWG" });
+ }
+ let installDir = "/opt/amnezia-admin";
+ try {
+ installDir = assertSafeUnixPath(process.env.WARP_SSH_INSTALL_DIR?.trim() || "/opt/amnezia-admin");
+ } catch {
+ return res.status(500).json({ error: "Некорректная переменная WARP_SSH_INSTALL_DIR на сервере панели" });
+ }
+ const host = process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1";
+ const remoteCmd = `bash -lc 'cd ${installDir} && chmod +x scripts/warp-amnezia.sh 2>/dev/null || true && AWG_CONTAINER=${container} ./scripts/warp-amnezia.sh ${cmd}'`;
+ try {
+ const out = await sshRootRun(pw.trim(), host, remoteCmd);
+ res.json({ ok: true, output: out.slice(0, 8000) });
+ } catch (e) {
+ console.warn("warp host-setup:", e);
+ res.status(400).json({
+ error:
+ String(e.message || e) ||
+ "Не удалось выполнить по SSH. Проверьте пароль root, что вход root по паролю разрешён, переменную TIME_SYNC_SSH_HOST и наличие каталога со скриптом на хосте.",
+ });
+ }
+});
+
+app.post("/api/login", (req, res) => {
+ const pw = req.body?.password;
+ if (typeof pw !== "string" || !pw) {
+ res.status(400).json({ error: "password required" });
+ return;
+ }
+ if (!verifyPassword(pw, passwordHashStored)) {
+ res.status(401).json({ error: "Неверный пароль" });
+ return;
+ }
+ const token = signSession({ exp: Date.now() + SESSION_MS });
+ setSessionCookie(res, token, Math.floor(SESSION_MS / 1000));
+ res.json({ ok: true });
+});
+
+app.post("/api/logout", (_req, res) => {
+ clearSessionCookie(res);
+ res.json({ ok: true });
+});
+
+app.post("/api/change-password", requireAuth, (req, res) => {
+ const cur = req.body?.currentPassword;
+ const neu = req.body?.newPassword;
+ if (typeof cur !== "string" || typeof neu !== "string") {
+ res.status(400).json({ error: "currentPassword и newPassword обязательны" });
+ return;
+ }
+ if (neu.length < 8) {
+ res.status(400).json({ error: "Новый пароль — не короче 8 символов" });
+ return;
+ }
+ if (!verifyPassword(cur, passwordHashStored)) {
+ res.status(401).json({ error: "Текущий пароль неверный" });
+ return;
+ }
+ passwordHashStored = hashPassword(neu);
+ fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 });
+ rotateSessionSecret();
+ clearSessionCookie(res);
+ res.json({ ok: true, message: "Пароль изменён. Войдите снова." });
+});
+
+app.get("/api/protocols", requireAuth, (req, res) => {
+ const rt = runtimeForRequest(req);
+ const hintSingle =
+ PROFILES.length < 2
+ ? IS_COMMUNITY
+ ? "Один инстанс в интерфейсе. Несколько контейнеров и профиль AWG_PROFILES — в полной панели PRO."
+ : "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json."
+ : "";
+ res.json({
+ currentId: rt.profile.id,
+ currentLabel: rt.profile.label,
+ profiles: PROFILES.map((p) => ({
+ id: p.id,
+ label: p.label,
+ container: p.container,
+ })),
+ singleProfile: PROFILES.length < 2,
+ profilesPersistHint: hintSingle,
+ edition: editionPayload(),
+ });
+});
+
+app.post("/api/protocol", requireAuth, (req, res) => {
+ const id = req.body?.profileId;
+ if (typeof id !== "string" || !PROFILES.some((p) => p.id === id)) {
+ res.status(400).json({ error: "Неизвестный profileId" });
+ return;
+ }
+ setProfileCookie(res, id);
+ res.json({ ok: true });
+});
+
+app.get("/api/clients", requireAuth, async (req, res) => {
+ const rt = runtimeForRequest(req);
+ try {
+ let wgShow = "";
+ try {
+ wgShow = await rt.dockerExec(`${rt.profile.wgBinary} show ${rt.profile.iface}`);
+ } catch {
+ wgShow = "";
+ }
+ const warpMeta = await warpSummaryForRt(rt);
+ const warpSelected = new Set(
+ warpMeta.supported && warpMeta.installed ? warpMeta.selectedAllowedIps : [],
+ );
+ const { conf, clients, peerByKey } = await rt.loadState();
+ const rows = clients.map((c) => {
+ const id = c.clientId;
+ const peer = peerByKey.get(id);
+ const ud = c.userData || {};
+ const activeInConf = !!peer;
+ return {
+ clientId: id,
+ name: ud.clientName || `${id.slice(0, 10)}…`,
+ allowedIps: peer?.allowedIPs || ud.allowedIps || ud.preservedAllowedIPs || null,
+ activeInConf,
+ disabled: !activeInConf,
+ disabledAt: ud.disabledAt || null,
+ lastDisconnectedAt: ud.lastDisconnectedAt || null,
+ scheduledTunnelDisconnectAt: ud.scheduledTunnelDisconnectAt || null,
+ creationDate: ud.creationDate || null,
+ latestHandshake: ud.latestHandshake || null,
+ dataReceived: ud.dataReceived || null,
+ dataSent: ud.dataSent || null,
+ warpEnabled:
+ Boolean(warpMeta.supported && warpMeta.installed) &&
+ activeInConf &&
+ peerUsesWarp(peer, warpSelected),
+ exportAvailable: clientHasExportableLastConfig(c),
+ };
+ });
+ const warpOut =
+ warpMeta.supported === false
+ ? { supported: false }
+ : {
+ supported: true,
+ installed: warpMeta.installed,
+ running: warpMeta.running,
+ exitIp: warpMeta.exitIp,
+ wgShowWarp: warpMeta.wgShowWarp || "",
+ selectedAllowedIps: warpMeta.selectedAllowedIps,
+ paths: warpMeta.paths,
+ hostSshInstall: hostTimeSyncConfigured(),
+ sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
+ installDir: process.env.WARP_SSH_INSTALL_DIR?.trim() || "/opt/amnezia-admin",
+ };
+ res.json({
+ profileId: rt.profile.id,
+ profileLabel: rt.profile.label,
+ container: rt.profile.container,
+ protocol: "AmneziaWG",
+ peerCount: conf.peers.length,
+ clients: rows,
+ wgShow,
+ warp: warpOut,
+ uiHidden: { ...effectiveUiHidden() },
+ edition: editionPayload(),
+ });
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: String(e.message || e) });
+ }
+});
+
+async function serveClientConfigExport(req, res) {
+ if (IS_COMMUNITY) {
+ res.status(403).json({
+ error: "Экспорт .conf доступен в версии PRO.",
+ upgradeRequired: true,
+ upgradeUrl: COMMUNITY_UPGRADE_URL,
+ });
+ return;
+ }
+ const tokenOk =
+ req.method === "GET" &&
+ verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "");
+
+ let rt;
+ if (tokenOk) {
+ if (PROFILES.length > 1) {
+ const pid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : "";
+ const p = PROFILES.find((x) => x.id === pid);
+ if (!p) {
+ res.status(400).json({
+ error:
+ "При нескольких инстансах укажите в URL параметр profileId (как в списке «Инстанс» в панели).",
+ });
+ return;
+ }
+ rt = createRuntime(p);
+ } else {
+ rt = createRuntime(PROFILES[0]);
+ }
+ } else {
+ rt = runtimeFromExportRequest(req);
+ }
+
+ const rawId =
+ req.method === "POST"
+ ? req.body?.clientId
+ : req.query.clientId ?? req.query.id;
+ const clientId = typeof rawId === "string" ? decodeURIComponent(rawId.trim()) : "";
+ if (!clientId) {
+ res.status(400).json({ error: "Укажите clientId (в теле POST или query GET)" });
+ return;
+ }
+ try {
+ const { conf, clients } = await rt.loadState();
+ const row = clients.find((c) => c.clientId === clientId);
+ if (!row) {
+ res.status(404).json({ error: "Клиент не найден в clientsTable" });
+ return;
+ }
+ const lc = parseLastConfigFromClientRow(row);
+ if (!lc) {
+ res.status(404).json({
+ error:
+ "На сервере нет userData.last_config для этого клиента. Полный конфиг хранится в приложении Amnezia на устройстве, где ключ создавали (или синхронизируйте клиентов с сервером из приложения).",
+ });
+ return;
+ }
+ const ifaceMap = parseInterfaceKeyValues(conf.head);
+ let text;
+ try {
+ text = await buildClientConfExport(rt, lc, ifaceMap, req, row, conf);
+ } catch (e) {
+ res.status(400).json({ error: String(e.message || e) });
+ return;
+ }
+ const ud = row.userData || {};
+ const baseName = safeExportFilenamePart(ud.clientName, clientId.slice(0, 12));
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
+ res.setHeader("Content-Disposition", `attachment; filename="amnezia-${baseName}.conf"`);
+ res.send(text);
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: String(e.message || e) });
+ }
+}
+
+app.get("/api/clients/export-config", requireAuthOrExportToken, (req, res) => {
+ void serveClientConfigExport(req, res);
+});
+
+app.post("/api/clients/export-config", requireAuth, (req, res) => {
+ void serveClientConfigExport(req, res);
+});
+
+/**
+ * Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config,
+ * отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел).
+ */
+app.post("/api/clients/create-cascade", requireAuth, requireProTier, async (req, res) => {
+ if (UI_HIDDEN.cascade) {
+ return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
+ }
+ const rt = runtimeFromExportRequest(req);
+ let endpointHost;
+ try {
+ endpointHost = assertCascadeEndpointHost(req.body?.endpointHost);
+ } catch (e) {
+ res.status(400).json({ error: String(e.message || e) });
+ return;
+ }
+ let endpointPort;
+ const rawPort = req.body?.endpointPort;
+ if (rawPort != null && rawPort !== "") {
+ endpointPort = Number(rawPort);
+ if (!Number.isFinite(endpointPort) || endpointPort < 1 || endpointPort > 65535) {
+ res.status(400).json({ error: "Некорректный порт Endpoint (1–65535)." });
+ return;
+ }
+ }
+ try {
+ await rt.backupRemoteFiles();
+ const { conf, clients } = await rt.loadState();
+ const ifaceMap = parseInterfaceKeyValues(conf.head);
+ if (!ifaceMap.PrivateKey) {
+ res.status(400).json({ error: "В wg/awg конфиге сервера нет PrivateKey в [Interface]." });
+ return;
+ }
+
+ const tunnelIp = normalizeCascadeTunnelIp(conf, ifaceMap, req.body?.tunnelIp);
+ const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN;
+ if (endpointPort == null) {
+ endpointPort =
+ Number.isFinite(listenPort) && listenPort > 0
+ ? listenPort
+ : rt.profile.wgBinary === "awg"
+ ? 55424
+ : 51820;
+ }
+
+ const psk = await rt.inferPskFromConf(conf);
+ if (!psk || typeof psk !== "string") {
+ res.status(400).json({ error: "Не удалось определить PresharedKey (нет peer или файла psk)." });
+ return;
+ }
+
+ const serverPub = await wgPubkeyFromPrivate(rt, ifaceMap.PrivateKey);
+ const { priv, pub } = await awgGenKeypair(rt);
+ if (clients.some((c) => c.clientId === pub)) {
+ res.status(409).json({ error: "Коллизия ключей — попробуйте ещё раз." });
+ return;
+ }
+
+ const obf = obfuscationFieldsFromServerHead(ifaceMap);
+ const lc = {
+ client_priv_key: priv,
+ server_pub_key: serverPub,
+ psk_key: psk,
+ client_ip: tunnelIp,
+ hostName: endpointHost,
+ port: endpointPort,
+ allowed_ips: ["0.0.0.0/0", "::/0"],
+ ...obf,
+ };
+
+ const peerRaw = `[Peer]
+PublicKey = ${pub}
+PresharedKey = ${psk}
+AllowedIPs = ${tunnelIp}/32
+`;
+ const peer = parsePeerBlock(`${peerRaw}\n`);
+ const nextPeers = [...conf.peers, peer];
+ const nextConfText = serializeAwgConf(conf.head, nextPeers);
+
+ const rawName = req.body?.clientName;
+ const clientName =
+ typeof rawName === "string" && rawName.trim()
+ ? rawName.trim().replace(/\s+/g, " ").slice(0, 200)
+ : `Каскад ${tunnelIp}`;
+
+ const last_config = JSON.stringify(lc);
+ const newRow = {
+ clientId: pub,
+ userData: {
+ clientName,
+ creationDate: new Date().toISOString(),
+ last_config,
+ allowedIps: `${tunnelIp}/32`,
+ },
+ };
+ const nextClients = [...clients, newRow];
+
+ await rt.dockerWriteFile(rt.confPath, nextConfText);
+ await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients));
+ await rt.applySyncconf();
+
+ const confAfter = { ...conf, peers: nextPeers };
+ let text;
+ try {
+ text = await buildClientConfExport(rt, lc, ifaceMap, req, newRow, confAfter);
+ } catch (e) {
+ res.status(500).json({ error: String(e.message || e) });
+ return;
+ }
+
+ const baseName = safeExportFilenamePart(clientName, pub.slice(0, 12));
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
+ res.setHeader("Content-Disposition", `attachment; filename="amnezia-cascade-${baseName}.conf"`);
+ res.send(text);
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: String(e.message || e) });
+ }
+});
+
+app.post("/api/warp/start", requireAuth, requireProTier, 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 не установлен (нет warp.conf). На хосте: scripts/warp-amnezia.sh install — или игнорируйте раздел, если WARP не нужен (см. README).",
+ });
+ }
+ try {
+ await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`);
+ await rt.dockerExec(`wg-quick up '${rt.profile.warpConf}'`);
+ res.json({ ok: true });
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: String(e.message || e) });
+ }
+});
+
+app.post("/api/warp/stop", requireAuth, requireProTier, 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 не установлен." });
+ }
+ try {
+ await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`);
+ res.json({ ok: true });
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: String(e.message || e) });
+ }
+});
+
+app.post("/api/warp/routing", requireAuth, requireProTier, 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 не установлен. Сначала scripts/warp-amnezia.sh install на хосте VPS (root), либо не используйте этот раздел.",
+ });
+ }
+ const raw = req.body?.selectedAllowedIps;
+ if (!Array.isArray(raw)) {
+ return res.status(400).json({ error: "Ожидается selectedAllowedIps: массив адресов вида 10.8.1.2/32" });
+ }
+ let selected;
+ try {
+ selected = raw.map((x) => assertAllowedIpCidr(String(x).trim()));
+ } catch (e) {
+ return res.status(400).json({ error: String(e.message || e) });
+ }
+ try {
+ const { conf } = await rt.loadState();
+ const allowed = activePeerAllowedIpSet(conf);
+ for (const ip of selected) {
+ if (!allowed.has(ip)) {
+ return res.status(400).json({
+ error: `Адрес ${ip} не совпадает ни с одним активным peer (AllowedIPs) в текущем инстансе.`,
+ });
+ }
+ }
+ await warpPersistAndRestart(rt, selected);
+ res.json({ ok: true });
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: String(e.message || e) });
+ }
+});
+
+app.post("/api/clients/disable", requireAuth, requireProTier, async (req, res) => {
+ const rt = runtimeForRequest(req);
+ const clientId = req.body?.clientId;
+ if (!clientId) return res.status(400).json({ error: "clientId required" });
+ let ts;
+ try {
+ ts = normalizeDisconnectedAtOptional(req.body?.disconnectedAt);
+ } catch (e) {
+ return res.status(400).json({ error: String(e.message || e) });
+ }
+ try {
+ await disableClient(rt, clientId, ts);
+ res.json({ ok: true });
+ } catch (e) {
+ const msg = String(e.message || e);
+ if (msg.includes("already disabled") || msg.includes("Peer not in config")) {
+ return res.status(404).json({ error: msg });
+ }
+ console.error(e);
+ res.status(500).json({ error: msg });
+ }
+});
+
+app.post("/api/clients/enable", requireAuth, requireProTier, async (req, res) => {
+ const rt = runtimeForRequest(req);
+ const clientId = req.body?.clientId;
+ if (!clientId) return res.status(400).json({ error: "clientId required" });
+ try {
+ await rt.backupRemoteFiles();
+ const { conf, clients } = await rt.loadState();
+ const existing = conf.peers.find((p) => p.publicKey === clientId);
+ if (existing) {
+ return res.status(409).json({ error: "Peer already enabled" });
+ }
+ const idx = clients.findIndex((c) => c.clientId === clientId);
+ if (idx === -1) {
+ return res.status(404).json({ error: "Client not in clientsTable" });
+ }
+ const ud = { ...(clients[idx].userData || {}) };
+ const psk =
+ ud.preservedPresharedKey ||
+ conf.peers[0]?.presharedKey ||
+ (await rt.inferPskFromConf(conf));
+ const ips = ud.preservedAllowedIPs || ud.allowedIps;
+ if (!psk || !ips) {
+ return res.status(400).json({
+ error:
+ "Missing preserved keys — cannot enable (restore from backup or re-import in Amnezia)",
+ });
+ }
+ const raw = `[Peer]
+PublicKey = ${clientId}
+PresharedKey = ${psk}
+AllowedIPs = ${ips}`;
+ const peer = parsePeerBlock(`${raw}\n`);
+ const nextPeers = [...conf.peers, peer];
+ const nextConfText = serializeAwgConf(conf.head, nextPeers);
+ delete ud.disabled;
+ delete ud.disabledAt;
+ delete ud.scheduledTunnelDisconnectAt;
+ delete ud.preservedPresharedKey;
+ delete ud.preservedAllowedIPs;
+ clients[idx] = { ...clients[idx], userData: ud };
+ await rt.dockerWriteFile(rt.confPath, nextConfText);
+ await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients));
+ await rt.applySyncconf();
+ res.json({ ok: true });
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: String(e.message || e) });
+ }
+});
+
+app.post("/api/clients/disconnect-date", requireAuth, requireProTier, async (req, res) => {
+ const rt = runtimeForRequest(req);
+ const clientId = req.body?.clientId;
+ if (!clientId) return res.status(400).json({ error: "clientId required" });
+ let iso;
+ try {
+ iso = requireDisconnectedAt(req.body?.disconnectedAt);
+ } catch (e) {
+ return res.status(400).json({ error: String(e.message || e) });
+ }
+ const scheduleTunnelDisconnect = Boolean(req.body?.scheduleTunnelDisconnect);
+ try {
+ const { clients, peerByKey } = await rt.loadState();
+ const idx = clients.findIndex((c) => c.clientId === clientId);
+ if (idx === -1) return res.status(404).json({ error: "Client not in clientsTable" });
+ const peer = peerByKey.get(clientId);
+ const ud = { ...(clients[idx].userData || {}) };
+ if (scheduleTunnelDisconnect) {
+ if (!peer) {
+ return res.status(400).json({
+ error: "Клиент не в туннеле — отложенное отключение недоступно",
+ });
+ }
+ ud.scheduledTunnelDisconnectAt = iso;
+ } else {
+ delete ud.scheduledTunnelDisconnectAt;
+ ud.lastDisconnectedAt = iso;
+ if (!peer) {
+ ud.disabledAt = iso;
+ }
+ }
+ clients[idx] = { ...clients[idx], userData: ud };
+ await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients));
+ res.json({ ok: true });
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: String(e.message || e) });
+ }
+});
+
+app.post("/api/clients/rename", requireAuth, requireProTier, async (req, res) => {
+ const rt = runtimeForRequest(req);
+ const clientId = req.body?.clientId;
+ const rawName = req.body?.name ?? req.body?.clientName;
+ if (!clientId) return res.status(400).json({ error: "clientId required" });
+ if (typeof rawName !== "string") {
+ return res.status(400).json({ error: "name required" });
+ }
+ const name = rawName.trim().replace(/\s+/g, " ");
+ if (!name) return res.status(400).json({ error: "Имя не может быть пустым" });
+ if (name.length > 200) {
+ return res.status(400).json({ error: "Имя не длиннее 200 символов" });
+ }
+ try {
+ const { clients } = await rt.loadState();
+ const idx = clients.findIndex((c) => c.clientId === clientId);
+ if (idx === -1) return res.status(404).json({ error: "Client not in clientsTable" });
+ const ud = { ...(clients[idx].userData || {}), clientName: name };
+ clients[idx] = { ...clients[idx], userData: ud };
+ await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients));
+ res.json({ ok: true });
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: String(e.message || e) });
+ }
+});
+
+app.post("/api/clients/delete", requireAuth, requireProTier, async (req, res) => {
+ const rt = runtimeForRequest(req);
+ const clientId = req.body?.clientId;
+ if (!clientId) return res.status(400).json({ error: "clientId required" });
+ try {
+ await rt.backupRemoteFiles();
+ const { conf, clients } = await rt.loadState();
+ const nextPeers = conf.peers.filter((p) => p.publicKey !== clientId);
+ const nextClients = clients.filter((c) => c.clientId !== clientId);
+ if (nextClients.length === clients.length) {
+ return res.status(404).json({ error: "Client not in clientsTable" });
+ }
+ const nextConfText = serializeAwgConf(conf.head, nextPeers);
+ await rt.dockerWriteFile(rt.confPath, nextConfText);
+ await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients));
+ await rt.applySyncconf();
+ res.json({ ok: true });
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: String(e.message || e) });
+ }
+});
+
+const pub = path.join(__dirname, "public");
+if (fs.existsSync(pub)) {
+ app.use(
+ express.static(pub, {
+ setHeaders(res, filePath) {
+ const lower = filePath.toLowerCase();
+ if (lower.endsWith(".html") || lower.endsWith(".js") || lower.endsWith(".css")) {
+ res.setHeader("Cache-Control", "no-store");
+ }
+ },
+ }),
+ );
+}
+
+app.use((_req, res) => {
+ res.status(404).send("Not found");
+});
+
+app.listen(PORT, "0.0.0.0", () => {
+ const summary = PROFILES.map((p) => `${p.label}→${p.container}`).join("; ");
+ console.log(`amnezia-admin on :${PORT} · ${summary} · data:${DATA_DIR}`);
+});
+
+setInterval(() => {
+ if (!IS_COMMUNITY) {
+ processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
+ }
+}, SCHEDULER_MS);
+
+setTimeout(() => {
+ if (!IS_COMMUNITY) {
+ processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
+ }
+}, 4000);