mirror of
https://github.com/andrey271192/amnezia_web.git
synced 2026-09-20 14:41:59 +00:00
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:
1
.amnezia-panel-edition
Normal file
1
.amnezia-panel-edition
Normal file
@@ -0,0 +1 @@
|
||||
community
|
||||
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
5
.github/FUNDING.yml
vendored
Normal file
5
.github/FUNDING.yml
vendored
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
*.swp
|
||||
.env
|
||||
.env.*
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal 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
21
LICENSE
Normal 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.
|
||||
71
README.md
71
README.md
@@ -1 +1,72 @@
|
||||
# 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
93
docs/panel-guide.md
Normal 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).
|
||||
1
docs/screenshots/README.txt
Normal file
1
docs/screenshots/README.txt
Normal file
@@ -0,0 +1 @@
|
||||
Screenshots live in the PRO repo to keep this clone small.
|
||||
3
landing/Dockerfile
Normal file
3
landing/Dockerfile
Normal 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
1
landing/admin-port.js
Normal file
@@ -0,0 +1 @@
|
||||
window.__AMNEZIA_ADMIN_PORT__ = "8080";
|
||||
355
landing/index.html
Normal file
355
landing/index.html
Normal 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> Повторите шаги 1–3 из инструкции выше</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
20
landing/nginx.conf
Normal 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
949
landing/styles.css
Normal 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
18
package.json
Normal 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
1197
public/app.js
Normal file
File diff suppressed because it is too large
Load Diff
241
public/index.html
Normal file
241
public/index.html
Normal 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 СБП</a>
|
||||
<span class="support-sep" aria-hidden="true">·</span>
|
||||
<a href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">✉️ Telegram @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
952
public/styles.css
Normal 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
249
scripts/install.sh
Executable 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
46
scripts/uninstall.sh
Executable 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
280
scripts/warp-amnezia.sh
Executable 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
|
||||
Reference in New Issue
Block a user