feat: open community panel (read-only clients only)

Ship full install stack with .amnezia-panel-edition=community; docs and
footer point to Boosty/PRO. Managing peers, export, cascade and WARP
require private PRO build.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-14 21:51:24 +03:00
parent bb96d29ec8
commit 7f5fb921e6
22 changed files with 6658 additions and 1 deletions

1
.amnezia-panel-edition Normal file
View File

@@ -0,0 +1 @@
community

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
npm-debug.log

5
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
# Как в https://github.com/andrey271192/domen_hydra/blob/main/.github/FUNDING.yml
github: andrey271192
custom:
- https://boosty.to/andrey27/donate

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
npm-debug.log*
.DS_Store
*.swp
.env
.env.*

20
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

View File

@@ -1 +1,72 @@
# amnezia_web # 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).

93
docs/panel-guide.md Normal file
View File

@@ -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).

View File

@@ -0,0 +1 @@
Screenshots live in the PRO repo to keep this clone small.

3
landing/Dockerfile Normal file
View File

@@ -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/

1
landing/admin-port.js Normal file
View File

@@ -0,0 +1 @@
window.__AMNEZIA_ADMIN_PORT__ = "8080";

355
landing/index.html Normal file
View File

@@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Подключение к VPN · частный сервер</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="site-header">
<div class="header-inner">
<a href="#" class="logo"><span class="logo-icon" aria-hidden="true">🔒</span>Подключение к VPN</a>
<div class="header-actions">
<a id="admin-panel-link" class="header-admin" href="#">Админ-панель</a>
<div class="status-pill"><span class="status-dot"></span>Сервер активен</div>
</div>
</div>
</header>
<section class="hero">
<div class="hero-badge">Частный сервер</div>
<h1>Подключение к VPN</h1>
<p>Используйте приложение <strong>AmneziaVPN</strong>. Конфигурацию выдаёт администратор — импортируйте файл или ключ, который вам передали, и включите туннель. Ниже — пошаговые инструкции для WireGuard и Amnezia VPN.</p>
</section>
<section class="server-summary" aria-labelledby="server-card-title">
<div class="server-summary-card">
<div id="server-card-title" class="server-summary-label">Сервер</div>
<span class="server-addr-label">Адрес</span>
<div class="server-addr-row">
<div id="server-address-display" class="addr-box" aria-live="polite"></div>
<button type="button" class="copy-btn" onclick="copyServerAddr(this)">Копировать</button>
</div>
<p class="services-line"><strong>Сервисы на машине:</strong> <span id="server-services">XRay (TCP) · AmneziaWG (UDP)</span></p>
<p class="server-note">Номера портов и тип протокола уже зашиты в конфиг Amnezia — вручную их обычно не вводят.</p>
</div>
</section>
<nav class="page-nav">
<button class="page-nav-btn active" onclick="switchPage('vpn',this)"> Подключение VPN</button>
<button class="page-nav-btn" onclick="switchPage('region',this)"> Смена региона App Store</button>
</nav>
<!-- PAGE: VPN -->
<div class="page-section visible" id="page-vpn">
<div class="vpn-switcher">
<div class="vpn-tabs">
<button class="vpn-tab active" onclick="switchVpn('wg',this)">WireGuard <span class="vpn-tab-badge wg-badge">Стандарт</span></button>
<button class="vpn-tab" onclick="switchVpn('amn',this)">Amnezia VPN <span class="vpn-tab-badge amn-badge">Обход DPI</span></button>
</div>
</div>
<div class="content">
<!-- WIREGUARD -->
<div class="vpn-section visible" id="vpn-wg">
<div class="platform-selector" id="wg-plats">
<button class="plat-btn wg-active" onclick="switchPlat('wg','ios',this)"> iOS</button>
<button class="plat-btn" onclick="switchPlat('wg','android',this)"> Android</button>
<button class="plat-btn" onclick="switchPlat('wg','windows',this)"> Windows</button>
<button class="plat-btn" onclick="switchPlat('wg','macos',this)"> macOS</button>
</div>
<div class="steps-panel visible" id="wg-ios">
<div class="guide-card">
<div class="guide-card-header"><div class="plat-icon-lg ios-icon"></div><div class="guide-card-header-text"><h2>WireGuard на iOS</h2><p>iPhone и iPad · iOS 15 и новее</p></div></div>
<ul class="step-list">
<li class="step-item"><span class="step-circle wg-circle">1</span><div class="step-body"><p>Откройте <b>App Store</b>, найдите <b>WireGuard</b> и нажмите «Установить»</p></div></li>
<li class="step-item"><span class="step-circle wg-circle">2</span><div class="step-body"><p>Откройте приложение, нажмите <b>+</b> в правом верхнем углу</p></div></li>
<li class="step-item"><span class="step-circle wg-circle">3</span><div class="step-body"><p>Выберите <b>«Сканировать QR-код»</b> и наведите камеру на QR — конфиг добавится автоматически</p><div class="alt-step">Или «Создать из файла» → выберите файл <span class="code-tag">.conf</span></div></div></li>
<li class="step-item"><span class="step-circle wg-circle">4</span><div class="step-body"><p>Придумайте имя туннелю и нажмите <b>«Сохранить»</b></p></div></li>
<li class="step-item"><span class="step-circle wg-circle">5</span><div class="step-body"><p>Нажмите тумблер → появится запрос, нажмите <b>«Разрешить»</b></p></div></li>
</ul>
<div class="notice notice-success"><span class="notice-icon"></span><p>Тумблер зелёный — вы подключены. Значок VPN в строке статуса.</p></div>
</div>
</div>
<div class="steps-panel" id="wg-android">
<div class="guide-card">
<div class="guide-card-header"><div class="plat-icon-lg android-icon"></div><div class="guide-card-header-text"><h2>WireGuard на Android</h2><p>Android 8 и новее</p></div></div>
<ul class="step-list">
<li class="step-item"><span class="step-circle wg-circle">1</span><div class="step-body"><p>Откройте <b>Google Play</b>, найдите <b>WireGuard</b> и установите</p></div></li>
<li class="step-item"><span class="step-circle wg-circle">2</span><div class="step-body"><p>Запустите приложение, нажмите <b>синюю кнопку +</b> внизу</p></div></li>
<li class="step-item"><span class="step-circle wg-circle">3</span><div class="step-body"><p>Выберите <b>«Сканировать QR-код»</b> и поднесите телефон к коду</p><div class="alt-step">Или «Создать из файла» → файл <span class="code-tag">.conf</span> из загрузок</div></div></li>
<li class="step-item"><span class="step-circle wg-circle">4</span><div class="step-body"><p>Дайте имя туннелю и нажмите <b>«Создать туннель»</b></p></div></li>
<li class="step-item"><span class="step-circle wg-circle">5</span><div class="step-body"><p>Переключите тумблер → нажмите <b>«ОК»</b> в системном запросе</p></div></li>
</ul>
<div class="notice notice-success"><span class="notice-icon"></span><p>Значок замка в строке состояния — VPN активен.</p></div>
</div>
</div>
<div class="steps-panel" id="wg-windows">
<div class="guide-card">
<div class="guide-card-header"><div class="plat-icon-lg windows-icon"></div><div class="guide-card-header-text"><h2>WireGuard на Windows</h2><p>Windows 10 и 11</p></div></div>
<ul class="step-list">
<li class="step-item"><span class="step-circle wg-circle">1</span><div class="step-body"><p>Скачайте установщик с <b>wireguard.com/install</b> и запустите</p></div></li>
<li class="step-item"><span class="step-circle wg-circle">2</span><div class="step-body"><p>Откройте WireGuard → <b>«Добавить туннель»</b><b>«Импорт из файла»</b></p></div></li>
<li class="step-item"><span class="step-circle wg-circle">3</span><div class="step-body"><p>Выберите ваш файл <span class="code-tag">.conf</span></p></div></li>
<li class="step-item"><span class="step-circle wg-circle">4</span><div class="step-body"><p>Нажмите <b>«Подключиться»</b> — статус сменится на «Активен»</p></div></li>
</ul>
<div class="notice notice-tip"><span class="notice-icon"></span><p>Если появится запрос UAC — нажмите «Да». Нужно для создания VPN-адаптера.</p></div>
<div class="notice notice-success"><span class="notice-icon"></span><p>Статус «Активен» — VPN работает.</p></div>
</div>
</div>
<div class="steps-panel" id="wg-macos">
<div class="guide-card">
<div class="guide-card-header"><div class="plat-icon-lg macos-icon"></div><div class="guide-card-header-text"><h2>WireGuard на macOS</h2><p>macOS 12 и новее</p></div></div>
<ul class="step-list">
<li class="step-item"><span class="step-circle wg-circle">1</span><div class="step-body"><p>Установите <b>WireGuard</b> из Mac App Store</p></div></li>
<li class="step-item"><span class="step-circle wg-circle">2</span><div class="step-body"><p>Откройте приложение → в меню <b>File → Import Tunnel(s)</b></p></div></li>
<li class="step-item"><span class="step-circle wg-circle">3</span><div class="step-body"><p>Выберите файл <span class="code-tag">.conf</span> и нажмите «Открыть»</p></div></li>
<li class="step-item"><span class="step-circle wg-circle">4</span><div class="step-body"><p>Нажмите <b>«Подключить»</b> и разрешите VPN в системных настройках</p></div></li>
</ul>
<div class="notice notice-success"><span class="notice-icon"></span><p>Иконка в меню-баре активна — вы подключены.</p></div>
</div>
</div>
</div>
<!-- AMNEZIA -->
<div class="vpn-section" id="vpn-amn">
<div class="platform-selector" id="amn-plats">
<button class="plat-btn amn-active" onclick="switchPlat('amn','ios',this)"> iOS</button>
<button class="plat-btn" onclick="switchPlat('amn','android',this)"> Android</button>
<button class="plat-btn" onclick="switchPlat('amn','windows',this)"> Windows</button>
<button class="plat-btn" onclick="switchPlat('amn','macos',this)"> macOS</button>
</div>
<div class="steps-panel visible" id="amn-ios">
<div class="guide-card">
<div class="guide-card-header"><div class="plat-icon-lg ios-icon"></div><div class="guide-card-header-text"><h2>Amnezia VPN на iOS</h2><p>iPhone и iPad · Обходит глубокую блокировку</p></div></div>
<ul class="step-list">
<li class="step-item"><span class="step-circle amn-circle">1</span><div class="step-body"><p>Откройте <b>App Store</b>, найдите <b>Amnezia VPN</b> и установите</p></div></li>
<li class="step-item"><span class="step-circle amn-circle">2</span><div class="step-body"><p>Запустите приложение и нажмите <b>«Добавить сервер»</b></p></div></li>
<li class="step-item"><span class="step-circle amn-circle">3</span><div class="step-body"><p>Нажмите <b>«У меня есть файл настроек»</b> и выберите файл <span class="code-tag">.vpn</span></p><div class="alt-step">Или «Сканировать QR» → поднесите камеру к коду</div></div></li>
<li class="step-item"><span class="step-circle amn-circle">4</span><div class="step-body"><p>Нажмите <b>«Подключиться»</b> и разрешите добавить VPN-профиль</p></div></li>
</ul>
<div class="notice notice-tip"><span class="notice-icon"></span><p>Amnezia маскирует трафик под обычный HTTPS — работает там, где WireGuard блокируется.</p></div>
<div class="notice notice-success"><span class="notice-icon"></span><p>Кнопка стала оранжевой — вы защищены.</p></div>
</div>
</div>
<div class="steps-panel" id="amn-android">
<div class="guide-card">
<div class="guide-card-header"><div class="plat-icon-lg android-icon"></div><div class="guide-card-header-text"><h2>Amnezia VPN на Android</h2><p>Android 8 и новее</p></div></div>
<ul class="step-list">
<li class="step-item"><span class="step-circle amn-circle">1</span><div class="step-body"><p>Установите <b>Amnezia VPN</b> из Google Play или APK с <b>amnezia.org</b></p></div></li>
<li class="step-item"><span class="step-circle amn-circle">2</span><div class="step-body"><p>Откройте → <b>«Добавить сервер»</b></p></div></li>
<li class="step-item"><span class="step-circle amn-circle">3</span><div class="step-body"><p>Нажмите <b>«Загрузить файл»</b> и выберите файл <span class="code-tag">.vpn</span></p></div></li>
<li class="step-item"><span class="step-circle amn-circle">4</span><div class="step-body"><p>Нажмите <b>«Подключиться»</b> → разрешите системный запрос VPN</p></div></li>
</ul>
<div class="notice notice-success"><span class="notice-icon"></span><p>Замок в строке состояния — VPN работает.</p></div>
</div>
</div>
<div class="steps-panel" id="amn-windows">
<div class="guide-card">
<div class="guide-card-header"><div class="plat-icon-lg windows-icon"></div><div class="guide-card-header-text"><h2>Amnezia VPN на Windows</h2><p>Windows 10 и 11 · Протокол AmneziaWG</p></div></div>
<ul class="step-list">
<li class="step-item"><span class="step-circle amn-circle">1</span><div class="step-body"><p>Скачайте с <b>amnezia.org</b> для Windows и запустите установщик</p></div></li>
<li class="step-item"><span class="step-circle amn-circle">2</span><div class="step-body"><p>Откройте → <b>«Добавить сервер»</b><b>«Открыть файл конфигурации»</b></p></div></li>
<li class="step-item"><span class="step-circle amn-circle">3</span><div class="step-body"><p>Выберите файл <span class="code-tag">.vpn</span> и нажмите <b>«Подключиться»</b></p></div></li>
</ul>
<div class="notice notice-tip"><span class="notice-icon"></span><p>AmneziaWG — улучшенный WireGuard с обходом DPI.</p></div>
<div class="notice notice-success"><span class="notice-icon"></span><p>Индикатор зелёный — VPN активен.</p></div>
</div>
</div>
<div class="steps-panel" id="amn-macos">
<div class="guide-card">
<div class="guide-card-header"><div class="plat-icon-lg macos-icon"></div><div class="guide-card-header-text"><h2>Amnezia VPN на macOS</h2><p>macOS 12 и новее</p></div></div>
<ul class="step-list">
<li class="step-item"><span class="step-circle amn-circle">1</span><div class="step-body"><p>Скачайте с <b>amnezia.org</b> для macOS</p></div></li>
<li class="step-item"><span class="step-circle amn-circle">2</span><div class="step-body"><p>Перетащите в папку «Программы» и запустите</p></div></li>
<li class="step-item"><span class="step-circle amn-circle">3</span><div class="step-body"><p>Нажмите <b>«Добавить сервер»</b> → загрузите файл <span class="code-tag">.vpn</span></p></div></li>
<li class="step-item"><span class="step-circle amn-circle">4</span><div class="step-body"><p>Нажмите <b>«Подключиться»</b> и разрешите сетевые расширения в системных настройках</p></div></li>
</ul>
<div class="notice notice-success"><span class="notice-icon"></span><p>Иконка Amnezia в меню-баре активна — вы защищены.</p></div>
</div>
</div>
</div>
<div class="ip-check-section">
<h3>Как проверить, что VPN работает?</h3>
<div class="check-cards">
<div class="check-card"><span class="n">1.</span> Откройте <span class="code-tag">2ip.ru</span> или <span class="code-tag">whatismyip.com</span></div>
<div class="check-card"><span class="n">2.</span> Страна должна измениться на страну вашего сервера</div>
<div class="check-card"><span class="n">3.</span> Попробуйте открыть нужный сайт — загрузится</div>
</div>
</div>
<div class="trouble-section">
<h3>Что делать, если что-то пошло не так</h3>
<div class="trouble-grid">
<div class="trouble-card"><div class="trouble-icon"></div><div class="trouble-title">VPN не подключается</div><ul class="trouble-list"><li>Перезапустите приложение</li><li>Выключите/включите Wi-Fi или мобильный интернет</li><li>Удалите туннель и добавьте конфиг заново</li><li>Попробуйте другую сеть</li></ul></div>
<div class="trouble-card"><div class="trouble-icon"></div><div class="trouble-title">VPN включён, нет интернета</div><ul class="trouble-list"><li>Отключитесь и подключитесь снова</li><li>Убедитесь, что конфиг актуальный</li><li>Перезагрузите устройство</li><li>Напишите в поддержку</li></ul></div>
<div class="trouble-card"><div class="trouble-icon"></div><div class="trouble-title">Сайты не открываются</div><ul class="trouble-list"><li>Очистите кеш браузера</li><li>Смените DNS на 1.1.1.1</li><li>Откройте в режиме инкогнито</li><li>Попробуйте другой браузер</li></ul></div>
<div class="trouble-card"><div class="trouble-icon"></div><div class="trouble-title">Медленная скорость</div><ul class="trouble-list"><li>Проверьте скорость без VPN</li><li>Переподключитесь</li><li>Закройте лишние приложения</li><li>Переключитесь Wi-Fi ↔ 4G</li></ul></div>
</div>
</div>
</div>
</div>
<!-- PAGE: REGION -->
<div class="page-section" id="page-region">
<div class="content">
<div class="region-hero">
<h2>Смена региона App Store</h2>
<p>Пошаговые инструкции для iPhone и iPad</p>
</div>
<div class="region-tabs">
<button class="region-tab active" onclick="switchRegion('kz',this)"> Казахстан</button>
<button class="region-tab" onclick="switchRegion('us',this)"> США</button>
</div>
<div class="region-panel visible" id="region-kz">
<div class="warn-box"><span style="font-size:18px;flex-shrink:0;"></span><p>Смена региона позволяет скачивать приложения, недоступные в вашем регионе. Покупки сохранятся, но активные подписки могут потребовать подтверждения.</p></div>
<div class="guide-card">
<div class="guide-card-header"><div class="plat-icon-lg" style="background:linear-gradient(135deg,#1a4a1a,#2ea82e);"></div><div class="guide-card-header-text"><h2>Регион: Казахстан</h2><p>iPhone · iPad · iOS 15 и новее</p></div></div>
<ul class="step-list">
<li class="step-item"><span class="step-circle wg-circle">1</span><div class="step-body"><p>Откройте <b>Настройки</b> → нажмите на своё <b>имя</b> вверху</p></div></li>
<li class="step-item"><span class="step-circle wg-circle">2</span><div class="step-body"><p>Нажмите <b>«Медиаматериалы и покупки»</b><b>«Просмотр учётной записи»</b></p></div></li>
<li class="step-item"><span class="step-circle wg-circle">3</span><div class="step-body"><p>Нажмите <b>«Страна/Регион»</b><b>«Сменить страну или регион»</b></p></div></li>
<li class="step-item"><span class="step-circle wg-circle">4</span><div class="step-body"><p>Найдите и выберите <b>Казахстан</b></p></div></li>
<li class="step-item"><span class="step-circle wg-circle">5</span><div class="step-body"><p>Нажмите <b>«Принять»</b> условия</p></div></li>
<li class="step-item"><span class="step-circle wg-circle">6</span><div class="step-body"><p>Способ оплаты — выберите <b>«Нет»</b></p></div></li>
<li class="step-item"><span class="step-circle wg-circle">7</span><div class="step-body"><p>Заполните адрес:</p><div class="addr-card"><div class="addr-card-title">Пример адреса · Алматы</div><div class="addr-row"><span class="addr-label">Город</span><span class="addr-val">Алматы</span></div><div class="addr-row"><span class="addr-label">Адрес</span><span class="addr-val">ул. Абая 1</span></div><div class="addr-row"><span class="addr-label">Индекс</span><span class="addr-val">050000</span></div><div class="addr-row"><span class="addr-label">Телефон</span><span class="addr-val">+7 727 000 0000</span></div></div></div></li>
<li class="step-item"><span class="step-circle wg-circle">8</span><div class="step-body"><p>Нажмите <b>«Далее»</b> — регион изменён</p></div></li>
</ul>
<div class="notice notice-success"><span class="notice-icon"></span><p>Готово! Откройте App Store — доступны приложения казахстанского региона.</p></div>
</div>
</div>
<div class="region-panel" id="region-us">
<div class="warn-box"><span style="font-size:18px;flex-shrink:0;"></span><p>Американский App Store даёт максимальный доступ к приложениям. Рекомендуем штат <b>Орегон</b> — там нет налога с продаж.</p></div>
<div class="guide-card">
<div class="guide-card-header"><div class="plat-icon-lg" style="background:linear-gradient(135deg,#1a3a6e,#3a6bd4);"></div><div class="guide-card-header-text"><h2>Регион: США · Oregon</h2><p>iPhone · iPad · Без налога с продаж</p></div></div>
<ul class="step-list">
<li class="step-item"><span class="step-circle amn-circle">1</span><div class="step-body"><p>Откройте <b>Настройки</b> → нажмите на своё <b>имя</b> вверху</p></div></li>
<li class="step-item"><span class="step-circle amn-circle">2</span><div class="step-body"><p>Нажмите <b>«Медиаматериалы и покупки»</b><b>«Просмотр учётной записи»</b></p></div></li>
<li class="step-item"><span class="step-circle amn-circle">3</span><div class="step-body"><p>Нажмите <b>«Страна/Регион»</b><b>«Сменить страну или регион»</b></p></div></li>
<li class="step-item"><span class="step-circle amn-circle">4</span><div class="step-body"><p>Найдите и выберите <b>Соединённые Штаты</b></p></div></li>
<li class="step-item"><span class="step-circle amn-circle">5</span><div class="step-body"><p>Нажмите <b>«Принять»</b> условия</p></div></li>
<li class="step-item"><span class="step-circle amn-circle">6</span><div class="step-body"><p>Способ оплаты — выберите <b>«None / Нет»</b></p></div></li>
<li class="step-item"><span class="step-circle amn-circle">7</span><div class="step-body"><p>Заполните адрес (штат Орегон — без налогов):</p><div class="addr-card"><div class="addr-card-title">Пример адреса · Oregon, No Tax</div><div class="addr-row"><span class="addr-label">Street</span><span class="addr-val">1234 SW Main St</span></div><div class="addr-row"><span class="addr-label">City</span><span class="addr-val">Portland</span></div><div class="addr-row"><span class="addr-label">State</span><span class="addr-val">Oregon (OR)</span></div><div class="addr-row"><span class="addr-label">ZIP</span><span class="addr-val">97201</span></div><div class="addr-row"><span class="addr-label">Phone</span><span class="addr-val">503-555-0100</span></div></div></div></li>
<li class="step-item"><span class="step-circle amn-circle">8</span><div class="step-body"><p>Нажмите <b>«Далее»</b> — регион изменён</p></div></li>
</ul>
<div class="notice notice-success"><span class="notice-icon"></span><p>Готово! Бесплатные приложения доступны сразу — без карты.</p></div>
<div class="notice notice-tip"><span class="notice-icon"></span><p>Для платных нужна американская карта или подарочная карта iTunes (Gift Card).</p></div>
</div>
<div class="ip-check-section" style="margin-top:1.5rem;">
<h3>Как вернуть свой регион обратно?</h3>
<div class="check-cards">
<div class="check-card"><span class="n">1.</span> Повторите шаги 13 из инструкции выше</div>
<div class="check-card"><span class="n">2.</span> Выберите свою страну из списка</div>
<div class="check-card"><span class="n">3.</span> Скачанные приложения останутся на телефоне</div>
</div>
</div>
</div>
</div>
</div>
<footer class="site-footer">
<p class="site-footer-lead">Возникли проблемы с подключением — напишите администратору сервера.</p>
<p class="disclaimer-footer">Страница только для приглашённых пользователей. Не передавайте конфиг третьим лицам.</p>
</footer>
<script src="admin-port.js"></script>
<script>
(function () {
var port =
(typeof window.__AMNEZIA_ADMIN_PORT__ !== "undefined" && window.__AMNEZIA_ADMIN_PORT__) || "8080";
var h = location.hostname || "";
var proto = location.protocol || "http:";
var adminUrl = proto + "//" + h + ":" + port + "/";
var a = document.getElementById("admin-panel-link");
if (a) {
a.href = adminUrl;
a.title = "Открыть в новой вкладке: " + adminUrl;
}
var ipEl = document.getElementById("server-address-display");
if (ipEl) {
ipEl.textContent = h || "—";
}
})();
function copyServerAddr(btn) {
var t = document.getElementById("server-address-display");
if (!t) return;
var v = (t.textContent || "").trim();
if (!v || v === "—") return;
function done() {
if (!btn) return;
var prev = btn.dataset.prevLabel || btn.textContent;
btn.dataset.prevLabel = prev;
btn.textContent = "Скопировано";
btn.classList.add("copied");
setTimeout(function () {
btn.textContent = btn.dataset.prevLabel;
btn.classList.remove("copied");
}, 1800);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(v).then(done).catch(function () {
fallbackCopy(v, done);
});
} else {
fallbackCopy(v, done);
}
}
function fallbackCopy(text, cb) {
var ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
if (cb) cb();
} finally {
document.body.removeChild(ta);
}
}
function switchPage(page, btn) {
document.querySelectorAll('.page-section').forEach(function(s){ s.classList.remove('visible'); });
document.getElementById('page-' + page).classList.add('visible');
document.querySelectorAll('.page-nav-btn').forEach(function(b){ b.classList.remove('active'); });
btn.classList.add('active');
window.scrollTo({top:0, behavior:'smooth'});
}
function switchVpn(vpn, btn) {
document.querySelectorAll('.vpn-section').forEach(function(s){ s.classList.remove('visible'); });
document.getElementById('vpn-' + vpn).classList.add('visible');
document.querySelectorAll('.vpn-tab').forEach(function(b){ b.classList.remove('active'); });
btn.classList.add('active');
}
function switchPlat(vpn, plat, btn) {
['ios','android','windows','macos'].forEach(function(p){
var el = document.getElementById(vpn+'-'+p);
if(el) el.classList.remove('visible');
});
var t = document.getElementById(vpn+'-'+plat);
if(t) t.classList.add('visible');
var sel = document.getElementById(vpn+'-plats');
if(sel) sel.querySelectorAll('.plat-btn').forEach(function(b){ b.classList.remove('wg-active','amn-active'); });
btn.classList.add(vpn==='wg' ? 'wg-active' : 'amn-active');
}
function switchRegion(r, btn) {
document.querySelectorAll('.region-panel').forEach(function(p){ p.classList.remove('visible'); });
document.getElementById('region-'+r).classList.add('visible');
document.querySelectorAll('.region-tab').forEach(function(b){ b.classList.remove('active'); });
btn.classList.add('active');
}
</script>
</body>
</html>

20
landing/nginx.conf Normal file
View File

@@ -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";
}
}

949
landing/styles.css Normal file
View File

@@ -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;
}
}

18
package.json Normal file
View File

@@ -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"
}
}

1197
public/app.js Normal file

File diff suppressed because it is too large Load Diff

241
public/index.html Normal file
View File

@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AmneziaWG — просмотр клиентов</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body class="page">
<div id="login-gate" class="gate hidden" aria-hidden="true">
<div class="gate-card">
<p class="eyebrow">Панель сервера</p>
<h1>Вход</h1>
<p class="sub">Введите пароль администратора панели.</p>
<form id="login-form">
<label for="login-password">Пароль</label>
<input id="login-password" type="password" autocomplete="current-password" required>
<button type="submit" class="btn primary full">Войти</button>
<p id="login-error" class="status err" role="alert"></p>
</form>
</div>
</div>
<div id="app-root" class="shell hidden">
<header class="top">
<div>
<p class="eyebrow">Панель сервера</p>
<h1>Пользователи AmneziaWG</h1>
<p class="sub">Базовая открытая панель: просмотр клиентов и статусов туннеля. Управление peer, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста доступны в версии PRO (ссылка в жёлтой плашке после входа).</p>
</div>
<div class="token-box">
<div class="session-actions">
<button type="button" id="logout" class="btn ghost full">Выйти</button>
</div>
<details class="pw-change">
<summary>Сменить пароль</summary>
<form id="pw-form">
<label for="pw-current">Текущий пароль</label>
<input id="pw-current" type="password" autocomplete="current-password" required>
<label for="pw-new">Новый пароль</label>
<input id="pw-new" type="password" autocomplete="new-password" required minlength="8">
<label for="pw-new2">Повтор нового пароля</label>
<input id="pw-new2" type="password" autocomplete="new-password" required minlength="8">
<button type="submit" class="btn primary full">Сохранить новый пароль</button>
<p id="pw-msg" class="status" role="status"></p>
</form>
</details>
</div>
</header>
<div id="edition-banner" class="edition-banner hidden" role="region" aria-label="Редакция панели"></div>
<section class="toolbar">
<div class="pill proto-pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
<div id="proto-switch" class="proto-switch hidden">
<label for="proto-select" class="proto-switch-label muted">Инстанс</label>
<select id="proto-select" class="proto-select" aria-label="Инстанс AmneziaWG"></select>
</div>
<div class="clock-strip" role="status" aria-live="polite">
<div class="clock-row clock-row--server">
<span class="muted clock-kind">Сервер <span class="clock-kind-sub">(часы VPS / контейнера)</span></span>
<time id="clock-server" class="clock-time"></time>
</div>
<div class="clock-row clock-row--your-place">
<span class="muted clock-kind">Ваше место <span class="clock-kind-sub">(браузер)</span></span>
<time id="clock-local" class="clock-time"></time>
</div>
<p id="clock-zone-diff" class="clock-zone-diff muted hidden" role="note"></p>
<p class="clock-hint muted">
Цифры разнятся, если пояса разные — это нормально для одного момента UTC. «Сверить» подтягивает время с VPS.
</p>
<button type="button" id="clock-sync" class="btn small ghost clock-sync-btn">
Сверить время
</button>
<details class="clock-host-sync">
<summary class="clock-sync-summary">Синхронизация: выставить часы <strong>хоста VPS</strong> под <strong>ваше устройство</strong></summary>
<p id="sync-host-hint" class="muted clock-sync-hint"></p>
<label for="sync-root-pw">Пароль root на хосте (не сохраняется)</label>
<input id="sync-root-pw" type="password" autocomplete="off" class="clock-sync-input">
<button type="button" id="sync-host-time" class="btn small primary clock-sync-submit">
Взять время с этого устройства и записать на хост VPS
</button>
</details>
</div>
<button type="button" id="refresh" class="btn primary">Обновить</button>
</section>
<p id="profile-hint" class="profile-hint muted hidden" role="note"></p>
<p id="status" class="status" role="status"></p>
<details class="panel-fold warp-panel" id="warp-panel" hidden>
<summary class="fold-summary">
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-titles">
<span class="fold-h">Cloudflare WARP</span>
<span class="muted fold-meta" id="warp-status-line"></span>
</span>
</summary>
<div class="panel-fold-body">
<p class="muted warp-intro">
<strong>Необязательно.</strong> Если достаточно обычного AmneziaWG без выхода части клиентов через Cloudflare — раздел можно не трогать (статус «Не установлен» — норма).
Если WARP нужен: выход в интернет через Cloudflare для <em>выбранных</em> клиентов (правила маршрутизации и NAT в контейнере).
Один раз на хосте VPS под root: в каталоге с репозиторием выполните <code class="inline">bash scripts/warp-amnezia.sh install</code>.
Затем отметьте клиентов ниже и нажмите «Применить» (контейнер AWG перезапустится). Установить или удалить WARP с хоста можно кнопками ниже (пароль root по SSH — как при синхронизации времени). Убрать вручную: <code class="inline">bash scripts/warp-amnezia.sh uninstall</code> — см. README.
</p>
<div id="warp-actions" class="warp-actions"></div>
<div id="warp-client-list" class="warp-client-list"></div>
<details class="raw warp-raw">
<summary>Вывод wg show warp</summary>
<pre id="warp-wg-show"></pre>
</details>
</div>
</details>
<details class="panel-fold cascade-panel" id="cascade-panel" open>
<summary class="fold-summary">
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-titles">
<span class="fold-h">Новый клиент под каскад</span>
<span class="muted fold-meta">Создать peer и скачать .conf</span>
</span>
</summary>
<div class="panel-fold-body">
<p class="muted cascade-intro">
Укажите <strong>Endpoint</strong> — публичный IP или DNS узла, <em>куда клиент будет подключаться первым шагом</em>
(промежуточный сервер, домашний роутер с пробросом порта и т.д.). Порт по умолчанию совпадает с <code class="inline">ListenPort</code> этого инстанса.
Клиент будет <strong>создан на текущем сервере AmneziaWG</strong> (новые ключи); IP в туннеле можно задать явно или оставить пустым — подберём свободный в той же подсети, что у остальных.
</p>
<form id="cascade-form" class="cascade-form">
<label for="cascade-endpoint">Endpoint (хост для клиента)</label>
<input id="cascade-endpoint" type="text" placeholder="Например 203.0.113.50 или ddns.example.com" required autocomplete="off">
<label for="cascade-port">Порт Endpoint</label>
<input id="cascade-port" type="number" min="1" max="65535" placeholder="Пусто — как у сервера" autocomplete="off">
<label for="cascade-tunnel-ip">IP в VPN-туннеле (необязательно)</label>
<input id="cascade-tunnel-ip" type="text" placeholder="Например 10.8.1.20 — та же подсеть /24 что у других" autocomplete="off">
<label for="cascade-name">Имя в таблице</label>
<input id="cascade-name" type="text" placeholder="Например Телефон через узел X" autocomplete="off">
<button type="submit" class="btn primary">Создать на сервере и скачать .conf</button>
</form>
</div>
</details>
<details class="panel-fold" id="users-panel" open>
<summary class="fold-summary">
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-titles">
<span class="fold-h">Пользователи</span>
<span class="muted fold-meta" id="peer-count"></span>
</span>
</summary>
<div class="panel-fold-body">
<div class="table-wrap">
<table class="clients-table">
<thead>
<tr>
<th>Имя</th>
<th>IP</th>
<th>Статус</th>
<th>Последнее отключение</th>
<th class="th-actions">Действия</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
</div>
</details>
<details class="raw panel-fold-raw" id="wg-raw-details">
<summary class="fold-summary fold-summary--raw">
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-titles">
<span class="fold-h">Вывод awg show (отладка)</span>
</span>
</summary>
<pre id="wg-show"></pre>
</details>
</div>
<footer class="support-footer">
<div class="support-line">
<strong class="support-title">amnezia_web</strong>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://github.com/andrey271192/amnezia_web" target="_blank" rel="noopener noreferrer">⭐ GitHub</a>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener noreferrer">💳 Ozon&nbsp;СБП</a>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">✉️ Telegram&nbsp;@lot_andrey</a>
</div>
<p class="support-blurb">
Открытая базовая сборка — только просмотр клиентов. Полная панель PRO (приватный репозиторий) и доступ по подписке — см. Boosty и плашку в интерфейсе после входа.
</p>
</footer>
<dialog id="disconnect-dt-dialog" class="dt-dialog">
<div class="dt-dialog-inner">
<h3 id="dt-dialog-title">Дата отключения</h3>
<p class="muted" id="dt-dialog-client"></p>
<label for="dt-dialog-input">Дата и время</label>
<input type="datetime-local" id="dt-dialog-input" step="60">
<div id="dt-dialog-extra" class="dt-dialog-extra hidden">
<p class="dt-dialog-hint" id="dt-dialog-hint"></p>
<label class="dt-checkbox-label">
<input type="checkbox" id="dt-dialog-schedule-tunnel">
Убрать из туннеля в указанное время (до этого клиент остаётся в туннеле)
</label>
</div>
<div class="dt-dialog-actions">
<button type="button" class="btn ghost" id="dt-dialog-cancel">Отмена</button>
<button type="button" class="btn primary" id="dt-dialog-ok">Подтвердить</button>
</div>
</div>
</dialog>
<dialog id="warp-ssh-dialog" class="dt-dialog">
<div class="dt-dialog-inner">
<h3 id="warp-ssh-title">WARP на хосте VPS</h3>
<p class="muted" id="warp-ssh-lead"></p>
<label for="warp-ssh-pw">Пароль root на хосте VPS (не сохраняется)</label>
<input id="warp-ssh-pw" type="password" autocomplete="off" class="clock-sync-input" />
<div class="dt-dialog-actions">
<button type="button" class="btn ghost" id="warp-ssh-cancel">Отмена</button>
<button type="button" class="btn primary" id="warp-ssh-ok">Выполнить</button>
</div>
<p id="warp-ssh-err" class="status err" role="alert"></p>
</div>
</dialog>
<script src="/app.js" type="module"></script>
</body>
</html>

952
public/styles.css Normal file
View File

@@ -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;
}

249
scripts/install.sh Executable file
View File

@@ -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"

46
scripts/uninstall.sh Executable file
View File

@@ -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 — полная очистка."

280
scripts/warp-amnezia.sh Executable file
View File

@@ -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' <<WARPEOF
[Interface]
PrivateKey = ${pk}
Address = ${addr}
MTU = 1280
Table = off
[Peer]
PublicKey = ${pub}
AllowedIPs = 0.0.0.0/0
Endpoint = ${endpoint_ip}:2408
PersistentKeepalive = 25
WARPEOF
chmod 600 '$AWG_WARP_CONF'"
}
warp_up() {
docker exec "$CONTAINER" sh -c "wg-quick down '$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

2126
server.js Normal file

File diff suppressed because it is too large Load Diff