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 — вручную их обычно не вводят.

+
+
+ + + + +
+
+
+ + +
+
+
+ + +
+
+ + + + +
+
+
+

WireGuard на iOS

iPhone и iPad · iOS 15 и новее

+
    +
  • 1

    Откройте App Store, найдите WireGuard и нажмите «Установить»

  • +
  • 2

    Откройте приложение, нажмите + в правом верхнем углу

  • +
  • 3

    Выберите «Сканировать QR-код» и наведите камеру на QR — конфиг добавится автоматически

    Или «Создать из файла» → выберите файл .conf
  • +
  • 4

    Придумайте имя туннелю и нажмите «Сохранить»

  • +
  • 5

    Нажмите тумблер → появится запрос, нажмите «Разрешить»

  • +
+

Тумблер зелёный — вы подключены. Значок VPN в строке статуса.

+
+
+
+
+

WireGuard на Android

Android 8 и новее

+
    +
  • 1

    Откройте Google Play, найдите WireGuard и установите

  • +
  • 2

    Запустите приложение, нажмите синюю кнопку + внизу

  • +
  • 3

    Выберите «Сканировать QR-код» и поднесите телефон к коду

    Или «Создать из файла» → файл .conf из загрузок
  • +
  • 4

    Дайте имя туннелю и нажмите «Создать туннель»

  • +
  • 5

    Переключите тумблер → нажмите «ОК» в системном запросе

  • +
+

Значок замка в строке состояния — VPN активен.

+
+
+
+
+

WireGuard на Windows

Windows 10 и 11

+
    +
  • 1

    Скачайте установщик с wireguard.com/install и запустите

  • +
  • 2

    Откройте WireGuard → «Добавить туннель»«Импорт из файла»

  • +
  • 3

    Выберите ваш файл .conf

  • +
  • 4

    Нажмите «Подключиться» — статус сменится на «Активен»

  • +
+

Если появится запрос UAC — нажмите «Да». Нужно для создания VPN-адаптера.

+

Статус «Активен» — VPN работает.

+
+
+
+
+

WireGuard на macOS

macOS 12 и новее

+
    +
  • 1

    Установите WireGuard из Mac App Store

  • +
  • 2

    Откройте приложение → в меню File → Import Tunnel(s)

  • +
  • 3

    Выберите файл .conf и нажмите «Открыть»

  • +
  • 4

    Нажмите «Подключить» и разрешите VPN в системных настройках

  • +
+

Иконка в меню-баре активна — вы подключены.

+
+
+
+ + +
+
+ + + + +
+
+
+

Amnezia VPN на iOS

iPhone и iPad · Обходит глубокую блокировку

+
    +
  • 1

    Откройте App Store, найдите Amnezia VPN и установите

  • +
  • 2

    Запустите приложение и нажмите «Добавить сервер»

  • +
  • 3

    Нажмите «У меня есть файл настроек» и выберите файл .vpn

    Или «Сканировать QR» → поднесите камеру к коду
  • +
  • 4

    Нажмите «Подключиться» и разрешите добавить VPN-профиль

  • +
+

Amnezia маскирует трафик под обычный HTTPS — работает там, где WireGuard блокируется.

+

Кнопка стала оранжевой — вы защищены.

+
+
+
+
+

Amnezia VPN на Android

Android 8 и новее

+
    +
  • 1

    Установите Amnezia VPN из Google Play или APK с amnezia.org

  • +
  • 2

    Откройте → «Добавить сервер»

  • +
  • 3

    Нажмите «Загрузить файл» и выберите файл .vpn

  • +
  • 4

    Нажмите «Подключиться» → разрешите системный запрос VPN

  • +
+

Замок в строке состояния — VPN работает.

+
+
+
+
+

Amnezia VPN на Windows

Windows 10 и 11 · Протокол AmneziaWG

+
    +
  • 1

    Скачайте с amnezia.org для Windows и запустите установщик

  • +
  • 2

    Откройте → «Добавить сервер»«Открыть файл конфигурации»

  • +
  • 3

    Выберите файл .vpn и нажмите «Подключиться»

  • +
+

AmneziaWG — улучшенный WireGuard с обходом DPI.

+

Индикатор зелёный — VPN активен.

+
+
+
+
+

Amnezia VPN на macOS

macOS 12 и новее

+
    +
  • 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

+
+
+ + +
+ +
+

Смена региона позволяет скачивать приложения, недоступные в вашем регионе. Покупки сохранятся, но активные подписки могут потребовать подтверждения.

+
+

Регион: Казахстан

iPhone · iPad · iOS 15 и новее

+
    +
  • 1

    Откройте Настройки → нажмите на своё имя вверху

  • +
  • 2

    Нажмите «Медиаматериалы и покупки»«Просмотр учётной записи»

  • +
  • 3

    Нажмите «Страна/Регион»«Сменить страну или регион»

  • +
  • 4

    Найдите и выберите Казахстан

  • +
  • 5

    Нажмите «Принять» условия

  • +
  • 6

    Способ оплаты — выберите «Нет»

  • +
  • 7

    Заполните адрес:

    Пример адреса · Алматы
    ГородАлматы
    Адресул. Абая 1
    Индекс050000
    Телефон+7 727 000 0000
  • +
  • 8

    Нажмите «Далее» — регион изменён

  • +
+

Готово! Откройте App Store — доступны приложения казахстанского региона.

+
+
+ +
+

Американский App Store даёт максимальный доступ к приложениям. Рекомендуем штат Орегон — там нет налога с продаж.

+
+

Регион: США · Oregon

iPhone · iPad · Без налога с продаж

+
    +
  • 1

    Откройте Настройки → нажмите на своё имя вверху

  • +
  • 2

    Нажмите «Медиаматериалы и покупки»«Просмотр учётной записи»

  • +
  • 3

    Нажмите «Страна/Регион»«Сменить страну или регион»

  • +
  • 4

    Найдите и выберите Соединённые Штаты

  • +
  • 5

    Нажмите «Принять» условия

  • +
  • 6

    Способ оплаты — выберите «None / Нет»

  • +
  • 7

    Заполните адрес (штат Орегон — без налогов):

    Пример адреса · Oregon, No Tax
    Street1234 SW Main St
    CityPortland
    StateOregon (OR)
    ZIP97201
    Phone503-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 — просмотр клиентов + + + + + + + + + + +
+
+ amnezia_web + + ⭐ GitHub + + 💖 Boosty + + 💳 Ozon СБП + + ✉️ Telegram @lot_andrey +
+

+ Открытая базовая сборка — только просмотр клиентов. Полная панель PRO (приватный репозиторий) и доступ по подписке — см. Boosty и плашку в интерфейсе после входа. +

+
+ + +
+

Дата отключения

+

+ + + +
+ + +
+
+
+ + +
+

WARP на хосте 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);